diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 77dce596892d5..b1da45e74ab98 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2346,6 +2346,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerMenuItemsRef, activeComposerMenuItemRef, setComposerDraftPrompt, + setComposerDraftThreadContexts, + setComposerDraftEnvironmentContexts, setComposerCursor, setComposerTrigger, setComposerHighlightedItemId, diff --git a/apps/web/src/components/chat/composerCommandKey.ts b/apps/web/src/components/chat/composerCommandKey.ts index 40650fe6c8952..243b69d09e4df 100644 --- a/apps/web/src/components/chat/composerCommandKey.ts +++ b/apps/web/src/components/chat/composerCommandKey.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/client-runtime/state/composer-dispatch"; import { type ComposerPromptEditorHandle } from "../ComposerPromptEditorTiptap"; import { + type ComposerPromptHistoryMentions, type ComposerPromptHistoryPosition, buildComposerPromptHistoryEntries, stepComposerPromptHistory, @@ -17,7 +18,11 @@ import { composerSubmissionIntentForKey, } from "../../composer-logic"; import { type PendingUserInput } from "../../session-logic"; -import { type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { + type EnvironmentContextRecord, + type PreviewAnnotationPayload, + type ThreadContextRecord, +} from "@t3tools/contracts"; import { type ReviewCommentContext } from "../../reviewCommentContext"; import { composerTargetKey } from "../../composerDraftStore"; import { @@ -63,6 +68,14 @@ export function composerCommandKeyHandler(input: { target: ChatComposerProps["composerDraftTarget"], prompt: string, ) => void; + readonly setComposerDraftThreadContexts: ( + target: ChatComposerProps["composerDraftTarget"], + records: ReadonlyArray, + ) => void; + readonly setComposerDraftEnvironmentContexts: ( + target: ChatComposerProps["composerDraftTarget"], + records: ReadonlyArray, + ) => void; readonly setComposerCursor: Dispatch>; readonly setComposerTrigger: (trigger: ComposerTrigger | null) => void; readonly setComposerHighlightedItemId: Dispatch>; @@ -110,6 +123,8 @@ export function composerCommandKeyHandler(input: { composerMenuItemsRef, activeComposerMenuItemRef, setComposerDraftPrompt, + setComposerDraftThreadContexts, + setComposerDraftEnvironmentContexts, setComposerCursor, setComposerTrigger, setComposerHighlightedItemId, @@ -129,9 +144,14 @@ export function composerCommandKeyHandler(input: { // otherwise turn B's own draft into a browsing position. const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); - const replacePromptFromHistory = (nextPrompt: string) => { + const replacePromptFromHistory = ( + nextPrompt: string, + mentions: ComposerPromptHistoryMentions, + ) => { promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerDraftThreadContexts(composerDraftTarget, mentions.threadContexts); + setComposerDraftEnvironmentContexts(composerDraftTarget, mentions.environmentContexts); setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); setComposerTrigger(null); setComposerHighlightedItemId(null); @@ -176,7 +196,7 @@ export function composerCommandKeyHandler(input: { promptHistoryPositionRef.current = step.position ? { targetKey: promptHistoryTargetKey, position: step.position } : null; - replacePromptFromHistory(step.prompt); + replacePromptFromHistory(step.prompt, step.mentions); return true; }; diff --git a/apps/web/src/components/chat/composerPromptHistory.test.ts b/apps/web/src/components/chat/composerPromptHistory.test.ts index 39bf0c1f21e74..fb1db28ed1646 100644 --- a/apps/web/src/components/chat/composerPromptHistory.test.ts +++ b/apps/web/src/components/chat/composerPromptHistory.test.ts @@ -1,4 +1,8 @@ -import { ScheduledTaskId } from "@t3tools/contracts"; +import { EnvironmentId, ScheduledTaskId, ThreadId } from "@t3tools/contracts"; +import { + formatComposerContextReference, + threadContextRecord, +} from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { @@ -7,6 +11,7 @@ import { stepComposerPromptHistory, type ComposerPromptHistoryPosition, } from "./composerPromptHistory"; +import { environmentContextRecord } from "../../lib/composerContextRecords"; const entries = buildComposerPromptHistoryEntries([ { id: "m1", role: "user", text: "first" }, @@ -94,13 +99,51 @@ describe("buildComposerPromptHistoryEntries", () => { { id: "m3", role: "user", text: "other" }, { id: "m4", role: "user", text: "same" }, ]); - expect(collapsed).toEqual([ + expect(collapsed.map(({ id, prompt }) => ({ id, prompt }))).toEqual([ { id: "m2", prompt: "same" }, { id: "m3", prompt: "other" }, { id: "m4", prompt: "same" }, ]); }); + it("keeps thread and machine mentions with the records the draft needs to resolve them", () => { + const environmentId = EnvironmentId.make("env-1"); + const thread = threadContextRecord( + { environmentId, threadId: ThreadId.make("thread-1") }, + "Correct invoice charge categories", + ); + const unmentioned = threadContextRecord( + { environmentId, threadId: ThreadId.make("thread-2") }, + "Removed before send", + ); + const machine = environmentContextRecord(environmentId, "Build box"); + const threadChip = formatComposerContextReference(thread); + const machineChip = formatComposerContextReference(machine); + const [entry] = buildComposerPromptHistoryEntries([ + { + id: "m1", + role: "user", + text: `Agent in ${threadChip} finished on ${machineChip}. See [Gone](t3-context://v1/thread/gone) ![shot](t3-context://v1/image/img-1)`, + context: { version: 1, records: [thread, unmentioned, machine] }, + }, + ]); + expect(entry).toEqual({ + id: "m1", + prompt: `Agent in ${threadChip} finished on ${machineChip}. See`, + mentions: { threadContexts: [thread], environmentContexts: [machine] }, + }); + const recalled = stepComposerPromptHistory({ + direction: "backward", + entries: [entry!], + position: null, + currentPrompt: "", + }); + expect(recalled?.mentions).toEqual({ + threadContexts: [thread], + environmentContexts: [machine], + }); + }); + it("skips messages sent by automations, agents, and T3 Code", () => { const typed = buildComposerPromptHistoryEntries([ { id: "m1", role: "user", text: "typed" }, @@ -113,21 +156,28 @@ describe("buildComposerPromptHistoryEntries", () => { { id: "m3", role: "user", text: "from agent", createdBy: "agent", creationSource: "mcp" }, { id: "m4", role: "user", text: "from server", createdBy: "agent", creationSource: "server" }, ]); - expect(typed).toEqual([{ id: "m1", prompt: "typed" }]); + expect(typed.map((entry) => entry.id)).toEqual(["m1"]); }); }); describe("stepComposerPromptHistory", () => { it("walks back from the newest entry", () => { const first = backward(null, ""); - expect(first).toEqual({ position: { entryId: "m3", recalled: "third" }, prompt: "third" }); + expect(first).toMatchObject({ + position: { entryId: "m3", recalled: "third" }, + prompt: "third", + }); expect(backward(first!.position, "third")?.prompt).toBe("second"); }); it("walks forward and empties the composer past the newest entry", () => { const newer = forward({ entryId: "m2", recalled: "second" }, "second"); expect(newer?.prompt).toBe("third"); - expect(forward(newer!.position, "third")).toEqual({ position: null, prompt: "" }); + expect(forward(newer!.position, "third")).toEqual({ + position: null, + prompt: "", + mentions: { threadContexts: [], environmentContexts: [] }, + }); }); it("treats an edited recall as a fresh draft", () => { diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts index 0d3f68b674b5e..a4f4ed62a7c04 100644 --- a/apps/web/src/components/chat/composerPromptHistory.ts +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -1,8 +1,14 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; +import { + EnvironmentContextRecord, + type OrchestrationMessageContext, + ThreadContextRecord, +} from "@t3tools/contracts"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, collectComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; +import { Schema } from "effect"; import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n"; @@ -10,13 +16,26 @@ const REVIEW_COMMENT_BLOCK_PATTERN = /]*>[\s\S]*?<\/review_c const TRAILING_LEGACY_CONTEXT = /\n*<(terminal_context|element_context|preview_annotation)>\n([\s\S]*?)\n<\/\1>\s*$/; +const isThreadContextRecord = Schema.is(ThreadContextRecord); +const isEnvironmentContextRecord = Schema.is(EnvironmentContextRecord); + type ComposerPromptHistoryMessage = Parameters[0] & { readonly id: string; + readonly context?: OrchestrationMessageContext | undefined; }; +/** Thread and machine mentions; the draft holds these records, so recall can restore their chips. */ +export interface ComposerPromptHistoryMentions { + readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; +} + +const NO_MENTIONS: ComposerPromptHistoryMentions = { threadContexts: [], environmentContexts: [] }; + interface ComposerPromptHistoryEntry { readonly id: string; readonly prompt: string; + readonly mentions: ComposerPromptHistoryMentions; } export interface ComposerPromptHistoryPosition { @@ -36,6 +55,7 @@ function findActive( interface ComposerPromptHistoryStep { readonly position: ComposerPromptHistoryPosition | null; readonly prompt: string; + readonly mentions: ComposerPromptHistoryMentions; } function stripTrailingReviewComments(prompt: string): string { @@ -70,7 +90,10 @@ function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray = new Set(), +): string { let prompt = messageText.trim(); if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) { prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length); @@ -99,8 +122,9 @@ export function recallableComposerPrompt(messageText: string): string { break; } - // Recall strips chips because their backing records are absent. + // Chips without a record the draft can hold would render as unavailable. for (const reference of collectComposerContextReferences(prompt).toReversed()) { + if (keptContextIds.has(reference.contextId)) continue; let { start, end } = reference; if (prompt[end] === " ") end += 1; else if (prompt[start - 1] === " ") start -= 1; @@ -117,6 +141,29 @@ export function recallableComposerPrompt(messageText: string): string { return trimmed; } +function recallMessage( + message: ComposerPromptHistoryMessage, +): Pick { + const records = message.context?.records ?? []; + const threadRecords = records.filter(isThreadContextRecord); + const environmentRecords = records.filter(isEnvironmentContextRecord); + const prompt = recallableComposerPrompt( + message.text, + new Set([...threadRecords, ...environmentRecords].map((record) => record.contextId)), + ); + // A record whose chip is gone from the text would put the chip back when written to the draft. + const referenced = new Set( + collectComposerContextReferences(prompt).map((reference) => reference.contextId), + ); + return { + prompt, + mentions: { + threadContexts: threadRecords.filter((record) => referenced.has(record.contextId)), + environmentContexts: environmentRecords.filter((record) => referenced.has(record.contextId)), + }, + }; +} + export function buildComposerPromptHistoryEntries( messages: ReadonlyArray, ): ComposerPromptHistoryEntry[] { @@ -125,18 +172,27 @@ export function buildComposerPromptHistoryEntries( if (message.role !== "user") continue; // Automations and other agents send user-role messages; recall only what the user typed. if (resolveUserMessagePresentation(message).attribution !== null) continue; - const prompt = recallableComposerPrompt(message.text); + const { prompt, mentions } = recallMessage(message); if (prompt.length === 0) continue; + const entry = { id: message.id, prompt, mentions }; const previous = entries[entries.length - 1]; if (previous && previous.prompt === prompt) { - entries[entries.length - 1] = { id: message.id, prompt }; + entries[entries.length - 1] = entry; continue; } - entries.push({ id: message.id, prompt }); + entries.push(entry); } return entries; } +function recall(entry: ComposerPromptHistoryEntry): ComposerPromptHistoryStep { + return { + position: { entryId: entry.id, recalled: entry.prompt }, + prompt: entry.prompt, + mentions: entry.mentions, + }; +} + export function stepComposerPromptHistory(input: { readonly direction: "backward" | "forward"; readonly entries: ReadonlyArray; @@ -151,11 +207,11 @@ export function stepComposerPromptHistory(input: { if (activeIndex < 0 && currentPrompt.length > 0) return null; const entry = entries[activeIndex < 0 ? entries.length - 1 : activeIndex - 1]; if (!entry) return null; - return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; + return recall(entry); } if (activeIndex < 0) return null; const entry = entries[activeIndex + 1]; - if (!entry) return { position: null, prompt: "" }; - return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; + if (!entry) return { position: null, prompt: "", mentions: NO_MENTIONS }; + return recall(entry); }