| author | |
| committer | |
| log | 1310045629f6613b2fd3e9b82e2e371495e0ca2a |
| tree | f7484c65f9ed21539c5815ab3446dad6c7a6ed9d |
| parent | 579a69eb7756b2169d26299d36f936838db39caa |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>27 files changed, 2870 insertions(+), 216 deletions(-)
.gitignore+1| ... | ... | @@ -17,3 +17,4 @@ release/ |
| 17 | 17 | apps/web/.playwright |
| 18 | 18 | apps/web/playwright-report |
| 19 | 19 | apps/web/src/components/__screenshots__ |
| 20 | .vitest-* | |
| \ No newline at end of file |
apps/web/src/components/ChatView.browser.tsx+190| ... | ... | @@ -21,6 +21,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } |
| 21 | 21 | import { render } from "vitest-browser-react"; |
| 22 | 22 | |
| 23 | 23 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { | |
| 25 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 26 | type TerminalContextDraft, | |
| 27 | } from "../lib/terminalContext"; | |
| 24 | 28 | import { isMacPlatform } from "../lib/utils"; |
| 25 | 29 | import { getRouter } from "../router"; |
| 26 | 30 | import { useStore } from "../store"; |
| ... | ... | @@ -150,6 +154,25 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe |
| 150 | 154 | }; |
| 151 | 155 | } |
| 152 | 156 | |
| 157 | function createTerminalContext(input: { | |
| 158 | id: string; | |
| 159 | terminalLabel: string; | |
| 160 | lineStart: number; | |
| 161 | lineEnd: number; | |
| 162 | text: string; | |
| 163 | }): TerminalContextDraft { | |
| 164 | return { | |
| 165 | id: input.id, | |
| 166 | threadId: THREAD_ID, | |
| 167 | terminalId: `terminal-${input.id}`, | |
| 168 | terminalLabel: input.terminalLabel, | |
| 169 | lineStart: input.lineStart, | |
| 170 | lineEnd: input.lineEnd, | |
| 171 | text: input.text, | |
| 172 | createdAt: NOW_ISO, | |
| 173 | }; | |
| 174 | } | |
| 175 | ||
| 153 | 176 | function createSnapshotForTargetUser(options: { |
| 154 | 177 | targetMessageId: MessageId; |
| 155 | 178 | targetText: string; |
| ... | ... | @@ -531,6 +554,13 @@ async function waitForComposerEditor(): Promise<HTMLElement> { |
| 531 | 554 | ); |
| 532 | 555 | } |
| 533 | 556 | |
| 557 | async function waitForSendButton(): Promise<HTMLButtonElement> { | |
| 558 | return waitForElement( | |
| 559 | () => document.querySelector<HTMLButtonElement>('button[aria-label="Send message"]'), | |
| 560 | "Unable to find send button.", | |
| 561 | ); | |
| 562 | } | |
| 563 | ||
| 534 | 564 | async function waitForInteractionModeButton( |
| 535 | 565 | expectedLabel: "Chat" | "Plan", |
| 536 | 566 | ): Promise<HTMLButtonElement> { |
| ... | ... | @@ -1011,6 +1041,166 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1011 | 1041 | } |
| 1012 | 1042 | }); |
| 1013 | 1043 | |
| 1044 | it("keeps backspaced terminal context pills removed when a new one is added", async () => { | |
| 1045 | const removedLabel = "Terminal 1 lines 1-2"; | |
| 1046 | const addedLabel = "Terminal 2 lines 9-10"; | |
| 1047 | useComposerDraftStore.getState().addTerminalContext( | |
| 1048 | THREAD_ID, | |
| 1049 | createTerminalContext({ | |
| 1050 | id: "ctx-removed", | |
| 1051 | terminalLabel: "Terminal 1", | |
| 1052 | lineStart: 1, | |
| 1053 | lineEnd: 2, | |
| 1054 | text: "bun i\nno changes", | |
| 1055 | }), | |
| 1056 | ); | |
| 1057 | ||
| 1058 | const mounted = await mountChatView({ | |
| 1059 | viewport: DEFAULT_VIEWPORT, | |
| 1060 | snapshot: createSnapshotForTargetUser({ | |
| 1061 | targetMessageId: "msg-user-terminal-pill-backspace" as MessageId, | |
| 1062 | targetText: "terminal pill backspace target", | |
| 1063 | }), | |
| 1064 | }); | |
| 1065 | ||
| 1066 | try { | |
| 1067 | await vi.waitFor( | |
| 1068 | () => { | |
| 1069 | expect(document.body.textContent).toContain(removedLabel); | |
| 1070 | }, | |
| 1071 | { timeout: 8_000, interval: 16 }, | |
| 1072 | ); | |
| 1073 | ||
| 1074 | const composerEditor = await waitForComposerEditor(); | |
| 1075 | composerEditor.focus(); | |
| 1076 | composerEditor.dispatchEvent( | |
| 1077 | new KeyboardEvent("keydown", { | |
| 1078 | key: "Backspace", | |
| 1079 | bubbles: true, | |
| 1080 | cancelable: true, | |
| 1081 | }), | |
| 1082 | ); | |
| 1083 | ||
| 1084 | await vi.waitFor( | |
| 1085 | () => { | |
| 1086 | expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); | |
| 1087 | expect(document.body.textContent).not.toContain(removedLabel); | |
| 1088 | }, | |
| 1089 | { timeout: 8_000, interval: 16 }, | |
| 1090 | ); | |
| 1091 | ||
| 1092 | useComposerDraftStore.getState().addTerminalContext( | |
| 1093 | THREAD_ID, | |
| 1094 | createTerminalContext({ | |
| 1095 | id: "ctx-added", | |
| 1096 | terminalLabel: "Terminal 2", | |
| 1097 | lineStart: 9, | |
| 1098 | lineEnd: 10, | |
| 1099 | text: "git status\nOn branch main", | |
| 1100 | }), | |
| 1101 | ); | |
| 1102 | ||
| 1103 | await vi.waitFor( | |
| 1104 | () => { | |
| 1105 | const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; | |
| 1106 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); | |
| 1107 | expect(document.body.textContent).toContain(addedLabel); | |
| 1108 | expect(document.body.textContent).not.toContain(removedLabel); | |
| 1109 | }, | |
| 1110 | { timeout: 8_000, interval: 16 }, | |
| 1111 | ); | |
| 1112 | } finally { | |
| 1113 | await mounted.cleanup(); | |
| 1114 | } | |
| 1115 | }); | |
| 1116 | ||
| 1117 | it("disables send when the composer only contains an expired terminal pill", async () => { | |
| 1118 | const expiredLabel = "Terminal 1 line 4"; | |
| 1119 | useComposerDraftStore.getState().addTerminalContext( | |
| 1120 | THREAD_ID, | |
| 1121 | createTerminalContext({ | |
| 1122 | id: "ctx-expired-only", | |
| 1123 | terminalLabel: "Terminal 1", | |
| 1124 | lineStart: 4, | |
| 1125 | lineEnd: 4, | |
| 1126 | text: "", | |
| 1127 | }), | |
| 1128 | ); | |
| 1129 | ||
| 1130 | const mounted = await mountChatView({ | |
| 1131 | viewport: DEFAULT_VIEWPORT, | |
| 1132 | snapshot: createSnapshotForTargetUser({ | |
| 1133 | targetMessageId: "msg-user-expired-pill-disabled" as MessageId, | |
| 1134 | targetText: "expired pill disabled target", | |
| 1135 | }), | |
| 1136 | }); | |
| 1137 | ||
| 1138 | try { | |
| 1139 | await vi.waitFor( | |
| 1140 | () => { | |
| 1141 | expect(document.body.textContent).toContain(expiredLabel); | |
| 1142 | }, | |
| 1143 | { timeout: 8_000, interval: 16 }, | |
| 1144 | ); | |
| 1145 | ||
| 1146 | const sendButton = await waitForSendButton(); | |
| 1147 | expect(sendButton.disabled).toBe(true); | |
| 1148 | } finally { | |
| 1149 | await mounted.cleanup(); | |
| 1150 | } | |
| 1151 | }); | |
| 1152 | ||
| 1153 | it("warns when sending text while omitting expired terminal pills", async () => { | |
| 1154 | const expiredLabel = "Terminal 1 line 4"; | |
| 1155 | useComposerDraftStore.getState().addTerminalContext( | |
| 1156 | THREAD_ID, | |
| 1157 | createTerminalContext({ | |
| 1158 | id: "ctx-expired-send-warning", | |
| 1159 | terminalLabel: "Terminal 1", | |
| 1160 | lineStart: 4, | |
| 1161 | lineEnd: 4, | |
| 1162 | text: "", | |
| 1163 | }), | |
| 1164 | ); | |
| 1165 | useComposerDraftStore | |
| 1166 | .getState() | |
| 1167 | .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); | |
| 1168 | ||
| 1169 | const mounted = await mountChatView({ | |
| 1170 | viewport: DEFAULT_VIEWPORT, | |
| 1171 | snapshot: createSnapshotForTargetUser({ | |
| 1172 | targetMessageId: "msg-user-expired-pill-warning" as MessageId, | |
| 1173 | targetText: "expired pill warning target", | |
| 1174 | }), | |
| 1175 | }); | |
| 1176 | ||
| 1177 | try { | |
| 1178 | await vi.waitFor( | |
| 1179 | () => { | |
| 1180 | expect(document.body.textContent).toContain(expiredLabel); | |
| 1181 | }, | |
| 1182 | { timeout: 8_000, interval: 16 }, | |
| 1183 | ); | |
| 1184 | ||
| 1185 | const sendButton = await waitForSendButton(); | |
| 1186 | expect(sendButton.disabled).toBe(false); | |
| 1187 | sendButton.click(); | |
| 1188 | ||
| 1189 | await vi.waitFor( | |
| 1190 | () => { | |
| 1191 | expect(document.body.textContent).toContain( | |
| 1192 | "Expired terminal context omitted from message", | |
| 1193 | ); | |
| 1194 | expect(document.body.textContent).not.toContain(expiredLabel); | |
| 1195 | expect(document.body.textContent).toContain("yoowaddup"); | |
| 1196 | }, | |
| 1197 | { timeout: 8_000, interval: 16 }, | |
| 1198 | ); | |
| 1199 | } finally { | |
| 1200 | await mounted.cleanup(); | |
| 1201 | } | |
| 1202 | }); | |
| 1203 | ||
| 1014 | 1204 | it("shows a pointer cursor for the running stop button", async () => { |
| 1015 | 1205 | const mounted = await mountChatView({ |
| 1016 | 1206 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.logic.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; | |
| 5 | ||
| 6 | describe("deriveComposerSendState", () => { | |
| 7 | it("treats expired terminal pills as non-sendable content", () => { | |
| 8 | const state = deriveComposerSendState({ | |
| 9 | prompt: "\uFFFC", | |
| 10 | imageCount: 0, | |
| 11 | terminalContexts: [ | |
| 12 | { | |
| 13 | id: "ctx-expired", | |
| 14 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 15 | terminalId: "default", | |
| 16 | terminalLabel: "Terminal 1", | |
| 17 | lineStart: 4, | |
| 18 | lineEnd: 4, | |
| 19 | text: "", | |
| 20 | createdAt: "2026-03-17T12:52:29.000Z", | |
| 21 | }, | |
| 22 | ], | |
| 23 | }); | |
| 24 | ||
| 25 | expect(state.trimmedPrompt).toBe(""); | |
| 26 | expect(state.sendableTerminalContexts).toEqual([]); | |
| 27 | expect(state.expiredTerminalContextCount).toBe(1); | |
| 28 | expect(state.hasSendableContent).toBe(false); | |
| 29 | }); | |
| 30 | ||
| 31 | it("keeps text sendable while excluding expired terminal pills", () => { | |
| 32 | const state = deriveComposerSendState({ | |
| 33 | prompt: `yoo \uFFFC waddup`, | |
| 34 | imageCount: 0, | |
| 35 | terminalContexts: [ | |
| 36 | { | |
| 37 | id: "ctx-expired", | |
| 38 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 39 | terminalId: "default", | |
| 40 | terminalLabel: "Terminal 1", | |
| 41 | lineStart: 4, | |
| 42 | lineEnd: 4, | |
| 43 | text: "", | |
| 44 | createdAt: "2026-03-17T12:52:29.000Z", | |
| 45 | }, | |
| 46 | ], | |
| 47 | }); | |
| 48 | ||
| 49 | expect(state.trimmedPrompt).toBe("yoo waddup"); | |
| 50 | expect(state.expiredTerminalContextCount).toBe(1); | |
| 51 | expect(state.hasSendableContent).toBe(true); | |
| 52 | }); | |
| 53 | }); | |
| 54 | ||
| 55 | describe("buildExpiredTerminalContextToastCopy", () => { | |
| 56 | it("formats clear empty-state guidance", () => { | |
| 57 | expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ | |
| 58 | title: "Expired terminal context won't be sent", | |
| 59 | description: "Remove it or re-add it to include terminal output.", | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | it("formats omission guidance for sent messages", () => { | |
| 64 | expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ | |
| 65 | title: "Expired terminal contexts omitted from message", | |
| 66 | description: "Re-add it if you want that terminal output included.", | |
| 67 | }); | |
| 68 | }); | |
| 69 | }); |
apps/web/src/components/ChatView.logic.ts+46| ... | ... | @@ -4,6 +4,11 @@ import { randomUUID } from "~/lib/utils"; |
| 4 | 4 | import { getAppModelOptions } from "../appSettings"; |
| 5 | 5 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | import { | |
| 8 | filterTerminalContextsWithText, | |
| 9 | stripInlineTerminalContextPlaceholders, | |
| 10 | type TerminalContextDraft, | |
| 11 | } from "../lib/terminalContext"; | |
| 7 | 12 | |
| 8 | 13 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 9 | 14 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| ... | ... | @@ -123,3 +128,44 @@ export function getCustomModelOptionsByProvider(settings: { |
| 123 | 128 | codex: getAppModelOptions("codex", settings.customCodexModels), |
| 124 | 129 | }; |
| 125 | 130 | } |
| 131 | ||
| 132 | export function deriveComposerSendState(options: { | |
| 133 | prompt: string; | |
| 134 | imageCount: number; | |
| 135 | terminalContexts: ReadonlyArray<TerminalContextDraft>; | |
| 136 | }): { | |
| 137 | trimmedPrompt: string; | |
| 138 | sendableTerminalContexts: TerminalContextDraft[]; | |
| 139 | expiredTerminalContextCount: number; | |
| 140 | hasSendableContent: boolean; | |
| 141 | } { | |
| 142 | const trimmedPrompt = stripInlineTerminalContextPlaceholders(options.prompt).trim(); | |
| 143 | const sendableTerminalContexts = filterTerminalContextsWithText(options.terminalContexts); | |
| 144 | const expiredTerminalContextCount = | |
| 145 | options.terminalContexts.length - sendableTerminalContexts.length; | |
| 146 | return { | |
| 147 | trimmedPrompt, | |
| 148 | sendableTerminalContexts, | |
| 149 | expiredTerminalContextCount, | |
| 150 | hasSendableContent: | |
| 151 | trimmedPrompt.length > 0 || options.imageCount > 0 || sendableTerminalContexts.length > 0, | |
| 152 | }; | |
| 153 | } | |
| 154 | ||
| 155 | export function buildExpiredTerminalContextToastCopy( | |
| 156 | expiredTerminalContextCount: number, | |
| 157 | variant: "omitted" | "empty", | |
| 158 | ): { title: string; description: string } { | |
| 159 | const count = Math.max(1, Math.floor(expiredTerminalContextCount)); | |
| 160 | const noun = count === 1 ? "Expired terminal context" : "Expired terminal contexts"; | |
| 161 | if (variant === "empty") { | |
| 162 | return { | |
| 163 | title: `${noun} won't be sent`, | |
| 164 | description: "Remove it or re-add it to include terminal output.", | |
| 165 | }; | |
| 166 | } | |
| 167 | return { | |
| 168 | title: `${noun} omitted from message`, | |
| 169 | description: "Re-add it if you want that terminal output included.", | |
| 170 | }; | |
| 171 | } |
apps/web/src/components/ChatView.tsx+270-82| ... | ... | @@ -127,6 +127,14 @@ import { |
| 127 | 127 | useComposerDraftStore, |
| 128 | 128 | useComposerThreadDraft, |
| 129 | 129 | } from "../composerDraftStore"; |
| 130 | import { | |
| 131 | appendTerminalContextsToPrompt, | |
| 132 | formatTerminalContextLabel, | |
| 133 | insertInlineTerminalContextPlaceholder, | |
| 134 | removeInlineTerminalContextPlaceholder, | |
| 135 | type TerminalContextDraft, | |
| 136 | type TerminalContextSelection, | |
| 137 | } from "../lib/terminalContext"; | |
| 130 | 138 | import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; |
| 131 | 139 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 132 | 140 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; |
| ... | ... | @@ -145,10 +153,12 @@ import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; |
| 145 | 153 | import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; |
| 146 | 154 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 147 | 155 | import { |
| 156 | buildExpiredTerminalContextToastCopy, | |
| 148 | 157 | buildLocalDraftThread, |
| 149 | 158 | buildTemporaryWorktreeBranchName, |
| 150 | 159 | cloneComposerImageForRetry, |
| 151 | 160 | collectUserMessageBlobPreviewUrls, |
| 161 | deriveComposerSendState, | |
| 152 | 162 | getCustomModelOptionsByProvider, |
| 153 | 163 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| 154 | 164 | LastInvokedScriptByProjectSchema, |
| ... | ... | @@ -185,6 +195,23 @@ const extendReplacementRangeForTrailingSpace = ( |
| 185 | 195 | return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; |
| 186 | 196 | }; |
| 187 | 197 | |
| 198 | const syncTerminalContextsByIds = ( | |
| 199 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 200 | ids: ReadonlyArray<string>, | |
| 201 | ): TerminalContextDraft[] => { | |
| 202 | const contextsById = new Map(contexts.map((context) => [context.id, context])); | |
| 203 | return ids.flatMap((id) => { | |
| 204 | const context = contextsById.get(id); | |
| 205 | return context ? [context] : []; | |
| 206 | }); | |
| 207 | }; | |
| 208 | ||
| 209 | const terminalContextIdListsEqual = ( | |
| 210 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 211 | ids: ReadonlyArray<string>, | |
| 212 | ): boolean => | |
| 213 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | |
| 214 | ||
| 188 | 215 | interface ChatViewProps { |
| 189 | 216 | threadId: ThreadId; |
| 190 | 217 | } |
| ... | ... | @@ -209,6 +236,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 209 | 236 | const composerDraft = useComposerThreadDraft(threadId); |
| 210 | 237 | const prompt = composerDraft.prompt; |
| 211 | 238 | const composerImages = composerDraft.images; |
| 239 | const composerTerminalContexts = composerDraft.terminalContexts; | |
| 240 | const composerSendState = useMemo( | |
| 241 | () => | |
| 242 | deriveComposerSendState({ | |
| 243 | prompt, | |
| 244 | imageCount: composerImages.length, | |
| 245 | terminalContexts: composerTerminalContexts, | |
| 246 | }), | |
| 247 | [composerImages.length, composerTerminalContexts, prompt], | |
| 248 | ); | |
| 212 | 249 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 213 | 250 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 214 | 251 | const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); |
| ... | ... | @@ -222,6 +259,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 222 | 259 | const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); |
| 223 | 260 | const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); |
| 224 | 261 | const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); |
| 262 | const insertComposerDraftTerminalContext = useComposerDraftStore( | |
| 263 | (store) => store.insertTerminalContext, | |
| 264 | ); | |
| 265 | const addComposerDraftTerminalContexts = useComposerDraftStore( | |
| 266 | (store) => store.addTerminalContexts, | |
| 267 | ); | |
| 268 | const removeComposerDraftTerminalContext = useComposerDraftStore( | |
| 269 | (store) => store.removeTerminalContext, | |
| 270 | ); | |
| 271 | const setComposerDraftTerminalContexts = useComposerDraftStore( | |
| 272 | (store) => store.setTerminalContexts, | |
| 273 | ); | |
| 225 | 274 | const clearComposerDraftPersistedAttachments = useComposerDraftStore( |
| 226 | 275 | (store) => store.clearPersistedAttachments, |
| 227 | 276 | ); |
| ... | ... | @@ -248,6 +297,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 248 | 297 | const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]); |
| 249 | 298 | const optimisticUserMessagesRef = useRef(optimisticUserMessages); |
| 250 | 299 | optimisticUserMessagesRef.current = optimisticUserMessages; |
| 300 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>(composerTerminalContexts); | |
| 251 | 301 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 252 | 302 | Record<ThreadId, string | null> |
| 253 | 303 | >({}); |
| ... | ... | @@ -350,12 +400,40 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 350 | 400 | }, |
| 351 | 401 | [addComposerDraftImages, threadId], |
| 352 | 402 | ); |
| 403 | const addComposerTerminalContextsToDraft = useCallback( | |
| 404 | (contexts: TerminalContextDraft[]) => { | |
| 405 | addComposerDraftTerminalContexts(threadId, contexts); | |
| 406 | }, | |
| 407 | [addComposerDraftTerminalContexts, threadId], | |
| 408 | ); | |
| 353 | 409 | const removeComposerImageFromDraft = useCallback( |
| 354 | 410 | (imageId: string) => { |
| 355 | 411 | removeComposerDraftImage(threadId, imageId); |
| 356 | 412 | }, |
| 357 | 413 | [removeComposerDraftImage, threadId], |
| 358 | 414 | ); |
| 415 | const removeComposerTerminalContextFromDraft = useCallback( | |
| 416 | (contextId: string) => { | |
| 417 | const contextIndex = composerTerminalContexts.findIndex( | |
| 418 | (context) => context.id === contextId, | |
| 419 | ); | |
| 420 | if (contextIndex < 0) { | |
| 421 | return; | |
| 422 | } | |
| 423 | const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); | |
| 424 | promptRef.current = nextPrompt.prompt; | |
| 425 | setPrompt(nextPrompt.prompt); | |
| 426 | removeComposerDraftTerminalContext(threadId, contextId); | |
| 427 | setComposerCursor(nextPrompt.cursor); | |
| 428 | setComposerTrigger( | |
| 429 | detectComposerTrigger( | |
| 430 | nextPrompt.prompt, | |
| 431 | expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), | |
| 432 | ), | |
| 433 | ); | |
| 434 | }, | |
| 435 | [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], | |
| 436 | ); | |
| 359 | 437 | |
| 360 | 438 | const serverThread = threads.find((t) => t.id === threadId); |
| 361 | 439 | const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); |
| ... | ... | @@ -1093,6 +1171,48 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1093 | 1171 | focusComposer(); |
| 1094 | 1172 | }); |
| 1095 | 1173 | }, [focusComposer]); |
| 1174 | const addTerminalContextToDraft = useCallback( | |
| 1175 | (selection: TerminalContextSelection) => { | |
| 1176 | if (!activeThread) { | |
| 1177 | return; | |
| 1178 | } | |
| 1179 | const snapshot = composerEditorRef.current?.readSnapshot() ?? { | |
| 1180 | value: promptRef.current, | |
| 1181 | cursor: composerCursor, | |
| 1182 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), | |
| 1183 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 1184 | }; | |
| 1185 | const insertion = insertInlineTerminalContextPlaceholder( | |
| 1186 | snapshot.value, | |
| 1187 | snapshot.expandedCursor, | |
| 1188 | ); | |
| 1189 | const nextCollapsedCursor = collapseExpandedComposerCursor( | |
| 1190 | insertion.prompt, | |
| 1191 | insertion.cursor, | |
| 1192 | ); | |
| 1193 | const inserted = insertComposerDraftTerminalContext( | |
| 1194 | activeThread.id, | |
| 1195 | insertion.prompt, | |
| 1196 | { | |
| 1197 | id: randomUUID(), | |
| 1198 | threadId: activeThread.id, | |
| 1199 | createdAt: new Date().toISOString(), | |
| 1200 | ...selection, | |
| 1201 | }, | |
| 1202 | insertion.contextIndex, | |
| 1203 | ); | |
| 1204 | if (!inserted) { | |
| 1205 | return; | |
| 1206 | } | |
| 1207 | promptRef.current = insertion.prompt; | |
| 1208 | setComposerCursor(nextCollapsedCursor); | |
| 1209 | setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); | |
| 1210 | window.requestAnimationFrame(() => { | |
| 1211 | composerEditorRef.current?.focusAt(nextCollapsedCursor); | |
| 1212 | }); | |
| 1213 | }, | |
| 1214 | [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], | |
| 1215 | ); | |
| 1096 | 1216 | const setTerminalOpen = useCallback( |
| 1097 | 1217 | (open: boolean) => { |
| 1098 | 1218 | if (!activeThreadId) return; |
| ... | ... | @@ -1730,6 +1850,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1730 | 1850 | composerImagesRef.current = composerImages; |
| 1731 | 1851 | }, [composerImages]); |
| 1732 | 1852 | |
| 1853 | useEffect(() => { | |
| 1854 | composerTerminalContextsRef.current = composerTerminalContexts; | |
| 1855 | }, [composerTerminalContexts]); | |
| 1856 | ||
| 1733 | 1857 | useEffect(() => { |
| 1734 | 1858 | if (!activeThread?.id) return; |
| 1735 | 1859 | if (activeThread.messages.length === 0) { |
| ... | ... | @@ -2204,7 +2328,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2204 | 2328 | onAdvanceActivePendingUserInput(); |
| 2205 | 2329 | return; |
| 2206 | 2330 | } |
| 2207 | const trimmed = prompt.trim(); | |
| 2331 | const promptForSend = promptRef.current; | |
| 2332 | const { | |
| 2333 | trimmedPrompt: trimmed, | |
| 2334 | sendableTerminalContexts: sendableComposerTerminalContexts, | |
| 2335 | expiredTerminalContextCount, | |
| 2336 | hasSendableContent, | |
| 2337 | } = deriveComposerSendState({ | |
| 2338 | prompt: promptForSend, | |
| 2339 | imageCount: composerImages.length, | |
| 2340 | terminalContexts: composerTerminalContexts, | |
| 2341 | }); | |
| 2208 | 2342 | if (showPlanFollowUpPrompt && activeProposedPlan) { |
| 2209 | 2343 | const followUp = resolvePlanFollowUpSubmission({ |
| 2210 | 2344 | draftText: trimmed, |
| ... | ... | @@ -2222,7 +2356,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2222 | 2356 | return; |
| 2223 | 2357 | } |
| 2224 | 2358 | const standaloneSlashCommand = |
| 2225 | composerImages.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; | |
| 2359 | composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 | |
| 2360 | ? parseStandaloneComposerSlashCommand(trimmed) | |
| 2361 | : null; | |
| 2226 | 2362 | if (standaloneSlashCommand) { |
| 2227 | 2363 | await handleInteractionModeChange(standaloneSlashCommand); |
| 2228 | 2364 | promptRef.current = ""; |
| ... | ... | @@ -2232,7 +2368,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2232 | 2368 | setComposerTrigger(null); |
| 2233 | 2369 | return; |
| 2234 | 2370 | } |
| 2235 | if (!trimmed && composerImages.length === 0) return; | |
| 2371 | if (!hasSendableContent) { | |
| 2372 | if (expiredTerminalContextCount > 0) { | |
| 2373 | const toastCopy = buildExpiredTerminalContextToastCopy( | |
| 2374 | expiredTerminalContextCount, | |
| 2375 | "empty", | |
| 2376 | ); | |
| 2377 | toastManager.add({ | |
| 2378 | type: "warning", | |
| 2379 | title: toastCopy.title, | |
| 2380 | description: toastCopy.description, | |
| 2381 | }); | |
| 2382 | } | |
| 2383 | return; | |
| 2384 | } | |
| 2236 | 2385 | if (!activeProject) return; |
| 2237 | 2386 | const threadIdForSend = activeThread.id; |
| 2238 | 2387 | const isFirstMessage = !isServerThread || activeThread.messages.length === 0; |
| ... | ... | @@ -2257,6 +2406,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2257 | 2406 | beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); |
| 2258 | 2407 | |
| 2259 | 2408 | const composerImagesSnapshot = [...composerImages]; |
| 2409 | const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; | |
| 2410 | const messageTextForSend = appendTerminalContextsToPrompt( | |
| 2411 | promptForSend, | |
| 2412 | composerTerminalContextsSnapshot, | |
| 2413 | ); | |
| 2260 | 2414 | const messageIdForSend = newMessageId(); |
| 2261 | 2415 | const messageCreatedAt = new Date().toISOString(); |
| 2262 | 2416 | const turnAttachmentsPromise = Promise.all( |
| ... | ... | @@ -2281,7 +2435,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2281 | 2435 | { |
| 2282 | 2436 | id: messageIdForSend, |
| 2283 | 2437 | role: "user", |
| 2284 | text: trimmed, | |
| 2438 | text: messageTextForSend, | |
| 2285 | 2439 | ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), |
| 2286 | 2440 | createdAt: messageCreatedAt, |
| 2287 | 2441 | streaming: false, |
| ... | ... | @@ -2292,6 +2446,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2292 | 2446 | forceStickToBottom(); |
| 2293 | 2447 | |
| 2294 | 2448 | setThreadError(threadIdForSend, null); |
| 2449 | if (expiredTerminalContextCount > 0) { | |
| 2450 | const toastCopy = buildExpiredTerminalContextToastCopy( | |
| 2451 | expiredTerminalContextCount, | |
| 2452 | "omitted", | |
| 2453 | ); | |
| 2454 | toastManager.add({ | |
| 2455 | type: "warning", | |
| 2456 | title: toastCopy.title, | |
| 2457 | description: toastCopy.description, | |
| 2458 | }); | |
| 2459 | } | |
| 2295 | 2460 | promptRef.current = ""; |
| 2296 | 2461 | clearComposerDraftContent(threadIdForSend); |
| 2297 | 2462 | setComposerHighlightedItemId(null); |
| ... | ... | @@ -2339,6 +2504,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2339 | 2504 | if (!titleSeed) { |
| 2340 | 2505 | if (firstComposerImageName) { |
| 2341 | 2506 | titleSeed = `Image: ${firstComposerImageName}`; |
| 2507 | } else if (composerTerminalContextsSnapshot.length > 0) { | |
| 2508 | titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); | |
| 2342 | 2509 | } else { |
| 2343 | 2510 | titleSeed = "New thread"; |
| 2344 | 2511 | } |
| ... | ... | @@ -2419,7 +2586,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2419 | 2586 | message: { |
| 2420 | 2587 | messageId: messageIdForSend, |
| 2421 | 2588 | role: "user", |
| 2422 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 2589 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 2423 | 2590 | attachments: turnAttachments, |
| 2424 | 2591 | }, |
| 2425 | 2592 | model: selectedModel || undefined, |
| ... | ... | @@ -2447,7 +2614,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2447 | 2614 | if ( |
| 2448 | 2615 | !turnStartSucceeded && |
| 2449 | 2616 | promptRef.current.length === 0 && |
| 2450 | composerImagesRef.current.length === 0 | |
| 2617 | composerImagesRef.current.length === 0 && | |
| 2618 | composerTerminalContextsRef.current.length === 0 | |
| 2451 | 2619 | ) { |
| 2452 | 2620 | setOptimisticUserMessages((existing) => { |
| 2453 | 2621 | const removed = existing.filter((message) => message.id === messageIdForSend); |
| ... | ... | @@ -2457,11 +2625,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2457 | 2625 | const next = existing.filter((message) => message.id !== messageIdForSend); |
| 2458 | 2626 | return next.length === existing.length ? existing : next; |
| 2459 | 2627 | }); |
| 2460 | promptRef.current = trimmed; | |
| 2461 | setPrompt(trimmed); | |
| 2462 | setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); | |
| 2628 | promptRef.current = promptForSend; | |
| 2629 | setPrompt(promptForSend); | |
| 2630 | setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); | |
| 2463 | 2631 | addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); |
| 2464 | setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); | |
| 2632 | addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); | |
| 2633 | setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); | |
| 2465 | 2634 | } |
| 2466 | 2635 | setThreadError( |
| 2467 | 2636 | threadIdForSend, |
| ... | ... | @@ -2969,6 +3138,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2969 | 3138 | value: string; |
| 2970 | 3139 | cursor: number; |
| 2971 | 3140 | expandedCursor: number; |
| 3141 | terminalContextIds: string[]; | |
| 2972 | 3142 | } => { |
| 2973 | 3143 | const editorSnapshot = composerEditorRef.current?.readSnapshot(); |
| 2974 | 3144 | if (editorSnapshot) { |
| ... | ... | @@ -2978,8 +3148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2978 | 3148 | value: promptRef.current, |
| 2979 | 3149 | cursor: composerCursor, |
| 2980 | 3150 | expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), |
| 3151 | terminalContextIds: composerTerminalContexts.map((context) => context.id), | |
| 2981 | 3152 | }; |
| 2982 | }, [composerCursor]); | |
| 3153 | }, [composerCursor, composerTerminalContexts]); | |
| 2983 | 3154 | |
| 2984 | 3155 | const resolveActiveComposerTrigger = useCallback((): { |
| 2985 | 3156 | snapshot: { value: string; cursor: number; expandedCursor: number }; |
| ... | ... | @@ -3095,6 +3266,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3095 | 3266 | nextCursor: number, |
| 3096 | 3267 | expandedCursor: number, |
| 3097 | 3268 | cursorAdjacentToMention: boolean, |
| 3269 | terminalContextIds: string[], | |
| 3098 | 3270 | ) => { |
| 3099 | 3271 | if (activePendingProgress?.activeQuestion && activePendingUserInput) { |
| 3100 | 3272 | onChangeActivePendingUserInputCustomAnswer( |
| ... | ... | @@ -3108,6 +3280,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3108 | 3280 | } |
| 3109 | 3281 | promptRef.current = nextPrompt; |
| 3110 | 3282 | setPrompt(nextPrompt); |
| 3283 | if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { | |
| 3284 | setComposerDraftTerminalContexts( | |
| 3285 | threadId, | |
| 3286 | syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), | |
| 3287 | ); | |
| 3288 | } | |
| 3111 | 3289 | setComposerCursor(nextCursor); |
| 3112 | 3290 | setComposerTrigger( |
| 3113 | 3291 | cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), |
| ... | ... | @@ -3116,8 +3294,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3116 | 3294 | [ |
| 3117 | 3295 | activePendingProgress?.activeQuestion, |
| 3118 | 3296 | activePendingUserInput, |
| 3297 | composerTerminalContexts, | |
| 3119 | 3298 | onChangeActivePendingUserInputCustomAnswer, |
| 3120 | 3299 | setPrompt, |
| 3300 | setComposerDraftTerminalContexts, | |
| 3301 | threadId, | |
| 3121 | 3302 | ], |
| 3122 | 3303 | ); |
| 3123 | 3304 | |
| ... | ... | @@ -3384,75 +3565,77 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3384 | 3565 | </div> |
| 3385 | 3566 | )} |
| 3386 | 3567 | |
| 3387 | {!isComposerApprovalState && | |
| 3388 | pendingUserInputs.length === 0 && | |
| 3389 | composerImages.length > 0 && ( | |
| 3390 | <div className="mb-3 flex flex-wrap gap-2"> | |
| 3391 | {composerImages.map((image) => ( | |
| 3392 | <div | |
| 3393 | key={image.id} | |
| 3394 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | |
| 3395 | > | |
| 3396 | {image.previewUrl ? ( | |
| 3397 | <button | |
| 3398 | type="button" | |
| 3399 | className="h-full w-full cursor-zoom-in" | |
| 3400 | aria-label={`Preview ${image.name}`} | |
| 3401 | onClick={() => { | |
| 3402 | const preview = buildExpandedImagePreview( | |
| 3403 | composerImages, | |
| 3404 | image.id, | |
| 3405 | ); | |
| 3406 | if (!preview) return; | |
| 3407 | setExpandedImage(preview); | |
| 3408 | }} | |
| 3409 | > | |
| 3410 | <img | |
| 3411 | src={image.previewUrl} | |
| 3412 | alt={image.name} | |
| 3413 | className="h-full w-full object-cover" | |
| 3414 | /> | |
| 3415 | </button> | |
| 3416 | ) : ( | |
| 3417 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | |
| 3418 | {image.name} | |
| 3419 | </div> | |
| 3420 | )} | |
| 3421 | {nonPersistedComposerImageIdSet.has(image.id) && ( | |
| 3422 | <Tooltip> | |
| 3423 | <TooltipTrigger | |
| 3424 | render={ | |
| 3425 | <span | |
| 3426 | role="img" | |
| 3427 | aria-label="Draft attachment may not persist" | |
| 3428 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | |
| 3429 | > | |
| 3430 | <CircleAlertIcon className="size-3" /> | |
| 3431 | </span> | |
| 3432 | } | |
| 3433 | /> | |
| 3434 | <TooltipPopup | |
| 3435 | side="top" | |
| 3436 | className="max-w-64 whitespace-normal leading-tight" | |
| 3437 | > | |
| 3438 | Draft attachment could not be saved locally and may be lost on | |
| 3439 | navigation. | |
| 3440 | </TooltipPopup> | |
| 3441 | </Tooltip> | |
| 3442 | )} | |
| 3443 | <Button | |
| 3444 | variant="ghost" | |
| 3445 | size="icon-xs" | |
| 3446 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | |
| 3447 | onClick={() => removeComposerImage(image.id)} | |
| 3448 | aria-label={`Remove ${image.name}`} | |
| 3568 | {!isComposerApprovalState && pendingUserInputs.length === 0 && ( | |
| 3569 | <> | |
| 3570 | {composerImages.length > 0 && ( | |
| 3571 | <div className="mb-3 flex flex-wrap gap-2"> | |
| 3572 | {composerImages.map((image) => ( | |
| 3573 | <div | |
| 3574 | key={image.id} | |
| 3575 | className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" | |
| 3449 | 3576 | > |
| 3450 | <XIcon /> | |
| 3451 | </Button> | |
| 3452 | </div> | |
| 3453 | ))} | |
| 3454 | </div> | |
| 3455 | )} | |
| 3577 | {image.previewUrl ? ( | |
| 3578 | <button | |
| 3579 | type="button" | |
| 3580 | className="h-full w-full cursor-zoom-in" | |
| 3581 | aria-label={`Preview ${image.name}`} | |
| 3582 | onClick={() => { | |
| 3583 | const preview = buildExpandedImagePreview( | |
| 3584 | composerImages, | |
| 3585 | image.id, | |
| 3586 | ); | |
| 3587 | if (!preview) return; | |
| 3588 | setExpandedImage(preview); | |
| 3589 | }} | |
| 3590 | > | |
| 3591 | <img | |
| 3592 | src={image.previewUrl} | |
| 3593 | alt={image.name} | |
| 3594 | className="h-full w-full object-cover" | |
| 3595 | /> | |
| 3596 | </button> | |
| 3597 | ) : ( | |
| 3598 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | |
| 3599 | {image.name} | |
| 3600 | </div> | |
| 3601 | )} | |
| 3602 | {nonPersistedComposerImageIdSet.has(image.id) && ( | |
| 3603 | <Tooltip> | |
| 3604 | <TooltipTrigger | |
| 3605 | render={ | |
| 3606 | <span | |
| 3607 | role="img" | |
| 3608 | aria-label="Draft attachment may not persist" | |
| 3609 | className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600" | |
| 3610 | > | |
| 3611 | <CircleAlertIcon className="size-3" /> | |
| 3612 | </span> | |
| 3613 | } | |
| 3614 | /> | |
| 3615 | <TooltipPopup | |
| 3616 | side="top" | |
| 3617 | className="max-w-64 whitespace-normal leading-tight" | |
| 3618 | > | |
| 3619 | Draft attachment could not be saved locally and may be lost on | |
| 3620 | navigation. | |
| 3621 | </TooltipPopup> | |
| 3622 | </Tooltip> | |
| 3623 | )} | |
| 3624 | <Button | |
| 3625 | variant="ghost" | |
| 3626 | size="icon-xs" | |
| 3627 | className="absolute right-1 top-1 bg-background/80 hover:bg-background/90" | |
| 3628 | onClick={() => removeComposerImage(image.id)} | |
| 3629 | aria-label={`Remove ${image.name}`} | |
| 3630 | > | |
| 3631 | <XIcon /> | |
| 3632 | </Button> | |
| 3633 | </div> | |
| 3634 | ))} | |
| 3635 | </div> | |
| 3636 | )} | |
| 3637 | </> | |
| 3638 | )} | |
| 3456 | 3639 | <ComposerPromptEditor |
| 3457 | 3640 | ref={composerEditorRef} |
| 3458 | 3641 | value={ |
| ... | ... | @@ -3463,6 +3646,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3463 | 3646 | : prompt |
| 3464 | 3647 | } |
| 3465 | 3648 | cursor={composerCursor} |
| 3649 | terminalContexts={ | |
| 3650 | !isComposerApprovalState && pendingUserInputs.length === 0 | |
| 3651 | ? composerTerminalContexts | |
| 3652 | : [] | |
| 3653 | } | |
| 3654 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} | |
| 3466 | 3655 | onChange={onPromptChange} |
| 3467 | 3656 | onCommandKeyDown={onComposerCommandKey} |
| 3468 | 3657 | onPaste={onComposerPaste} |
| ... | ... | @@ -3740,9 +3929,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3740 | 3929 | type="submit" |
| 3741 | 3930 | className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" |
| 3742 | 3931 | disabled={ |
| 3743 | isSendBusy || | |
| 3744 | isConnecting || | |
| 3745 | (!prompt.trim() && composerImages.length === 0) | |
| 3932 | isSendBusy || isConnecting || !composerSendState.hasSendableContent | |
| 3746 | 3933 | } |
| 3747 | 3934 | aria-label={ |
| 3748 | 3935 | isConnecting |
| ... | ... | @@ -3873,6 +4060,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3873 | 4060 | onActiveTerminalChange={activateTerminal} |
| 3874 | 4061 | onCloseTerminal={closeTerminal} |
| 3875 | 4062 | onHeightChange={setTerminalHeight} |
| 4063 | onAddTerminalContext={addTerminalContextToDraft} | |
| 3876 | 4064 | /> |
| 3877 | 4065 | ); |
| 3878 | 4066 | })()} |
apps/web/src/components/ComposerPromptEditor.tsx+330-98| ... | ... | @@ -26,8 +26,10 @@ import { |
| 26 | 26 | COMMAND_PRIORITY_HIGH, |
| 27 | 27 | KEY_BACKSPACE_COMMAND, |
| 28 | 28 | $getRoot, |
| 29 | DecoratorNode, | |
| 29 | 30 | type ElementNode, |
| 30 | 31 | type LexicalNode, |
| 32 | type SerializedLexicalNode, | |
| 31 | 33 | TextNode, |
| 32 | 34 | type EditorConfig, |
| 33 | 35 | type EditorState, |
| ... | ... | @@ -36,14 +38,17 @@ import { |
| 36 | 38 | type Spread, |
| 37 | 39 | } from "lexical"; |
| 38 | 40 | import { |
| 41 | createContext, | |
| 39 | 42 | forwardRef, |
| 40 | 43 | useCallback, |
| 44 | useContext, | |
| 41 | 45 | useEffect, |
| 42 | 46 | useImperativeHandle, |
| 43 | 47 | useLayoutEffect, |
| 44 | 48 | useMemo, |
| 45 | 49 | useRef, |
| 46 | 50 | type ClipboardEventHandler, |
| 51 | type ReactElement, | |
| 47 | 52 | type Ref, |
| 48 | 53 | } from "react"; |
| 49 | 54 | |
| ... | ... | @@ -51,11 +56,21 @@ import { |
| 51 | 56 | clampCollapsedComposerCursor, |
| 52 | 57 | collapseExpandedComposerCursor, |
| 53 | 58 | expandCollapsedComposerCursor, |
| 54 | isCollapsedCursorAdjacentToMention, | |
| 59 | isCollapsedCursorAdjacentToInlineToken, | |
| 55 | 60 | } from "~/composer-logic"; |
| 56 | 61 | import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; |
| 62 | import { | |
| 63 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 64 | type TerminalContextDraft, | |
| 65 | } from "~/lib/terminalContext"; | |
| 57 | 66 | import { cn } from "~/lib/utils"; |
| 58 | import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; | |
| 67 | import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; | |
| 68 | import { | |
| 69 | COMPOSER_INLINE_CHIP_CLASS_NAME, | |
| 70 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | |
| 71 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 72 | } from "./composerInlineChip"; | |
| 73 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; | |
| 59 | 74 | |
| 60 | 75 | const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; |
| 61 | 76 | |
| ... | ... | @@ -68,6 +83,21 @@ type SerializedComposerMentionNode = Spread< |
| 68 | 83 | SerializedTextNode |
| 69 | 84 | >; |
| 70 | 85 | |
| 86 | type SerializedComposerTerminalContextNode = Spread< | |
| 87 | { | |
| 88 | context: TerminalContextDraft; | |
| 89 | type: "composer-terminal-context"; | |
| 90 | version: 1; | |
| 91 | }, | |
| 92 | SerializedLexicalNode | |
| 93 | >; | |
| 94 | ||
| 95 | const ComposerTerminalContextActionsContext = createContext<{ | |
| 96 | onRemoveTerminalContext: (contextId: string) => void; | |
| 97 | }>({ | |
| 98 | onRemoveTerminalContext: () => {}, | |
| 99 | }); | |
| 100 | ||
| 71 | 101 | class ComposerMentionNode extends TextNode { |
| 72 | 102 | __path: string; |
| 73 | 103 | |
| ... | ... | @@ -100,8 +130,7 @@ class ComposerMentionNode extends TextNode { |
| 100 | 130 | |
| 101 | 131 | override createDOM(_config: EditorConfig): HTMLElement { |
| 102 | 132 | const dom = document.createElement("span"); |
| 103 | dom.className = | |
| 104 | "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"; | |
| 133 | dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME; | |
| 105 | 134 | dom.contentEditable = "false"; |
| 106 | 135 | dom.setAttribute("spellcheck", "false"); |
| 107 | 136 | renderMentionChipDom(dom, this.__path); |
| ... | ... | @@ -141,15 +170,76 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { |
| 141 | 170 | return $applyNodeReplacement(new ComposerMentionNode(path)); |
| 142 | 171 | } |
| 143 | 172 | |
| 144 | function inferMentionPathKind(pathValue: string): "file" | "directory" { | |
| 145 | const base = basenameOfPath(pathValue); | |
| 146 | if (base.startsWith(".") && !base.slice(1).includes(".")) { | |
| 147 | return "directory"; | |
| 173 | function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { | |
| 174 | return <ComposerPendingTerminalContextChip context={props.context} />; | |
| 175 | } | |
| 176 | ||
| 177 | class ComposerTerminalContextNode extends DecoratorNode<ReactElement> { | |
| 178 | __context: TerminalContextDraft; | |
| 179 | ||
| 180 | static override getType(): string { | |
| 181 | return "composer-terminal-context"; | |
| 182 | } | |
| 183 | ||
| 184 | static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { | |
| 185 | return new ComposerTerminalContextNode(node.__context, node.__key); | |
| 186 | } | |
| 187 | ||
| 188 | static override importJSON( | |
| 189 | serializedNode: SerializedComposerTerminalContextNode, | |
| 190 | ): ComposerTerminalContextNode { | |
| 191 | return $createComposerTerminalContextNode(serializedNode.context); | |
| 192 | } | |
| 193 | ||
| 194 | constructor(context: TerminalContextDraft, key?: NodeKey) { | |
| 195 | super(key); | |
| 196 | this.__context = context; | |
| 197 | } | |
| 198 | ||
| 199 | override exportJSON(): SerializedComposerTerminalContextNode { | |
| 200 | return { | |
| 201 | ...super.exportJSON(), | |
| 202 | context: this.__context, | |
| 203 | type: "composer-terminal-context", | |
| 204 | version: 1, | |
| 205 | }; | |
| 206 | } | |
| 207 | ||
| 208 | override createDOM(): HTMLElement { | |
| 209 | const dom = document.createElement("span"); | |
| 210 | dom.className = "inline-flex align-middle leading-none"; | |
| 211 | return dom; | |
| 212 | } | |
| 213 | ||
| 214 | override updateDOM(): false { | |
| 215 | return false; | |
| 216 | } | |
| 217 | ||
| 218 | override getTextContent(): string { | |
| 219 | return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; | |
| 148 | 220 | } |
| 149 | if (base.includes(".")) { | |
| 150 | return "file"; | |
| 221 | ||
| 222 | override isInline(): true { | |
| 223 | return true; | |
| 151 | 224 | } |
| 152 | return "directory"; | |
| 225 | ||
| 226 | override decorate(): ReactElement { | |
| 227 | return <ComposerTerminalContextDecorator context={this.__context} />; | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | function $createComposerTerminalContextNode( | |
| 232 | context: TerminalContextDraft, | |
| 233 | ): ComposerTerminalContextNode { | |
| 234 | return $applyNodeReplacement(new ComposerTerminalContextNode(context)); | |
| 235 | } | |
| 236 | ||
| 237 | type ComposerInlineTokenNode = ComposerMentionNode | ComposerTerminalContextNode; | |
| 238 | ||
| 239 | function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { | |
| 240 | return ( | |
| 241 | candidate instanceof ComposerMentionNode || candidate instanceof ComposerTerminalContextNode | |
| 242 | ); | |
| 153 | 243 | } |
| 154 | 244 | |
| 155 | 245 | function resolvedThemeFromDocument(): "light" | "dark" { |
| ... | ... | @@ -165,25 +255,91 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { |
| 165 | 255 | const icon = document.createElement("img"); |
| 166 | 256 | icon.alt = ""; |
| 167 | 257 | icon.ariaHidden = "true"; |
| 168 | icon.className = "size-3.5 shrink-0 opacity-85"; | |
| 258 | icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME; | |
| 169 | 259 | icon.loading = "lazy"; |
| 170 | icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme); | |
| 260 | icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme); | |
| 171 | 261 | |
| 172 | 262 | const label = document.createElement("span"); |
| 173 | label.className = "truncate select-none leading-tight"; | |
| 263 | label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME; | |
| 174 | 264 | label.textContent = basenameOfPath(pathValue); |
| 175 | 265 | |
| 176 | 266 | container.append(icon, label); |
| 177 | 267 | } |
| 178 | 268 | |
| 269 | function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>): string { | |
| 270 | return contexts | |
| 271 | .map((context) => | |
| 272 | [ | |
| 273 | context.id, | |
| 274 | context.threadId, | |
| 275 | context.terminalId, | |
| 276 | context.terminalLabel, | |
| 277 | context.lineStart, | |
| 278 | context.lineEnd, | |
| 279 | context.createdAt, | |
| 280 | context.text, | |
| 281 | ].join("\u001f"), | |
| 282 | ) | |
| 283 | .join("\u001e"); | |
| 284 | } | |
| 285 | ||
| 179 | 286 | function clampExpandedCursor(value: string, cursor: number): number { |
| 180 | 287 | if (!Number.isFinite(cursor)) return value.length; |
| 181 | 288 | return Math.max(0, Math.min(value.length, Math.floor(cursor))); |
| 182 | 289 | } |
| 183 | 290 | |
| 291 | function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { | |
| 292 | return 1; | |
| 293 | } | |
| 294 | ||
| 295 | function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { | |
| 296 | return node.getTextContentSize(); | |
| 297 | } | |
| 298 | ||
| 299 | function getAbsoluteOffsetForInlineTokenPoint( | |
| 300 | node: ComposerInlineTokenNode, | |
| 301 | absoluteOffset: number, | |
| 302 | pointOffset: number, | |
| 303 | ): number { | |
| 304 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); | |
| 305 | } | |
| 306 | ||
| 307 | function getExpandedAbsoluteOffsetForInlineTokenPoint( | |
| 308 | node: ComposerInlineTokenNode, | |
| 309 | absoluteOffset: number, | |
| 310 | pointOffset: number, | |
| 311 | ): number { | |
| 312 | return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); | |
| 313 | } | |
| 314 | ||
| 315 | function findSelectionPointForInlineToken( | |
| 316 | node: ComposerInlineTokenNode, | |
| 317 | remainingRef: { value: number }, | |
| 318 | ): { key: string; offset: number; type: "element" } | null { | |
| 319 | const parent = node.getParent(); | |
| 320 | if (!parent || !$isElementNode(parent)) return null; | |
| 321 | const index = node.getIndexWithinParent(); | |
| 322 | if (remainingRef.value === 0) { | |
| 323 | return { | |
| 324 | key: parent.getKey(), | |
| 325 | offset: index, | |
| 326 | type: "element", | |
| 327 | }; | |
| 328 | } | |
| 329 | if (remainingRef.value === getComposerInlineTokenTextLength(node)) { | |
| 330 | return { | |
| 331 | key: parent.getKey(), | |
| 332 | offset: index + 1, | |
| 333 | type: "element", | |
| 334 | }; | |
| 335 | } | |
| 336 | remainingRef.value -= getComposerInlineTokenTextLength(node); | |
| 337 | return null; | |
| 338 | } | |
| 339 | ||
| 184 | 340 | function getComposerNodeTextLength(node: LexicalNode): number { |
| 185 | if (node instanceof ComposerMentionNode) { | |
| 186 | return 1; | |
| 341 | if (isComposerInlineTokenNode(node)) { | |
| 342 | return getComposerInlineTokenTextLength(node); | |
| 187 | 343 | } |
| 188 | 344 | if ($isTextNode(node)) { |
| 189 | 345 | return node.getTextContentSize(); |
| ... | ... | @@ -198,6 +354,9 @@ function getComposerNodeTextLength(node: LexicalNode): number { |
| 198 | 354 | } |
| 199 | 355 | |
| 200 | 356 | function getComposerNodeExpandedTextLength(node: LexicalNode): number { |
| 357 | if (isComposerInlineTokenNode(node)) { | |
| 358 | return getComposerInlineTokenExpandedTextLength(node); | |
| 359 | } | |
| 201 | 360 | if ($isTextNode(node)) { |
| 202 | 361 | return node.getTextContentSize(); |
| 203 | 362 | } |
| ... | ... | @@ -233,10 +392,13 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb |
| 233 | 392 | |
| 234 | 393 | if ($isTextNode(node)) { |
| 235 | 394 | if (node instanceof ComposerMentionNode) { |
| 236 | return offset + (pointOffset > 0 ? 1 : 0); | |
| 395 | return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | |
| 237 | 396 | } |
| 238 | 397 | return offset + Math.min(pointOffset, node.getTextContentSize()); |
| 239 | 398 | } |
| 399 | if (node instanceof ComposerTerminalContextNode) { | |
| 400 | return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | |
| 401 | } | |
| 240 | 402 | |
| 241 | 403 | if ($isLineBreakNode(node)) { |
| 242 | 404 | return offset + Math.min(pointOffset, 1); |
| ... | ... | @@ -277,10 +439,13 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe |
| 277 | 439 | |
| 278 | 440 | if ($isTextNode(node)) { |
| 279 | 441 | if (node instanceof ComposerMentionNode) { |
| 280 | return offset + (pointOffset > 0 ? node.getTextContentSize() : 0); | |
| 442 | return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | |
| 281 | 443 | } |
| 282 | 444 | return offset + Math.min(pointOffset, node.getTextContentSize()); |
| 283 | 445 | } |
| 446 | if (node instanceof ComposerTerminalContextNode) { | |
| 447 | return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); | |
| 448 | } | |
| 284 | 449 | |
| 285 | 450 | if ($isLineBreakNode(node)) { |
| 286 | 451 | return offset + Math.min(pointOffset, 1); |
| ... | ... | @@ -305,25 +470,10 @@ function findSelectionPointAtOffset( |
| 305 | 470 | remainingRef: { value: number }, |
| 306 | 471 | ): { key: string; offset: number; type: "text" | "element" } | null { |
| 307 | 472 | if (node instanceof ComposerMentionNode) { |
| 308 | const parent = node.getParent(); | |
| 309 | if (!parent || !$isElementNode(parent)) return null; | |
| 310 | const index = node.getIndexWithinParent(); | |
| 311 | if (remainingRef.value === 0) { | |
| 312 | return { | |
| 313 | key: parent.getKey(), | |
| 314 | offset: index, | |
| 315 | type: "element", | |
| 316 | }; | |
| 317 | } | |
| 318 | if (remainingRef.value === 1) { | |
| 319 | return { | |
| 320 | key: parent.getKey(), | |
| 321 | offset: index + 1, | |
| 322 | type: "element", | |
| 323 | }; | |
| 324 | } | |
| 325 | remainingRef.value -= 1; | |
| 326 | return null; | |
| 473 | return findSelectionPointForInlineToken(node, remainingRef); | |
| 474 | } | |
| 475 | if (node instanceof ComposerTerminalContextNode) { | |
| 476 | return findSelectionPointForInlineToken(node, remainingRef); | |
| 327 | 477 | } |
| 328 | 478 | |
| 329 | 479 | if ($isTextNode(node)) { |
| ... | ... | @@ -438,40 +588,67 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { |
| 438 | 588 | } |
| 439 | 589 | } |
| 440 | 590 | |
| 441 | function $setComposerEditorPrompt(prompt: string): void { | |
| 591 | function $setComposerEditorPrompt( | |
| 592 | prompt: string, | |
| 593 | terminalContexts: ReadonlyArray<TerminalContextDraft>, | |
| 594 | ): void { | |
| 442 | 595 | const root = $getRoot(); |
| 443 | 596 | root.clear(); |
| 444 | 597 | const paragraph = $createParagraphNode(); |
| 445 | 598 | root.append(paragraph); |
| 446 | 599 | |
| 447 | const segments = splitPromptIntoComposerSegments(prompt); | |
| 600 | const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); | |
| 448 | 601 | for (const segment of segments) { |
| 449 | 602 | if (segment.type === "mention") { |
| 450 | 603 | paragraph.append($createComposerMentionNode(segment.path)); |
| 451 | 604 | continue; |
| 452 | 605 | } |
| 606 | if (segment.type === "terminal-context") { | |
| 607 | if (segment.context) { | |
| 608 | paragraph.append($createComposerTerminalContextNode(segment.context)); | |
| 609 | } | |
| 610 | continue; | |
| 611 | } | |
| 453 | 612 | $appendTextWithLineBreaks(paragraph, segment.text); |
| 454 | 613 | } |
| 455 | 614 | } |
| 456 | 615 | |
| 616 | function collectTerminalContextIds(node: LexicalNode): string[] { | |
| 617 | if (node instanceof ComposerTerminalContextNode) { | |
| 618 | return [node.__context.id]; | |
| 619 | } | |
| 620 | if ($isElementNode(node)) { | |
| 621 | return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); | |
| 622 | } | |
| 623 | return []; | |
| 624 | } | |
| 625 | ||
| 457 | 626 | export interface ComposerPromptEditorHandle { |
| 458 | 627 | focus: () => void; |
| 459 | 628 | focusAt: (cursor: number) => void; |
| 460 | 629 | focusAtEnd: () => void; |
| 461 | readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; | |
| 630 | readSnapshot: () => { | |
| 631 | value: string; | |
| 632 | cursor: number; | |
| 633 | expandedCursor: number; | |
| 634 | terminalContextIds: string[]; | |
| 635 | }; | |
| 462 | 636 | } |
| 463 | 637 | |
| 464 | 638 | interface ComposerPromptEditorProps { |
| 465 | 639 | value: string; |
| 466 | 640 | cursor: number; |
| 641 | terminalContexts: ReadonlyArray<TerminalContextDraft>; | |
| 467 | 642 | disabled: boolean; |
| 468 | 643 | placeholder: string; |
| 469 | 644 | className?: string; |
| 645 | onRemoveTerminalContext: (contextId: string) => void; | |
| 470 | 646 | onChange: ( |
| 471 | 647 | nextValue: string, |
| 472 | 648 | nextCursor: number, |
| 473 | 649 | expandedCursor: number, |
| 474 | 650 | cursorAdjacentToMention: boolean, |
| 651 | terminalContextIds: string[], | |
| 475 | 652 | ) => void; |
| 476 | 653 | onCommandKeyDown?: ( |
| 477 | 654 | key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", |
| ... | ... | @@ -540,7 +717,7 @@ function ComposerCommandKeyPlugin(props: { |
| 540 | 717 | return null; |
| 541 | 718 | } |
| 542 | 719 | |
| 543 | function ComposerMentionArrowPlugin() { | |
| 720 | function ComposerInlineTokenArrowPlugin() { | |
| 544 | 721 | const [editor] = useLexicalComposerContext(); |
| 545 | 722 | |
| 546 | 723 | useEffect(() => { |
| ... | ... | @@ -554,7 +731,7 @@ function ComposerMentionArrowPlugin() { |
| 554 | 731 | const currentOffset = $readSelectionOffsetFromEditorState(0); |
| 555 | 732 | if (currentOffset <= 0) return; |
| 556 | 733 | const promptValue = $getRoot().getTextContent(); |
| 557 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) { | |
| 734 | if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { | |
| 558 | 735 | return; |
| 559 | 736 | } |
| 560 | 737 | nextOffset = currentOffset - 1; |
| ... | ... | @@ -581,7 +758,7 @@ function ComposerMentionArrowPlugin() { |
| 581 | 758 | const composerLength = $getComposerRootLength(); |
| 582 | 759 | if (currentOffset >= composerLength) return; |
| 583 | 760 | const promptValue = $getRoot().getTextContent(); |
| 584 | if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) { | |
| 761 | if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { | |
| 585 | 762 | return; |
| 586 | 763 | } |
| 587 | 764 | nextOffset = currentOffset + 1; |
| ... | ... | @@ -606,7 +783,7 @@ function ComposerMentionArrowPlugin() { |
| 606 | 783 | return null; |
| 607 | 784 | } |
| 608 | 785 | |
| 609 | function ComposerMentionSelectionNormalizePlugin() { | |
| 786 | function ComposerInlineTokenSelectionNormalizePlugin() { | |
| 610 | 787 | const [editor] = useLexicalComposerContext(); |
| 611 | 788 | |
| 612 | 789 | useEffect(() => { |
| ... | ... | @@ -616,7 +793,7 @@ function ComposerMentionSelectionNormalizePlugin() { |
| 616 | 793 | const selection = $getSelection(); |
| 617 | 794 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 618 | 795 | const anchorNode = selection.anchor.getNode(); |
| 619 | if (!(anchorNode instanceof ComposerMentionNode)) return; | |
| 796 | if (!isComposerInlineTokenNode(anchorNode)) return; | |
| 620 | 797 | if (selection.anchor.offset === 0) return; |
| 621 | 798 | const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); |
| 622 | 799 | afterOffset = beforeOffset + 1; |
| ... | ... | @@ -634,8 +811,9 @@ function ComposerMentionSelectionNormalizePlugin() { |
| 634 | 811 | return null; |
| 635 | 812 | } |
| 636 | 813 | |
| 637 | function ComposerMentionBackspacePlugin() { | |
| 814 | function ComposerInlineTokenBackspacePlugin() { | |
| 638 | 815 | const [editor] = useLexicalComposerContext(); |
| 816 | const { onRemoveTerminalContext } = useContext(ComposerTerminalContextActionsContext); | |
| 639 | 817 | |
| 640 | 818 | useEffect(() => { |
| 641 | 819 | return editor.registerCommand( |
| ... | ... | @@ -647,18 +825,23 @@ function ComposerMentionBackspacePlugin() { |
| 647 | 825 | } |
| 648 | 826 | |
| 649 | 827 | const anchorNode = selection.anchor.getNode(); |
| 650 | const removeMentionNode = (candidate: unknown): boolean => { | |
| 651 | if (!(candidate instanceof ComposerMentionNode)) { | |
| 828 | const selectionOffset = $readSelectionOffsetFromEditorState(0); | |
| 829 | const removeInlineTokenNode = (candidate: unknown): boolean => { | |
| 830 | if (!isComposerInlineTokenNode(candidate)) { | |
| 652 | 831 | return false; |
| 653 | 832 | } |
| 654 | const mentionStart = getAbsoluteOffsetForPoint(candidate, 0); | |
| 833 | const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); | |
| 655 | 834 | candidate.remove(); |
| 656 | $setSelectionAtComposerOffset(mentionStart); | |
| 835 | if (candidate instanceof ComposerTerminalContextNode) { | |
| 836 | onRemoveTerminalContext(candidate.__context.id); | |
| 837 | $setSelectionAtComposerOffset(selectionOffset); | |
| 838 | } else { | |
| 839 | $setSelectionAtComposerOffset(tokenStart); | |
| 840 | } | |
| 657 | 841 | event?.preventDefault(); |
| 658 | 842 | return true; |
| 659 | 843 | }; |
| 660 | ||
| 661 | if (removeMentionNode(anchorNode)) { | |
| 844 | if (removeInlineTokenNode(anchorNode)) { | |
| 662 | 845 | return true; |
| 663 | 846 | } |
| 664 | 847 | |
| ... | ... | @@ -666,13 +849,13 @@ function ComposerMentionBackspacePlugin() { |
| 666 | 849 | if (selection.anchor.offset > 0) { |
| 667 | 850 | return false; |
| 668 | 851 | } |
| 669 | if (removeMentionNode(anchorNode.getPreviousSibling())) { | |
| 852 | if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { | |
| 670 | 853 | return true; |
| 671 | 854 | } |
| 672 | 855 | const parent = anchorNode.getParent(); |
| 673 | 856 | if ($isElementNode(parent)) { |
| 674 | 857 | const index = anchorNode.getIndexWithinParent(); |
| 675 | if (index > 0 && removeMentionNode(parent.getChildAtIndex(index - 1))) { | |
| 858 | if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { | |
| 676 | 859 | return true; |
| 677 | 860 | } |
| 678 | 861 | } |
| ... | ... | @@ -681,7 +864,7 @@ function ComposerMentionBackspacePlugin() { |
| 681 | 864 | |
| 682 | 865 | if ($isElementNode(anchorNode)) { |
| 683 | 866 | const childIndex = selection.anchor.offset - 1; |
| 684 | if (childIndex >= 0 && removeMentionNode(anchorNode.getChildAtIndex(childIndex))) { | |
| 867 | if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { | |
| 685 | 868 | return true; |
| 686 | 869 | } |
| 687 | 870 | } |
| ... | ... | @@ -690,7 +873,7 @@ function ComposerMentionBackspacePlugin() { |
| 690 | 873 | }, |
| 691 | 874 | COMMAND_PRIORITY_HIGH, |
| 692 | 875 | ); |
| 693 | }, [editor]); | |
| 876 | }, [editor, onRemoveTerminalContext]); | |
| 694 | 877 | |
| 695 | 878 | return null; |
| 696 | 879 | } |
| ... | ... | @@ -698,9 +881,11 @@ function ComposerMentionBackspacePlugin() { |
| 698 | 881 | function ComposerPromptEditorInner({ |
| 699 | 882 | value, |
| 700 | 883 | cursor, |
| 884 | terminalContexts, | |
| 701 | 885 | disabled, |
| 702 | 886 | placeholder, |
| 703 | 887 | className, |
| 888 | onRemoveTerminalContext, | |
| 704 | 889 | onChange, |
| 705 | 890 | onCommandKeyDown, |
| 706 | 891 | onPaste, |
| ... | ... | @@ -709,12 +894,19 @@ function ComposerPromptEditorInner({ |
| 709 | 894 | const [editor] = useLexicalComposerContext(); |
| 710 | 895 | const onChangeRef = useRef(onChange); |
| 711 | 896 | const initialCursor = clampCollapsedComposerCursor(value, cursor); |
| 897 | const terminalContextsSignature = terminalContextSignature(terminalContexts); | |
| 898 | const terminalContextsSignatureRef = useRef(terminalContextsSignature); | |
| 712 | 899 | const snapshotRef = useRef({ |
| 713 | 900 | value, |
| 714 | 901 | cursor: initialCursor, |
| 715 | 902 | expandedCursor: expandCollapsedComposerCursor(value, initialCursor), |
| 903 | terminalContextIds: terminalContexts.map((context) => context.id), | |
| 716 | 904 | }); |
| 717 | 905 | const isApplyingControlledUpdateRef = useRef(false); |
| 906 | const terminalContextActions = useMemo( | |
| 907 | () => ({ onRemoveTerminalContext }), | |
| 908 | [onRemoveTerminalContext], | |
| 909 | ); | |
| 718 | 910 | |
| 719 | 911 | useEffect(() => { |
| 720 | 912 | onChangeRef.current = onChange; |
| ... | ... | @@ -727,7 +919,12 @@ function ComposerPromptEditorInner({ |
| 727 | 919 | useLayoutEffect(() => { |
| 728 | 920 | const normalizedCursor = clampCollapsedComposerCursor(value, cursor); |
| 729 | 921 | const previousSnapshot = snapshotRef.current; |
| 730 | if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { | |
| 922 | const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; | |
| 923 | if ( | |
| 924 | previousSnapshot.value === value && | |
| 925 | previousSnapshot.cursor === normalizedCursor && | |
| 926 | !contextsChanged | |
| 927 | ) { | |
| 731 | 928 | return; |
| 732 | 929 | } |
| 733 | 930 | |
| ... | ... | @@ -735,28 +932,30 @@ function ComposerPromptEditorInner({ |
| 735 | 932 | value, |
| 736 | 933 | cursor: normalizedCursor, |
| 737 | 934 | expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), |
| 935 | terminalContextIds: terminalContexts.map((context) => context.id), | |
| 738 | 936 | }; |
| 937 | terminalContextsSignatureRef.current = terminalContextsSignature; | |
| 739 | 938 | |
| 740 | 939 | const rootElement = editor.getRootElement(); |
| 741 | 940 | const isFocused = Boolean(rootElement && document.activeElement === rootElement); |
| 742 | if (previousSnapshot.value === value && !isFocused) { | |
| 941 | if (previousSnapshot.value === value && !contextsChanged && !isFocused) { | |
| 743 | 942 | return; |
| 744 | 943 | } |
| 745 | 944 | |
| 746 | 945 | isApplyingControlledUpdateRef.current = true; |
| 747 | 946 | editor.update(() => { |
| 748 | const valueChanged = previousSnapshot.value !== value; | |
| 749 | if (previousSnapshot.value !== value) { | |
| 750 | $setComposerEditorPrompt(value); | |
| 947 | const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged; | |
| 948 | if (shouldRewriteEditorState) { | |
| 949 | $setComposerEditorPrompt(value, terminalContexts); | |
| 751 | 950 | } |
| 752 | if (valueChanged || isFocused) { | |
| 951 | if (shouldRewriteEditorState || isFocused) { | |
| 753 | 952 | $setSelectionAtComposerOffset(normalizedCursor); |
| 754 | 953 | } |
| 755 | 954 | }); |
| 756 | 955 | queueMicrotask(() => { |
| 757 | 956 | isApplyingControlledUpdateRef.current = false; |
| 758 | 957 | }); |
| 759 | }, [cursor, editor, value]); | |
| 958 | }, [cursor, editor, terminalContexts, terminalContextsSignature, value]); | |
| 760 | 959 | |
| 761 | 960 | const focusAt = useCallback( |
| 762 | 961 | (nextCursor: number) => { |
| ... | ... | @@ -771,12 +970,14 @@ function ComposerPromptEditorInner({ |
| 771 | 970 | value: snapshotRef.current.value, |
| 772 | 971 | cursor: boundedCursor, |
| 773 | 972 | expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), |
| 973 | terminalContextIds: snapshotRef.current.terminalContextIds, | |
| 774 | 974 | }; |
| 775 | 975 | onChangeRef.current( |
| 776 | 976 | snapshotRef.current.value, |
| 777 | 977 | boundedCursor, |
| 778 | 978 | snapshotRef.current.expandedCursor, |
| 779 | 979 | false, |
| 980 | snapshotRef.current.terminalContextIds, | |
| 780 | 981 | ); |
| 781 | 982 | }, |
| 782 | 983 | [editor], |
| ... | ... | @@ -786,6 +987,7 @@ function ComposerPromptEditorInner({ |
| 786 | 987 | value: string; |
| 787 | 988 | cursor: number; |
| 788 | 989 | expandedCursor: number; |
| 990 | terminalContextIds: string[]; | |
| 789 | 991 | } => { |
| 790 | 992 | let snapshot = snapshotRef.current; |
| 791 | 993 | editor.getEditorState().read(() => { |
| ... | ... | @@ -803,10 +1005,12 @@ function ComposerPromptEditorInner({ |
| 803 | 1005 | nextValue, |
| 804 | 1006 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), |
| 805 | 1007 | ); |
| 1008 | const terminalContextIds = collectTerminalContextIds($getRoot()); | |
| 806 | 1009 | snapshot = { |
| 807 | 1010 | value: nextValue, |
| 808 | 1011 | cursor: nextCursor, |
| 809 | 1012 | expandedCursor: nextExpandedCursor, |
| 1013 | terminalContextIds, | |
| 810 | 1014 | }; |
| 811 | 1015 | }); |
| 812 | 1016 | snapshotRef.current = snapshot; |
| ... | ... | @@ -849,11 +1053,14 @@ function ComposerPromptEditorInner({ |
| 849 | 1053 | nextValue, |
| 850 | 1054 | $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), |
| 851 | 1055 | ); |
| 1056 | const terminalContextIds = collectTerminalContextIds($getRoot()); | |
| 852 | 1057 | const previousSnapshot = snapshotRef.current; |
| 853 | 1058 | if ( |
| 854 | 1059 | previousSnapshot.value === nextValue && |
| 855 | 1060 | previousSnapshot.cursor === nextCursor && |
| 856 | previousSnapshot.expandedCursor === nextExpandedCursor | |
| 1061 | previousSnapshot.expandedCursor === nextExpandedCursor && | |
| 1062 | previousSnapshot.terminalContextIds.length === terminalContextIds.length && | |
| 1063 | previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) | |
| 857 | 1064 | ) { |
| 858 | 1065 | return; |
| 859 | 1066 | } |
| ... | ... | @@ -864,43 +1071,54 @@ function ComposerPromptEditorInner({ |
| 864 | 1071 | value: nextValue, |
| 865 | 1072 | cursor: nextCursor, |
| 866 | 1073 | expandedCursor: nextExpandedCursor, |
| 1074 | terminalContextIds, | |
| 867 | 1075 | }; |
| 868 | 1076 | const cursorAdjacentToMention = |
| 869 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || | |
| 870 | isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); | |
| 871 | onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); | |
| 1077 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || | |
| 1078 | isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); | |
| 1079 | onChangeRef.current( | |
| 1080 | nextValue, | |
| 1081 | nextCursor, | |
| 1082 | nextExpandedCursor, | |
| 1083 | cursorAdjacentToMention, | |
| 1084 | terminalContextIds, | |
| 1085 | ); | |
| 872 | 1086 | }); |
| 873 | 1087 | }, []); |
| 874 | 1088 | |
| 875 | 1089 | return ( |
| 876 | <div className="relative"> | |
| 877 | <PlainTextPlugin | |
| 878 | contentEditable={ | |
| 879 | <ContentEditable | |
| 880 | className={cn( | |
| 881 | "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none", | |
| 882 | className, | |
| 883 | )} | |
| 884 | data-testid="composer-editor" | |
| 885 | aria-placeholder={placeholder} | |
| 886 | placeholder={<span />} | |
| 887 | onPaste={onPaste} | |
| 888 | /> | |
| 889 | } | |
| 890 | placeholder={ | |
| 891 | <div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35"> | |
| 892 | {placeholder} | |
| 893 | </div> | |
| 894 | } | |
| 895 | ErrorBoundary={LexicalErrorBoundary} | |
| 896 | /> | |
| 897 | <OnChangePlugin onChange={handleEditorChange} /> | |
| 898 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> | |
| 899 | <ComposerMentionArrowPlugin /> | |
| 900 | <ComposerMentionSelectionNormalizePlugin /> | |
| 901 | <ComposerMentionBackspacePlugin /> | |
| 902 | <HistoryPlugin /> | |
| 903 | </div> | |
| 1090 | <ComposerTerminalContextActionsContext.Provider value={terminalContextActions}> | |
| 1091 | <div className="relative"> | |
| 1092 | <PlainTextPlugin | |
| 1093 | contentEditable={ | |
| 1094 | <ContentEditable | |
| 1095 | className={cn( | |
| 1096 | "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none", | |
| 1097 | className, | |
| 1098 | )} | |
| 1099 | data-testid="composer-editor" | |
| 1100 | aria-placeholder={placeholder} | |
| 1101 | placeholder={<span />} | |
| 1102 | onPaste={onPaste} | |
| 1103 | /> | |
| 1104 | } | |
| 1105 | placeholder={ | |
| 1106 | terminalContexts.length > 0 ? null : ( | |
| 1107 | <div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35"> | |
| 1108 | {placeholder} | |
| 1109 | </div> | |
| 1110 | ) | |
| 1111 | } | |
| 1112 | ErrorBoundary={LexicalErrorBoundary} | |
| 1113 | /> | |
| 1114 | <OnChangePlugin onChange={handleEditorChange} /> | |
| 1115 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> | |
| 1116 | <ComposerInlineTokenArrowPlugin /> | |
| 1117 | <ComposerInlineTokenSelectionNormalizePlugin /> | |
| 1118 | <ComposerInlineTokenBackspacePlugin /> | |
| 1119 | <HistoryPlugin /> | |
| 1120 | </div> | |
| 1121 | </ComposerTerminalContextActionsContext.Provider> | |
| 904 | 1122 | ); |
| 905 | 1123 | } |
| 906 | 1124 | |
| ... | ... | @@ -908,17 +1126,29 @@ export const ComposerPromptEditor = forwardRef< |
| 908 | 1126 | ComposerPromptEditorHandle, |
| 909 | 1127 | ComposerPromptEditorProps |
| 910 | 1128 | >(function ComposerPromptEditor( |
| 911 | { value, cursor, disabled, placeholder, className, onChange, onCommandKeyDown, onPaste }, | |
| 1129 | { | |
| 1130 | value, | |
| 1131 | cursor, | |
| 1132 | terminalContexts, | |
| 1133 | disabled, | |
| 1134 | placeholder, | |
| 1135 | className, | |
| 1136 | onRemoveTerminalContext, | |
| 1137 | onChange, | |
| 1138 | onCommandKeyDown, | |
| 1139 | onPaste, | |
| 1140 | }, | |
| 912 | 1141 | ref, |
| 913 | 1142 | ) { |
| 914 | 1143 | const initialValueRef = useRef(value); |
| 1144 | const initialTerminalContextsRef = useRef(terminalContexts); | |
| 915 | 1145 | const initialConfig = useMemo<InitialConfigType>( |
| 916 | 1146 | () => ({ |
| 917 | 1147 | namespace: "t3tools-composer-editor", |
| 918 | 1148 | editable: true, |
| 919 | nodes: [ComposerMentionNode], | |
| 1149 | nodes: [ComposerMentionNode, ComposerTerminalContextNode], | |
| 920 | 1150 | editorState: () => { |
| 921 | $setComposerEditorPrompt(initialValueRef.current); | |
| 1151 | $setComposerEditorPrompt(initialValueRef.current, initialTerminalContextsRef.current); | |
| 922 | 1152 | }, |
| 923 | 1153 | onError: (error) => { |
| 924 | 1154 | throw error; |
| ... | ... | @@ -932,8 +1162,10 @@ export const ComposerPromptEditor = forwardRef< |
| 932 | 1162 | <ComposerPromptEditorInner |
| 933 | 1163 | value={value} |
| 934 | 1164 | cursor={cursor} |
| 1165 | terminalContexts={terminalContexts} | |
| 935 | 1166 | disabled={disabled} |
| 936 | 1167 | placeholder={placeholder} |
| 1168 | onRemoveTerminalContext={onRemoveTerminalContext} | |
| 937 | 1169 | onChange={onChange} |
| 938 | 1170 | onPaste={onPaste} |
| 939 | 1171 | editorRef={ref} |
apps/web/src/components/ThreadTerminalDrawer.test.ts created+75| ... | ... | @@ -0,0 +1,75 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | resolveTerminalSelectionActionPosition, | |
| 5 | shouldHandleTerminalSelectionMouseUp, | |
| 6 | terminalSelectionActionDelayForClickCount, | |
| 7 | } from "./ThreadTerminalDrawer"; | |
| 8 | ||
| 9 | describe("resolveTerminalSelectionActionPosition", () => { | |
| 10 | it("prefers the selection rect over the last pointer position", () => { | |
| 11 | expect( | |
| 12 | resolveTerminalSelectionActionPosition({ | |
| 13 | bounds: { left: 100, top: 50, width: 500, height: 220 }, | |
| 14 | selectionRect: { right: 260, bottom: 140 }, | |
| 15 | pointer: { x: 520, y: 200 }, | |
| 16 | viewport: { width: 1024, height: 768 }, | |
| 17 | }), | |
| 18 | ).toEqual({ | |
| 19 | x: 260, | |
| 20 | y: 144, | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("falls back to the pointer position when no selection rect is available", () => { | |
| 25 | expect( | |
| 26 | resolveTerminalSelectionActionPosition({ | |
| 27 | bounds: { left: 100, top: 50, width: 500, height: 220 }, | |
| 28 | selectionRect: null, | |
| 29 | pointer: { x: 180, y: 130 }, | |
| 30 | viewport: { width: 1024, height: 768 }, | |
| 31 | }), | |
| 32 | ).toEqual({ | |
| 33 | x: 180, | |
| 34 | y: 130, | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | it("clamps the pointer fallback into the terminal drawer bounds", () => { | |
| 39 | expect( | |
| 40 | resolveTerminalSelectionActionPosition({ | |
| 41 | bounds: { left: 100, top: 50, width: 500, height: 220 }, | |
| 42 | selectionRect: null, | |
| 43 | pointer: { x: 720, y: 340 }, | |
| 44 | viewport: { width: 1024, height: 768 }, | |
| 45 | }), | |
| 46 | ).toEqual({ | |
| 47 | x: 600, | |
| 48 | y: 270, | |
| 49 | }); | |
| 50 | ||
| 51 | expect( | |
| 52 | resolveTerminalSelectionActionPosition({ | |
| 53 | bounds: { left: 100, top: 50, width: 500, height: 220 }, | |
| 54 | selectionRect: null, | |
| 55 | pointer: { x: 40, y: 20 }, | |
| 56 | viewport: { width: 1024, height: 768 }, | |
| 57 | }), | |
| 58 | ).toEqual({ | |
| 59 | x: 100, | |
| 60 | y: 50, | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 64 | it("delays multi-click selection actions so triple-click selection can complete", () => { | |
| 65 | expect(terminalSelectionActionDelayForClickCount(1)).toBe(0); | |
| 66 | expect(terminalSelectionActionDelayForClickCount(2)).toBe(260); | |
| 67 | expect(terminalSelectionActionDelayForClickCount(3)).toBe(260); | |
| 68 | }); | |
| 69 | ||
| 70 | it("only handles mouseup when the selection gesture started in the terminal", () => { | |
| 71 | expect(shouldHandleTerminalSelectionMouseUp(true, 0)).toBe(true); | |
| 72 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); | |
| 73 | expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); | |
| 74 | }); | |
| 75 | }); |
apps/web/src/components/ThreadTerminalDrawer.tsx+217-1| ... | ... | @@ -12,6 +12,7 @@ import { |
| 12 | 12 | useState, |
| 13 | 13 | } from "react"; |
| 14 | 14 | import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 15 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | |
| 15 | 16 | import { openInPreferredEditor } from "../editorPreferences"; |
| 16 | 17 | import { |
| 17 | 18 | extractTerminalLinks, |
| ... | ... | @@ -29,6 +30,7 @@ import { readNativeApi } from "~/nativeApi"; |
| 29 | 30 | |
| 30 | 31 | const MIN_DRAWER_HEIGHT = 180; |
| 31 | 32 | const MAX_DRAWER_HEIGHT_RATIO = 0.75; |
| 33 | const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; | |
| 32 | 34 | |
| 33 | 35 | function maxDrawerHeight(): number { |
| 34 | 36 | if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; |
| ... | ... | @@ -107,12 +109,85 @@ function terminalThemeFromApp(): ITheme { |
| 107 | 109 | }; |
| 108 | 110 | } |
| 109 | 111 | |
| 112 | function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { | |
| 113 | const selection = window.getSelection(); | |
| 114 | if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { | |
| 115 | return null; | |
| 116 | } | |
| 117 | ||
| 118 | const range = selection.getRangeAt(0); | |
| 119 | const commonAncestor = range.commonAncestorContainer; | |
| 120 | const selectionRoot = | |
| 121 | commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; | |
| 122 | if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { | |
| 123 | return null; | |
| 124 | } | |
| 125 | ||
| 126 | const rects = Array.from(range.getClientRects()).filter( | |
| 127 | (rect) => rect.width > 0 || rect.height > 0, | |
| 128 | ); | |
| 129 | if (rects.length > 0) { | |
| 130 | return rects[rects.length - 1] ?? null; | |
| 131 | } | |
| 132 | ||
| 133 | const boundingRect = range.getBoundingClientRect(); | |
| 134 | return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; | |
| 135 | } | |
| 136 | ||
| 137 | export function resolveTerminalSelectionActionPosition(options: { | |
| 138 | bounds: { left: number; top: number; width: number; height: number }; | |
| 139 | selectionRect: { right: number; bottom: number } | null; | |
| 140 | pointer: { x: number; y: number } | null; | |
| 141 | viewport?: { width: number; height: number } | null; | |
| 142 | }): { x: number; y: number } { | |
| 143 | const { bounds, selectionRect, pointer, viewport } = options; | |
| 144 | const viewportWidth = | |
| 145 | viewport?.width ?? | |
| 146 | (typeof window === "undefined" ? bounds.left + bounds.width + 8 : window.innerWidth); | |
| 147 | const viewportHeight = | |
| 148 | viewport?.height ?? | |
| 149 | (typeof window === "undefined" ? bounds.top + bounds.height + 8 : window.innerHeight); | |
| 150 | const drawerLeft = Math.round(bounds.left); | |
| 151 | const drawerTop = Math.round(bounds.top); | |
| 152 | const drawerRight = Math.round(bounds.left + bounds.width); | |
| 153 | const drawerBottom = Math.round(bounds.top + bounds.height); | |
| 154 | const preferredX = | |
| 155 | selectionRect !== null | |
| 156 | ? Math.round(selectionRect.right) | |
| 157 | : pointer === null | |
| 158 | ? Math.round(bounds.left + bounds.width - 140) | |
| 159 | : Math.max(drawerLeft, Math.min(Math.round(pointer.x), drawerRight)); | |
| 160 | const preferredY = | |
| 161 | selectionRect !== null | |
| 162 | ? Math.round(selectionRect.bottom + 4) | |
| 163 | : pointer === null | |
| 164 | ? Math.round(bounds.top + 12) | |
| 165 | : Math.max(drawerTop, Math.min(Math.round(pointer.y), drawerBottom)); | |
| 166 | return { | |
| 167 | x: Math.max(8, Math.min(preferredX, Math.max(viewportWidth - 8, 8))), | |
| 168 | y: Math.max(8, Math.min(preferredY, Math.max(viewportHeight - 8, 8))), | |
| 169 | }; | |
| 170 | } | |
| 171 | ||
| 172 | export function terminalSelectionActionDelayForClickCount(clickCount: number): number { | |
| 173 | return clickCount >= 2 ? MULTI_CLICK_SELECTION_ACTION_DELAY_MS : 0; | |
| 174 | } | |
| 175 | ||
| 176 | export function shouldHandleTerminalSelectionMouseUp( | |
| 177 | selectionGestureActive: boolean, | |
| 178 | button: number, | |
| 179 | ): boolean { | |
| 180 | return selectionGestureActive && button === 0; | |
| 181 | } | |
| 182 | ||
| 110 | 183 | interface TerminalViewportProps { |
| 111 | 184 | threadId: ThreadId; |
| 112 | 185 | terminalId: string; |
| 186 | terminalLabel: string; | |
| 113 | 187 | cwd: string; |
| 114 | 188 | runtimeEnv?: Record<string, string>; |
| 115 | 189 | onSessionExited: () => void; |
| 190 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | |
| 116 | 191 | focusRequestId: number; |
| 117 | 192 | autoFocus: boolean; |
| 118 | 193 | resizeEpoch: number; |
| ... | ... | @@ -122,9 +197,11 @@ interface TerminalViewportProps { |
| 122 | 197 | function TerminalViewport({ |
| 123 | 198 | threadId, |
| 124 | 199 | terminalId, |
| 200 | terminalLabel, | |
| 125 | 201 | cwd, |
| 126 | 202 | runtimeEnv, |
| 127 | 203 | onSessionExited, |
| 204 | onAddTerminalContext, | |
| 128 | 205 | focusRequestId, |
| 129 | 206 | autoFocus, |
| 130 | 207 | resizeEpoch, |
| ... | ... | @@ -134,12 +211,27 @@ function TerminalViewport({ |
| 134 | 211 | const terminalRef = useRef<Terminal | null>(null); |
| 135 | 212 | const fitAddonRef = useRef<FitAddon | null>(null); |
| 136 | 213 | const onSessionExitedRef = useRef(onSessionExited); |
| 214 | const onAddTerminalContextRef = useRef(onAddTerminalContext); | |
| 215 | const terminalLabelRef = useRef(terminalLabel); | |
| 137 | 216 | const hasHandledExitRef = useRef(false); |
| 217 | const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); | |
| 218 | const selectionGestureActiveRef = useRef(false); | |
| 219 | const selectionActionRequestIdRef = useRef(0); | |
| 220 | const selectionActionOpenRef = useRef(false); | |
| 221 | const selectionActionTimerRef = useRef<number | null>(null); | |
| 138 | 222 | |
| 139 | 223 | useEffect(() => { |
| 140 | 224 | onSessionExitedRef.current = onSessionExited; |
| 141 | 225 | }, [onSessionExited]); |
| 142 | 226 | |
| 227 | useEffect(() => { | |
| 228 | onAddTerminalContextRef.current = onAddTerminalContext; | |
| 229 | }, [onAddTerminalContext]); | |
| 230 | ||
| 231 | useEffect(() => { | |
| 232 | terminalLabelRef.current = terminalLabel; | |
| 233 | }, [terminalLabel]); | |
| 234 | ||
| 143 | 235 | useEffect(() => { |
| 144 | 236 | const mount = containerRef.current; |
| 145 | 237 | if (!mount) return; |
| ... | ... | @@ -165,6 +257,81 @@ function TerminalViewport({ |
| 165 | 257 | const api = readNativeApi(); |
| 166 | 258 | if (!api) return; |
| 167 | 259 | |
| 260 | const clearSelectionAction = () => { | |
| 261 | selectionActionRequestIdRef.current += 1; | |
| 262 | if (selectionActionTimerRef.current !== null) { | |
| 263 | window.clearTimeout(selectionActionTimerRef.current); | |
| 264 | selectionActionTimerRef.current = null; | |
| 265 | } | |
| 266 | }; | |
| 267 | ||
| 268 | const readSelectionAction = (): { | |
| 269 | position: { x: number; y: number }; | |
| 270 | selection: TerminalContextSelection; | |
| 271 | } | null => { | |
| 272 | const activeTerminal = terminalRef.current; | |
| 273 | const mountElement = containerRef.current; | |
| 274 | if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { | |
| 275 | return null; | |
| 276 | } | |
| 277 | const selectionText = activeTerminal.getSelection(); | |
| 278 | const selectionPosition = activeTerminal.getSelectionPosition(); | |
| 279 | const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); | |
| 280 | if (!selectionPosition || normalizedText.length === 0) { | |
| 281 | return null; | |
| 282 | } | |
| 283 | const lineStart = selectionPosition.start.y + 1; | |
| 284 | const lineCount = normalizedText.split("\n").length; | |
| 285 | const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); | |
| 286 | const bounds = mountElement.getBoundingClientRect(); | |
| 287 | const selectionRect = getTerminalSelectionRect(mountElement); | |
| 288 | const position = resolveTerminalSelectionActionPosition({ | |
| 289 | bounds, | |
| 290 | selectionRect: | |
| 291 | selectionRect === null | |
| 292 | ? null | |
| 293 | : { right: selectionRect.right, bottom: selectionRect.bottom }, | |
| 294 | pointer: selectionPointerRef.current, | |
| 295 | }); | |
| 296 | return { | |
| 297 | position, | |
| 298 | selection: { | |
| 299 | terminalId, | |
| 300 | terminalLabel: terminalLabelRef.current, | |
| 301 | lineStart, | |
| 302 | lineEnd, | |
| 303 | text: normalizedText, | |
| 304 | }, | |
| 305 | }; | |
| 306 | }; | |
| 307 | ||
| 308 | const showSelectionAction = async () => { | |
| 309 | if (selectionActionOpenRef.current) { | |
| 310 | return; | |
| 311 | } | |
| 312 | const nextAction = readSelectionAction(); | |
| 313 | if (!nextAction) { | |
| 314 | clearSelectionAction(); | |
| 315 | return; | |
| 316 | } | |
| 317 | const requestId = ++selectionActionRequestIdRef.current; | |
| 318 | selectionActionOpenRef.current = true; | |
| 319 | try { | |
| 320 | const clicked = await api.contextMenu.show( | |
| 321 | [{ id: "add-to-chat", label: "Add to chat" }], | |
| 322 | nextAction.position, | |
| 323 | ); | |
| 324 | if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { | |
| 325 | return; | |
| 326 | } | |
| 327 | onAddTerminalContextRef.current(nextAction.selection); | |
| 328 | terminalRef.current?.clearSelection(); | |
| 329 | terminalRef.current?.focus(); | |
| 330 | } finally { | |
| 331 | selectionActionOpenRef.current = false; | |
| 332 | } | |
| 333 | }; | |
| 334 | ||
| 168 | 335 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 169 | 336 | const activeTerminal = terminalRef.current; |
| 170 | 337 | if (!activeTerminal) return; |
| ... | ... | @@ -259,6 +426,38 @@ function TerminalViewport({ |
| 259 | 426 | ); |
| 260 | 427 | }); |
| 261 | 428 | |
| 429 | const selectionDisposable = terminal.onSelectionChange(() => { | |
| 430 | if (terminalRef.current?.hasSelection()) { | |
| 431 | return; | |
| 432 | } | |
| 433 | clearSelectionAction(); | |
| 434 | }); | |
| 435 | ||
| 436 | const handleMouseUp = (event: MouseEvent) => { | |
| 437 | const shouldHandle = shouldHandleTerminalSelectionMouseUp( | |
| 438 | selectionGestureActiveRef.current, | |
| 439 | event.button, | |
| 440 | ); | |
| 441 | selectionGestureActiveRef.current = false; | |
| 442 | if (!shouldHandle) { | |
| 443 | return; | |
| 444 | } | |
| 445 | selectionPointerRef.current = { x: event.clientX, y: event.clientY }; | |
| 446 | const delay = terminalSelectionActionDelayForClickCount(event.detail); | |
| 447 | selectionActionTimerRef.current = window.setTimeout(() => { | |
| 448 | selectionActionTimerRef.current = null; | |
| 449 | window.requestAnimationFrame(() => { | |
| 450 | void showSelectionAction(); | |
| 451 | }); | |
| 452 | }, delay); | |
| 453 | }; | |
| 454 | const handlePointerDown = (event: PointerEvent) => { | |
| 455 | clearSelectionAction(); | |
| 456 | selectionGestureActiveRef.current = event.button === 0; | |
| 457 | }; | |
| 458 | window.addEventListener("mouseup", handleMouseUp); | |
| 459 | mount.addEventListener("pointerdown", handlePointerDown); | |
| 460 | ||
| 262 | 461 | const themeObserver = new MutationObserver(() => { |
| 263 | 462 | const activeTerminal = terminalRef.current; |
| 264 | 463 | if (!activeTerminal) return; |
| ... | ... | @@ -310,11 +509,13 @@ function TerminalViewport({ |
| 310 | 509 | |
| 311 | 510 | if (event.type === "output") { |
| 312 | 511 | activeTerminal.write(event.data); |
| 512 | clearSelectionAction(); | |
| 313 | 513 | return; |
| 314 | 514 | } |
| 315 | 515 | |
| 316 | 516 | if (event.type === "started" || event.type === "restarted") { |
| 317 | 517 | hasHandledExitRef.current = false; |
| 518 | clearSelectionAction(); | |
| 318 | 519 | activeTerminal.write("\u001bc"); |
| 319 | 520 | if (event.snapshot.history.length > 0) { |
| 320 | 521 | activeTerminal.write(event.snapshot.history); |
| ... | ... | @@ -323,6 +524,7 @@ function TerminalViewport({ |
| 323 | 524 | } |
| 324 | 525 | |
| 325 | 526 | if (event.type === "cleared") { |
| 527 | clearSelectionAction(); | |
| 326 | 528 | activeTerminal.clear(); |
| 327 | 529 | activeTerminal.write("\u001bc"); |
| 328 | 530 | return; |
| ... | ... | @@ -383,7 +585,13 @@ function TerminalViewport({ |
| 383 | 585 | window.clearTimeout(fitTimer); |
| 384 | 586 | unsubscribe(); |
| 385 | 587 | inputDisposable.dispose(); |
| 588 | selectionDisposable.dispose(); | |
| 386 | 589 | terminalLinksDisposable.dispose(); |
| 590 | if (selectionActionTimerRef.current !== null) { | |
| 591 | window.clearTimeout(selectionActionTimerRef.current); | |
| 592 | } | |
| 593 | window.removeEventListener("mouseup", handleMouseUp); | |
| 594 | mount.removeEventListener("pointerdown", handlePointerDown); | |
| 387 | 595 | themeObserver.disconnect(); |
| 388 | 596 | terminalRef.current = null; |
| 389 | 597 | fitAddonRef.current = null; |
| ... | ... | @@ -430,7 +638,9 @@ function TerminalViewport({ |
| 430 | 638 | window.cancelAnimationFrame(frame); |
| 431 | 639 | }; |
| 432 | 640 | }, [drawerHeight, resizeEpoch, terminalId, threadId]); |
| 433 | return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />; | |
| 641 | return ( | |
| 642 | <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-[4px]" /> | |
| 643 | ); | |
| 434 | 644 | } |
| 435 | 645 | |
| 436 | 646 | interface ThreadTerminalDrawerProps { |
| ... | ... | @@ -451,6 +661,7 @@ interface ThreadTerminalDrawerProps { |
| 451 | 661 | onActiveTerminalChange: (terminalId: string) => void; |
| 452 | 662 | onCloseTerminal: (terminalId: string) => void; |
| 453 | 663 | onHeightChange: (height: number) => void; |
| 664 | onAddTerminalContext: (selection: TerminalContextSelection) => void; | |
| 454 | 665 | } |
| 455 | 666 | |
| 456 | 667 | interface TerminalActionButtonProps { |
| ... | ... | @@ -500,6 +711,7 @@ export default function ThreadTerminalDrawer({ |
| 500 | 711 | onActiveTerminalChange, |
| 501 | 712 | onCloseTerminal, |
| 502 | 713 | onHeightChange, |
| 714 | onAddTerminalContext, | |
| 503 | 715 | }: ThreadTerminalDrawerProps) { |
| 504 | 716 | const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); |
| 505 | 717 | const [resizeEpoch, setResizeEpoch] = useState(0); |
| ... | ... | @@ -796,9 +1008,11 @@ export default function ThreadTerminalDrawer({ |
| 796 | 1008 | <TerminalViewport |
| 797 | 1009 | threadId={threadId} |
| 798 | 1010 | terminalId={terminalId} |
| 1011 | terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} | |
| 799 | 1012 | cwd={cwd} |
| 800 | 1013 | {...(runtimeEnv ? { runtimeEnv } : {})} |
| 801 | 1014 | onSessionExited={() => onCloseTerminal(terminalId)} |
| 1015 | onAddTerminalContext={onAddTerminalContext} | |
| 802 | 1016 | focusRequestId={focusRequestId} |
| 803 | 1017 | autoFocus={terminalId === resolvedActiveTerminalId} |
| 804 | 1018 | resizeEpoch={resizeEpoch} |
| ... | ... | @@ -814,9 +1028,11 @@ export default function ThreadTerminalDrawer({ |
| 814 | 1028 | key={resolvedActiveTerminalId} |
| 815 | 1029 | threadId={threadId} |
| 816 | 1030 | terminalId={resolvedActiveTerminalId} |
| 1031 | terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} | |
| 817 | 1032 | cwd={cwd} |
| 818 | 1033 | {...(runtimeEnv ? { runtimeEnv } : {})} |
| 819 | 1034 | onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} |
| 1035 | onAddTerminalContext={onAddTerminalContext} | |
| 820 | 1036 | focusRequestId={focusRequestId} |
| 821 | 1037 | autoFocus |
| 822 | 1038 | resizeEpoch={resizeEpoch} |
apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it } from "vitest"; | |
| 4 | ||
| 5 | import { ComposerPendingTerminalContextChip } from "./ComposerPendingTerminalContexts"; | |
| 6 | ||
| 7 | describe("ComposerPendingTerminalContextChip", () => { | |
| 8 | it("renders expired terminal contexts with error styling", () => { | |
| 9 | const markup = renderToStaticMarkup( | |
| 10 | <ComposerPendingTerminalContextChip | |
| 11 | context={{ | |
| 12 | id: "ctx-expired", | |
| 13 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 14 | terminalId: "default", | |
| 15 | terminalLabel: "Terminal 1", | |
| 16 | lineStart: 2, | |
| 17 | lineEnd: 4, | |
| 18 | text: "", | |
| 19 | createdAt: "2026-03-17T18:42:05.449Z", | |
| 20 | }} | |
| 21 | />, | |
| 22 | ); | |
| 23 | ||
| 24 | expect(markup).toContain('data-terminal-context-expired="true"'); | |
| 25 | expect(markup).toContain("border-destructive/35"); | |
| 26 | expect(markup).toContain("Terminal 1 lines 2-4"); | |
| 27 | }); | |
| 28 | }); |
apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { cn } from "~/lib/utils"; | |
| 2 | import { | |
| 3 | type TerminalContextDraft, | |
| 4 | formatTerminalContextLabel, | |
| 5 | isTerminalContextExpired, | |
| 6 | } from "~/lib/terminalContext"; | |
| 7 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | |
| 8 | ||
| 9 | interface ComposerPendingTerminalContextsProps { | |
| 10 | contexts: ReadonlyArray<TerminalContextDraft>; | |
| 11 | className?: string; | |
| 12 | } | |
| 13 | ||
| 14 | interface ComposerPendingTerminalContextChipProps { | |
| 15 | context: TerminalContextDraft; | |
| 16 | } | |
| 17 | ||
| 18 | export function ComposerPendingTerminalContextChip({ | |
| 19 | context, | |
| 20 | }: ComposerPendingTerminalContextChipProps) { | |
| 21 | const label = formatTerminalContextLabel(context); | |
| 22 | const expired = isTerminalContextExpired(context); | |
| 23 | const tooltipText = expired | |
| 24 | ? `Terminal context expired. Remove and re-add ${label} to include it in your message.` | |
| 25 | : context.text; | |
| 26 | ||
| 27 | return <TerminalContextInlineChip label={label} tooltipText={tooltipText} expired={expired} />; | |
| 28 | } | |
| 29 | ||
| 30 | export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { | |
| 31 | const { contexts, className } = props; | |
| 32 | ||
| 33 | if (contexts.length === 0) { | |
| 34 | return null; | |
| 35 | } | |
| 36 | ||
| 37 | return ( | |
| 38 | <div className={cn("flex flex-wrap gap-1.5", className)}> | |
| 39 | {contexts.map((context) => ( | |
| 40 | <ComposerPendingTerminalContextChip key={context.id} context={context} /> | |
| 41 | ))} | |
| 42 | </div> | |
| 43 | ); | |
| 44 | } |
apps/web/src/components/chat/MessagesTimeline.test.tsx created+99| ... | ... | @@ -0,0 +1,99 @@ |
| 1 | import { MessageId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { beforeAll, describe, expect, it, vi } from "vitest"; | |
| 4 | ||
| 5 | function matchMedia() { | |
| 6 | return { | |
| 7 | matches: false, | |
| 8 | addEventListener: () => {}, | |
| 9 | removeEventListener: () => {}, | |
| 10 | }; | |
| 11 | } | |
| 12 | ||
| 13 | beforeAll(() => { | |
| 14 | const classList = { | |
| 15 | add: () => {}, | |
| 16 | remove: () => {}, | |
| 17 | toggle: () => {}, | |
| 18 | contains: () => false, | |
| 19 | }; | |
| 20 | ||
| 21 | vi.stubGlobal("localStorage", { | |
| 22 | getItem: () => null, | |
| 23 | setItem: () => {}, | |
| 24 | removeItem: () => {}, | |
| 25 | clear: () => {}, | |
| 26 | }); | |
| 27 | vi.stubGlobal("window", { | |
| 28 | matchMedia, | |
| 29 | addEventListener: () => {}, | |
| 30 | removeEventListener: () => {}, | |
| 31 | desktopBridge: undefined, | |
| 32 | }); | |
| 33 | vi.stubGlobal("document", { | |
| 34 | documentElement: { | |
| 35 | classList, | |
| 36 | offsetHeight: 0, | |
| 37 | }, | |
| 38 | }); | |
| 39 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | |
| 40 | callback(0); | |
| 41 | return 0; | |
| 42 | }); | |
| 43 | }); | |
| 44 | ||
| 45 | describe("MessagesTimeline", () => { | |
| 46 | it("renders inline terminal labels with the composer chip UI", async () => { | |
| 47 | const { MessagesTimeline } = await import("./MessagesTimeline"); | |
| 48 | const markup = renderToStaticMarkup( | |
| 49 | <MessagesTimeline | |
| 50 | hasMessages | |
| 51 | isWorking={false} | |
| 52 | activeTurnInProgress={false} | |
| 53 | activeTurnStartedAt={null} | |
| 54 | scrollContainer={null} | |
| 55 | timelineEntries={[ | |
| 56 | { | |
| 57 | id: "entry-1", | |
| 58 | kind: "message", | |
| 59 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 60 | message: { | |
| 61 | id: MessageId.makeUnsafe("message-2"), | |
| 62 | role: "user", | |
| 63 | text: [ | |
| 64 | "yoo what's @terminal-1:1-5 mean", | |
| 65 | "", | |
| 66 | "<terminal_context>", | |
| 67 | "- Terminal 1 lines 1-5:", | |
| 68 | " 1 | julius@mac effect-http-ws-cli % bun i", | |
| 69 | " 2 | bun install v1.3.9 (cf6cdbbb)", | |
| 70 | "</terminal_context>", | |
| 71 | ].join("\n"), | |
| 72 | createdAt: "2026-03-17T19:12:28.000Z", | |
| 73 | streaming: false, | |
| 74 | }, | |
| 75 | }, | |
| 76 | ]} | |
| 77 | completionDividerBeforeEntryId={null} | |
| 78 | completionSummary={null} | |
| 79 | turnDiffSummaryByAssistantMessageId={new Map()} | |
| 80 | nowIso="2026-03-17T19:12:30.000Z" | |
| 81 | expandedWorkGroups={{}} | |
| 82 | onToggleWorkGroup={() => {}} | |
| 83 | onOpenTurnDiff={() => {}} | |
| 84 | revertTurnCountByUserMessageId={new Map()} | |
| 85 | onRevertUserMessage={() => {}} | |
| 86 | isRevertingCheckpoint={false} | |
| 87 | onImageExpand={() => {}} | |
| 88 | markdownCwd={undefined} | |
| 89 | resolvedTheme="light" | |
| 90 | timestampFormat="locale" | |
| 91 | workspaceRoot={undefined} | |
| 92 | />, | |
| 93 | ); | |
| 94 | ||
| 95 | expect(markup).toContain("Terminal 1 lines 1-5"); | |
| 96 | expect(markup).toContain("lucide-terminal"); | |
| 97 | expect(markup).toContain("yoo what&#x27;s "); | |
| 98 | }); | |
| 99 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+135-6| ... | ... | @@ -1,5 +1,14 @@ |
| 1 | 1 | import { type MessageId, type TurnId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 2 | import { | |
| 3 | memo, | |
| 4 | useCallback, | |
| 5 | useEffect, | |
| 6 | useLayoutEffect, | |
| 7 | useMemo, | |
| 8 | useRef, | |
| 9 | useState, | |
| 10 | type ReactNode, | |
| 11 | } from "react"; | |
| 3 | 12 | import { |
| 4 | 13 | measureElement as measureVirtualElement, |
| 5 | 14 | type VirtualItem, |
| ... | ... | @@ -33,9 +42,19 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; |
| 33 | 42 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 34 | 43 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 35 | 44 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; |
| 45 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | |
| 46 | import { | |
| 47 | deriveDisplayedUserMessageState, | |
| 48 | type ParsedTerminalContextEntry, | |
| 49 | } from "~/lib/terminalContext"; | |
| 36 | 50 | import { cn } from "~/lib/utils"; |
| 37 | 51 | import { type TimestampFormat } from "../../appSettings"; |
| 38 | 52 | import { formatTimestamp } from "../../timestampFormat"; |
| 53 | import { | |
| 54 | buildInlineTerminalContextText, | |
| 55 | formatInlineTerminalContextLabel, | |
| 56 | textContainsInlineTerminalContextLabels, | |
| 57 | } from "./userMessageTerminalContexts"; | |
| 39 | 58 | |
| 40 | 59 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| 41 | 60 | const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; |
| ... | ... | @@ -337,6 +356,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 337 | 356 | row.message.role === "user" && |
| 338 | 357 | (() => { |
| 339 | 358 | const userImages = row.message.attachments ?? []; |
| 359 | const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); | |
| 360 | const terminalContexts = displayedUserMessage.contexts; | |
| 340 | 361 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); |
| 341 | 362 | return ( |
| 342 | 363 | <div className="flex justify-end"> |
| ... | ... | @@ -378,14 +399,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 378 | 399 | )} |
| 379 | 400 | </div> |
| 380 | 401 | )} |
| 381 | {row.message.text && ( | |
| 382 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 383 | {row.message.text} | |
| 384 | </pre> | |
| 402 | {(displayedUserMessage.visibleText.trim().length > 0 || | |
| 403 | terminalContexts.length > 0) && ( | |
| 404 | <UserMessageBody | |
| 405 | text={displayedUserMessage.visibleText} | |
| 406 | terminalContexts={terminalContexts} | |
| 407 | /> | |
| 385 | 408 | )} |
| 386 | 409 | <div className="mt-1.5 flex items-center justify-end gap-2"> |
| 387 | 410 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> |
| 388 | {row.message.text && <MessageCopyButton text={row.message.text} />} | |
| 411 | {displayedUserMessage.copyText && ( | |
| 412 | <MessageCopyButton text={displayedUserMessage.copyText} /> | |
| 413 | )} | |
| 389 | 414 | {canRevertAgentWork && ( |
| 390 | 415 | <Button |
| 391 | 416 | type="button" |
| ... | ... | @@ -636,6 +661,110 @@ function formatMessageMeta( |
| 636 | 661 | return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; |
| 637 | 662 | } |
| 638 | 663 | |
| 664 | const UserMessageTerminalContextInlineLabel = memo( | |
| 665 | function UserMessageTerminalContextInlineLabel(props: { context: ParsedTerminalContextEntry }) { | |
| 666 | const tooltipText = | |
| 667 | props.context.body.length > 0 | |
| 668 | ? `${props.context.header}\n${props.context.body}` | |
| 669 | : props.context.header; | |
| 670 | ||
| 671 | return <TerminalContextInlineChip label={props.context.header} tooltipText={tooltipText} />; | |
| 672 | }, | |
| 673 | ); | |
| 674 | ||
| 675 | const UserMessageBody = memo(function UserMessageBody(props: { | |
| 676 | text: string; | |
| 677 | terminalContexts: ParsedTerminalContextEntry[]; | |
| 678 | }) { | |
| 679 | if (props.terminalContexts.length > 0) { | |
| 680 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( | |
| 681 | props.text, | |
| 682 | props.terminalContexts, | |
| 683 | ); | |
| 684 | const inlinePrefix = buildInlineTerminalContextText(props.terminalContexts); | |
| 685 | const inlineNodes: ReactNode[] = []; | |
| 686 | ||
| 687 | if (hasEmbeddedInlineLabels) { | |
| 688 | let cursor = 0; | |
| 689 | ||
| 690 | for (const context of props.terminalContexts) { | |
| 691 | const label = formatInlineTerminalContextLabel(context.header); | |
| 692 | const matchIndex = props.text.indexOf(label, cursor); | |
| 693 | if (matchIndex === -1) { | |
| 694 | inlineNodes.length = 0; | |
| 695 | break; | |
| 696 | } | |
| 697 | if (matchIndex > cursor) { | |
| 698 | inlineNodes.push( | |
| 699 | <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}> | |
| 700 | {props.text.slice(cursor, matchIndex)} | |
| 701 | </span>, | |
| 702 | ); | |
| 703 | } | |
| 704 | inlineNodes.push( | |
| 705 | <UserMessageTerminalContextInlineLabel | |
| 706 | key={`user-terminal-context-inline:${context.header}`} | |
| 707 | context={context} | |
| 708 | />, | |
| 709 | ); | |
| 710 | cursor = matchIndex + label.length; | |
| 711 | } | |
| 712 | ||
| 713 | if (inlineNodes.length > 0) { | |
| 714 | if (cursor < props.text.length) { | |
| 715 | inlineNodes.push( | |
| 716 | <span key={`user-message-terminal-context-inline-rest:${cursor}`}> | |
| 717 | {props.text.slice(cursor)} | |
| 718 | </span>, | |
| 719 | ); | |
| 720 | } | |
| 721 | ||
| 722 | return ( | |
| 723 | <div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground"> | |
| 724 | {inlineNodes} | |
| 725 | </div> | |
| 726 | ); | |
| 727 | } | |
| 728 | } | |
| 729 | ||
| 730 | for (const context of props.terminalContexts) { | |
| 731 | inlineNodes.push( | |
| 732 | <UserMessageTerminalContextInlineLabel | |
| 733 | key={`user-terminal-context-inline:${context.header}`} | |
| 734 | context={context} | |
| 735 | />, | |
| 736 | ); | |
| 737 | inlineNodes.push( | |
| 738 | <span key={`user-terminal-context-inline-space:${context.header}`} aria-hidden="true"> | |
| 739 | {" "} | |
| 740 | </span>, | |
| 741 | ); | |
| 742 | } | |
| 743 | ||
| 744 | if (props.text.length > 0) { | |
| 745 | inlineNodes.push(<span key="user-message-terminal-context-inline-text">{props.text}</span>); | |
| 746 | } else if (inlinePrefix.length === 0) { | |
| 747 | return null; | |
| 748 | } | |
| 749 | ||
| 750 | return ( | |
| 751 | <div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground"> | |
| 752 | {inlineNodes} | |
| 753 | </div> | |
| 754 | ); | |
| 755 | } | |
| 756 | ||
| 757 | if (props.text.length === 0) { | |
| 758 | return null; | |
| 759 | } | |
| 760 | ||
| 761 | return ( | |
| 762 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | |
| 763 | {props.text} | |
| 764 | </pre> | |
| 765 | ); | |
| 766 | }); | |
| 767 | ||
| 639 | 768 | function workToneIcon(tone: TimelineWorkEntry["tone"]): { |
| 640 | 769 | icon: LucideIcon; |
| 641 | 770 | className: string; |
apps/web/src/components/chat/TerminalContextInlineChip.tsx created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import { TerminalIcon } from "lucide-react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | import { | |
| 5 | COMPOSER_INLINE_CHIP_CLASS_NAME, | |
| 6 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | |
| 7 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 8 | } from "../composerInlineChip"; | |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 10 | ||
| 11 | interface TerminalContextInlineChipProps { | |
| 12 | label: string; | |
| 13 | tooltipText: string; | |
| 14 | expired?: boolean; | |
| 15 | } | |
| 16 | ||
| 17 | export function TerminalContextInlineChip(props: TerminalContextInlineChipProps) { | |
| 18 | const { label, tooltipText, expired = false } = props; | |
| 19 | ||
| 20 | return ( | |
| 21 | <Tooltip> | |
| 22 | <TooltipTrigger | |
| 23 | render={ | |
| 24 | <span | |
| 25 | className={cn( | |
| 26 | COMPOSER_INLINE_CHIP_CLASS_NAME, | |
| 27 | expired && "border-destructive/35 bg-destructive/8 text-destructive", | |
| 28 | )} | |
| 29 | data-terminal-context-expired={expired ? "true" : undefined} | |
| 30 | > | |
| 31 | <TerminalIcon | |
| 32 | className={cn( | |
| 33 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | |
| 34 | "size-3.5", | |
| 35 | expired && "opacity-100", | |
| 36 | )} | |
| 37 | /> | |
| 38 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{label}</span> | |
| 39 | </span> | |
| 40 | } | |
| 41 | /> | |
| 42 | <TooltipPopup side="top" className="max-w-80 whitespace-pre-wrap leading-tight"> | |
| 43 | {tooltipText} | |
| 44 | </TooltipPopup> | |
| 45 | </Tooltip> | |
| 46 | ); | |
| 47 | } |
apps/web/src/components/chat/userMessageTerminalContexts.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | buildInlineTerminalContextText, | |
| 5 | formatInlineTerminalContextLabel, | |
| 6 | textContainsInlineTerminalContextLabels, | |
| 7 | } from "./userMessageTerminalContexts"; | |
| 8 | ||
| 9 | describe("userMessageTerminalContexts", () => { | |
| 10 | it("builds plain inline terminal text labels", () => { | |
| 11 | expect( | |
| 12 | buildInlineTerminalContextText([ | |
| 13 | { header: "Terminal 1 lines 12-13" }, | |
| 14 | { header: "Terminal 2 line 4" }, | |
| 15 | ]), | |
| 16 | ).toBe("@terminal-1:12-13 @terminal-2:4"); | |
| 17 | }); | |
| 18 | ||
| 19 | it("formats individual inline terminal labels compactly", () => { | |
| 20 | expect(formatInlineTerminalContextLabel("Terminal 1 lines 12-13")).toBe("@terminal-1:12-13"); | |
| 21 | expect(formatInlineTerminalContextLabel("Terminal 2 line 4")).toBe("@terminal-2:4"); | |
| 22 | }); | |
| 23 | ||
| 24 | it("detects inline terminal labels embedded in user message text", () => { | |
| 25 | expect( | |
| 26 | textContainsInlineTerminalContextLabels("yo @terminal-1:12-13 whats up", [ | |
| 27 | { header: "Terminal 1 lines 12-13" }, | |
| 28 | ]), | |
| 29 | ).toBe(true); | |
| 30 | expect( | |
| 31 | textContainsInlineTerminalContextLabels("yo whats up", [ | |
| 32 | { header: "Terminal 1 lines 12-13" }, | |
| 33 | ]), | |
| 34 | ).toBe(false); | |
| 35 | }); | |
| 36 | }); |
apps/web/src/components/chat/userMessageTerminalContexts.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import { formatInlineTerminalContextLabel as formatInlineTerminalContextSelectionLabel } from "~/lib/terminalContext"; | |
| 2 | ||
| 3 | const TERMINAL_CONTEXT_HEADER_PATTERN = /^(.*?)\s+line(?:s)?\s+(\d+)(?:-(\d+))?$/i; | |
| 4 | ||
| 5 | export function buildInlineTerminalContextText( | |
| 6 | contexts: ReadonlyArray<{ | |
| 7 | header: string; | |
| 8 | }>, | |
| 9 | ): string { | |
| 10 | return contexts | |
| 11 | .map((context) => context.header.trim()) | |
| 12 | .filter((header) => header.length > 0) | |
| 13 | .map(formatInlineTerminalContextLabel) | |
| 14 | .join(" "); | |
| 15 | } | |
| 16 | ||
| 17 | export function formatInlineTerminalContextLabel(header: string): string { | |
| 18 | const trimmedHeader = header.trim(); | |
| 19 | const match = TERMINAL_CONTEXT_HEADER_PATTERN.exec(trimmedHeader); | |
| 20 | if (!match) { | |
| 21 | return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`; | |
| 22 | } | |
| 23 | ||
| 24 | const lineStart = Number.parseInt(match[2] ?? "", 10); | |
| 25 | const lineEnd = Number.parseInt(match[3] ?? match[2] ?? "", 10); | |
| 26 | if (!Number.isFinite(lineStart) || !Number.isFinite(lineEnd)) { | |
| 27 | return `@${trimmedHeader.toLowerCase().replace(/\s+/g, "-")}`; | |
| 28 | } | |
| 29 | ||
| 30 | return formatInlineTerminalContextSelectionLabel({ | |
| 31 | terminalLabel: match[1]?.trim() || "terminal", | |
| 32 | lineStart, | |
| 33 | lineEnd, | |
| 34 | }); | |
| 35 | } | |
| 36 | ||
| 37 | export function textContainsInlineTerminalContextLabels( | |
| 38 | text: string, | |
| 39 | contexts: ReadonlyArray<{ | |
| 40 | header: string; | |
| 41 | }>, | |
| 42 | ): boolean { | |
| 43 | let searchStartIndex = 0; | |
| 44 | ||
| 45 | for (const context of contexts) { | |
| 46 | const label = formatInlineTerminalContextLabel(context.header); | |
| 47 | const matchIndex = text.indexOf(label, searchStartIndex); | |
| 48 | if (matchIndex === -1) { | |
| 49 | return false; | |
| 50 | } | |
| 51 | searchStartIndex = matchIndex + label.length; | |
| 52 | } | |
| 53 | ||
| 54 | return true; | |
| 55 | } |
apps/web/src/components/composerInlineChip.ts created+9| ... | ... | @@ -0,0 +1,9 @@ |
| 1 | export const COMPOSER_INLINE_CHIP_CLASS_NAME = | |
| 2 | "inline-flex max-w-full 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"; | |
| 3 | ||
| 4 | export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; | |
| 5 | ||
| 6 | export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leading-tight"; | |
| 7 | ||
| 8 | export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = | |
| 9 | "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; |
apps/web/src/components/timelineHeight.test.ts+31| ... | ... | @@ -1,5 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | import { appendTerminalContextsToPrompt } from "../lib/terminalContext"; | |
| 4 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | |
| 3 | 5 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 4 | 6 | |
| 5 | 7 | describe("estimateTimelineMessageHeight", () => { |
| ... | ... | @@ -75,6 +77,35 @@ describe("estimateTimelineMessageHeight", () => { |
| 75 | 77 | ).toBe(162); |
| 76 | 78 | }); |
| 77 | 79 | |
| 80 | it("adds terminal context chrome without counting the hidden block as message text", () => { | |
| 81 | const prompt = appendTerminalContextsToPrompt("Investigate this", [ | |
| 82 | { | |
| 83 | terminalId: "default", | |
| 84 | terminalLabel: "Terminal 1", | |
| 85 | lineStart: 40, | |
| 86 | lineEnd: 43, | |
| 87 | text: [ | |
| 88 | "git status", | |
| 89 | "M apps/web/src/components/chat/MessagesTimeline.tsx", | |
| 90 | "?? tmp", | |
| 91 | "", | |
| 92 | ].join("\n"), | |
| 93 | }, | |
| 94 | ]); | |
| 95 | ||
| 96 | expect( | |
| 97 | estimateTimelineMessageHeight({ | |
| 98 | role: "user", | |
| 99 | text: prompt, | |
| 100 | }), | |
| 101 | ).toBe( | |
| 102 | estimateTimelineMessageHeight({ | |
| 103 | role: "user", | |
| 104 | text: `${buildInlineTerminalContextText([{ header: "Terminal 1 lines 40-43" }])} Investigate this`, | |
| 105 | }), | |
| 106 | ); | |
| 107 | }); | |
| 108 | ||
| 78 | 109 | it("uses narrower width to increase user line wrapping", () => { |
| 79 | 110 | const message = { |
| 80 | 111 | role: "user" as const, |
apps/web/src/components/timelineHeight.ts+14-1| ... | ... | @@ -1,3 +1,6 @@ |
| 1 | import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; | |
| 2 | import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; | |
| 3 | ||
| 1 | 4 | const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; |
| 2 | 5 | const USER_CHARS_PER_LINE_FALLBACK = 56; |
| 3 | 6 | const LINE_HEIGHT_PX = 22; |
| ... | ... | @@ -75,7 +78,17 @@ export function estimateTimelineMessageHeight( |
| 75 | 78 | |
| 76 | 79 | if (message.role === "user") { |
| 77 | 80 | const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); |
| 78 | const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); | |
| 81 | const displayedUserMessage = deriveDisplayedUserMessageState(message.text); | |
| 82 | const renderedText = | |
| 83 | displayedUserMessage.contexts.length > 0 | |
| 84 | ? [ | |
| 85 | buildInlineTerminalContextText(displayedUserMessage.contexts), | |
| 86 | displayedUserMessage.visibleText, | |
| 87 | ] | |
| 88 | .filter((part) => part.length > 0) | |
| 89 | .join(" ") | |
| 90 | : displayedUserMessage.visibleText; | |
| 91 | const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); | |
| 79 | 92 | const attachmentCount = message.attachments?.length ?? 0; |
| 80 | 93 | const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); |
| 81 | 94 | const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; |
apps/web/src/composer-editor-mentions.test.ts+14| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 4 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | |
| 4 | 5 | |
| 5 | 6 | describe("splitPromptIntoComposerSegments", () => { |
| 6 | 7 | it("splits mention tokens followed by whitespace into mention segments", () => { |
| ... | ... | @@ -24,4 +25,17 @@ describe("splitPromptIntoComposerSegments", () => { |
| 24 | 25 | { type: "text", text: " \ntwo" }, |
| 25 | 26 | ]); |
| 26 | 27 | }); |
| 28 | ||
| 29 | it("keeps inline terminal context placeholders at their prompt positions", () => { | |
| 30 | expect( | |
| 31 | splitPromptIntoComposerSegments( | |
| 32 | `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}@AGENTS.md please`, | |
| 33 | ), | |
| 34 | ).toEqual([ | |
| 35 | { type: "text", text: "Inspect " }, | |
| 36 | { type: "terminal-context", context: null }, | |
| 37 | { type: "mention", path: "AGENTS.md" }, | |
| 38 | { type: "text", text: " please" }, | |
| 39 | ]); | |
| 40 | }); | |
| 27 | 41 | }); |
apps/web/src/composer-editor-mentions.ts+51-7| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 3 | type TerminalContextDraft, | |
| 4 | } from "./lib/terminalContext"; | |
| 5 | ||
| 1 | 6 | export type ComposerPromptSegment = |
| 2 | 7 | | { |
| 3 | 8 | type: "text"; |
| ... | ... | @@ -6,6 +11,10 @@ export type ComposerPromptSegment = |
| 6 | 11 | | { |
| 7 | 12 | type: "mention"; |
| 8 | 13 | path: string; |
| 14 | } | |
| 15 | | { | |
| 16 | type: "terminal-context"; | |
| 17 | context: TerminalContextDraft | null; | |
| 9 | 18 | }; |
| 10 | 19 | |
| 11 | 20 | const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; |
| ... | ... | @@ -20,14 +29,14 @@ function pushTextSegment(segments: ComposerPromptSegment[], text: string): void |
| 20 | 29 | segments.push({ type: "text", text }); |
| 21 | 30 | } |
| 22 | 31 | |
| 23 | export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptSegment[] { | |
| 32 | function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegment[] { | |
| 24 | 33 | const segments: ComposerPromptSegment[] = []; |
| 25 | if (!prompt) { | |
| 34 | if (!text) { | |
| 26 | 35 | return segments; |
| 27 | 36 | } |
| 28 | 37 | |
| 29 | 38 | let cursor = 0; |
| 30 | for (const match of prompt.matchAll(MENTION_TOKEN_REGEX)) { | |
| 39 | for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { | |
| 31 | 40 | const fullMatch = match[0]; |
| 32 | 41 | const prefix = match[1] ?? ""; |
| 33 | 42 | const path = match[2] ?? ""; |
| ... | ... | @@ -36,20 +45,55 @@ export function splitPromptIntoComposerSegments(prompt: string): ComposerPromptS |
| 36 | 45 | const mentionEnd = mentionStart + fullMatch.length - prefix.length; |
| 37 | 46 | |
| 38 | 47 | if (mentionStart > cursor) { |
| 39 | pushTextSegment(segments, prompt.slice(cursor, mentionStart)); | |
| 48 | pushTextSegment(segments, text.slice(cursor, mentionStart)); | |
| 40 | 49 | } |
| 41 | 50 | |
| 42 | 51 | if (path.length > 0) { |
| 43 | 52 | segments.push({ type: "mention", path }); |
| 44 | 53 | } else { |
| 45 | pushTextSegment(segments, prompt.slice(mentionStart, mentionEnd)); | |
| 54 | pushTextSegment(segments, text.slice(mentionStart, mentionEnd)); | |
| 46 | 55 | } |
| 47 | 56 | |
| 48 | 57 | cursor = mentionEnd; |
| 49 | 58 | } |
| 50 | 59 | |
| 51 | if (cursor < prompt.length) { | |
| 52 | pushTextSegment(segments, prompt.slice(cursor)); | |
| 60 | if (cursor < text.length) { | |
| 61 | pushTextSegment(segments, text.slice(cursor)); | |
| 62 | } | |
| 63 | ||
| 64 | return segments; | |
| 65 | } | |
| 66 | ||
| 67 | export function splitPromptIntoComposerSegments( | |
| 68 | prompt: string, | |
| 69 | terminalContexts: ReadonlyArray<TerminalContextDraft> = [], | |
| 70 | ): ComposerPromptSegment[] { | |
| 71 | if (!prompt) { | |
| 72 | return []; | |
| 73 | } | |
| 74 | ||
| 75 | const segments: ComposerPromptSegment[] = []; | |
| 76 | let textCursor = 0; | |
| 77 | let terminalContextIndex = 0; | |
| 78 | ||
| 79 | for (let index = 0; index < prompt.length; index += 1) { | |
| 80 | if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { | |
| 81 | continue; | |
| 82 | } | |
| 83 | ||
| 84 | if (index > textCursor) { | |
| 85 | segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor, index))); | |
| 86 | } | |
| 87 | segments.push({ | |
| 88 | type: "terminal-context", | |
| 89 | context: terminalContexts[terminalContextIndex] ?? null, | |
| 90 | }); | |
| 91 | terminalContextIndex += 1; | |
| 92 | textCursor = index + 1; | |
| 93 | } | |
| 94 | ||
| 95 | if (textCursor < prompt.length) { | |
| 96 | segments.push(...splitPromptTextIntoComposerSegments(prompt.slice(textCursor))); | |
| 53 | 97 | } |
| 54 | 98 | |
| 55 | 99 | return segments; |
apps/web/src/composer-logic.test.ts+22-12| ... | ... | @@ -5,10 +5,11 @@ import { |
| 5 | 5 | collapseExpandedComposerCursor, |
| 6 | 6 | detectComposerTrigger, |
| 7 | 7 | expandCollapsedComposerCursor, |
| 8 | isCollapsedCursorAdjacentToMention, | |
| 8 | isCollapsedCursorAdjacentToInlineToken, | |
| 9 | 9 | parseStandaloneComposerSlashCommand, |
| 10 | 10 | replaceTextRange, |
| 11 | 11 | } from "./composer-logic"; |
| 12 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | |
| 12 | 13 | |
| 13 | 14 | describe("detectComposerTrigger", () => { |
| 14 | 15 | it("detects @path trigger at cursor", () => { |
| ... | ... | @@ -192,16 +193,16 @@ describe("replaceTextRange trailing space consumption", () => { |
| 192 | 193 | }); |
| 193 | 194 | }); |
| 194 | 195 | |
| 195 | describe("isCollapsedCursorAdjacentToMention", () => { | |
| 196 | describe("isCollapsedCursorAdjacentToInlineToken", () => { | |
| 196 | 197 | it("returns false when no mention exists", () => { |
| 197 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); | |
| 198 | expect(isCollapsedCursorAdjacentToMention("plain text", 6, "right")).toBe(false); | |
| 198 | expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "left")).toBe(false); | |
| 199 | expect(isCollapsedCursorAdjacentToInlineToken("plain text", 6, "right")).toBe(false); | |
| 199 | 200 | }); |
| 200 | 201 | |
| 201 | 202 | it("keeps @query typing non-adjacent while no mention pill exists", () => { |
| 202 | 203 | const text = "hello @pac"; |
| 203 | expect(isCollapsedCursorAdjacentToMention(text, text.length, "left")).toBe(false); | |
| 204 | expect(isCollapsedCursorAdjacentToMention(text, text.length, "right")).toBe(false); | |
| 204 | expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "left")).toBe(false); | |
| 205 | expect(isCollapsedCursorAdjacentToInlineToken(text, text.length, "right")).toBe(false); | |
| 205 | 206 | }); |
| 206 | 207 | |
| 207 | 208 | it("detects left adjacency only when cursor is directly after a mention", () => { |
| ... | ... | @@ -209,9 +210,9 @@ describe("isCollapsedCursorAdjacentToMention", () => { |
| 209 | 210 | const mentionStart = "open ".length; |
| 210 | 211 | const mentionEnd = mentionStart + 1; |
| 211 | 212 | |
| 212 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "left")).toBe(true); | |
| 213 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "left")).toBe(false); | |
| 214 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd + 1, "left")).toBe(false); | |
| 213 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "left")).toBe(true); | |
| 214 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "left")).toBe(false); | |
| 215 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd + 1, "left")).toBe(false); | |
| 215 | 216 | }); |
| 216 | 217 | |
| 217 | 218 | it("detects right adjacency only when cursor is directly before a mention", () => { |
| ... | ... | @@ -219,9 +220,18 @@ describe("isCollapsedCursorAdjacentToMention", () => { |
| 219 | 220 | const mentionStart = "open ".length; |
| 220 | 221 | const mentionEnd = mentionStart + 1; |
| 221 | 222 | |
| 222 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "right")).toBe(true); | |
| 223 | expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "right")).toBe(false); | |
| 224 | expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); | |
| 223 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart, "right")).toBe(true); | |
| 224 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionEnd, "right")).toBe(false); | |
| 225 | expect(isCollapsedCursorAdjacentToInlineToken(text, mentionStart - 1, "right")).toBe(false); | |
| 226 | }); | |
| 227 | ||
| 228 | it("treats terminal pills as inline tokens for adjacency checks", () => { | |
| 229 | const text = `open ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} next`; | |
| 230 | const tokenStart = "open ".length; | |
| 231 | const tokenEnd = tokenStart + 1; | |
| 232 | ||
| 233 | expect(isCollapsedCursorAdjacentToInlineToken(text, tokenEnd, "left")).toBe(true); | |
| 234 | expect(isCollapsedCursorAdjacentToInlineToken(text, tokenStart, "right")).toBe(true); | |
| 225 | 235 | }); |
| 226 | 236 | }); |
| 227 | 237 |
apps/web/src/composer-logic.ts+40-7| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | |
| 2 | 3 | |
| 3 | 4 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; |
| 4 | 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| ... | ... | @@ -11,6 +12,9 @@ export interface ComposerTrigger { |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; |
| 15 | const isInlineTokenSegment = ( | |
| 16 | segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, | |
| 17 | ): boolean => segment.type !== "text"; | |
| 14 | 18 | |
| 15 | 19 | function clampCursor(text: string, cursor: number): number { |
| 16 | 20 | if (!Number.isFinite(cursor)) return text.length; |
| ... | ... | @@ -18,7 +22,13 @@ function clampCursor(text: string, cursor: number): number { |
| 18 | 22 | } |
| 19 | 23 | |
| 20 | 24 | function isWhitespace(char: string): boolean { |
| 21 | return char === " " || char === "\n" || char === "\t" || char === "\r"; | |
| 25 | return ( | |
| 26 | char === " " || | |
| 27 | char === "\n" || | |
| 28 | char === "\t" || | |
| 29 | char === "\r" || | |
| 30 | char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER | |
| 31 | ); | |
| 22 | 32 | } |
| 23 | 33 | |
| 24 | 34 | function tokenStartForCursor(text: string, cursor: number): number { |
| ... | ... | @@ -49,6 +59,14 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 49 | 59 | expandedCursor += expandedLength; |
| 50 | 60 | continue; |
| 51 | 61 | } |
| 62 | if (segment.type === "terminal-context") { | |
| 63 | if (remaining <= 1) { | |
| 64 | return expandedCursor + remaining; | |
| 65 | } | |
| 66 | remaining -= 1; | |
| 67 | expandedCursor += 1; | |
| 68 | continue; | |
| 69 | } | |
| 52 | 70 | |
| 53 | 71 | const segmentLength = segment.text.length; |
| 54 | 72 | if (remaining <= segmentLength) { |
| ... | ... | @@ -62,13 +80,18 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) |
| 62 | 80 | } |
| 63 | 81 | |
| 64 | 82 | function collapsedSegmentLength( |
| 65 | segment: { type: "text"; text: string } | { type: "mention" }, | |
| 83 | segment: { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" }, | |
| 66 | 84 | ): number { |
| 67 | return segment.type === "mention" ? 1 : segment.text.length; | |
| 85 | if (segment.type === "text") { | |
| 86 | return segment.text.length; | |
| 87 | } | |
| 88 | return 1; | |
| 68 | 89 | } |
| 69 | 90 | |
| 70 | 91 | function clampCollapsedComposerCursorForSegments( |
| 71 | segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, | |
| 92 | segments: ReadonlyArray< | |
| 93 | { type: "text"; text: string } | { type: "mention" } | { type: "terminal-context" } | |
| 94 | >, | |
| 72 | 95 | cursorInput: number, |
| 73 | 96 | ): number { |
| 74 | 97 | const collapsedLength = segments.reduce( |
| ... | ... | @@ -111,6 +134,14 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number |
| 111 | 134 | collapsedCursor += 1; |
| 112 | 135 | continue; |
| 113 | 136 | } |
| 137 | if (segment.type === "terminal-context") { | |
| 138 | if (remaining <= 1) { | |
| 139 | return collapsedCursor + remaining; | |
| 140 | } | |
| 141 | remaining -= 1; | |
| 142 | collapsedCursor += 1; | |
| 143 | continue; | |
| 144 | } | |
| 114 | 145 | |
| 115 | 146 | const segmentLength = segment.text.length; |
| 116 | 147 | if (remaining <= segmentLength) { |
| ... | ... | @@ -123,13 +154,13 @@ export function collapseExpandedComposerCursor(text: string, cursorInput: number |
| 123 | 154 | return collapsedCursor; |
| 124 | 155 | } |
| 125 | 156 | |
| 126 | export function isCollapsedCursorAdjacentToMention( | |
| 157 | export function isCollapsedCursorAdjacentToInlineToken( | |
| 127 | 158 | text: string, |
| 128 | 159 | cursorInput: number, |
| 129 | 160 | direction: "left" | "right", |
| 130 | 161 | ): boolean { |
| 131 | 162 | const segments = splitPromptIntoComposerSegments(text); |
| 132 | if (!segments.some((segment) => segment.type === "mention")) { | |
| 163 | if (!segments.some(isInlineTokenSegment)) { | |
| 133 | 164 | return false; |
| 134 | 165 | } |
| 135 | 166 | |
| ... | ... | @@ -137,7 +168,7 @@ export function isCollapsedCursorAdjacentToMention( |
| 137 | 168 | let collapsedOffset = 0; |
| 138 | 169 | |
| 139 | 170 | for (const segment of segments) { |
| 140 | if (segment.type === "mention") { | |
| 171 | if (isInlineTokenSegment(segment)) { | |
| 141 | 172 | if (direction === "left" && cursor === collapsedOffset + 1) { |
| 142 | 173 | return true; |
| 143 | 174 | } |
| ... | ... | @@ -151,6 +182,8 @@ export function isCollapsedCursorAdjacentToMention( |
| 151 | 182 | return false; |
| 152 | 183 | } |
| 153 | 184 | |
| 185 | export const isCollapsedCursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken; | |
| 186 | ||
| 154 | 187 | export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { |
| 155 | 188 | const cursor = clampCursor(text, cursorInput); |
| 156 | 189 | const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; |
apps/web/src/composerDraftStore.test.ts+167| ... | ... | @@ -6,6 +6,11 @@ import { |
| 6 | 6 | createDebouncedStorage, |
| 7 | 7 | useComposerDraftStore, |
| 8 | 8 | } from "./composerDraftStore"; |
| 9 | import { | |
| 10 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 11 | insertInlineTerminalContextPlaceholder, | |
| 12 | type TerminalContextDraft, | |
| 13 | } from "./lib/terminalContext"; | |
| 9 | 14 | |
| 10 | 15 | function makeImage(input: { |
| 11 | 16 | id: string; |
| ... | ... | @@ -34,6 +39,26 @@ function makeImage(input: { |
| 34 | 39 | }; |
| 35 | 40 | } |
| 36 | 41 | |
| 42 | function makeTerminalContext(input: { | |
| 43 | id: string; | |
| 44 | text?: string; | |
| 45 | terminalId?: string; | |
| 46 | terminalLabel?: string; | |
| 47 | lineStart?: number; | |
| 48 | lineEnd?: number; | |
| 49 | }): TerminalContextDraft { | |
| 50 | return { | |
| 51 | id: input.id, | |
| 52 | threadId: ThreadId.makeUnsafe("thread-dedupe"), | |
| 53 | terminalId: input.terminalId ?? "default", | |
| 54 | terminalLabel: input.terminalLabel ?? "Terminal 1", | |
| 55 | lineStart: input.lineStart ?? 4, | |
| 56 | lineEnd: input.lineEnd ?? 5, | |
| 57 | text: input.text ?? "git status\nOn branch main", | |
| 58 | createdAt: "2026-03-13T12:00:00.000Z", | |
| 59 | }; | |
| 60 | } | |
| 61 | ||
| 37 | 62 | describe("composerDraftStore addImages", () => { |
| 38 | 63 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 39 | 64 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| ... | ... | @@ -158,6 +183,148 @@ describe("composerDraftStore clearComposerContent", () => { |
| 158 | 183 | }); |
| 159 | 184 | }); |
| 160 | 185 | |
| 186 | describe("composerDraftStore terminal contexts", () => { | |
| 187 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); | |
| 188 | ||
| 189 | beforeEach(() => { | |
| 190 | useComposerDraftStore.setState({ | |
| 191 | draftsByThreadId: {}, | |
| 192 | draftThreadsByThreadId: {}, | |
| 193 | projectDraftThreadIdByProjectId: {}, | |
| 194 | }); | |
| 195 | }); | |
| 196 | ||
| 197 | it("deduplicates identical terminal contexts by selection signature", () => { | |
| 198 | const first = makeTerminalContext({ id: "ctx-1" }); | |
| 199 | const duplicate = makeTerminalContext({ id: "ctx-2" }); | |
| 200 | ||
| 201 | useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); | |
| 202 | ||
| 203 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 204 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); | |
| 205 | }); | |
| 206 | ||
| 207 | it("clears terminal contexts when clearing composer content", () => { | |
| 208 | useComposerDraftStore | |
| 209 | .getState() | |
| 210 | .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); | |
| 211 | ||
| 212 | useComposerDraftStore.getState().clearComposerContent(threadId); | |
| 213 | ||
| 214 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | |
| 215 | }); | |
| 216 | ||
| 217 | it("inserts terminal contexts at the requested inline prompt position", () => { | |
| 218 | const firstInsertion = insertInlineTerminalContextPlaceholder("alpha beta", 6); | |
| 219 | const secondInsertion = insertInlineTerminalContextPlaceholder(firstInsertion.prompt, 0); | |
| 220 | ||
| 221 | expect( | |
| 222 | useComposerDraftStore | |
| 223 | .getState() | |
| 224 | .insertTerminalContext( | |
| 225 | threadId, | |
| 226 | firstInsertion.prompt, | |
| 227 | makeTerminalContext({ id: "ctx-1" }), | |
| 228 | firstInsertion.contextIndex, | |
| 229 | ), | |
| 230 | ).toBe(true); | |
| 231 | expect( | |
| 232 | useComposerDraftStore.getState().insertTerminalContext( | |
| 233 | threadId, | |
| 234 | secondInsertion.prompt, | |
| 235 | makeTerminalContext({ | |
| 236 | id: "ctx-2", | |
| 237 | terminalLabel: "Terminal 2", | |
| 238 | lineStart: 9, | |
| 239 | lineEnd: 10, | |
| 240 | }), | |
| 241 | secondInsertion.contextIndex, | |
| 242 | ), | |
| 243 | ).toBe(true); | |
| 244 | ||
| 245 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | |
| 246 | expect(draft?.prompt).toBe( | |
| 247 | `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, | |
| 248 | ); | |
| 249 | expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-2", "ctx-1"]); | |
| 250 | }); | |
| 251 | ||
| 252 | it("omits terminal context text from persisted drafts", () => { | |
| 253 | useComposerDraftStore | |
| 254 | .getState() | |
| 255 | .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); | |
| 256 | ||
| 257 | const persistApi = useComposerDraftStore.persist as unknown as { | |
| 258 | getOptions: () => { | |
| 259 | partialize: (state: ReturnType<typeof useComposerDraftStore.getState>) => unknown; | |
| 260 | }; | |
| 261 | }; | |
| 262 | const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { | |
| 263 | draftsByThreadId?: Record<string, { terminalContexts?: Array<Record<string, unknown>> }>; | |
| 264 | }; | |
| 265 | ||
| 266 | expect( | |
| 267 | persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], | |
| 268 | "Expected terminal context metadata to be persisted.", | |
| 269 | ).toMatchObject({ | |
| 270 | id: "ctx-persist", | |
| 271 | terminalId: "default", | |
| 272 | terminalLabel: "Terminal 1", | |
| 273 | lineStart: 4, | |
| 274 | lineEnd: 5, | |
| 275 | }); | |
| 276 | expect( | |
| 277 | persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, | |
| 278 | ).toBeUndefined(); | |
| 279 | }); | |
| 280 | ||
| 281 | it("hydrates persisted terminal contexts without in-memory snapshot text", () => { | |
| 282 | const persistApi = useComposerDraftStore.persist as unknown as { | |
| 283 | getOptions: () => { | |
| 284 | merge: ( | |
| 285 | persistedState: unknown, | |
| 286 | currentState: ReturnType<typeof useComposerDraftStore.getState>, | |
| 287 | ) => ReturnType<typeof useComposerDraftStore.getState>; | |
| 288 | }; | |
| 289 | }; | |
| 290 | const mergedState = persistApi.getOptions().merge( | |
| 291 | { | |
| 292 | draftsByThreadId: { | |
| 293 | [threadId]: { | |
| 294 | prompt: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 295 | attachments: [], | |
| 296 | terminalContexts: [ | |
| 297 | { | |
| 298 | id: "ctx-rehydrated", | |
| 299 | threadId, | |
| 300 | createdAt: "2026-03-13T12:00:00.000Z", | |
| 301 | terminalId: "default", | |
| 302 | terminalLabel: "Terminal 1", | |
| 303 | lineStart: 4, | |
| 304 | lineEnd: 5, | |
| 305 | }, | |
| 306 | ], | |
| 307 | }, | |
| 308 | }, | |
| 309 | draftThreadsByThreadId: {}, | |
| 310 | projectDraftThreadIdByProjectId: {}, | |
| 311 | }, | |
| 312 | useComposerDraftStore.getInitialState(), | |
| 313 | ); | |
| 314 | ||
| 315 | expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ | |
| 316 | { | |
| 317 | id: "ctx-rehydrated", | |
| 318 | terminalId: "default", | |
| 319 | terminalLabel: "Terminal 1", | |
| 320 | lineStart: 4, | |
| 321 | lineEnd: 5, | |
| 322 | text: "", | |
| 323 | }, | |
| 324 | ]); | |
| 325 | }); | |
| 326 | }); | |
| 327 | ||
| 161 | 328 | describe("composerDraftStore project draft thread mapping", () => { |
| 162 | 329 | const projectId = ProjectId.makeUnsafe("project-a"); |
| 163 | 330 | const otherProjectId = ProjectId.makeUnsafe("project-b"); |
apps/web/src/composerDraftStore.ts+303-2| ... | ... | @@ -10,6 +10,11 @@ import { |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 12 | 12 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 13 | import { | |
| 14 | type TerminalContextDraft, | |
| 15 | ensureInlineTerminalContextPlaceholders, | |
| 16 | normalizeTerminalContextText, | |
| 17 | } from "./lib/terminalContext"; | |
| 13 | 18 | import { Debouncer } from "@tanstack/react-pacer"; |
| 14 | 19 | import { create } from "zustand"; |
| 15 | 20 | import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; |
| ... | ... | @@ -71,9 +76,20 @@ export interface ComposerImageAttachment extends Omit<ChatImageAttachment, "prev |
| 71 | 76 | file: File; |
| 72 | 77 | } |
| 73 | 78 | |
| 79 | interface PersistedTerminalContextDraft { | |
| 80 | id: string; | |
| 81 | threadId: ThreadId; | |
| 82 | createdAt: string; | |
| 83 | terminalId: string; | |
| 84 | terminalLabel: string; | |
| 85 | lineStart: number; | |
| 86 | lineEnd: number; | |
| 87 | } | |
| 88 | ||
| 74 | 89 | interface PersistedComposerThreadDraftState { |
| 75 | 90 | prompt: string; |
| 76 | 91 | attachments: PersistedComposerImageAttachment[]; |
| 92 | terminalContexts?: PersistedTerminalContextDraft[]; | |
| 77 | 93 | provider?: ProviderKind | null; |
| 78 | 94 | model?: string | null; |
| 79 | 95 | runtimeMode?: RuntimeMode | null; |
| ... | ... | @@ -104,6 +120,7 @@ interface ComposerThreadDraftState { |
| 104 | 120 | images: ComposerImageAttachment[]; |
| 105 | 121 | nonPersistedImageIds: string[]; |
| 106 | 122 | persistedAttachments: PersistedComposerImageAttachment[]; |
| 123 | terminalContexts: TerminalContextDraft[]; | |
| 107 | 124 | provider: ProviderKind | null; |
| 108 | 125 | model: string | null; |
| 109 | 126 | runtimeMode: RuntimeMode | null; |
| ... | ... | @@ -160,6 +177,7 @@ interface ComposerDraftStoreState { |
| 160 | 177 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; |
| 161 | 178 | clearDraftThread: (threadId: ThreadId) => void; |
| 162 | 179 | setPrompt: (threadId: ThreadId, prompt: string) => void; |
| 180 | setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | |
| 163 | 181 | setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; |
| 164 | 182 | setModel: (threadId: ThreadId, model: string | null | undefined) => void; |
| 165 | 183 | setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; |
| ... | ... | @@ -172,6 +190,16 @@ interface ComposerDraftStoreState { |
| 172 | 190 | addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; |
| 173 | 191 | addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; |
| 174 | 192 | removeImage: (threadId: ThreadId, imageId: string) => void; |
| 193 | insertTerminalContext: ( | |
| 194 | threadId: ThreadId, | |
| 195 | prompt: string, | |
| 196 | context: TerminalContextDraft, | |
| 197 | index: number, | |
| 198 | ) => boolean; | |
| 199 | addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; | |
| 200 | addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | |
| 201 | removeTerminalContext: (threadId: ThreadId, contextId: string) => void; | |
| 202 | clearTerminalContexts: (threadId: ThreadId) => void; | |
| 175 | 203 | clearPersistedAttachments: (threadId: ThreadId) => void; |
| 176 | 204 | syncPersistedAttachments: ( |
| 177 | 205 | threadId: ThreadId, |
| ... | ... | @@ -190,14 +218,17 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| 190 | 218 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| 191 | 219 | const EMPTY_IDS: string[] = []; |
| 192 | 220 | const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; |
| 221 | const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; | |
| 193 | 222 | Object.freeze(EMPTY_IMAGES); |
| 194 | 223 | Object.freeze(EMPTY_IDS); |
| 195 | 224 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 225 | Object.freeze(EMPTY_TERMINAL_CONTEXTS); | |
| 196 | 226 | const EMPTY_THREAD_DRAFT = Object.freeze({ |
| 197 | 227 | prompt: "", |
| 198 | 228 | images: EMPTY_IMAGES, |
| 199 | 229 | nonPersistedImageIds: EMPTY_IDS, |
| 200 | 230 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, |
| 231 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, | |
| 201 | 232 | provider: null, |
| 202 | 233 | model: null, |
| 203 | 234 | runtimeMode: null, |
| ... | ... | @@ -216,6 +247,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 216 | 247 | images: [], |
| 217 | 248 | nonPersistedImageIds: [], |
| 218 | 249 | persistedAttachments: [], |
| 250 | terminalContexts: [], | |
| 219 | 251 | provider: null, |
| 220 | 252 | model: null, |
| 221 | 253 | runtimeMode: null, |
| ... | ... | @@ -231,11 +263,63 @@ function composerImageDedupKey(image: ComposerImageAttachment): string { |
| 231 | 263 | return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`; |
| 232 | 264 | } |
| 233 | 265 | |
| 266 | function terminalContextDedupKey(context: TerminalContextDraft): string { | |
| 267 | return `${context.terminalId}\u0000${context.lineStart}\u0000${context.lineEnd}`; | |
| 268 | } | |
| 269 | ||
| 270 | function normalizeTerminalContextForThread( | |
| 271 | threadId: ThreadId, | |
| 272 | context: TerminalContextDraft, | |
| 273 | ): TerminalContextDraft | null { | |
| 274 | const terminalId = context.terminalId.trim(); | |
| 275 | const terminalLabel = context.terminalLabel.trim(); | |
| 276 | if (terminalId.length === 0 || terminalLabel.length === 0) { | |
| 277 | return null; | |
| 278 | } | |
| 279 | const lineStart = Math.max(1, Math.floor(context.lineStart)); | |
| 280 | const lineEnd = Math.max(lineStart, Math.floor(context.lineEnd)); | |
| 281 | return { | |
| 282 | ...context, | |
| 283 | threadId, | |
| 284 | terminalId, | |
| 285 | terminalLabel, | |
| 286 | lineStart, | |
| 287 | lineEnd, | |
| 288 | text: normalizeTerminalContextText(context.text), | |
| 289 | }; | |
| 290 | } | |
| 291 | ||
| 292 | function normalizeTerminalContextsForThread( | |
| 293 | threadId: ThreadId, | |
| 294 | contexts: ReadonlyArray<TerminalContextDraft>, | |
| 295 | ): TerminalContextDraft[] { | |
| 296 | const existingIds = new Set<string>(); | |
| 297 | const existingDedupKeys = new Set<string>(); | |
| 298 | const normalizedContexts: TerminalContextDraft[] = []; | |
| 299 | ||
| 300 | for (const context of contexts) { | |
| 301 | const normalizedContext = normalizeTerminalContextForThread(threadId, context); | |
| 302 | if (!normalizedContext) { | |
| 303 | continue; | |
| 304 | } | |
| 305 | const dedupKey = terminalContextDedupKey(normalizedContext); | |
| 306 | if (existingIds.has(normalizedContext.id) || existingDedupKeys.has(dedupKey)) { | |
| 307 | continue; | |
| 308 | } | |
| 309 | normalizedContexts.push(normalizedContext); | |
| 310 | existingIds.add(normalizedContext.id); | |
| 311 | existingDedupKeys.add(dedupKey); | |
| 312 | } | |
| 313 | ||
| 314 | return normalizedContexts; | |
| 315 | } | |
| 316 | ||
| 234 | 317 | function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 235 | 318 | return ( |
| 236 | 319 | draft.prompt.length === 0 && |
| 237 | 320 | draft.images.length === 0 && |
| 238 | 321 | draft.persistedAttachments.length === 0 && |
| 322 | draft.terminalContexts.length === 0 && | |
| 239 | 323 | draft.provider === null && |
| 240 | 324 | draft.model === null && |
| 241 | 325 | draft.runtimeMode === null && |
| ... | ... | @@ -290,6 +374,51 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt |
| 290 | 374 | }; |
| 291 | 375 | } |
| 292 | 376 | |
| 377 | function normalizePersistedTerminalContextDraft( | |
| 378 | value: unknown, | |
| 379 | ): PersistedTerminalContextDraft | null { | |
| 380 | if (!value || typeof value !== "object") { | |
| 381 | return null; | |
| 382 | } | |
| 383 | const candidate = value as Record<string, unknown>; | |
| 384 | const id = candidate.id; | |
| 385 | const threadId = candidate.threadId; | |
| 386 | const createdAt = candidate.createdAt; | |
| 387 | const lineStart = candidate.lineStart; | |
| 388 | const lineEnd = candidate.lineEnd; | |
| 389 | if ( | |
| 390 | typeof id !== "string" || | |
| 391 | id.length === 0 || | |
| 392 | typeof threadId !== "string" || | |
| 393 | threadId.length === 0 || | |
| 394 | typeof createdAt !== "string" || | |
| 395 | createdAt.length === 0 || | |
| 396 | typeof lineStart !== "number" || | |
| 397 | !Number.isFinite(lineStart) || | |
| 398 | typeof lineEnd !== "number" || | |
| 399 | !Number.isFinite(lineEnd) | |
| 400 | ) { | |
| 401 | return null; | |
| 402 | } | |
| 403 | const terminalId = typeof candidate.terminalId === "string" ? candidate.terminalId.trim() : ""; | |
| 404 | const terminalLabel = | |
| 405 | typeof candidate.terminalLabel === "string" ? candidate.terminalLabel.trim() : ""; | |
| 406 | if (terminalId.length === 0 || terminalLabel.length === 0) { | |
| 407 | return null; | |
| 408 | } | |
| 409 | const normalizedLineStart = Math.max(1, Math.floor(lineStart)); | |
| 410 | const normalizedLineEnd = Math.max(normalizedLineStart, Math.floor(lineEnd)); | |
| 411 | return { | |
| 412 | id, | |
| 413 | threadId: threadId as ThreadId, | |
| 414 | createdAt, | |
| 415 | terminalId, | |
| 416 | terminalLabel, | |
| 417 | lineStart: normalizedLineStart, | |
| 418 | lineEnd: normalizedLineEnd, | |
| 419 | }; | |
| 420 | } | |
| 421 | ||
| 293 | 422 | function normalizeDraftThreadEnvMode( |
| 294 | 423 | value: unknown, |
| 295 | 424 | fallbackWorktreePath: string | null, |
| ... | ... | @@ -397,13 +526,19 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 397 | 526 | continue; |
| 398 | 527 | } |
| 399 | 528 | const draftCandidate = draftValue as Record<string, unknown>; |
| 400 | const prompt = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; | |
| 529 | const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; | |
| 401 | 530 | const attachments = Array.isArray(draftCandidate.attachments) |
| 402 | 531 | ? draftCandidate.attachments.flatMap((entry) => { |
| 403 | 532 | const normalized = normalizePersistedAttachment(entry); |
| 404 | 533 | return normalized ? [normalized] : []; |
| 405 | 534 | }) |
| 406 | 535 | : []; |
| 536 | const terminalContexts = Array.isArray(draftCandidate.terminalContexts) | |
| 537 | ? draftCandidate.terminalContexts.flatMap((entry) => { | |
| 538 | const normalized = normalizePersistedTerminalContextDraft(entry); | |
| 539 | return normalized ? [normalized] : []; | |
| 540 | }) | |
| 541 | : []; | |
| 407 | 542 | const provider = normalizeProviderKind(draftCandidate.provider); |
| 408 | 543 | const model = |
| 409 | 544 | typeof draftCandidate.model === "string" |
| ... | ... | @@ -427,9 +562,14 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 427 | 562 | const codexFastMode = |
| 428 | 563 | draftCandidate.codexFastMode === true || |
| 429 | 564 | (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); |
| 565 | const prompt = ensureInlineTerminalContextPlaceholders( | |
| 566 | promptCandidate, | |
| 567 | terminalContexts.length, | |
| 568 | ); | |
| 430 | 569 | if ( |
| 431 | prompt.length === 0 && | |
| 570 | promptCandidate.length === 0 && | |
| 432 | 571 | attachments.length === 0 && |
| 572 | terminalContexts.length === 0 && | |
| 433 | 573 | !provider && |
| 434 | 574 | !model && |
| 435 | 575 | !runtimeMode && |
| ... | ... | @@ -442,6 +582,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer |
| 442 | 582 | nextDraftsByThreadId[threadId as ThreadId] = { |
| 443 | 583 | prompt, |
| 444 | 584 | attachments, |
| 585 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), | |
| 445 | 586 | ...(provider ? { provider } : {}), |
| 446 | 587 | ...(model ? { model } : {}), |
| 447 | 588 | ...(runtimeMode ? { runtimeMode } : {}), |
| ... | ... | @@ -548,6 +689,11 @@ function toHydratedThreadDraft( |
| 548 | 689 | images: hydrateImagesFromPersisted(persistedDraft.attachments), |
| 549 | 690 | nonPersistedImageIds: [], |
| 550 | 691 | persistedAttachments: persistedDraft.attachments, |
| 692 | terminalContexts: | |
| 693 | persistedDraft.terminalContexts?.map((context) => ({ | |
| 694 | ...context, | |
| 695 | text: "", | |
| 696 | })) ?? [], | |
| 551 | 697 | provider: persistedDraft.provider ?? null, |
| 552 | 698 | model: persistedDraft.model ?? null, |
| 553 | 699 | runtimeMode: persistedDraft.runtimeMode ?? null, |
| ... | ... | @@ -817,6 +963,30 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 817 | 963 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 818 | 964 | }); |
| 819 | 965 | }, |
| 966 | setTerminalContexts: (threadId, contexts) => { | |
| 967 | if (threadId.length === 0) { | |
| 968 | return; | |
| 969 | } | |
| 970 | const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); | |
| 971 | set((state) => { | |
| 972 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 973 | const nextDraft: ComposerThreadDraftState = { | |
| 974 | ...existing, | |
| 975 | prompt: ensureInlineTerminalContextPlaceholders( | |
| 976 | existing.prompt, | |
| 977 | normalizedContexts.length, | |
| 978 | ), | |
| 979 | terminalContexts: normalizedContexts, | |
| 980 | }; | |
| 981 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 982 | if (shouldRemoveDraft(nextDraft)) { | |
| 983 | delete nextDraftsByThreadId[threadId]; | |
| 984 | } else { | |
| 985 | nextDraftsByThreadId[threadId] = nextDraft; | |
| 986 | } | |
| 987 | return { draftsByThreadId: nextDraftsByThreadId }; | |
| 988 | }); | |
| 989 | }, | |
| 820 | 990 | setProvider: (threadId, provider) => { |
| 821 | 991 | if (threadId.length === 0) { |
| 822 | 992 | return; |
| ... | ... | @@ -1066,6 +1236,124 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1066 | 1236 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1067 | 1237 | }); |
| 1068 | 1238 | }, |
| 1239 | insertTerminalContext: (threadId, prompt, context, index) => { | |
| 1240 | if (threadId.length === 0) { | |
| 1241 | return false; | |
| 1242 | } | |
| 1243 | let inserted = false; | |
| 1244 | set((state) => { | |
| 1245 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1246 | const normalizedContext = normalizeTerminalContextForThread(threadId, context); | |
| 1247 | if (!normalizedContext) { | |
| 1248 | return state; | |
| 1249 | } | |
| 1250 | const dedupKey = terminalContextDedupKey(normalizedContext); | |
| 1251 | if ( | |
| 1252 | existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || | |
| 1253 | existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) | |
| 1254 | ) { | |
| 1255 | return state; | |
| 1256 | } | |
| 1257 | inserted = true; | |
| 1258 | const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); | |
| 1259 | const nextDraft: ComposerThreadDraftState = { | |
| 1260 | ...existing, | |
| 1261 | prompt, | |
| 1262 | terminalContexts: [ | |
| 1263 | ...existing.terminalContexts.slice(0, boundedIndex), | |
| 1264 | normalizedContext, | |
| 1265 | ...existing.terminalContexts.slice(boundedIndex), | |
| 1266 | ], | |
| 1267 | }; | |
| 1268 | return { | |
| 1269 | draftsByThreadId: { | |
| 1270 | ...state.draftsByThreadId, | |
| 1271 | [threadId]: nextDraft, | |
| 1272 | }, | |
| 1273 | }; | |
| 1274 | }); | |
| 1275 | return inserted; | |
| 1276 | }, | |
| 1277 | addTerminalContext: (threadId, context) => { | |
| 1278 | if (threadId.length === 0) { | |
| 1279 | return; | |
| 1280 | } | |
| 1281 | get().addTerminalContexts(threadId, [context]); | |
| 1282 | }, | |
| 1283 | addTerminalContexts: (threadId, contexts) => { | |
| 1284 | if (threadId.length === 0 || contexts.length === 0) { | |
| 1285 | return; | |
| 1286 | } | |
| 1287 | set((state) => { | |
| 1288 | const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); | |
| 1289 | const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ | |
| 1290 | ...existing.terminalContexts, | |
| 1291 | ...contexts, | |
| 1292 | ]).slice(existing.terminalContexts.length); | |
| 1293 | if (acceptedContexts.length === 0) { | |
| 1294 | return state; | |
| 1295 | } | |
| 1296 | return { | |
| 1297 | draftsByThreadId: { | |
| 1298 | ...state.draftsByThreadId, | |
| 1299 | [threadId]: { | |
| 1300 | ...existing, | |
| 1301 | prompt: ensureInlineTerminalContextPlaceholders( | |
| 1302 | existing.prompt, | |
| 1303 | existing.terminalContexts.length + acceptedContexts.length, | |
| 1304 | ), | |
| 1305 | terminalContexts: [...existing.terminalContexts, ...acceptedContexts], | |
| 1306 | }, | |
| 1307 | }, | |
| 1308 | }; | |
| 1309 | }); | |
| 1310 | }, | |
| 1311 | removeTerminalContext: (threadId, contextId) => { | |
| 1312 | if (threadId.length === 0 || contextId.length === 0) { | |
| 1313 | return; | |
| 1314 | } | |
| 1315 | set((state) => { | |
| 1316 | const current = state.draftsByThreadId[threadId]; | |
| 1317 | if (!current) { | |
| 1318 | return state; | |
| 1319 | } | |
| 1320 | const nextDraft: ComposerThreadDraftState = { | |
| 1321 | ...current, | |
| 1322 | terminalContexts: current.terminalContexts.filter( | |
| 1323 | (context) => context.id !== contextId, | |
| 1324 | ), | |
| 1325 | }; | |
| 1326 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1327 | if (shouldRemoveDraft(nextDraft)) { | |
| 1328 | delete nextDraftsByThreadId[threadId]; | |
| 1329 | } else { | |
| 1330 | nextDraftsByThreadId[threadId] = nextDraft; | |
| 1331 | } | |
| 1332 | return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1333 | }); | |
| 1334 | }, | |
| 1335 | clearTerminalContexts: (threadId) => { | |
| 1336 | if (threadId.length === 0) { | |
| 1337 | return; | |
| 1338 | } | |
| 1339 | set((state) => { | |
| 1340 | const current = state.draftsByThreadId[threadId]; | |
| 1341 | if (!current || current.terminalContexts.length === 0) { | |
| 1342 | return state; | |
| 1343 | } | |
| 1344 | const nextDraft: ComposerThreadDraftState = { | |
| 1345 | ...current, | |
| 1346 | terminalContexts: [], | |
| 1347 | }; | |
| 1348 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | |
| 1349 | if (shouldRemoveDraft(nextDraft)) { | |
| 1350 | delete nextDraftsByThreadId[threadId]; | |
| 1351 | } else { | |
| 1352 | nextDraftsByThreadId[threadId] = nextDraft; | |
| 1353 | } | |
| 1354 | return { draftsByThreadId: nextDraftsByThreadId }; | |
| 1355 | }); | |
| 1356 | }, | |
| 1069 | 1357 | clearPersistedAttachments: (threadId) => { |
| 1070 | 1358 | if (threadId.length === 0) { |
| 1071 | 1359 | return; |
| ... | ... | @@ -1159,6 +1447,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1159 | 1447 | images: [], |
| 1160 | 1448 | nonPersistedImageIds: [], |
| 1161 | 1449 | persistedAttachments: [], |
| 1450 | terminalContexts: [], | |
| 1162 | 1451 | }; |
| 1163 | 1452 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1164 | 1453 | if (shouldRemoveDraft(nextDraft)) { |
| ... | ... | @@ -1218,6 +1507,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1218 | 1507 | if ( |
| 1219 | 1508 | draft.prompt.length === 0 && |
| 1220 | 1509 | draft.persistedAttachments.length === 0 && |
| 1510 | draft.terminalContexts.length === 0 && | |
| 1221 | 1511 | draft.provider === null && |
| 1222 | 1512 | draft.model === null && |
| 1223 | 1513 | draft.runtimeMode === null && |
| ... | ... | @@ -1231,6 +1521,17 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1231 | 1521 | prompt: draft.prompt, |
| 1232 | 1522 | attachments: draft.persistedAttachments, |
| 1233 | 1523 | }; |
| 1524 | if (draft.terminalContexts.length > 0) { | |
| 1525 | persistedDraft.terminalContexts = draft.terminalContexts.map((context) => ({ | |
| 1526 | id: context.id, | |
| 1527 | threadId: context.threadId, | |
| 1528 | createdAt: context.createdAt, | |
| 1529 | terminalId: context.terminalId, | |
| 1530 | terminalLabel: context.terminalLabel, | |
| 1531 | lineStart: context.lineStart, | |
| 1532 | lineEnd: context.lineEnd, | |
| 1533 | })); | |
| 1534 | } | |
| 1234 | 1535 | if (draft.model) { |
| 1235 | 1536 | persistedDraft.model = draft.model; |
| 1236 | 1537 | } |
apps/web/src/lib/terminalContext.test.ts created+210| ... | ... | @@ -0,0 +1,210 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { | |
| 5 | appendTerminalContextsToPrompt, | |
| 6 | buildTerminalContextPreviewTitle, | |
| 7 | buildTerminalContextBlock, | |
| 8 | countInlineTerminalContextPlaceholders, | |
| 9 | deriveDisplayedUserMessageState, | |
| 10 | ensureInlineTerminalContextPlaceholders, | |
| 11 | extractTrailingTerminalContexts, | |
| 12 | filterTerminalContextsWithText, | |
| 13 | formatInlineTerminalContextLabel, | |
| 14 | formatTerminalContextLabel, | |
| 15 | hasTerminalContextText, | |
| 16 | INLINE_TERMINAL_CONTEXT_PLACEHOLDER, | |
| 17 | insertInlineTerminalContextPlaceholder, | |
| 18 | isTerminalContextExpired, | |
| 19 | materializeInlineTerminalContextPrompt, | |
| 20 | removeInlineTerminalContextPlaceholder, | |
| 21 | stripInlineTerminalContextPlaceholders, | |
| 22 | type TerminalContextDraft, | |
| 23 | } from "./terminalContext"; | |
| 24 | ||
| 25 | function makeContext(overrides?: Partial<TerminalContextDraft>): TerminalContextDraft { | |
| 26 | return { | |
| 27 | id: "context-1", | |
| 28 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 29 | terminalId: "default", | |
| 30 | terminalLabel: "Terminal 1", | |
| 31 | lineStart: 12, | |
| 32 | lineEnd: 13, | |
| 33 | text: "git status\nOn branch main", | |
| 34 | createdAt: "2026-03-13T12:00:00.000Z", | |
| 35 | ...overrides, | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | describe("terminalContext", () => { | |
| 40 | it("formats terminal labels with line ranges", () => { | |
| 41 | expect(formatTerminalContextLabel(makeContext())).toBe("Terminal 1 lines 12-13"); | |
| 42 | expect( | |
| 43 | formatTerminalContextLabel( | |
| 44 | makeContext({ | |
| 45 | lineStart: 9, | |
| 46 | lineEnd: 9, | |
| 47 | }), | |
| 48 | ), | |
| 49 | ).toBe("Terminal 1 line 9"); | |
| 50 | }); | |
| 51 | ||
| 52 | it("builds a numbered terminal context block", () => { | |
| 53 | expect(buildTerminalContextBlock([makeContext()])).toBe( | |
| 54 | [ | |
| 55 | "<terminal_context>", | |
| 56 | "- Terminal 1 lines 12-13:", | |
| 57 | " 12 | git status", | |
| 58 | " 13 | On branch main", | |
| 59 | "</terminal_context>", | |
| 60 | ].join("\n"), | |
| 61 | ); | |
| 62 | }); | |
| 63 | ||
| 64 | it("appends terminal context blocks after prompt text", () => { | |
| 65 | expect(appendTerminalContextsToPrompt("Investigate this", [makeContext()])).toBe( | |
| 66 | [ | |
| 67 | "Investigate this", | |
| 68 | "", | |
| 69 | "<terminal_context>", | |
| 70 | "- Terminal 1 lines 12-13:", | |
| 71 | " 12 | git status", | |
| 72 | " 13 | On branch main", | |
| 73 | "</terminal_context>", | |
| 74 | ].join("\n"), | |
| 75 | ); | |
| 76 | }); | |
| 77 | ||
| 78 | it("replaces inline placeholders with inline terminal labels before appending context blocks", () => { | |
| 79 | expect( | |
| 80 | appendTerminalContextsToPrompt( | |
| 81 | `Investigate ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} carefully`, | |
| 82 | [makeContext()], | |
| 83 | ), | |
| 84 | ).toBe( | |
| 85 | [ | |
| 86 | "Investigate @terminal-1:12-13 carefully", | |
| 87 | "", | |
| 88 | "<terminal_context>", | |
| 89 | "- Terminal 1 lines 12-13:", | |
| 90 | " 12 | git status", | |
| 91 | " 13 | On branch main", | |
| 92 | "</terminal_context>", | |
| 93 | ].join("\n"), | |
| 94 | ); | |
| 95 | }); | |
| 96 | ||
| 97 | it("extracts terminal context blocks from message text", () => { | |
| 98 | const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]); | |
| 99 | expect(extractTrailingTerminalContexts(prompt)).toEqual({ | |
| 100 | promptText: "Investigate this", | |
| 101 | contextCount: 1, | |
| 102 | previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", | |
| 103 | contexts: [ | |
| 104 | { | |
| 105 | header: "Terminal 1 lines 12-13", | |
| 106 | body: "12 | git status\n13 | On branch main", | |
| 107 | }, | |
| 108 | ], | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | it("derives displayed user message state from terminal context prompts", () => { | |
| 113 | const prompt = appendTerminalContextsToPrompt("Investigate this", [makeContext()]); | |
| 114 | expect(deriveDisplayedUserMessageState(prompt)).toEqual({ | |
| 115 | visibleText: "Investigate this", | |
| 116 | copyText: prompt, | |
| 117 | contextCount: 1, | |
| 118 | previewTitle: "Terminal 1 lines 12-13\n12 | git status\n13 | On branch main", | |
| 119 | contexts: [ | |
| 120 | { | |
| 121 | header: "Terminal 1 lines 12-13", | |
| 122 | body: "12 | git status\n13 | On branch main", | |
| 123 | }, | |
| 124 | ], | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | it("preserves prompt text when no trailing terminal context block exists", () => { | |
| 129 | expect(extractTrailingTerminalContexts("No attached context")).toEqual({ | |
| 130 | promptText: "No attached context", | |
| 131 | contextCount: 0, | |
| 132 | previewTitle: null, | |
| 133 | contexts: [], | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | it("returns null preview title when every context is invalid", () => { | |
| 138 | expect( | |
| 139 | buildTerminalContextPreviewTitle([ | |
| 140 | makeContext({ | |
| 141 | terminalId: " ", | |
| 142 | }), | |
| 143 | makeContext({ | |
| 144 | id: "context-2", | |
| 145 | text: "\n\n", | |
| 146 | }), | |
| 147 | ]), | |
| 148 | ).toBeNull(); | |
| 149 | }); | |
| 150 | ||
| 151 | it("tracks inline terminal context placeholders in prompt text", () => { | |
| 152 | const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; | |
| 153 | expect(countInlineTerminalContextPlaceholders(`a${placeholder}b${placeholder}`)).toBe(2); | |
| 154 | expect(ensureInlineTerminalContextPlaceholders("Investigate this", 2)).toBe( | |
| 155 | `${placeholder}${placeholder}Investigate this`, | |
| 156 | ); | |
| 157 | expect(insertInlineTerminalContextPlaceholder("abc", 1)).toEqual({ | |
| 158 | prompt: `a ${placeholder} bc`, | |
| 159 | cursor: 4, | |
| 160 | contextIndex: 0, | |
| 161 | }); | |
| 162 | expect(removeInlineTerminalContextPlaceholder(`a${placeholder}b${placeholder}c`, 1)).toEqual({ | |
| 163 | prompt: `a${placeholder}bc`, | |
| 164 | cursor: 3, | |
| 165 | }); | |
| 166 | expect(stripInlineTerminalContextPlaceholders(`a${placeholder}b`)).toBe("ab"); | |
| 167 | }); | |
| 168 | ||
| 169 | it("inserts a placeholder after a file mention when given the expanded prompt cursor", () => { | |
| 170 | const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; | |
| 171 | expect(insertInlineTerminalContextPlaceholder("Inspect @package.json ", 22)).toEqual({ | |
| 172 | prompt: `Inspect @package.json ${placeholder} `, | |
| 173 | cursor: 24, | |
| 174 | contextIndex: 0, | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | it("adds a trailing space and consumes an existing trailing space at the insertion point", () => { | |
| 179 | const placeholder = INLINE_TERMINAL_CONTEXT_PLACEHOLDER; | |
| 180 | expect(insertInlineTerminalContextPlaceholder("yo whats", 3)).toEqual({ | |
| 181 | prompt: `yo ${placeholder} whats`, | |
| 182 | cursor: 5, | |
| 183 | contextIndex: 0, | |
| 184 | }); | |
| 185 | }); | |
| 186 | ||
| 187 | it("marks contexts without snapshot text as expired and filters them from sendable contexts", () => { | |
| 188 | const liveContext = makeContext(); | |
| 189 | const expiredContext = makeContext({ | |
| 190 | id: "context-2", | |
| 191 | text: "", | |
| 192 | }); | |
| 193 | ||
| 194 | expect(hasTerminalContextText(liveContext)).toBe(true); | |
| 195 | expect(isTerminalContextExpired(liveContext)).toBe(false); | |
| 196 | expect(hasTerminalContextText(expiredContext)).toBe(false); | |
| 197 | expect(isTerminalContextExpired(expiredContext)).toBe(true); | |
| 198 | expect(filterTerminalContextsWithText([expiredContext, liveContext])).toEqual([liveContext]); | |
| 199 | }); | |
| 200 | ||
| 201 | it("formats and materializes inline terminal labels from placeholder positions", () => { | |
| 202 | expect(formatInlineTerminalContextLabel(makeContext())).toBe("@terminal-1:12-13"); | |
| 203 | expect( | |
| 204 | materializeInlineTerminalContextPrompt( | |
| 205 | `Investigate ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} carefully`, | |
| 206 | [makeContext()], | |
| 207 | ), | |
| 208 | ).toBe("Investigate @terminal-1:12-13 carefully"); | |
| 209 | }); | |
| 210 | }); |
apps/web/src/lib/terminalContext.ts created+356| ... | ... | @@ -0,0 +1,356 @@ |
| 1 | import { type ThreadId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface TerminalContextSelection { | |
| 4 | terminalId: string; | |
| 5 | terminalLabel: string; | |
| 6 | lineStart: number; | |
| 7 | lineEnd: number; | |
| 8 | text: string; | |
| 9 | } | |
| 10 | ||
| 11 | export interface TerminalContextDraft extends TerminalContextSelection { | |
| 12 | id: string; | |
| 13 | threadId: ThreadId; | |
| 14 | createdAt: string; | |
| 15 | } | |
| 16 | ||
| 17 | export interface ExtractedTerminalContexts { | |
| 18 | promptText: string; | |
| 19 | contextCount: number; | |
| 20 | previewTitle: string | null; | |
| 21 | contexts: ParsedTerminalContextEntry[]; | |
| 22 | } | |
| 23 | ||
| 24 | export interface DisplayedUserMessageState { | |
| 25 | visibleText: string; | |
| 26 | copyText: string; | |
| 27 | contextCount: number; | |
| 28 | previewTitle: string | null; | |
| 29 | contexts: ParsedTerminalContextEntry[]; | |
| 30 | } | |
| 31 | ||
| 32 | export interface ParsedTerminalContextEntry { | |
| 33 | header: string; | |
| 34 | body: string; | |
| 35 | } | |
| 36 | ||
| 37 | export const INLINE_TERMINAL_CONTEXT_PLACEHOLDER = "\uFFFC"; | |
| 38 | ||
| 39 | const TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN = | |
| 40 | /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/; | |
| 41 | ||
| 42 | export function normalizeTerminalContextText(text: string): string { | |
| 43 | return text.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); | |
| 44 | } | |
| 45 | ||
| 46 | export function hasTerminalContextText(context: { text: string }): boolean { | |
| 47 | return normalizeTerminalContextText(context.text).length > 0; | |
| 48 | } | |
| 49 | ||
| 50 | export function isTerminalContextExpired(context: { text: string }): boolean { | |
| 51 | return !hasTerminalContextText(context); | |
| 52 | } | |
| 53 | ||
| 54 | export function filterTerminalContextsWithText<T extends { text: string }>( | |
| 55 | contexts: ReadonlyArray<T>, | |
| 56 | ): T[] { | |
| 57 | return contexts.filter((context) => hasTerminalContextText(context)); | |
| 58 | } | |
| 59 | ||
| 60 | function previewTerminalContextText(text: string): string { | |
| 61 | const normalized = normalizeTerminalContextText(text); | |
| 62 | if (normalized.length === 0) { | |
| 63 | return ""; | |
| 64 | } | |
| 65 | const lines = normalized.split("\n"); | |
| 66 | const visibleLines = lines.slice(0, 3); | |
| 67 | if (lines.length > 3) { | |
| 68 | visibleLines.push("..."); | |
| 69 | } | |
| 70 | const preview = visibleLines.join("\n"); | |
| 71 | return preview.length > 180 ? `${preview.slice(0, 177)}...` : preview; | |
| 72 | } | |
| 73 | ||
| 74 | export function normalizeTerminalContextSelection( | |
| 75 | selection: TerminalContextSelection, | |
| 76 | ): TerminalContextSelection | null { | |
| 77 | const text = normalizeTerminalContextText(selection.text); | |
| 78 | const terminalId = selection.terminalId.trim(); | |
| 79 | const terminalLabel = selection.terminalLabel.trim(); | |
| 80 | if (text.length === 0 || terminalId.length === 0 || terminalLabel.length === 0) { | |
| 81 | return null; | |
| 82 | } | |
| 83 | const lineStart = Math.max(1, Math.floor(selection.lineStart)); | |
| 84 | const lineEnd = Math.max(lineStart, Math.floor(selection.lineEnd)); | |
| 85 | return { | |
| 86 | terminalId, | |
| 87 | terminalLabel, | |
| 88 | lineStart, | |
| 89 | lineEnd, | |
| 90 | text, | |
| 91 | }; | |
| 92 | } | |
| 93 | ||
| 94 | export function formatTerminalContextRange(selection: { | |
| 95 | lineStart: number; | |
| 96 | lineEnd: number; | |
| 97 | }): string { | |
| 98 | return selection.lineStart === selection.lineEnd | |
| 99 | ? `line ${selection.lineStart}` | |
| 100 | : `lines ${selection.lineStart}-${selection.lineEnd}`; | |
| 101 | } | |
| 102 | ||
| 103 | export function formatTerminalContextLabel(selection: { | |
| 104 | terminalLabel: string; | |
| 105 | lineStart: number; | |
| 106 | lineEnd: number; | |
| 107 | }): string { | |
| 108 | return `${selection.terminalLabel} ${formatTerminalContextRange(selection)}`; | |
| 109 | } | |
| 110 | ||
| 111 | export function formatInlineTerminalContextLabel(selection: { | |
| 112 | terminalLabel: string; | |
| 113 | lineStart: number; | |
| 114 | lineEnd: number; | |
| 115 | }): string { | |
| 116 | const terminalLabel = selection.terminalLabel.trim().toLowerCase().replace(/\s+/g, "-"); | |
| 117 | const range = | |
| 118 | selection.lineStart === selection.lineEnd | |
| 119 | ? `${selection.lineStart}` | |
| 120 | : `${selection.lineStart}-${selection.lineEnd}`; | |
| 121 | return `@${terminalLabel}:${range}`; | |
| 122 | } | |
| 123 | ||
| 124 | export function buildTerminalContextPreviewTitle( | |
| 125 | contexts: ReadonlyArray<TerminalContextSelection>, | |
| 126 | ): string | null { | |
| 127 | if (contexts.length === 0) { | |
| 128 | return null; | |
| 129 | } | |
| 130 | const previews = contexts | |
| 131 | .map((context) => { | |
| 132 | const normalized = normalizeTerminalContextSelection(context); | |
| 133 | if (!normalized) { | |
| 134 | return null; | |
| 135 | } | |
| 136 | const preview = previewTerminalContextText(normalized.text); | |
| 137 | return preview.length > 0 | |
| 138 | ? `${formatTerminalContextLabel(normalized)}\n${preview}` | |
| 139 | : formatTerminalContextLabel(normalized); | |
| 140 | }) | |
| 141 | .filter((value): value is string => value !== null) | |
| 142 | .join("\n\n"); | |
| 143 | return previews.length > 0 ? previews : null; | |
| 144 | } | |
| 145 | ||
| 146 | function buildTerminalContextBodyLines(selection: TerminalContextSelection): string[] { | |
| 147 | return normalizeTerminalContextText(selection.text) | |
| 148 | .split("\n") | |
| 149 | .map((line, index) => ` ${selection.lineStart + index} | ${line}`); | |
| 150 | } | |
| 151 | ||
| 152 | export function buildTerminalContextBlock( | |
| 153 | contexts: ReadonlyArray<TerminalContextSelection>, | |
| 154 | ): string { | |
| 155 | const normalizedContexts = contexts | |
| 156 | .map((context) => normalizeTerminalContextSelection(context)) | |
| 157 | .filter((context): context is TerminalContextSelection => context !== null); | |
| 158 | if (normalizedContexts.length === 0) { | |
| 159 | return ""; | |
| 160 | } | |
| 161 | const lines: string[] = []; | |
| 162 | for (let index = 0; index < normalizedContexts.length; index += 1) { | |
| 163 | const context = normalizedContexts[index]!; | |
| 164 | lines.push(`- ${formatTerminalContextLabel(context)}:`); | |
| 165 | lines.push(...buildTerminalContextBodyLines(context)); | |
| 166 | if (index < normalizedContexts.length - 1) { | |
| 167 | lines.push(""); | |
| 168 | } | |
| 169 | } | |
| 170 | return ["<terminal_context>", ...lines, "</terminal_context>"].join("\n"); | |
| 171 | } | |
| 172 | ||
| 173 | export function materializeInlineTerminalContextPrompt( | |
| 174 | prompt: string, | |
| 175 | contexts: ReadonlyArray<{ | |
| 176 | terminalLabel: string; | |
| 177 | lineStart: number; | |
| 178 | lineEnd: number; | |
| 179 | }>, | |
| 180 | ): string { | |
| 181 | let nextContextIndex = 0; | |
| 182 | let result = ""; | |
| 183 | ||
| 184 | for (const char of prompt) { | |
| 185 | if (char !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { | |
| 186 | result += char; | |
| 187 | continue; | |
| 188 | } | |
| 189 | const context = contexts[nextContextIndex] ?? null; | |
| 190 | nextContextIndex += 1; | |
| 191 | if (!context) { | |
| 192 | continue; | |
| 193 | } | |
| 194 | result += formatInlineTerminalContextLabel(context); | |
| 195 | } | |
| 196 | ||
| 197 | return result; | |
| 198 | } | |
| 199 | ||
| 200 | export function appendTerminalContextsToPrompt( | |
| 201 | prompt: string, | |
| 202 | contexts: ReadonlyArray<TerminalContextSelection>, | |
| 203 | ): string { | |
| 204 | const trimmedPrompt = materializeInlineTerminalContextPrompt(prompt, contexts).trim(); | |
| 205 | const contextBlock = buildTerminalContextBlock(contexts); | |
| 206 | if (contextBlock.length === 0) { | |
| 207 | return trimmedPrompt; | |
| 208 | } | |
| 209 | return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${contextBlock}` : contextBlock; | |
| 210 | } | |
| 211 | ||
| 212 | export function extractTrailingTerminalContexts(prompt: string): ExtractedTerminalContexts { | |
| 213 | const match = TRAILING_TERMINAL_CONTEXT_BLOCK_PATTERN.exec(prompt); | |
| 214 | if (!match) { | |
| 215 | return { | |
| 216 | promptText: prompt, | |
| 217 | contextCount: 0, | |
| 218 | previewTitle: null, | |
| 219 | contexts: [], | |
| 220 | }; | |
| 221 | } | |
| 222 | const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); | |
| 223 | const parsedContexts = parseTerminalContextEntries(match[1] ?? ""); | |
| 224 | return { | |
| 225 | promptText, | |
| 226 | contextCount: parsedContexts.length, | |
| 227 | previewTitle: | |
| 228 | parsedContexts.length > 0 | |
| 229 | ? parsedContexts | |
| 230 | .map(({ header, body }) => (body.length > 0 ? `${header}\n${body}` : header)) | |
| 231 | .join("\n\n") | |
| 232 | : null, | |
| 233 | contexts: parsedContexts, | |
| 234 | }; | |
| 235 | } | |
| 236 | ||
| 237 | export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState { | |
| 238 | const extractedContexts = extractTrailingTerminalContexts(prompt); | |
| 239 | return { | |
| 240 | visibleText: extractedContexts.promptText, | |
| 241 | copyText: prompt, | |
| 242 | contextCount: extractedContexts.contextCount, | |
| 243 | previewTitle: extractedContexts.previewTitle, | |
| 244 | contexts: extractedContexts.contexts, | |
| 245 | }; | |
| 246 | } | |
| 247 | ||
| 248 | function parseTerminalContextEntries(block: string): ParsedTerminalContextEntry[] { | |
| 249 | const entries: ParsedTerminalContextEntry[] = []; | |
| 250 | let current: { header: string; bodyLines: string[] } | null = null; | |
| 251 | ||
| 252 | const commitCurrent = () => { | |
| 253 | if (!current) { | |
| 254 | return; | |
| 255 | } | |
| 256 | entries.push({ | |
| 257 | header: current.header, | |
| 258 | body: current.bodyLines.join("\n").trimEnd(), | |
| 259 | }); | |
| 260 | current = null; | |
| 261 | }; | |
| 262 | ||
| 263 | for (const rawLine of block.split("\n")) { | |
| 264 | const headerMatch = /^- (.+):$/.exec(rawLine); | |
| 265 | if (headerMatch) { | |
| 266 | commitCurrent(); | |
| 267 | current = { | |
| 268 | header: headerMatch[1]!, | |
| 269 | bodyLines: [], | |
| 270 | }; | |
| 271 | continue; | |
| 272 | } | |
| 273 | if (!current) { | |
| 274 | continue; | |
| 275 | } | |
| 276 | if (rawLine.startsWith(" ")) { | |
| 277 | current.bodyLines.push(rawLine.slice(2)); | |
| 278 | continue; | |
| 279 | } | |
| 280 | if (rawLine.length === 0) { | |
| 281 | current.bodyLines.push(""); | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | commitCurrent(); | |
| 286 | return entries; | |
| 287 | } | |
| 288 | ||
| 289 | export function countInlineTerminalContextPlaceholders(prompt: string): number { | |
| 290 | let count = 0; | |
| 291 | for (const char of prompt) { | |
| 292 | if (char === INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { | |
| 293 | count += 1; | |
| 294 | } | |
| 295 | } | |
| 296 | return count; | |
| 297 | } | |
| 298 | ||
| 299 | export function ensureInlineTerminalContextPlaceholders( | |
| 300 | prompt: string, | |
| 301 | terminalContextCount: number, | |
| 302 | ): string { | |
| 303 | const missingCount = terminalContextCount - countInlineTerminalContextPlaceholders(prompt); | |
| 304 | if (missingCount <= 0) { | |
| 305 | return prompt; | |
| 306 | } | |
| 307 | return `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER.repeat(missingCount)}${prompt}`; | |
| 308 | } | |
| 309 | ||
| 310 | function isInlineTerminalContextBoundaryWhitespace(char: string | undefined): boolean { | |
| 311 | return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; | |
| 312 | } | |
| 313 | ||
| 314 | export function insertInlineTerminalContextPlaceholder( | |
| 315 | prompt: string, | |
| 316 | cursorInput: number, | |
| 317 | ): { prompt: string; cursor: number; contextIndex: number } { | |
| 318 | const cursor = Math.max(0, Math.min(prompt.length, Math.floor(cursorInput))); | |
| 319 | const needsLeadingSpace = !isInlineTerminalContextBoundaryWhitespace(prompt[cursor - 1]); | |
| 320 | const replacement = `${needsLeadingSpace ? " " : ""}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} `; | |
| 321 | const rangeEnd = prompt[cursor] === " " ? cursor + 1 : cursor; | |
| 322 | return { | |
| 323 | prompt: `${prompt.slice(0, cursor)}${replacement}${prompt.slice(rangeEnd)}`, | |
| 324 | cursor: cursor + replacement.length, | |
| 325 | contextIndex: countInlineTerminalContextPlaceholders(prompt.slice(0, cursor)), | |
| 326 | }; | |
| 327 | } | |
| 328 | ||
| 329 | export function stripInlineTerminalContextPlaceholders(prompt: string): string { | |
| 330 | return prompt.replaceAll(INLINE_TERMINAL_CONTEXT_PLACEHOLDER, ""); | |
| 331 | } | |
| 332 | ||
| 333 | export function removeInlineTerminalContextPlaceholder( | |
| 334 | prompt: string, | |
| 335 | contextIndex: number, | |
| 336 | ): { prompt: string; cursor: number } { | |
| 337 | if (contextIndex < 0) { | |
| 338 | return { prompt, cursor: prompt.length }; | |
| 339 | } | |
| 340 | ||
| 341 | let placeholderIndex = 0; | |
| 342 | for (let index = 0; index < prompt.length; index += 1) { | |
| 343 | if (prompt[index] !== INLINE_TERMINAL_CONTEXT_PLACEHOLDER) { | |
| 344 | continue; | |
| 345 | } | |
| 346 | if (placeholderIndex === contextIndex) { | |
| 347 | return { | |
| 348 | prompt: prompt.slice(0, index) + prompt.slice(index + 1), | |
| 349 | cursor: index, | |
| 350 | }; | |
| 351 | } | |
| 352 | placeholderIndex += 1; | |
| 353 | } | |
| 354 | ||
| 355 | return { prompt, cursor: prompt.length }; | |
| 356 | } |
apps/web/src/vscode-icons.ts+11| ... | ... | @@ -75,6 +75,17 @@ export function basenameOfPath(pathValue: string): string { |
| 75 | 75 | return pathValue.slice(slashIndex + 1); |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | export function inferEntryKindFromPath(pathValue: string): "file" | "directory" { | |
| 79 | const base = basenameOfPath(pathValue); | |
| 80 | if (base.startsWith(".") && !base.slice(1).includes(".")) { | |
| 81 | return "directory"; | |
| 82 | } | |
| 83 | if (base.includes(".")) { | |
| 84 | return "file"; | |
| 85 | } | |
| 86 | return "directory"; | |
| 87 | } | |
| 88 | ||
| 78 | 89 | function extensionCandidates(fileName: string): string[] { |
| 79 | 90 | const candidates = new Set<string>(); |
| 80 | 91 | if (fileName.includes(".")) { |