diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 1ece23ca055..6d867b5fce1 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -16,6 +16,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { conversationComposerPlaceholder } from "@t3tools/client-runtime/composer"; import { ComposerEditor, type ComposerEditorHandle } from "../../components/ComposerEditor"; import { @@ -896,7 +897,7 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} + placeholder={conversationComposerPlaceholder("new")} // Same collapsed centering as ThreadComposer: native vertical gravity // in a pill-height box. singleLineCentered={!isExpanded} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index fa6c1e95040..7820c37d878 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -53,6 +53,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ControlPill"; +import { conversationComposerPlaceholder } from "@t3tools/client-runtime/composer"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; @@ -717,7 +718,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread editorRef={composerEditorRef} draftMessage={props.draftMessage} draftAttachments={props.draftAttachments} - placeholder="Ask the repo agent, or run a command…" + placeholder={conversationComposerPlaceholder("existing")} contentMaxWidth={contentMaxWidth} connectionState={props.connectionStateLabel} connectionError={props.connectionError} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a13..a34811961fb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -15,6 +15,7 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, + resolveComposerConversationKind, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, @@ -170,6 +171,18 @@ describe("buildLoadingThreadFromShell", () => { }); }); +describe("resolveComposerConversationKind", () => { + it("preserves existing-thread guidance while message detail is loading", () => { + expect( + resolveComposerConversationKind({ + messageCount: 0, + threadDetailLoading: true, + latestUserMessageAt: now, + }), + ).toBe("existing"); + }); +}); + describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3..203fa6a5ab6 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -10,6 +10,7 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; +import type { ComposerConversationKind } from "@t3tools/client-runtime/composer"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -119,6 +120,16 @@ export function buildLoadingThreadFromShell(shell: ThreadShell): Thread { }; } +export function resolveComposerConversationKind(input: { + readonly messageCount: number; + readonly threadDetailLoading: boolean; + readonly latestUserMessageAt: string | null; +}): ComposerConversationKind { + return input.messageCount > 0 || (input.threadDetailLoading && input.latestUserMessageAt !== null) + ? "existing" + : "new"; +} + export function shouldWriteThreadErrorToCurrentServerThread(input: { activeServerThread: | { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c30..d49c84232a2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -289,6 +289,7 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, + resolveComposerConversationKind, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -2548,6 +2549,11 @@ function ChatViewContent(props: ChatViewProps) { ), [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); + const composerConversationKind = resolveComposerConversationKind({ + messageCount: timelineMessages.length, + threadDetailLoading, + latestUserMessageAt: routeServerThreadShell?.latestUserMessageAt ?? null, + }); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -6344,6 +6350,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread={activeThread} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} + conversationKind={composerConversationKind} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} phase={phase} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e918e775868..1c707f4e6c5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,6 +18,10 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { + conversationComposerPlaceholder, + type ComposerConversationKind, +} from "@t3tools/client-runtime/composer"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -503,6 +507,7 @@ export interface ChatComposerProps { activeThread: Thread | undefined; isServerThread: boolean; isLocalDraftThread: boolean; + conversationKind: ComposerConversationKind; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; @@ -612,6 +617,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, + conversationKind, forceExpandedOnMobile, projectSelectionRequired, phase, @@ -3049,9 +3055,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : noProviderAvailable ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : conversationComposerPlaceholder(conversationKind) } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 05ea2c9f04e..065a9ad3095 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -1,4 +1,5 @@ import { preloadPatchFile } from "@pierre/diffs/ssr"; +import { conversationComposerPlaceholder } from "@t3tools/client-runtime/composer"; import { useCallback, useEffect, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; @@ -43,7 +44,7 @@ export function PromptFontPreview() { terminalContexts={EMPTY_TERMINAL_CONTEXTS} skills={EMPTY_SKILLS} disabled={false} - placeholder="Ask for follow-up changes or attach images" + placeholder={conversationComposerPlaceholder("new")} className="max-h-40 min-h-12" onRemoveTerminalContext={noop} onChange={onChange} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 01600af4699..a115532f08a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./composer": { + "types": "./src/composer.ts", + "default": "./src/composer.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/composer.ts b/packages/client-runtime/src/composer.ts new file mode 100644 index 00000000000..f08890742ab --- /dev/null +++ b/packages/client-runtime/src/composer.ts @@ -0,0 +1,11 @@ +export type ComposerConversationKind = "new" | "existing"; + +const CONVERSATION_PLACEHOLDERS = { + new: "Describe the task, tag @files, use $skills or /commands", + existing: "Ask for changes, add context, or attach images", +} as const satisfies Record; + +/** Returns the ordinary composer guidance shared by web, desktop, and mobile. */ +export function conversationComposerPlaceholder(kind: ComposerConversationKind) { + return CONVERSATION_PLACEHOLDERS[kind]; +}