diff --git a/.gitignore b/.gitignore index c8a1c30805..3e8d287755 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ +.vitest-* \ No newline at end of file diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51b..7b31dbdf38 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; +import { + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, + type TerminalContextDraft, +} from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe }; } +function createTerminalContext(input: { + id: string; + terminalLabel: string; + lineStart: number; + lineEnd: number; + text: string; +}): TerminalContextDraft { + return { + id: input.id, + threadId: THREAD_ID, + terminalId: `terminal-${input.id}`, + terminalLabel: input.terminalLabel, + lineStart: input.lineStart, + lineEnd: input.lineEnd, + text: input.text, + createdAt: NOW_ISO, + }; +} + function createSnapshotForTargetUser(options: { targetMessageId: MessageId; targetText: string; @@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForSendButton(): Promise { + return waitForElement( + () => document.querySelector('button[aria-label="Send message"]'), + "Unable to find send button.", + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps backspaced terminal context pills removed when a new one is added", async () => { + const removedLabel = "Terminal 1 lines 1-2"; + const addedLabel = "Terminal 2 lines 9-10"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_ID, + createTerminalContext({ + id: "ctx-removed", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 2, + text: "bun i\nno changes", + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, + targetText: "terminal pill backspace target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Backspace", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); + expect(document.body.textContent).not.toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + useComposerDraftStore.getState().addTerminalContext( + THREAD_ID, + createTerminalContext({ + id: "ctx-added", + terminalLabel: "Terminal 2", + lineStart: 9, + lineEnd: 10, + text: "git status\nOn branch main", + }), + ); + + await vi.waitFor( + () => { + const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; + expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); + expect(document.body.textContent).toContain(addedLabel); + expect(document.body.textContent).not.toContain(removedLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("disables send when the composer only contains an expired terminal pill", async () => { + const expiredLabel = "Terminal 1 line 4"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_ID, + createTerminalContext({ + id: "ctx-expired-only", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + }), + ); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-expired-pill-disabled" as MessageId, + targetText: "expired pill disabled target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(expiredLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + + it("warns when sending text while omitting expired terminal pills", async () => { + const expiredLabel = "Terminal 1 line 4"; + useComposerDraftStore.getState().addTerminalContext( + THREAD_ID, + createTerminalContext({ + id: "ctx-expired-send-warning", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + }), + ); + useComposerDraftStore + .getState() + .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-expired-pill-warning" as MessageId, + targetText: "expired pill warning target", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain(expiredLabel); + }, + { timeout: 8_000, interval: 16 }, + ); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "Expired terminal context omitted from message", + ); + expect(document.body.textContent).not.toContain(expiredLabel); + expect(document.body.textContent).toContain("yoowaddup"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("shows a pointer cursor for the running stop button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts new file mode 100644 index 0000000000..bf72ec0b84 --- /dev/null +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -0,0 +1,69 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; + +describe("deriveComposerSendState", () => { + it("treats expired terminal pills as non-sendable content", () => { + const state = deriveComposerSendState({ + prompt: "\uFFFC", + imageCount: 0, + terminalContexts: [ + { + id: "ctx-expired", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: "2026-03-17T12:52:29.000Z", + }, + ], + }); + + expect(state.trimmedPrompt).toBe(""); + expect(state.sendableTerminalContexts).toEqual([]); + expect(state.expiredTerminalContextCount).toBe(1); + expect(state.hasSendableContent).toBe(false); + }); + + it("keeps text sendable while excluding expired terminal pills", () => { + const state = deriveComposerSendState({ + prompt: `yoo \uFFFC waddup`, + imageCount: 0, + terminalContexts: [ + { + id: "ctx-expired", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: "2026-03-17T12:52:29.000Z", + }, + ], + }); + + expect(state.trimmedPrompt).toBe("yoo waddup"); + expect(state.expiredTerminalContextCount).toBe(1); + expect(state.hasSendableContent).toBe(true); + }); +}); + +describe("buildExpiredTerminalContextToastCopy", () => { + it("formats clear empty-state guidance", () => { + expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ + title: "Expired terminal context won't be sent", + description: "Remove it or re-add it to include terminal output.", + }); + }); + + it("formats omission guidance for sent messages", () => { + expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ + title: "Expired terminal contexts omitted from message", + description: "Re-add it if you want that terminal output included.", + }); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e2904310..9b90567590 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; +import { + filterTerminalContextsWithText, + stripInlineTerminalContextPlaceholders, + type TerminalContextDraft, +} from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: { codex: getAppModelOptions("codex", settings.customCodexModels), }; } + +export function deriveComposerSendState(options: { + prompt: string; + imageCount: number; + terminalContexts: ReadonlyArray; +}): { + trimmedPrompt: string; + sendableTerminalContexts: TerminalContextDraft[]; + expiredTerminalContextCount: number; + hasSendableContent: boolean; +} { + const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); + const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); + const expiredTerminalContextCount = + options.terminalContexts.length - sendableTerminalContexts.length; + return { + trimmedPrompt, + sendableTerminalContexts, + expiredTerminalContextCount, + hasSendableContent: + trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, + }; +} + +export function buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount: number, + variant: "omitted" | "empty", +): { title: string; description: string } { + const count = Math.max(1, Math.floor(expiredTerminalContextCount)); + const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; + if (variant === "empty") { + return { + title: `${noun} won't be sent`, + description: "Remove it or re-add it to include terminal output.", + }; + } + return { + title: `${noun} omitted from message`, + description: "Re-add it if you want that terminal output included.", + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52637695e6..b97a8bc4cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -127,6 +127,14 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { + appendTerminalContextsToPrompt, + formatTerminalContextLabel, + insertInlineTerminalContextPlaceholder, + removeInlineTerminalContextPlaceholder, + type TerminalContextDraft, + type TerminalContextSelection, +} from "../lib/terminalContext"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { + buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, + deriveComposerSendState, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -185,6 +195,23 @@ const extendReplacementRangeForTrailingSpace = ( return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; +const syncTerminalContextsByIds = ( + contexts: ReadonlyArray, + ids: ReadonlyArray, +): TerminalContextDraft[] => { + const contextsById = new Map(contexts.map((context) => [context.id, context])); + return ids.flatMap((id) => { + const context = contextsById.get(id); + return context ? [context] : []; + }); +}; + +const terminalContextIdListsEqual = ( + contexts: ReadonlyArray, + ids: ReadonlyArray, +): boolean => + contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); + interface ChatViewProps { threadId: ThreadId; } @@ -209,6 +236,16 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; + const composerTerminalContexts = composerDraft.terminalContexts; + const composerSendState = useMemo( + () => + deriveComposerSendState({ + prompt, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }), + [composerImages.length, composerTerminalContexts, prompt], + ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); @@ -222,6 +259,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const insertComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.insertTerminalContext, + ); + const addComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.addTerminalContexts, + ); + const removeComposerDraftTerminalContext = useComposerDraftStore( + (store) => store.removeTerminalContext, + ); + const setComposerDraftTerminalContexts = useComposerDraftStore( + (store) => store.setTerminalContexts, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -248,6 +297,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; + const composerTerminalContextsRef = useRef(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); @@ -350,12 +400,40 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [addComposerDraftImages, threadId], ); + const addComposerTerminalContextsToDraft = useCallback( + (contexts: TerminalContextDraft[]) => { + addComposerDraftTerminalContexts(threadId, contexts); + }, + [addComposerDraftTerminalContexts, threadId], + ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); + const removeComposerTerminalContextFromDraft = useCallback( + (contextId: string) => { + const contextIndex = composerTerminalContexts.findIndex( + (context) => context.id === contextId, + ); + if (contextIndex < 0) { + return; + } + const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); + promptRef.current = nextPrompt.prompt; + setPrompt(nextPrompt.prompt); + removeComposerDraftTerminalContext(threadId, contextId); + setComposerCursor(nextPrompt.cursor); + setComposerTrigger( + detectComposerTrigger( + nextPrompt.prompt, + expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), + ), + ); + }, + [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); @@ -1093,6 +1171,48 @@ export default function ChatView({ threadId }: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + if (!activeThread) { + return; + } + const snapshot = composerEditorRef.current?.readSnapshot() ?? { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), + }; + const insertion = insertInlineTerminalContextPlaceholder( + snapshot.value, + snapshot.expandedCursor, + ); + const nextCollapsedCursor = collapseExpandedComposerCursor( + insertion.prompt, + insertion.cursor, + ); + const inserted = insertComposerDraftTerminalContext( + activeThread.id, + insertion.prompt, + { + id: randomUUID(), + threadId: activeThread.id, + createdAt: new Date().toISOString(), + ...selection, + }, + insertion.contextIndex, + ); + if (!inserted) { + return; + } + promptRef.current = insertion.prompt; + setComposerCursor(nextCollapsedCursor); + setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCollapsedCursor); + }); + }, + [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadId) return; @@ -1730,6 +1850,10 @@ export default function ChatView({ threadId }: ChatViewProps) { composerImagesRef.current = composerImages; }, [composerImages]); + useEffect(() => { + composerTerminalContextsRef.current = composerTerminalContexts; + }, [composerTerminalContexts]); + useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { @@ -2204,7 +2328,17 @@ export default function ChatView({ threadId }: ChatViewProps) { onAdvanceActivePendingUserInput(); return; } - const trimmed = prompt.trim(); + const promptForSend = promptRef.current; + const { + trimmedPrompt: trimmed, + sendableTerminalContexts: sendableComposerTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImages.length, + terminalContexts: composerTerminalContexts, + }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -2222,7 +2356,9 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } const standaloneSlashCommand = - composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 + ? parseStandaloneComposerSlashCommand(trimmed) + : null; if (standaloneSlashCommand) { await handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -2232,7 +2368,20 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger(null); return; } - if (!trimmed && composerImages.length === 0) return; + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return; + } if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; @@ -2257,6 +2406,11 @@ export default function ChatView({ threadId }: ChatViewProps) { beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; + const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; + const messageTextForSend = appendTerminalContextsToPrompt( + promptForSend, + composerTerminalContextsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( @@ -2281,7 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { id: messageIdForSend, role: "user", - text: trimmed, + text: messageTextForSend, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -2292,6 +2446,17 @@ export default function ChatView({ threadId }: ChatViewProps) { forceStickToBottom(); setThreadError(threadIdForSend, null); + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "omitted", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } promptRef.current = ""; clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); @@ -2339,6 +2504,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerTerminalContextsSnapshot.length > 0) { + titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { titleSeed = "New thread"; } @@ -2419,7 +2586,7 @@ export default function ChatView({ threadId }: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, attachments: turnAttachments, }, model: selectedModel || undefined, @@ -2447,7 +2614,8 @@ export default function ChatView({ threadId }: ChatViewProps) { if ( !turnStartSucceeded && promptRef.current.length === 0 && - composerImagesRef.current.length === 0 + composerImagesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -2457,11 +2625,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - promptRef.current = trimmed; - setPrompt(trimmed); - setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); + promptRef.current = promptForSend; + setPrompt(promptForSend); + setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); - setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); + addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); + setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); } setThreadError( threadIdForSend, @@ -2969,6 +3138,7 @@ export default function ChatView({ threadId }: ChatViewProps) { value: string; cursor: number; expandedCursor: number; + terminalContextIds: string[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { @@ -2978,8 +3148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { value: promptRef.current, cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + terminalContextIds: composerTerminalContexts.map((context) => context.id), }; - }, [composerCursor]); + }, [composerCursor, composerTerminalContexts]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; @@ -3095,6 +3266,7 @@ export default function ChatView({ threadId }: ChatViewProps) { nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, + terminalContextIds: string[], ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( @@ -3108,6 +3280,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } promptRef.current = nextPrompt; setPrompt(nextPrompt); + if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { + setComposerDraftTerminalContexts( + threadId, + syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), + ); + } setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), @@ -3116,8 +3294,11 @@ export default function ChatView({ threadId }: ChatViewProps) { [ activePendingProgress?.activeQuestion, activePendingUserInput, + composerTerminalContexts, onChangeActivePendingUserInputCustomAnswer, setPrompt, + setComposerDraftTerminalContexts, + threadId, ], ); @@ -3384,75 +3565,77 @@ export default function ChatView({ threadId }: ChatViewProps) { )} - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + + + ))} + + )} + + )} ); })()} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index ab68f1fcbd..338d9f7bf1 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -26,8 +26,10 @@ import { COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, $getRoot, + DecoratorNode, type ElementNode, type LexicalNode, + type SerializedLexicalNode, TextNode, type EditorConfig, type EditorState, @@ -36,14 +38,17 @@ import { type Spread, } from "lexical"; import { + createContext, forwardRef, useCallback, + useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, type ClipboardEventHandler, + type ReactElement, type Ref, } from "react"; @@ -51,11 +56,21 @@ import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, expandCollapsedComposerCursor, - isCollapsedCursorAdjacentToMention, + isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { + INLINE_TERMINAL_CONTEXT_PLACEHOLDER, + type TerminalContextDraft, +} from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; +import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "./composerInlineChip"; +import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; @@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread< SerializedTextNode >; +type SerializedComposerTerminalContextNode = Spread< + { + context: TerminalContextDraft; + type: "composer-terminal-context"; + version: 1; + }, + SerializedLexicalNode +>; + +const ComposerTerminalContextActionsContext = createContext<{ + onRemoveTerminalContext: (contextId: string) => void; +}>({ + onRemoveTerminalContext: () => {}, +}); + class ComposerMentionNode extends TextNode { __path: string; @@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode { override createDOM(_config: EditorConfig): HTMLElement { const dom = document.createElement("span"); - dom.className = - "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; + dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; dom.contentEditable = "false"; dom.setAttribute("spellcheck", "false"); renderMentionChipDom(dom, this.__path); @@ -141,15 +170,76 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path)); } -function inferMentionPathKind(pathValue: string): "file" | "directory" { - const base = basenameOfPath(pathValue); - if (base.startsWith(".") && !base.slice(1).includes(".")) { - return "directory"; +function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { + return ; +} + +class ComposerTerminalContextNode extends DecoratorNode { + __context: TerminalContextDraft; + + static override getType(): string { + return "composer-terminal-context"; + } + + static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { + return new ComposerTerminalContextNode(node.__context, node.__key); + } + + static override importJSON( + serializedNode: SerializedComposerTerminalContextNode, + ): ComposerTerminalContextNode { + return $createComposerTerminalContextNode(serializedNode.context); + } + + constructor(context: TerminalContextDraft, key?: NodeKey) { + super(key); + this.__context = context; + } + + override exportJSON(): SerializedComposerTerminalContextNode { + return { + ...super.exportJSON(), + context: this.__context, + type: "composer-terminal-context", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = "inline-flex align-middle leading-none"; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; } - if (base.includes(".")) { - return "file"; + + override isInline(): true { + return true; } - return "directory"; + + override decorate(): ReactElement { + return ; + } +} + +function $createComposerTerminalContextNode( + context: TerminalContextDraft, +): ComposerTerminalContextNode { + return $applyNodeReplacement(new ComposerTerminalContextNode(context)); +} + +type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; + +function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { + return ( + candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode + ); } function resolvedThemeFromDocument(): "light" | "dark" { @@ -165,25 +255,91 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { const icon = document.createElement("img"); icon.alt = ""; icon.ariaHidden = "true"; - icon.className = "size-3.5 shrink-0 opacity-85"; + icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme); + icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); const label = document.createElement("span"); - label.className = "truncate select-none leading-tight"; + label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; label.textContent = basenameOfPath(pathValue); container.append(icon, label); } +function terminalContextSignature(contexts: ReadonlyArray): string { + return contexts + .map((context) => + [ + context.id, + context.threadId, + context.terminalId, + context.terminalLabel, + context.lineStart, + context.lineEnd, + context.createdAt, + context.text, + ].join("\u001f"), + ) + .join("\u001e"); +} + function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); } +function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { + return 1; +} + +function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { + return node.getTextContentSize(); +} + +function getAbsoluteOffsetForInlineTokenPoint( + node: ComposerInlineTokenNode, + absoluteOffset: number, + pointOffset: number, +): number { + return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); +} + +function getExpandedAbsoluteOffsetForInlineTokenPoint( + node: ComposerInlineTokenNode, + absoluteOffset: number, + pointOffset: number, +): number { + return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); +} + +function findSelectionPointForInlineToken( + node: ComposerInlineTokenNode, + remainingRef: { value: number }, +): { key: string; offset: number; type: "element" } | null { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return null; + const index = node.getIndexWithinParent(); + if (remainingRef.value === 0) { + return { + key: parent.getKey(), + offset: index, + type: "element", + }; + } + if (remainingRef.value === getComposerInlineTokenTextLength(node)) { + return { + key: parent.getKey(), + offset: index + 1, + type: "element", + }; + } + remainingRef.value -= getComposerInlineTokenTextLength(node); + return null; +} + function getComposerNodeTextLength(node: LexicalNode): number { - if (node instanceof ComposerMentionNode) { - return 1; + if (isComposerInlineTokenNode(node)) { + return getComposerInlineTokenTextLength(node); } if ($isTextNode(node)) { return node.getTextContentSize(); @@ -198,6 +354,9 @@ function getComposerNodeTextLength(node: LexicalNode): number { } function getComposerNodeExpandedTextLength(node: LexicalNode): number { + if (isComposerInlineTokenNode(node)) { + return getComposerInlineTokenExpandedTextLength(node); + } if ($isTextNode(node)) { return node.getTextContentSize(); } @@ -233,10 +392,13 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb if ($isTextNode(node)) { if (node instanceof ComposerMentionNode) { - return offset + (pointOffset > 0 ? 1 : 0); + return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } + if (node instanceof ComposerTerminalContextNode) { + return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); + } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); @@ -277,10 +439,13 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe if ($isTextNode(node)) { if (node instanceof ComposerMentionNode) { - return offset + (pointOffset > 0 ? node.getTextContentSize() : 0); + return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } return offset + Math.min(pointOffset, node.getTextContentSize()); } + if (node instanceof ComposerTerminalContextNode) { + return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); + } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); @@ -305,25 +470,10 @@ function findSelectionPointAtOffset( remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { if (node instanceof ComposerMentionNode) { - const parent = node.getParent(); - if (!parent || !$isElementNode(parent)) return null; - const index = node.getIndexWithinParent(); - if (remainingRef.value === 0) { - return { - key: parent.getKey(), - offset: index, - type: "element", - }; - } - if (remainingRef.value === 1) { - return { - key: parent.getKey(), - offset: index + 1, - type: "element", - }; - } - remainingRef.value -= 1; - return null; + return findSelectionPointForInlineToken(node, remainingRef); + } + if (node instanceof ComposerTerminalContextNode) { + return findSelectionPointForInlineToken(node, remainingRef); } if ($isTextNode(node)) { @@ -438,40 +588,67 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { } } -function $setComposerEditorPrompt(prompt: string): void { +function $setComposerEditorPrompt( + prompt: string, + terminalContexts: ReadonlyArray, +): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); - const segments = splitPromptIntoComposerSegments(prompt); + const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path)); continue; } + if (segment.type === "terminal-context") { + if (segment.context) { + paragraph.append($createComposerTerminalContextNode(segment.context)); + } + continue; + } $appendTextWithLineBreaks(paragraph, segment.text); } } +function collectTerminalContextIds(node: LexicalNode): string[] { + if (node instanceof ComposerTerminalContextNode) { + return [node.__context.id]; + } + if ($isElementNode(node)) { + return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); + } + return []; +} + export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; - readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; + readSnapshot: () => { + value: string; + cursor: number; + expandedCursor: number; + terminalContextIds: string[]; + }; } interface ComposerPromptEditorProps { value: string; cursor: number; + terminalContexts: ReadonlyArray; disabled: boolean; placeholder: string; className?: string; + onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, + terminalContextIds: string[], ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -540,7 +717,7 @@ function ComposerCommandKeyPlugin(props: { return null; } -function ComposerMentionArrowPlugin() { +function ComposerInlineTokenArrowPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -554,7 +731,7 @@ function ComposerMentionArrowPlugin() { const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) { + if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { return; } nextOffset = currentOffset - 1; @@ -581,7 +758,7 @@ function ComposerMentionArrowPlugin() { const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); - if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) { + if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { return; } nextOffset = currentOffset + 1; @@ -606,7 +783,7 @@ function ComposerMentionArrowPlugin() { return null; } -function ComposerMentionSelectionNormalizePlugin() { +function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { @@ -616,7 +793,7 @@ function ComposerMentionSelectionNormalizePlugin() { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const anchorNode = selection.anchor.getNode(); - if (!(anchorNode instanceof ComposerMentionNode)) return; + if (!isComposerInlineTokenNode(anchorNode)) return; if (selection.anchor.offset === 0) return; const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); afterOffset = beforeOffset + 1; @@ -634,8 +811,9 @@ function ComposerMentionSelectionNormalizePlugin() { return null; } -function ComposerMentionBackspacePlugin() { +function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); + const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( @@ -647,18 +825,23 @@ function ComposerMentionBackspacePlugin() { } const anchorNode = selection.anchor.getNode(); - const removeMentionNode = (candidate: unknown): boolean => { - if (!(candidate instanceof ComposerMentionNode)) { + const selectionOffset = $readSelectionOffsetFromEditorState(0); + const removeInlineTokenNode = (candidate: unknown): boolean => { + if (!isComposerInlineTokenNode(candidate)) { return false; } - const mentionStart = getAbsoluteOffsetForPoint(candidate, 0); + const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); candidate.remove(); - $setSelectionAtComposerOffset(mentionStart); + if (candidate instanceof ComposerTerminalContextNode) { + onRemoveTerminalContext(candidate.__context.id); + $setSelectionAtComposerOffset(selectionOffset); + } else { + $setSelectionAtComposerOffset(tokenStart); + } event?.preventDefault(); return true; }; - - if (removeMentionNode(anchorNode)) { + if (removeInlineTokenNode(anchorNode)) { return true; } @@ -666,13 +849,13 @@ function ComposerMentionBackspacePlugin() { if (selection.anchor.offset > 0) { return false; } - if (removeMentionNode(anchorNode.getPreviousSibling())) { + if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { return true; } const parent = anchorNode.getParent(); if ($isElementNode(parent)) { const index = anchorNode.getIndexWithinParent(); - if (index > 0 && removeMentionNode(parent.getChildAtIndex(index - 1))) { + if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { return true; } } @@ -681,7 +864,7 @@ function ComposerMentionBackspacePlugin() { if ($isElementNode(anchorNode)) { const childIndex = selection.anchor.offset - 1; - if (childIndex >= 0 && removeMentionNode(anchorNode.getChildAtIndex(childIndex))) { + if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { return true; } } @@ -690,7 +873,7 @@ function ComposerMentionBackspacePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor]); + }, [editor, onRemoveTerminalContext]); return null; } @@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() { function ComposerPromptEditorInner({ value, cursor, + terminalContexts, disabled, placeholder, className, + onRemoveTerminalContext, onChange, onCommandKeyDown, onPaste, @@ -709,12 +894,19 @@ function ComposerPromptEditorInner({ const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); + const terminalContextsSignature = terminalContextSignature(terminalContexts); + const terminalContextsSignatureRef = useRef(terminalContextsSignature); const snapshotRef = useRef({ value, cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); + const terminalContextActions = useMemo( + () => ({ onRemoveTerminalContext }), + [onRemoveTerminalContext], + ); useEffect(() => { onChangeRef.current = onChange; @@ -727,7 +919,12 @@ function ComposerPromptEditorInner({ useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; - if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { + const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; + if ( + previousSnapshot.value === value && + previousSnapshot.cursor === normalizedCursor && + !contextsChanged + ) { return; } @@ -735,28 +932,30 @@ function ComposerPromptEditorInner({ value, cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + terminalContextIds: terminalContexts.map((context) => context.id), }; + terminalContextsSignatureRef.current = terminalContextsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (previousSnapshot.value === value && !isFocused) { + if (previousSnapshot.value === value && !contextsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; editor.update(() => { - const valueChanged = previousSnapshot.value !== value; - if (previousSnapshot.value !== value) { - $setComposerEditorPrompt(value); + const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; + if (shouldRewriteEditorState) { + $setComposerEditorPrompt(value, terminalContexts); } - if (valueChanged || isFocused) { + if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); } }); queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); - }, [cursor, editor, value]); + }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { @@ -771,12 +970,14 @@ function ComposerPromptEditorInner({ value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), + terminalContextIds: snapshotRef.current.terminalContextIds, }; onChangeRef.current( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, false, + snapshotRef.current.terminalContextIds, ); }, [editor], @@ -786,6 +987,7 @@ function ComposerPromptEditorInner({ value: string; cursor: number; expandedCursor: number; + terminalContextIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -803,10 +1005,12 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); + const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, + terminalContextIds, }; }); snapshotRef.current = snapshot; @@ -849,11 +1053,14 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); + const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && - previousSnapshot.expandedCursor === nextExpandedCursor + previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.terminalContextIds.length === terminalContextIds.length && + previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ) { return; } @@ -864,43 +1071,54 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, + terminalContextIds, }; const cursorAdjacentToMention = - isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || - isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); + onChangeRef.current( + nextValue, + nextCursor, + nextExpandedCursor, + cursorAdjacentToMention, + terminalContextIds, + ); }); }, []); return ( -
- } - onPaste={onPaste} - /> - } - placeholder={ -
- {placeholder} -
- } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - -
+ +
+ } + onPaste={onPaste} + /> + } + placeholder={ + terminalContexts.length > 0 ? null : ( +
+ {placeholder} +
+ ) + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + +
+
); } @@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef< ComposerPromptEditorHandle, ComposerPromptEditorProps >(function ComposerPromptEditor( - { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste }, + { + value, + cursor, + terminalContexts, + disabled, + placeholder, + className, + onRemoveTerminalContext, + onChange, + onCommandKeyDown, + onPaste, + }, ref, ) { const initialValueRef = useRef(value); + const initialTerminalContextsRef = useRef(terminalContexts); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode], + nodes: [ComposerMentionNode, ComposerTerminalContextNode], editorState: () => { - $setComposerEditorPrompt(initialValueRef.current); + $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); }, onError: (error) => { throw error; @@ -932,8 +1162,10 @@ export const ComposerPromptEditor = forwardRef< { + it("prefers the selection rect over the last pointer position", () => { + expect( + resolveTerminalSelectionActionPosition({ + bounds: { left: 100, top: 50, width: 500, height: 220 }, + selectionRect: { right: 260, bottom: 140 }, + pointer: { x: 520, y: 200 }, + viewport: { width: 1024, height: 768 }, + }), + ).toEqual({ + x: 260, + y: 144, + }); + }); + + it("falls back to the pointer position when no selection rect is available", () => { + expect( + resolveTerminalSelectionActionPosition({ + bounds: { left: 100, top: 50, width: 500, height: 220 }, + selectionRect: null, + pointer: { x: 180, y: 130 }, + viewport: { width: 1024, height: 768 }, + }), + ).toEqual({ + x: 180, + y: 130, + }); + }); + + it("clamps the pointer fallback into the terminal drawer bounds", () => { + expect( + resolveTerminalSelectionActionPosition({ + bounds: { left: 100, top: 50, width: 500, height: 220 }, + selectionRect: null, + pointer: { x: 720, y: 340 }, + viewport: { width: 1024, height: 768 }, + }), + ).toEqual({ + x: 600, + y: 270, + }); + + expect( + resolveTerminalSelectionActionPosition({ + bounds: { left: 100, top: 50, width: 500, height: 220 }, + selectionRect: null, + pointer: { x: 40, y: 20 }, + viewport: { width: 1024, height: 768 }, + }), + ).toEqual({ + x: 100, + y: 50, + }); + }); + + it("delays multi-click selection actions so triple-click selection can complete", () => { + expect(terminalSelectionActionDelayForClickCount(1)).toBe(0); + expect(terminalSelectionActionDelayForClickCount(2)).toBe(260); + expect(terminalSelectionActionDelayForClickCount(3)).toBe(260); + }); + + it("only handles mouseup when the selection gesture started in the terminal", () => { + expect(shouldHandleTerminalSelectionMouseUp(true, 0)).toBe(true); + expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); + expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8e480715f5..1bdbfb6ad6 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,6 +12,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { type TerminalContextSelection } from "~/lib/terminalContext"; import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, @@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; +const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -107,12 +109,85 @@ function terminalThemeFromApp(): ITheme { }; } +function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + + const range = selection.getRangeAt(0); + const commonAncestor = range.commonAncestorContainer; + const selectionRoot = + commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; + if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { + return null; + } + + const rects = Array.from(range.getClientRects()).filter( + (rect) => rect.width > 0 || rect.height > 0, + ); + if (rects.length > 0) { + return rects[rects.length - 1] ?? null; + } + + const boundingRect = range.getBoundingClientRect(); + return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; +} + +export function resolveTerminalSelectionActionPosition(options: { + bounds: { left: number; top: number; width: number; height: number }; + selectionRect: { right: number; bottom: number } | null; + pointer: { x: number; y: number } | null; + viewport?: { width: number; height: number } | null; +}): { x: number; y: number } { + const { bounds, selectionRect, pointer, viewport } = options; + const viewportWidth = + viewport?.width ?? + (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth); + const viewportHeight = + viewport?.height ?? + (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight); + const drawerLeft = Math.round(bounds.left); + const drawerTop = Math.round(bounds.top); + const drawerRight = Math.round(bounds.left + bounds.width); + const drawerBottom = Math.round(bounds.top + bounds.height); + const preferredX = + selectionRect !== null + ? Math.round(selectionRect.right) + : pointer === null + ? Math.round(bounds.left + bounds.width - 140) + : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight)); + const preferredY = + selectionRect !== null + ? Math.round(selectionRect.bottom + 4) + : pointer === null + ? Math.round(bounds.top + 12) + : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom)); + return { + x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))), + y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))), + }; +} + +export function terminalSelectionActionDelayForClickCount(clickCount: number): number { + return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0; +} + +export function shouldHandleTerminalSelectionMouseUp( + selectionGestureActive: boolean, + button: number, +): boolean { + return selectionGestureActive && button === 0; +} + interface TerminalViewportProps { threadId: ThreadId; terminalId: string; + terminalLabel: string; cwd: string; runtimeEnv?: Record; onSessionExited: () => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -122,9 +197,11 @@ interface TerminalViewportProps { function TerminalViewport({ threadId, terminalId, + terminalLabel, cwd, runtimeEnv, onSessionExited, + onAddTerminalContext, focusRequestId, autoFocus, resizeEpoch, @@ -134,12 +211,27 @@ function TerminalViewport({ const terminalRef = useRef(null); const fitAddonRef = useRef(null); const onSessionExitedRef = useRef(onSessionExited); + const onAddTerminalContextRef = useRef(onAddTerminalContext); + const terminalLabelRef = useRef(terminalLabel); const hasHandledExitRef = useRef(false); + const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); + const selectionGestureActiveRef = useRef(false); + const selectionActionRequestIdRef = useRef(0); + const selectionActionOpenRef = useRef(false); + const selectionActionTimerRef = useRef(null); useEffect(() => { onSessionExitedRef.current = onSessionExited; }, [onSessionExited]); + useEffect(() => { + onAddTerminalContextRef.current = onAddTerminalContext; + }, [onAddTerminalContext]); + + useEffect(() => { + terminalLabelRef.current = terminalLabel; + }, [terminalLabel]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -165,6 +257,81 @@ function TerminalViewport({ const api = readNativeApi(); if (!api) return; + const clearSelectionAction = () => { + selectionActionRequestIdRef.current += 1; + if (selectionActionTimerRef.current !== null) { + window.clearTimeout(selectionActionTimerRef.current); + selectionActionTimerRef.current = null; + } + }; + + const readSelectionAction = (): { + position: { x: number; y: number }; + selection: TerminalContextSelection; + } | null => { + const activeTerminal = terminalRef.current; + const mountElement = containerRef.current; + if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { + return null; + } + const selectionText = activeTerminal.getSelection(); + const selectionPosition = activeTerminal.getSelectionPosition(); + const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); + if (!selectionPosition || normalizedText.length === 0) { + return null; + } + const lineStart = selectionPosition.start.y + 1; + const lineCount = normalizedText.split("\n").length; + const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); + const bounds = mountElement.getBoundingClientRect(); + const selectionRect = getTerminalSelectionRect(mountElement); + const position = resolveTerminalSelectionActionPosition({ + bounds, + selectionRect: + selectionRect === null + ? null + : { right: selectionRect.right, bottom: selectionRect.bottom }, + pointer: selectionPointerRef.current, + }); + return { + position, + selection: { + terminalId, + terminalLabel: terminalLabelRef.current, + lineStart, + lineEnd, + text: normalizedText, + }, + }; + }; + + const showSelectionAction = async () => { + if (selectionActionOpenRef.current) { + return; + } + const nextAction = readSelectionAction(); + if (!nextAction) { + clearSelectionAction(); + return; + } + const requestId = ++selectionActionRequestIdRef.current; + selectionActionOpenRef.current = true; + try { + const clicked = await api.contextMenu.show( + [{ id: "add-to-chat", label: "Add to chat" }], + nextAction.position, + ); + if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { + return; + } + onAddTerminalContextRef.current(nextAction.selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + } finally { + selectionActionOpenRef.current = false; + } + }; + const sendTerminalInput = async (data: string, fallbackError: string) => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -259,6 +426,38 @@ function TerminalViewport({ ); }); + const selectionDisposable = terminal.onSelectionChange(() => { + if (terminalRef.current?.hasSelection()) { + return; + } + clearSelectionAction(); + }); + + const handleMouseUp = (event: MouseEvent) => { + const shouldHandle = shouldHandleTerminalSelectionMouseUp( + selectionGestureActiveRef.current, + event.button, + ); + selectionGestureActiveRef.current = false; + if (!shouldHandle) { + return; + } + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; + const delay = terminalSelectionActionDelayForClickCount(event.detail); + selectionActionTimerRef.current = window.setTimeout(() => { + selectionActionTimerRef.current = null; + window.requestAnimationFrame(() => { + void showSelectionAction(); + }); + }, delay); + }; + const handlePointerDown = (event: PointerEvent) => { + clearSelectionAction(); + selectionGestureActiveRef.current = event.button === 0; + }; + window.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); + const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -310,11 +509,13 @@ function TerminalViewport({ if (event.type === "output") { activeTerminal.write(event.data); + clearSelectionAction(); return; } if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; + clearSelectionAction(); activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); @@ -323,6 +524,7 @@ function TerminalViewport({ } if (event.type === "cleared") { + clearSelectionAction(); activeTerminal.clear(); activeTerminal.write("\u001bc"); return; @@ -383,7 +585,13 @@ function TerminalViewport({ window.clearTimeout(fitTimer); unsubscribe(); inputDisposable.dispose(); + selectionDisposable.dispose(); terminalLinksDisposable.dispose(); + if (selectionActionTimerRef.current !== null) { + window.clearTimeout(selectionActionTimerRef.current); + } + window.removeEventListener("mouseup", handleMouseUp); + mount.removeEventListener("pointerdown", handlePointerDown); themeObserver.disconnect(); terminalRef.current = null; fitAddonRef.current = null; @@ -430,7 +638,9 @@ function TerminalViewport({ window.cancelAnimationFrame(frame); }; }, [drawerHeight, resizeEpoch, terminalId, threadId]); - return
; + return ( +
+ ); } interface ThreadTerminalDrawerProps { @@ -451,6 +661,7 @@ interface ThreadTerminalDrawerProps { onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; onHeightChange: (height: number) => void; + onAddTerminalContext: (selection: TerminalContextSelection) => void; } interface TerminalActionButtonProps { @@ -500,6 +711,7 @@ export default function ThreadTerminalDrawer({ onActiveTerminalChange, onCloseTerminal, onHeightChange, + onAddTerminalContext, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -796,9 +1008,11 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -814,9 +1028,11 @@ export default function ThreadTerminalDrawer({ key={resolvedActiveTerminalId} threadId={threadId} terminalId={resolvedActiveTerminalId} + terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} + onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch} diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx new file mode 100644 index 0000000000..060f197e97 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx @@ -0,0 +1,28 @@ +import { ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts"; + +describe("ComposerPendingTerminalContextChip", () => { + it("renders expired terminal contexts with error styling", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-terminal-context-expired="true"'); + expect(markup).toContain("border-destructive/35"); + expect(markup).toContain("Terminal 1 lines 2-4"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx new file mode 100644 index 0000000000..37c05eab2d --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx @@ -0,0 +1,44 @@ +import { cn } from "~/lib/utils"; +import { + type TerminalContextDraft, + formatTerminalContextLabel, + isTerminalContextExpired, +} from "~/lib/terminalContext"; +import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; + +interface ComposerPendingTerminalContextsProps { + contexts: ReadonlyArray; + className?: string; +} + +interface ComposerPendingTerminalContextChipProps { + context: TerminalContextDraft; +} + +export function ComposerPendingTerminalContextChip({ + context, +}: ComposerPendingTerminalContextChipProps) { + const label = formatTerminalContextLabel(context); + const expired = isTerminalContextExpired(context); + const tooltipText = expired + ? `Terminal context expired. Remove and re-add ${label} to include it in your message.` + : context.text; + + return ; +} + +export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { + const { contexts, className } = props; + + if (contexts.length === 0) { + return null; + } + + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx new file mode 100644 index 0000000000..e694faa0f2 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -0,0 +1,99 @@ +import { MessageId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +function matchMedia() { + return { + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }; +} + +beforeAll(() => { + const classList = { + add: () => {}, + remove: () => {}, + toggle: () => {}, + contains: () => false, + }; + + vi.stubGlobal("localStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }); + vi.stubGlobal("window", { + matchMedia, + addEventListener: () => {}, + removeEventListener: () => {}, + desktopBridge: undefined, + }); + vi.stubGlobal("document", { + documentElement: { + classList, + offsetHeight: 0, + }, + }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); +}); + +describe("MessagesTimeline", () => { + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + completionDividerBeforeEntryId={null} + completionSummary={null} + turnDiffSummaryByAssistantMessageId={new Map()} + nowIso="2026-03-17T19:12:30.000Z" + expandedWorkGroups={{}} + onToggleWorkGroup={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain("Terminal 1 lines 1-5"); + expect(markup).toContain("lucide-terminal"); + expect(markup).toContain("yoo what's "); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e30801041f..f3e462f7fe 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,5 +1,14 @@ import { type MessageId, type TurnId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { measureElement as measureVirtualElement, type VirtualItem, @@ -33,9 +42,19 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { + deriveDisplayedUserMessageState, + type ParsedTerminalContextEntry, +} from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; +import { + buildInlineTerminalContextText, + formatInlineTerminalContextLabel, + textContainsInlineTerminalContextLabels, +} from "./userMessageTerminalContexts"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -337,6 +356,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "user" && (() => { const userImages = row.message.attachments ?? []; + const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); + const terminalContexts = displayedUserMessage.contexts; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
@@ -378,14 +399,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}
)} - {row.message.text && ( -
-                    {row.message.text}
-                  
+ {(displayedUserMessage.visibleText.trim().length > 0 || + terminalContexts.length > 0) && ( + )}
- {row.message.text && } + {displayedUserMessage.copyText && ( + + )} {canRevertAgentWork && (