Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
branchMismatchKey,
buildExpiredTerminalContextToastCopy,
buildLoadingThreadFromShell,
resolveComposerConversationKind,
buildThreadTurnInterruptInput,
createLocalDispatchSnapshot,
deriveComposerSendState,
Expand Down Expand Up @@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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:
| {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ import {
buildExpiredTerminalContextToastCopy,
buildLocalDraftThread,
buildLoadingThreadFromShell,
resolveComposerConversationKind,
buildThreadTurnInterruptInput,
collectUserMessageBlobPreviewUrls,
createLocalDispatchSnapshot,
Expand Down Expand Up @@ -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<string | null>(null);
const draftHeroDockRequested =
activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -503,6 +507,7 @@ export interface ChatComposerProps {
activeThread: Thread | undefined;
isServerThread: boolean;
isLocalDraftThread: boolean;
conversationKind: ComposerConversationKind;
forceExpandedOnMobile: boolean;
projectSelectionRequired: boolean;

Expand Down Expand Up @@ -612,6 +617,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeThread,
isServerThread: _isServerThread,
isLocalDraftThread: _isLocalDraftThread,
conversationKind,
forceExpandedOnMobile,
projectSelectionRequired,
phase,
Expand Down Expand Up @@ -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}
/>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/settings/SettingsFontPreviews.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions packages/client-runtime/src/composer.ts
Original file line number Diff line number Diff line change
@@ -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<ComposerConversationKind, string>;

/** Returns the ordinary composer guidance shared by web, desktop, and mobile. */
export function conversationComposerPlaceholder(kind: ComposerConversationKind) {
return CONVERSATION_PLACEHOLDERS[kind];
}
Loading