diff --git a/src/features/automations/ui/AutomationBuilderView.tsx b/src/features/automations/ui/AutomationBuilderView.tsx index 216b2491..cecfd271 100644 --- a/src/features/automations/ui/AutomationBuilderView.tsx +++ b/src/features/automations/ui/AutomationBuilderView.tsx @@ -116,6 +116,9 @@ export function AutomationBuilderView({ controls={{ agentModelPicker: false, projectPicker: false, + // The builder converses about an automation; its transcript + // is not a quotable source. + quotes: false, }} composerActions={{ onSend: (text) => builder.sendMessage(text), diff --git a/src/features/chat/hooks/useChat.ts b/src/features/chat/hooks/useChat.ts index 9a8c16b3..da0627bd 100644 --- a/src/features/chat/hooks/useChat.ts +++ b/src/features/chat/hooks/useChat.ts @@ -23,6 +23,7 @@ import { } from "../lib/sendCore"; import { perfLog } from "@/shared/lib/perfLog"; import { sanitizeReplayMessages } from "../lib/replaySanitizer"; +import { withRestoredStagedItems } from "../lib/submittedQuoteProvenance"; import { i18n } from "@/shared/i18n"; import type { ChatSendOptions } from "../types"; import { formatAcpErrorMessage } from "@/shared/api/acpErrors"; @@ -164,6 +165,10 @@ export function useChat( const sid = sessionId.slice(0, 8); const hasAttachments = (attachments?.length ?? 0) > 0; const hasAssistantPrompt = Boolean(sendOptions?.assistantPrompt?.trim()); + // Staged quotes deliberately do NOT make an empty send valid: a + // quote-only dispatch would carry an empty ACP prompt, which breaks + // replay provenance matching (withRestoredStagedItems skips + // empty-text turns). The composer enforces the same policy. const currentChatState = useChatStore .getState() .getSessionRuntime(sessionId).chatState; @@ -456,7 +461,10 @@ export function useChat( const buffer = getAndDeleteReplayBuffer(sessionId); if (buffer) { setMessages(sessionId, [ - ...sanitizeReplayMessages(buffer), + ...withRestoredStagedItems( + sessionId, + sanitizeReplayMessages(buffer), + ), createCompactionConfirmationMessage(), ]); } else { diff --git a/src/features/chat/hooks/useChatInputSubmit.ts b/src/features/chat/hooks/useChatInputSubmit.ts index 9672ad31..2f8e76bf 100644 --- a/src/features/chat/hooks/useChatInputSubmit.ts +++ b/src/features/chat/hooks/useChatInputSubmit.ts @@ -1,6 +1,10 @@ import { useCallback, type RefObject } from "react"; import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt"; -import type { ChatAttachmentDraft, MessageChip } from "@/shared/types/messages"; +import type { + ChatAttachmentDraft, + MessageChip, + StagedItem, +} from "@/shared/types/messages"; import { skillDraftSnapshotsMatch } from "../lib/chatInputSnapshots"; import { submitComposerMessage } from "../lib/submitComposerMessage"; import type { ChatInputSendHandler, ChatSkillDraft } from "../types"; @@ -8,6 +12,7 @@ import type { ChatInputSendHandler, ChatSkillDraft } from "../types"; interface UseChatInputSubmitOptions { attachmentsRef: RefObject; selectedSkillsRef: RefObject; + stagedItemsRef: RefObject; selectedChipsRef: RefObject; skillProviderId?: string | null; selectedPersonaId?: string | null; @@ -21,6 +26,7 @@ interface UseChatInputSubmitOptions { export function useChatInputSubmit({ attachmentsRef, selectedSkillsRef, + stagedItemsRef, selectedChipsRef, skillProviderId, selectedPersonaId, @@ -33,12 +39,14 @@ export function useChatInputSubmit({ submittedText: string, submittedAttachments: ChatAttachmentDraft[], submittedSkills: ChatSkillDraft[], + submittedStagedItems: StagedItem[], submitHandler: ChatInputSendHandler = onSend, ) => submitComposerMessage({ text: submittedText, attachments: submittedAttachments, skills: submittedSkills, + stagedItems: submittedStagedItems, chips: selectedChipsRef.current, skillProviderId, selectedPersonaId, @@ -62,6 +70,7 @@ export function useChatInputSubmit({ submittedText, submittedAttachments, submittedSkills, + stagedItemsRef.current, ); if ( accepted && @@ -74,6 +83,7 @@ export function useChatInputSubmit({ [ attachmentsRef, selectedSkillsRef, + stagedItemsRef, setSelectedSkills, submitChatInputMessage, ], diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index d6674d86..7f68c03a 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -7,7 +7,7 @@ import { useState, } from "react"; import { QueryClientContext } from "@tanstack/react-query"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { ChatAttachmentDraft, StagedItem } from "@/shared/types/messages"; import type { ChatSendOptions, ChatSkillDraft, ModelOption } from "../types"; import { INITIAL_TOKEN_STATE } from "@/shared/types/chat"; import { useChat } from "./useChat"; @@ -130,6 +130,7 @@ const DRAFT_STORE_UPDATE_DEBOUNCE_MS = 300; const PENDING_HOME_SESSION_ID = "__home_pending__"; const EMPTY_SKILL_DRAFTS: ChatSkillDraft[] = []; const EMPTY_ATTACHMENT_DRAFTS: ChatAttachmentDraft[] = []; +const EMPTY_STAGED_ITEMS: StagedItem[] = []; const AGENT_BUILDER_MENTION_INVOCATION = /^@agent-builder\s*$/i; const STEERING_SUPPORTED_AGENT_ID = "goose"; const EMPTY_PROMPT_STATE: { key: string; prompt: string | undefined } = { @@ -2562,6 +2563,11 @@ export function useChatSessionController({ const draftAttachments = sessionId ? sessionDraftAttachments : pendingDraftAttachments; + const stagedItems = useChatStore((s) => + sessionId + ? (s.stagedItemsBySession[sessionId] ?? EMPTY_STAGED_ITEMS) + : EMPTY_STAGED_ITEMS, + ); const draftValue = sessionId ? sessionDraftValue : pendingDraftValue; const storedSelectedSkills = sessionId ? sessionSkillDrafts @@ -2634,6 +2640,12 @@ export function useChatSessionController({ }, [stateSessionId], ); + const handleRemoveStagedItem = useCallback( + (itemId: string) => { + useChatStore.getState().removeStagedItem(stateSessionId, itemId); + }, + [stateSessionId], + ); useEffect(() => { const previousSelection = agentBuilderSkillSelectionRef.current; @@ -3087,6 +3099,8 @@ export function useChatSessionController({ handleDraftChange, draftAttachments, handleDraftAttachmentsChange, + stagedItems, + handleRemoveStagedItem, selectedSkills, handleSkillsChange, skillProjectDirs, diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index a19fd842..6ae440a7 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -30,6 +30,7 @@ import { ownsSessionPrompt, releaseSessionPrompt, } from "@/features/chat/lib/sessionPromptOwnership"; +import { prepareStagedQuoteDispatch } from "@/features/chat/lib/stagedQuoteSend"; import { perfLog } from "@/shared/lib/perfLog"; import { completeAssistantMessage } from "@/features/chat/lib/messageCompletion"; import { @@ -306,6 +307,17 @@ export async function dispatchPrompt( ); const acpPrompt = promptWithPaths || (images?.length ? " " : promptWithPaths); + // Quote serialization happens here, at the authoritative send attempt: + // any compaction for this attempt already ran, so the current transcript + // decides per quote source whether an anchor suffices or the excerpt + // must be re-sent in full (see stagedQuoteSend.ts). + const dispatchAssistantPrompt = prepareStagedQuoteDispatch({ + sessionId, + assistantPrompt, + acpPrompt, + stagedItems: userMessageMetadata?.stagedItems, + liveMessages: useChatStore.getState().messagesBySession[sessionId] ?? [], + }); const tAcp = performance.now(); if (!background) { perfLog( @@ -314,7 +326,9 @@ export async function dispatchPrompt( } const promptPromise = acpSendMessage(sessionId, acpPrompt, { systemPrompt, - ...(assistantPrompt ? { assistantPrompt } : {}), + ...(dispatchAssistantPrompt + ? { assistantPrompt: dispatchAssistantPrompt } + : {}), personaId: persona?.id, personaName: persona?.name, goose: acpGooseMetadata, diff --git a/src/features/chat/lib/sessionActivation.ts b/src/features/chat/lib/sessionActivation.ts index 39ad03d6..a7e60787 100644 --- a/src/features/chat/lib/sessionActivation.ts +++ b/src/features/chat/lib/sessionActivation.ts @@ -3,6 +3,7 @@ import { getAndDeleteReplayBuffer, } from "@/features/chat/hooks/replayBuffer"; import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer"; +import { withRestoredStagedItems } from "@/features/chat/lib/submittedQuoteProvenance"; import { completeReplayAssistantMessage } from "@/features/chat/acp/acpReplayAssistant"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { @@ -401,7 +402,9 @@ async function performSessionMessagesLoad( } const tFlush = performance.now(); const buffer = getAndDeleteReplayBuffer(sessionId); - const replayMessages = buffer ? sanitizeReplayMessages(buffer) : undefined; + const replayMessages = buffer + ? withRestoredStagedItems(sessionId, sanitizeReplayMessages(buffer)) + : undefined; const replayStats = getReplayPerf(sessionId); clearReplayPerf(sessionId); if (replayMessages) { diff --git a/src/features/chat/lib/stagedItemPresentation.test.ts b/src/features/chat/lib/stagedItemPresentation.test.ts new file mode 100644 index 00000000..05fd80d3 --- /dev/null +++ b/src/features/chat/lib/stagedItemPresentation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { StagedQuoteItem } from "@/shared/types/messages"; +import { + stagedQuoteLabel, + stagedQuoteSourceKind, + stagedQuoteWordCount, +} from "./stagedItemPresentation"; + +function quote(overrides: Partial = {}): StagedQuoteItem { + return { + id: "quote-1", + kind: "quote", + excerpt: "Saturn", + sources: [ + { + messageId: "message-1", + role: "assistant", + contentBlockIndex: 0, + start: 0, + end: 6, + }, + ], + ...overrides, + }; +} + +describe("staged quote presentation", () => { + it("keeps short selections verbatim", () => { + expect(stagedQuoteLabel(quote())).toBe("Saturn"); + }); + + it("creates a stable verbatim anchor for long selections", () => { + const label = stagedQuoteLabel( + quote({ excerpt: "A deliberately long selection ".repeat(5) }), + ); + expect(label.endsWith("…")).toBe(true); + expect(label.length).toBeLessThanOrEqual(73); + }); + + it("describes source and extent without replacing the excerpt", () => { + expect(stagedQuoteSourceKind(quote())).toBe("agentResponse"); + expect(stagedQuoteWordCount(quote())).toBe(1); + expect( + stagedQuoteSourceKind( + quote({ + sources: [ + quote().sources[0], + { ...quote().sources[0], messageId: "message-2" }, + ], + }), + ), + ).toBe("multipleMessages"); + }); + + it("treats multiple blocks of one message as a single-message quote", () => { + expect( + stagedQuoteSourceKind( + quote({ + sources: [ + quote().sources[0], + { ...quote().sources[0], contentBlockIndex: 1 }, + ], + }), + ), + ).toBe("agentResponse"); + }); +}); diff --git a/src/features/chat/lib/stagedItemPresentation.ts b/src/features/chat/lib/stagedItemPresentation.ts new file mode 100644 index 00000000..42d0caf5 --- /dev/null +++ b/src/features/chat/lib/stagedItemPresentation.ts @@ -0,0 +1,47 @@ +import type { StagedQuoteItem } from "@/shared/types/messages"; + +const SHORT_QUOTE_CHARACTER_LIMIT = 72; + +function compactWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function wordCount(value: string): number { + return compactWhitespace(value).split(" ").filter(Boolean).length; +} + +export function stagedQuoteLabel(quote: StagedQuoteItem): string { + const excerpt = compactWhitespace(quote.excerpt); + if (excerpt.length <= SHORT_QUOTE_CHARACTER_LIMIT) return excerpt; + return `${excerpt.slice(0, SHORT_QUOTE_CHARACTER_LIMIT).trimEnd()}…`; +} + +export type StagedQuoteSourceKind = + | "agentResponse" + | "yourMessage" + | "systemMessage" + | "multipleMessages"; + +/** Distinct messages the quote draws from; multiple blocks of one message + * still count as one message. */ +export function stagedQuoteMessageCount(quote: StagedQuoteItem): number { + return new Set(quote.sources.map((source) => source.messageId)).size; +} + +export function stagedQuoteSourceKind( + quote: StagedQuoteItem, +): StagedQuoteSourceKind { + if (stagedQuoteMessageCount(quote) > 1) return "multipleMessages"; + switch (quote.sources[0]?.role) { + case "user": + return "yourMessage"; + case "system": + return "systemMessage"; + default: + return "agentResponse"; + } +} + +export function stagedQuoteWordCount(quote: StagedQuoteItem): number { + return wordCount(quote.excerpt); +} diff --git a/src/features/chat/lib/stagedQuoteSend.test.ts b/src/features/chat/lib/stagedQuoteSend.test.ts new file mode 100644 index 00000000..c6f9d3e5 --- /dev/null +++ b/src/features/chat/lib/stagedQuoteSend.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import type { + Message, + StagedQuoteItem, + StagedQuoteSourceRange, +} from "@/shared/types/messages"; +import { + buildStagedQuoteDispatchPrompt, + stagedItemSnapshotsMatch, + stagedQuoteSourceIsLive, +} from "./stagedQuoteSend"; + +function makeSource( + overrides: Partial = {}, +): StagedQuoteSourceRange { + return { + messageId: "message-1", + role: "assistant", + contentBlockIndex: 0, + start: 0, + end: 12, + ...overrides, + }; +} + +function makeQuote(overrides: Partial = {}): StagedQuoteItem { + return { + id: "quote-1", + kind: "quote", + excerpt: "quoted words", + sources: [makeSource()], + ...overrides, + }; +} + +function makeMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 1, + content: [{ type: "text", text }], + }; +} + +describe("buildStagedQuoteDispatchPrompt", () => { + it("returns undefined without quotes", () => { + expect(buildStagedQuoteDispatchPrompt([], () => true)).toBeUndefined(); + }); + + it("anchors when every source is live", () => { + const prompt = buildStagedQuoteDispatchPrompt([makeQuote()], () => true); + expect(prompt).toContain(""); + expect(prompt).toContain("quoted words"); + expect(prompt).toContain("appears verbatim earlier"); + expect(prompt).not.toContain(""); + }); + + it("sends the full excerpt when a source is gone", () => { + const prompt = buildStagedQuoteDispatchPrompt([makeQuote()], () => false); + expect(prompt).toContain(""); + expect(prompt).toContain("quoted words"); + expect(prompt).not.toContain(""); + }); + + it("decides per quote, not session-wide", () => { + const liveQuote = makeQuote({ id: "quote-live" }); + const lostQuote = makeQuote({ + id: "quote-lost", + excerpt: "lost words", + sources: [makeSource({ messageId: "message-gone" })], + }); + const prompt = buildStagedQuoteDispatchPrompt( + [liveQuote, lostQuote], + (source) => source.messageId === "message-1", + ); + expect(prompt).toContain(""); + expect(prompt).toContain(""); + expect(prompt).toContain("lost words"); + }); + + it("keeps short anchored excerpts whole", () => { + const prompt = buildStagedQuoteDispatchPrompt([makeQuote()], () => true); + expect(prompt).not.toContain("[…]"); + }); + + it("elides long anchored excerpts to head and tail", () => { + const head = "The opening sentence of a very long quoted passage. "; + const tail = " And the closing sentence that ends the passage."; + const excerpt = head + "middle ".repeat(120) + tail; + const prompt = buildStagedQuoteDispatchPrompt( + [makeQuote({ excerpt })], + () => true, + ); + expect(prompt).toContain("[…]"); + expect(prompt).toContain("The opening sentence"); + expect(prompt).toContain("ends the passage."); + // The elided body is much shorter than the original excerpt. + expect(prompt?.length ?? 0).toBeLessThan(excerpt.length); + }); + + it("never elides full excerpts for lost sources", () => { + const excerpt = "word ".repeat(200).trim(); + const prompt = buildStagedQuoteDispatchPrompt( + [makeQuote({ excerpt })], + () => false, + ); + expect(prompt).toContain(excerpt); + expect(prompt).not.toContain("[…]"); + }); + + it("treats a quote with no sources as not anchorable", () => { + const prompt = buildStagedQuoteDispatchPrompt( + [makeQuote({ sources: [] })], + () => true, + ); + expect(prompt).toContain(""); + expect(prompt).not.toContain(""); + }); +}); + +describe("stagedQuoteSourceIsLive", () => { + it("accepts a source whose block still contains the range", () => { + expect( + stagedQuoteSourceIsLive( + [makeMessage("message-1", "quoted words and more")], + makeSource(), + ), + ).toBe(true); + }); + + it("rejects a missing message", () => { + expect( + stagedQuoteSourceIsLive( + [makeMessage("other-message", "quoted words")], + makeSource(), + ), + ).toBe(false); + }); + + it("rejects a missing or non-text block", () => { + expect( + stagedQuoteSourceIsLive( + [makeMessage("message-1", "quoted words")], + makeSource({ contentBlockIndex: 3 }), + ), + ).toBe(false); + }); + + it("rejects a block rewritten shorter than the quoted range", () => { + expect( + stagedQuoteSourceIsLive( + [makeMessage("message-1", "short")], + makeSource({ end: 12 }), + ), + ).toBe(false); + }); +}); + +describe("stagedItemSnapshotsMatch", () => { + it("matches identical snapshots and rejects drift", () => { + const items = [makeQuote()]; + expect(stagedItemSnapshotsMatch(items, [makeQuote()])).toBe(true); + expect(stagedItemSnapshotsMatch(items, [makeQuote({ id: "other" })])).toBe( + false, + ); + expect(stagedItemSnapshotsMatch(items, [])).toBe(false); + }); +}); diff --git a/src/features/chat/lib/stagedQuoteSend.ts b/src/features/chat/lib/stagedQuoteSend.ts new file mode 100644 index 00000000..1a4b41fd --- /dev/null +++ b/src/features/chat/lib/stagedQuoteSend.ts @@ -0,0 +1,138 @@ +import { composeSystemPrompt } from "@/features/projects/lib/chatProjectContext"; +import type { + Message, + StagedItem, + StagedQuoteItem, + StagedQuoteSourceRange, +} from "@/shared/types/messages"; +import { recordSubmittedStagedItems } from "./submittedQuoteProvenance"; + +/** + * Quote serialization happens at the authoritative send attempt, not in the + * composer: only dispatch knows whether compaction ran for this attempt and + * whether each quote's source turn still exists in the live transcript. + * + * - Anchor framing (source survives): the passage appears verbatim earlier + * in the conversation, so long excerpts are elided to head…tail anchors + * that uniquely locate it without re-sending the whole passage. + * - Full-excerpt framing (source lost): compaction summarized the source + * turn away, so the excerpt is repeated in full — the callback must not + * silently degrade just because history was compacted. + * + * The decision is per quote source, not session-wide: a quote taken after + * an old compaction can still anchor, while one whose source was just + * compacted needs its excerpt. + */ + +const CALLBACK_PREFIX = + "The user is referring specifically to this earlier passage:"; + +const CALLBACK_SUFFIX = + "Answer the user's message in relation to that passage, not the entire earlier response unless they explicitly ask for it."; + +/** Excerpts at or under this length are sent whole even when anchored. */ +const ANCHOR_ELISION_THRESHOLD = 400; +/** Head/tail lengths for elided anchors. */ +const ANCHOR_EDGE_LENGTH = 160; + +function anchorBody(excerpt: string): string { + if (excerpt.length <= ANCHOR_ELISION_THRESHOLD) return excerpt; + const head = excerpt.slice(0, ANCHOR_EDGE_LENGTH).trimEnd(); + const tail = excerpt.slice(-ANCHOR_EDGE_LENGTH).trimStart(); + return `${head}\n[…]\n${tail}`; +} + +function serializeQuote(quote: StagedQuoteItem, anchored: boolean): string { + if (anchored) { + return [ + "\n", + anchorBody(quote.excerpt), + "", + "(The full passage appears verbatim earlier in this conversation.)", + ].join("\n"); + } + return `\n\n${quote.excerpt}\n`; +} + +/** Builds the assistant-audience quote framing for one send attempt. + * `isSourceLive` reports whether a source's message still exists in the + * transcript at this attempt; a quote anchors only when every one of its + * sources survives. */ +export function buildStagedQuoteDispatchPrompt( + stagedItems: readonly StagedItem[], + isSourceLive: (source: StagedQuoteSourceRange) => boolean, +): string | undefined { + const quotes = stagedItems.filter((item) => item.kind === "quote"); + if (quotes.length === 0) return undefined; + + return [ + CALLBACK_PREFIX, + ...quotes.map((quote) => + serializeQuote( + quote, + quote.sources.length > 0 && quote.sources.every(isSourceLive), + ), + ), + `\n${CALLBACK_SUFFIX}`, + ].join("\n"); +} + +/** Whether a quote source's turn is still live in the transcript at this + * send attempt: its message exists and the referenced text block still + * contains the quoted range. Compaction that summarizes the turn away (or + * rewrites it shorter than the quote) fails this check, switching that + * quote to full-excerpt framing. */ +export function stagedQuoteSourceIsLive( + messages: readonly Pick[], + source: StagedQuoteSourceRange, +): boolean { + const message = messages.find( + (candidate) => candidate.id === source.messageId, + ); + const block = message?.content[source.contentBlockIndex]; + return ( + !!block && + block.type === "text" && + typeof block.text === "string" && + source.end <= block.text.length + ); +} + +/** The single quote-dispatch step shared by every authoritative send path + * (foreground send and steer). Composes the assistant-audience quote + * framing into the dispatch prompt and records durable provenance so + * replay can re-attach the quotes to this turn. Both callers must go + * through here: two copies of this sequence would drift the first time + * one is edited. Returns `assistantPrompt` unchanged when nothing is + * staged. */ +export function prepareStagedQuoteDispatch({ + sessionId, + assistantPrompt, + acpPrompt, + stagedItems, + liveMessages, +}: { + sessionId: string; + assistantPrompt: string | undefined; + /** The exact prompt text dispatched over ACP (provenance match key). */ + acpPrompt: string; + stagedItems: readonly StagedItem[] | undefined; + liveMessages: readonly Pick[]; +}): string | undefined { + if (!stagedItems?.length) return assistantPrompt; + const quotePrompt = buildStagedQuoteDispatchPrompt(stagedItems, (source) => + stagedQuoteSourceIsLive(liveMessages, source), + ); + recordSubmittedStagedItems(sessionId, acpPrompt, stagedItems); + return composeSystemPrompt(assistantPrompt, quotePrompt); +} + +export function stagedItemSnapshotsMatch( + current: readonly StagedItem[], + submitted: readonly StagedItem[], +): boolean { + return ( + current.length === submitted.length && + current.every((item, index) => item.id === submitted[index]?.id) + ); +} diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index a6c8dc5d..37f3c63c 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -1,5 +1,6 @@ import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useChatStore } from "@/features/chat/stores/chatStore"; +import { prepareStagedQuoteDispatch } from "@/features/chat/lib/stagedQuoteSend"; import { acpSteerMessage } from "@/shared/api/acp"; import { formatAcpErrorMessage } from "@/shared/api/acpErrors"; import { @@ -92,6 +93,17 @@ export async function steerPromptInSession( const promptWithPaths = appendAttachmentPaths(text.trim(), attachments); const acpPrompt = promptWithPaths || (images?.length ? " " : promptWithPaths); + // Quote serialization happens at the send attempt (see stagedQuoteSend.ts). + // A steer targets the currently running turn, so no compaction can + // intervene between here and pickup; the current transcript decides + // anchor-vs-full-excerpt per quote source. + const dispatchAssistantPrompt = prepareStagedQuoteDispatch({ + sessionId, + assistantPrompt: sendOptions?.assistantPrompt, + acpPrompt, + stagedItems: sendOptions?.userMessageMetadata?.stagedItems, + liveMessages: useChatStore.getState().messagesBySession[sessionId] ?? [], + }); const chatStore = useChatStore.getState(); chatStore.addMessage(sessionId, userMessage); chatStore.setPendingInterventionBoundary(sessionId, { @@ -104,8 +116,8 @@ export async function steerPromptInSession( activeRunId, acpPrompt, { - ...(sendOptions?.assistantPrompt - ? { assistantPrompt: sendOptions.assistantPrompt } + ...(dispatchAssistantPrompt + ? { assistantPrompt: dispatchAssistantPrompt } : {}), goose: sendOptions?.acpGooseMetadata, images: images?.map( diff --git a/src/features/chat/lib/submitComposerMessage.test.ts b/src/features/chat/lib/submitComposerMessage.test.ts index 57102a54..c4bbb6ed 100644 --- a/src/features/chat/lib/submitComposerMessage.test.ts +++ b/src/features/chat/lib/submitComposerMessage.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import type { + ChatAttachmentDraft, + StagedQuoteItem, +} from "@/shared/types/messages"; import { MAX_PROMPT_ATTACHMENT_BYTES } from "./attachmentPayloadBudget"; import { submitComposerMessage } from "./submitComposerMessage"; @@ -30,6 +33,46 @@ function imageDraft(base64: string): ChatAttachmentDraft { } describe("submitComposerMessage", () => { + it("sends a staged quote as structured intent without pre-serializing it", async () => { + const onSend = vi.fn().mockReturnValue(true); + const quote: StagedQuoteItem = { + id: "quote-1", + kind: "quote", + excerpt: "Ask reviewers to separate product concerns from visual polish.", + sources: [ + { + messageId: "message-1", + contentBlockIndex: 0, + start: 10, + end: 72, + }, + ], + }; + + await submitComposerMessage({ + text: "can you elaborate?", + attachments: [], + skills: [], + stagedItems: [quote], + onSend, + resolveSkillSlashCommand: () => null, + }); + + // Serialization (anchor vs full excerpt) is a dispatch-time decision + // made after any compaction for the attempt (see stagedQuoteSend.ts). + // The composer only snapshots the structured quote into the send. + expect(onSend).toHaveBeenCalledWith( + "can you elaborate?", + undefined, + undefined, + expect.objectContaining({ + userMessageMetadata: { stagedItems: [quote] }, + }), + ); + const sendOptions = onSend.mock.calls[0][3]; + expect(sendOptions.assistantPrompt).toBeUndefined(); + }); + it("adds skill instructions when a slash skill command matches", async () => { const onSend = vi.fn().mockReturnValue(true); diff --git a/src/features/chat/lib/submitComposerMessage.ts b/src/features/chat/lib/submitComposerMessage.ts index 00601632..04cec243 100644 --- a/src/features/chat/lib/submitComposerMessage.ts +++ b/src/features/chat/lib/submitComposerMessage.ts @@ -1,7 +1,11 @@ import { toast } from "sonner"; import type { SkillCommandMatch } from "@/features/skills/lib/skillChatPrompt"; import { isPromiseLike } from "@/shared/lib/isPromiseLike"; -import type { ChatAttachmentDraft, MessageChip } from "@/shared/types/messages"; +import type { + ChatAttachmentDraft, + MessageChip, + StagedItem, +} from "@/shared/types/messages"; import type { ChatInputSendHandler, ChatSkillDraft } from "../types"; import { formatAttachmentsTooLargeMessage, @@ -14,6 +18,7 @@ interface SubmitComposerMessageOptions { text: string; attachments: ChatAttachmentDraft[]; skills: ChatSkillDraft[]; + stagedItems?: StagedItem[]; chips?: MessageChip[]; skillProviderId?: string | null; selectedPersonaId?: string | null; @@ -47,6 +52,7 @@ export async function submitComposerMessage({ text, attachments, skills, + stagedItems = [], chips = [], skillProviderId, selectedPersonaId, @@ -69,9 +75,19 @@ export async function submitComposerMessage({ sendOptions?.chips && sendOptions.chips.length > 0 ? [...chips, ...sendOptions.chips] : chips; + // Staged quotes travel as structured intent only. Serialization into the + // assistant-audience prompt happens at the authoritative dispatch attempt + // (see stagedQuoteSend.ts), after any compaction for that attempt, when + // anchor-vs-full-excerpt can actually be decided. const mergedSendOptions = - mergedChips.length > 0 - ? { ...sendOptions, chips: mergedChips } + mergedChips.length > 0 || stagedItems.length > 0 + ? { + ...sendOptions, + ...(mergedChips.length > 0 ? { chips: mergedChips } : {}), + ...(stagedItems.length > 0 + ? { userMessageMetadata: { stagedItems: [...stagedItems] } } + : {}), + } : sendOptions; const submittedText = sendOptions ? messageText : messageText.trim(); const submittedAttachments = attachments.length > 0 ? attachments : undefined; diff --git a/src/features/chat/lib/submittedQuoteProvenance.test.ts b/src/features/chat/lib/submittedQuoteProvenance.test.ts new file mode 100644 index 00000000..56cdbb9d --- /dev/null +++ b/src/features/chat/lib/submittedQuoteProvenance.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Message, StagedQuoteItem } from "@/shared/types/messages"; +import { + clearSubmittedStagedItems, + loadSubmittedStagedItemRecords, + recordSubmittedStagedItems, + withRestoredStagedItems, +} from "./submittedQuoteProvenance"; + +function makeQuote(id: string, excerpt = "quoted words"): StagedQuoteItem { + return { + id, + kind: "quote", + excerpt, + sources: [ + { + messageId: "source-message", + role: "assistant", + contentBlockIndex: 0, + start: 0, + end: excerpt.length, + }, + ], + }; +} + +function makeUserMessage(id: string, text: string): Message { + return { + id, + role: "user", + created: 1, + content: [{ type: "text", text }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +function makeAssistantMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 2, + content: [{ type: "text", text }], + }; +} + +describe("submittedQuoteProvenance", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("records only quote staged items and survives reload round-trips", () => { + recordSubmittedStagedItems("session-1", "what about this?", [ + makeQuote("quote-1"), + ]); + const records = loadSubmittedStagedItemRecords(); + expect(records["session-1"]).toHaveLength(1); + expect(records["session-1"][0].matchText).toBe("what about this?"); + expect(records["session-1"][0].stagedItems[0].id).toBe("quote-1"); + }); + + it("does not record turns without quotes", () => { + recordSubmittedStagedItems("session-1", "plain message", []); + expect(loadSubmittedStagedItemRecords()).toEqual({}); + }); + + it("re-attaches staged items to the replayed user turn by prompt text", () => { + recordSubmittedStagedItems("session-1", "what about this?", [ + makeQuote("quote-1"), + ]); + + const restored = withRestoredStagedItems("session-1", [ + makeAssistantMessage("a-1", "an earlier answer"), + makeUserMessage("replayed-user", "what about this?"), + ]); + + expect(restored[1].metadata?.stagedItems?.[0]?.id).toBe("quote-1"); + // Non-matching messages pass through untouched. + expect(restored[0].metadata?.stagedItems).toBeUndefined(); + }); + + it("matches replayed text that gained surrounding whitespace", () => { + recordSubmittedStagedItems("session-1", "what about this?", [ + makeQuote("quote-1"), + ]); + const restored = withRestoredStagedItems("session-1", [ + makeUserMessage("replayed-user", " what about this?\n"), + ]); + expect(restored[0].metadata?.stagedItems?.[0]?.id).toBe("quote-1"); + }); + + it("consumes duplicate prompt texts in send order", () => { + recordSubmittedStagedItems("session-1", "same words", [ + makeQuote("quote-1", "first excerpt"), + ]); + recordSubmittedStagedItems("session-1", "same words", [ + makeQuote("quote-2", "second excerpt"), + ]); + + const restored = withRestoredStagedItems("session-1", [ + makeUserMessage("turn-1", "same words"), + makeUserMessage("turn-2", "same words"), + ]); + + expect(restored[0].metadata?.stagedItems?.[0]?.id).toBe("quote-1"); + expect(restored[1].metadata?.stagedItems?.[0]?.id).toBe("quote-2"); + }); + + it("never overwrites staged items a message already carries", () => { + recordSubmittedStagedItems("session-1", "what about this?", [ + makeQuote("quote-replayed"), + ]); + const live = makeUserMessage("live-user", "what about this?"); + live.metadata = { + ...live.metadata, + stagedItems: [makeQuote("quote-live")], + }; + + const restored = withRestoredStagedItems("session-1", [live]); + expect(restored[0].metadata?.stagedItems?.[0]?.id).toBe("quote-live"); + }); + + it("leaves records unmatched when the turn was compacted away", () => { + recordSubmittedStagedItems("session-1", "a turn compaction removed", [ + makeQuote("quote-1"), + ]); + const restored = withRestoredStagedItems("session-1", [ + makeUserMessage("other-turn", "a different surviving turn"), + ]); + expect(restored[0].metadata?.stagedItems).toBeUndefined(); + }); + + it("scopes records per session", () => { + recordSubmittedStagedItems("session-1", "shared text", [ + makeQuote("quote-1"), + ]); + const restored = withRestoredStagedItems("session-2", [ + makeUserMessage("turn", "shared text"), + ]); + expect(restored[0].metadata?.stagedItems).toBeUndefined(); + }); + + it("clears a session's records on demand", () => { + recordSubmittedStagedItems("session-1", "text", [makeQuote("quote-1")]); + clearSubmittedStagedItems("session-1"); + expect(loadSubmittedStagedItemRecords()).toEqual({}); + }); + + it("caps stored records per session, dropping oldest first", () => { + for (let index = 0; index < 105; index += 1) { + recordSubmittedStagedItems("session-1", `turn ${index}`, [ + makeQuote(`quote-${index}`), + ]); + } + const records = loadSubmittedStagedItemRecords()["session-1"]; + expect(records).toHaveLength(100); + expect(records[0].matchText).toBe("turn 5"); + expect(records[99].matchText).toBe("turn 104"); + }); + + it("ignores corrupted storage payloads", () => { + window.localStorage.setItem( + "chat-submitted-staged-items", + '{"session-1": "not-an-array"}', + ); + expect(loadSubmittedStagedItemRecords()).toEqual({}); + // And recording on top of corruption still works. + recordSubmittedStagedItems("session-1", "text", [makeQuote("quote-1")]); + expect(loadSubmittedStagedItemRecords()["session-1"]).toHaveLength(1); + }); +}); diff --git a/src/features/chat/lib/submittedQuoteProvenance.ts b/src/features/chat/lib/submittedQuoteProvenance.ts new file mode 100644 index 00000000..06e0e2d4 --- /dev/null +++ b/src/features/chat/lib/submittedQuoteProvenance.ts @@ -0,0 +1,151 @@ +import type { Message, StagedItem } from "@/shared/types/messages"; +import { getTextContent } from "@/shared/types/messages"; +import { isStagedItem } from "../stores/draftPersistence"; + +/** + * Durable Berd-owned provenance for submitted staged quotes (Option A of the + * quote-provenance decision: Berd-local persistence, no backend change). + * + * Goose persists user messages with server-generated ids that are never + * echoed to the client during the live turn, so submitted quote metadata + * stored on the locally created user message cannot be joined back to a + * replayed turn by id. It can be joined by content: the exact prompt text + * Berd dispatches is what Goose persists and replays as the turn's + * user-visible text (assistant-audience blocks are filtered to chips on + * replay), and replay preserves send order. Each submitted quote is + * therefore recorded with its dispatched prompt text, and on replay the + * records are re-attached to user turns by ordered text matching — + * duplicate texts consume records in order. + * + * When a turn disappears entirely (compaction summarized it away), its + * record simply finds no match: the quote card is gone exactly when the + * turn itself is gone. + */ + +const STORAGE_KEY = "chat-submitted-staged-items"; + +/** Upper bound per session; oldest records are dropped first. */ +const MAX_RECORDS_PER_SESSION = 100; + +export interface SubmittedStagedItemRecord { + /** The exact prompt text dispatched over ACP for this turn. */ + matchText: string; + stagedItems: StagedItem[]; + recordedAt: number; +} + +type RecordsBySession = Record; + +function isSubmittedRecord(value: unknown): value is SubmittedStagedItemRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const record = value as Record; + return ( + typeof record.matchText === "string" && + typeof record.recordedAt === "number" && + Array.isArray(record.stagedItems) && + record.stagedItems.every(isStagedItem) + ); +} + +export function loadSubmittedStagedItemRecords(): RecordsBySession { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (!stored) return {}; + const parsed: unknown = JSON.parse(stored); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).flatMap(([sessionId, value]) => { + if (!Array.isArray(value)) return []; + const records = value.filter(isSubmittedRecord); + return records.length > 0 ? [[sessionId, records]] : []; + }), + ); + } catch { + return {}; + } +} + +function persist(records: RecordsBySession): void { + if (typeof window === "undefined") return; + try { + const nonEmpty = Object.fromEntries( + Object.entries(records).filter(([, list]) => list.length > 0), + ); + if (Object.keys(nonEmpty).length === 0) { + window.localStorage.removeItem(STORAGE_KEY); + } else { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nonEmpty)); + } + } catch { + // localStorage may be unavailable + } +} + +/** Records the staged quotes of a dispatched user turn so their receipt and + * source coordinates survive replay, window reopen, and compaction. */ +export function recordSubmittedStagedItems( + sessionId: string, + matchText: string, + stagedItems: readonly StagedItem[], +): void { + const quotes = stagedItems.filter((item) => item.kind === "quote"); + if (quotes.length === 0) return; + const records = loadSubmittedStagedItemRecords(); + const sessionRecords = records[sessionId] ?? []; + sessionRecords.push({ + matchText, + stagedItems: [...quotes], + recordedAt: Date.now(), + }); + records[sessionId] = sessionRecords.slice(-MAX_RECORDS_PER_SESSION); + persist(records); +} + +/** Drops all records for a session (session deleted/archived). */ +export function clearSubmittedStagedItems(sessionId: string): void { + const records = loadSubmittedStagedItemRecords(); + if (!records[sessionId]) return; + delete records[sessionId]; + persist(records); +} + +function normalizedMatchText(value: string): string { + return value.trim(); +} + +/** Re-attaches submitted staged quotes to replayed user turns by ordered + * prompt-text matching. Pure with respect to the input array: returns new + * message objects where metadata was attached, and never overwrites + * staged items a message already carries. */ +export function withRestoredStagedItems( + sessionId: string, + messages: readonly Message[], +): Message[] { + const records = loadSubmittedStagedItemRecords()[sessionId]; + if (!records || records.length === 0) return [...messages]; + + const unconsumed = [...records]; + return messages.map((message) => { + if (message.role !== "user") return message; + if (message.metadata?.stagedItems?.length) return message; + const messageText = normalizedMatchText(getTextContent(message)); + if (!messageText) return message; + const index = unconsumed.findIndex( + (record) => normalizedMatchText(record.matchText) === messageText, + ); + if (index < 0) return message; + const [record] = unconsumed.splice(index, 1); + return { + ...message, + metadata: { + ...message.metadata, + stagedItems: [...record.stagedItems], + }, + }; + }); +} diff --git a/src/features/chat/lib/transcriptQuoteSelection.segments.test.tsx b/src/features/chat/lib/transcriptQuoteSelection.segments.test.tsx new file mode 100644 index 00000000..feb5ecd2 --- /dev/null +++ b/src/features/chat/lib/transcriptQuoteSelection.segments.test.tsx @@ -0,0 +1,347 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; +import { + quoteMessageAttributes, + quoteTextBlockAttributes, + stagedQuoteFromSelection, +} from "./transcriptQuoteSelection"; + +/** + * Integration coverage for renderer-produced canonical source segments: + * real Streamdown rendering with `sourceSegments`, real DOM selections, + * and the production mapper — no hand-built segment markup. + */ + +function makeMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 1, + content: [{ type: "text", text }], + }; +} + +function renderMarkdownMessage(id: string, markdown: string) { + const utils = render( +
+
+ + {markdown} + +
+
, + ); + const root = utils.container as HTMLElement; + const block = root.querySelector( + "[data-quote-content-block-index]", + ); + if (!block) throw new Error("missing text block"); + return { root, block }; +} + +function renderMarkdownTranscript( + messages: readonly { id: string; markdown: string }[], +) { + const utils = render( +
+ {messages.map((message) => ( +
+
+ + {message.markdown} + +
+
+ ))} +
, + ); + return { root: utils.container as HTMLElement }; +} + +function findTextNode(root: Node, match: string): { node: Text; at: number } { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const at = (node.data ?? "").indexOf(match); + if (at >= 0) return { node, at }; + } + throw new Error(`text not found in DOM: ${match}`); +} + +function selectBetween( + root: HTMLElement, + startText: string, + endText: string, +): Selection { + const start = findTextNode(root, startText); + const end = findTextNode(root, endText); + const range = document.createRange(); + range.setStart(start.node, start.at); + range.setEnd(end.node, end.at + endText.length); + const selection = window.getSelection(); + if (!selection) throw new Error("selection unavailable"); + selection.removeAllRanges(); + selection.addRange(range); + return selection; +} + +describe("stagedQuoteFromSelection with renderer source segments", () => { + it("maps a selection spanning two numbered list items to canonical source", () => { + const canonical = [ + "1. Set a clear critique goal upfront.", + "2. Ask reviewers to separate product concerns from visual polish.", + "3. End with explicit decisions and owners.", + ].join("\n"); + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween( + root, + "Ask reviewers", + "explicit decisions and owners.", + ); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote).not.toBeNull(); + expect(quote?.sources).toHaveLength(1); + const source = quote?.sources[0]; + const excerpt = canonical.slice(source?.start, source?.end); + expect(excerpt.startsWith("Ask reviewers")).toBe(true); + expect(excerpt.endsWith("explicit decisions and owners.")).toBe(true); + // The canonical excerpt keeps the source's own list marker between items. + expect(excerpt).toContain("3. End with"); + expect(quote?.excerpt).toBe(excerpt); + }); + + it("maps a selection inside bold text to canonical offsets excluding markers", () => { + const canonical = "Prefer **structured staged items** over pasted text."; + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween(root, "structured", "staged items"); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote?.excerpt).toBe("structured staged items"); + expect(quote?.sources[0]).toMatchObject({ + messageId: "message-1", + contentBlockIndex: 0, + start: canonical.indexOf("structured"), + end: canonical.indexOf("staged items") + "staged items".length, + }); + }); + + it("maps repeated phrases to the occurrence actually selected", () => { + const canonical = [ + "- Retry the request.", + "- Check the logs.", + "- Retry the request.", + ].join("\n"); + const { root } = renderMarkdownMessage("message-1", canonical); + + // Select the second occurrence by walking to the last matching text node. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let lastMatch: Text | null = null; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if ((node.data ?? "").includes("Retry the request.")) lastMatch = node; + } + if (!lastMatch) throw new Error("missing repeated phrase"); + const at = lastMatch.data.indexOf("Retry the request."); + const range = document.createRange(); + range.setStart(lastMatch, at); + range.setEnd(lastMatch, at + "Retry the request.".length); + const selection = window.getSelection(); + if (!selection) throw new Error("selection unavailable"); + selection.removeAllRanges(); + selection.addRange(range); + + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote?.excerpt).toBe("Retry the request."); + // The last occurrence starts after the first one. + expect(quote?.sources[0]?.start).toBe( + canonical.lastIndexOf("Retry the request."), + ); + }); + + it("maps a selection spanning a paragraph and a list across Streamdown blocks", () => { + const canonical = [ + "Consider these steps before shipping.", + "", + "1. Write the failing test.", + "2. Fix the bug.", + ].join("\n"); + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween(root, "these steps", "failing test."); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote).not.toBeNull(); + const source = quote?.sources[0]; + const excerpt = canonical.slice(source?.start, source?.end); + expect(excerpt.startsWith("these steps")).toBe(true); + expect(excerpt.endsWith("failing test.")).toBe(true); + }); + + it("maps a selection inside a link label to the label's canonical range", () => { + const canonical = "Read the [style guide](https://example.com) first."; + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween(root, "style", "guide"); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote?.excerpt).toBe("style guide"); + expect(quote?.sources[0]?.start).toBe(canonical.indexOf("style guide")); + }); + + it("maps a selection spanning two markdown messages into one ordered quote", () => { + const first = "The plan has **three** phases before launch."; + const second = "1. Ship the beta.\n2. Collect feedback."; + const { root } = renderMarkdownTranscript([ + { id: "message-1", markdown: first }, + { id: "message-2", markdown: second }, + ]); + + const selection = selectBetween(root, "three", "Ship the beta."); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [ + makeMessage("message-1", first), + makeMessage("message-2", second), + ], + root, + selection, + }); + + expect(quote).not.toBeNull(); + expect(quote?.sources.map((source) => source.messageId)).toEqual([ + "message-1", + "message-2", + ]); + const [firstSource, secondSource] = quote?.sources ?? []; + const firstExcerpt = first.slice(firstSource?.start, firstSource?.end); + const secondExcerpt = second.slice(secondSource?.start, secondSource?.end); + expect(firstExcerpt.startsWith("three")).toBe(true); + expect(firstExcerpt.endsWith("phases before launch.")).toBe(true); + expect(secondExcerpt).toBe("Ship the beta."); + expect(quote?.excerpt).toBe(`${firstExcerpt}\n\n${secondExcerpt}`); + }); + + it("clamps around non-text blocks between the selected messages", () => { + const first = "Here is the diagnosis."; + const second = "And here is the fix."; + const utils = render( +
+
+
+ + {first} + +
+
ran shell command: just check
+
+
+
+ + {second} + +
+
+
, + ); + const root = utils.container as HTMLElement; + + const selection = selectBetween(root, "the diagnosis.", "And here"); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [ + makeMessage("message-1", first), + makeMessage("message-2", second), + ], + root, + selection, + }); + + expect(quote).not.toBeNull(); + // The tool card's text is selected in the DOM but contributes nothing: + // only canonical text blocks produce sources and excerpt content. + expect(quote?.excerpt).not.toContain("just check"); + expect(quote?.sources.map((source) => source.messageId)).toEqual([ + "message-1", + "message-2", + ]); + expect(quote?.excerpt).toBe("the diagnosis.\n\nAnd here"); + }); + + it("maps list-item text after a hard line break despite dropped position data", () => { + // Hard break + lazy continuation: the Markdown transform strips the + // continuation indentation, dropping position data on the text node. + // The annotator must infer bounds so the quote keeps the subcontent. + const canonical = [ + "Three practical code review tips:", + "", + "1. **Review for intent first** ", + " Ask: does this change solve the right problem?", + "", + "2. **Leave actionable comments** ", + " Be specific and suggest a path forward.", + ].join("\n"); + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween(root, "nable comments", "path forward."); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote).not.toBeNull(); + expect(quote?.excerpt).toContain("nable comments"); + expect(quote?.excerpt).toContain("Be specific and suggest a path forward."); + }); + + it("returns a canonical-bounded quote when the selection covers inline code", () => { + const canonical = "Run `just check` before pushing."; + const { root } = renderMarkdownMessage("message-1", canonical); + + const selection = selectBetween(root, "Run", "before pushing."); + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", canonical)], + root, + selection, + }); + + expect(quote).not.toBeNull(); + const source = quote?.sources[0]; + const excerpt = canonical.slice(source?.start, source?.end); + expect(excerpt.startsWith("Run")).toBe(true); + expect(excerpt.endsWith("before pushing.")).toBe(true); + }); +}); diff --git a/src/features/chat/lib/transcriptQuoteSelection.test.ts b/src/features/chat/lib/transcriptQuoteSelection.test.ts new file mode 100644 index 00000000..d2643171 --- /dev/null +++ b/src/features/chat/lib/transcriptQuoteSelection.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { + getQuoteAffordancePosition, + stagedQuoteFromSelection, +} from "./transcriptQuoteSelection"; + +function makeMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 1, + content: [{ type: "text", text }], + }; +} + +function renderPlainTextMessage(id: string, text: string) { + const root = document.createElement("div"); + root.innerHTML = `
`; + const block = root.querySelector( + "[data-quote-content-block-index]", + ); + if (!block) throw new Error("missing text block"); + block.textContent = text; + document.body.append(root); + return { root, block, node: block.firstChild as Text }; +} + +function selectionFor(node: Text, start: number, end: number): Selection { + const selection = window.getSelection(); + if (!selection) throw new Error("selection unavailable"); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + selection.removeAllRanges(); + selection.addRange(range); + return selection; +} + +function makeRect(rect: { + left: number; + top: number; + width: number; + height: number; +}): DOMRect { + return { + ...rect, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON: () => ({}), + } as DOMRect; +} + +describe("getQuoteAffordancePosition", () => { + it("centers the pill over the selection's first line, not the bounding box", () => { + // A multi-line drag: the first line starts mid-paragraph (narrow rect on + // the right), later lines span the full width. The bounding rect's center + // sits far left of the swept text — the pre-fix behavior this test pins. + const root = document.createElement("div"); + Object.defineProperty(root, "getBoundingClientRect", { + value: () => makeRect({ left: 0, top: 0, width: 800, height: 600 }), + }); + const firstLine = makeRect({ left: 500, top: 100, width: 200, height: 20 }); + const secondLine = makeRect({ left: 0, top: 120, width: 800, height: 20 }); + const range = document.createRange(); + Object.defineProperty(range, "getClientRects", { + value: () => [firstLine, secondLine], + }); + Object.defineProperty(range, "getBoundingClientRect", { + value: () => makeRect({ left: 0, top: 100, width: 800, height: 40 }), + }); + + const position = getQuoteAffordancePosition(range, root); + + // First line center: 500 + 200/2 = 600. Bounding-box center would be 400. + expect(position).toEqual({ left: 600, top: 92 }); + }); + + it("unions inline segments sharing the first line before centering", () => { + // A selection starting inside a bold span produces one rect per inline + // segment: bold portion, then plain text — both on the same visual + // line. Centering on rects[0] alone (the pre-fix behavior) parks the + // pill over just the bold words instead of the swept line. + const root = document.createElement("div"); + Object.defineProperty(root, "getBoundingClientRect", { + value: () => makeRect({ left: 0, top: 0, width: 800, height: 600 }), + }); + const boldSegment = makeRect({ + left: 100, + top: 100, + width: 100, + height: 20, + }); + const plainSegment = makeRect({ + left: 200, + top: 100, + width: 300, + height: 20, + }); + const secondLine = makeRect({ left: 0, top: 120, width: 800, height: 20 }); + const range = document.createRange(); + Object.defineProperty(range, "getClientRects", { + value: () => [boldSegment, plainSegment, secondLine], + }); + Object.defineProperty(range, "getBoundingClientRect", { + value: () => makeRect({ left: 0, top: 100, width: 800, height: 40 }), + }); + + const position = getQuoteAffordancePosition(range, root); + + // First-line union spans 100..500, center 300. rects[0] alone would + // give 150; the bounding box would give 400. + expect(position).toEqual({ left: 300, top: 92 }); + }); + + it("falls back to the bounding rect when getClientRects is unavailable", () => { + const root = document.createElement("div"); + Object.defineProperty(root, "getBoundingClientRect", { + value: () => makeRect({ left: 0, top: 0, width: 800, height: 600 }), + }); + const range = document.createRange(); + Object.defineProperty(range, "getClientRects", { value: undefined }); + Object.defineProperty(range, "getBoundingClientRect", { + value: () => makeRect({ left: 100, top: 50, width: 200, height: 20 }), + }); + + expect(getQuoteAffordancePosition(range, root)).toEqual({ + left: 200, + top: 42, + }); + }); +}); + +describe("stagedQuoteFromSelection", () => { + it("maps a plain-text DOM selection to its canonical message range", () => { + const text = "A durable quote callback"; + const { root, node } = renderPlainTextMessage("message-1", text); + + const quote = stagedQuoteFromSelection({ + id: "quote-1", + messages: [makeMessage("message-1", text)], + root, + selection: selectionFor(node, 2, 15), + }); + + expect(quote).toEqual({ + id: "quote-1", + kind: "quote", + excerpt: "durable quote", + sources: [ + { + messageId: "message-1", + role: "assistant", + contentBlockIndex: 0, + start: 2, + end: 15, + }, + ], + }); + }); + + it("maps a selected numbered-list sentence back into the canonical Markdown source", () => { + const selected = + "Ask reviewers to separate product concerns from visual polish."; + const canonical = [ + "1. Set a clear critique goal upfront.", + `2. ${selected}`, + "3. End with explicit decisions and owners.", + ].join("\n"); + const message = makeMessage("message-1", canonical); + const { root, block } = renderPlainTextMessage( + "message-1", + [ + "Set a clear critique goal upfront.", + selected, + "End with explicit decisions and owners.", + ].join(""), + ); + const node = block.firstChild as Text; + const renderedStart = block.textContent?.indexOf(selected) ?? -1; + const canonicalStart = canonical.indexOf(selected); + + expect( + stagedQuoteFromSelection({ + id: "quote-1", + messages: [message], + root, + selection: selectionFor( + node, + renderedStart, + renderedStart + selected.length, + ), + }), + ).toEqual({ + id: "quote-1", + kind: "quote", + excerpt: selected, + sources: [ + { + messageId: "message-1", + role: "assistant", + contentBlockIndex: 0, + start: canonicalStart, + end: canonicalStart + selected.length, + }, + ], + }); + }); + + it("maps a selection that crosses message boundaries into one quote", () => { + const root = document.createElement("div"); + root.innerHTML = ` +
first
+
second
+ `; + document.body.append(root); + const nodes = root.querySelectorAll("[data-quote-content-block-index]"); + const range = document.createRange(); + range.setStart(nodes[0].firstChild as Text, 0); + range.setEnd(nodes[1].firstChild as Text, 6); + const selection = window.getSelection(); + if (!selection) throw new Error("selection unavailable"); + selection.removeAllRanges(); + selection.addRange(range); + + expect( + stagedQuoteFromSelection({ + id: "quote-1", + messages: [ + makeMessage("message-1", "first"), + makeMessage("message-2", "second"), + ], + root, + selection, + }), + ).toEqual({ + id: "quote-1", + kind: "quote", + excerpt: "first\n\nsecond", + sources: [ + { + messageId: "message-1", + role: "assistant", + contentBlockIndex: 0, + start: 0, + end: 5, + }, + { + messageId: "message-2", + role: "assistant", + contentBlockIndex: 0, + start: 0, + end: 6, + }, + ], + }); + }); +}); diff --git a/src/features/chat/lib/transcriptQuoteSelection.ts b/src/features/chat/lib/transcriptQuoteSelection.ts new file mode 100644 index 00000000..098055b6 --- /dev/null +++ b/src/features/chat/lib/transcriptQuoteSelection.ts @@ -0,0 +1,316 @@ +import { + readSourceSegmentCoordinates, + SOURCE_SEGMENT_SELECTOR, +} from "@/shared/ui/ai-elements/markdown-source-segments"; +import type { + Message, + StagedQuoteItem, + StagedQuoteSourceRange, + TextContent, +} from "@/shared/types/messages"; + +const MESSAGE_ID_ATTRIBUTE = "data-quote-message-id"; +const CONTENT_BLOCK_INDEX_ATTRIBUTE = "data-quote-content-block-index"; +const SOURCE_TEXT_START_ATTRIBUTE = "data-quote-source-text-start"; + +export const QUOTE_MESSAGE_SELECTOR = `[${MESSAGE_ID_ATTRIBUTE}]`; +export const QUOTE_TEXT_BLOCK_SELECTOR = `[${CONTENT_BLOCK_INDEX_ATTRIBUTE}]`; + +export function quoteMessageAttributes(messageId: string) { + return { [MESSAGE_ID_ATTRIBUTE]: messageId }; +} + +export function quoteTextBlockAttributes( + contentBlockIndex: number, + sourceTextStart = 0, +) { + return { + [CONTENT_BLOCK_INDEX_ATTRIBUTE]: String(contentBlockIndex), + [SOURCE_TEXT_START_ATTRIBUTE]: String(sourceTextStart), + }; +} + +function getBoundaryOffsetWithin(element: Element, node: Node, offset: number) { + const boundary = document.createRange(); + boundary.selectNodeContents(element); + boundary.setEnd(node, offset); + return boundary.toString().length; +} + +function rangeIntersectsNode(range: Range, node: Node): boolean { + if (typeof range.intersectsNode === "function") { + return range.intersectsNode(node); + } + const nodeRange = (node.ownerDocument ?? document).createRange(); + nodeRange.selectNodeContents(node); + return ( + range.compareBoundaryPoints(Range.END_TO_START, nodeRange) < 0 && + range.compareBoundaryPoints(Range.START_TO_END, nodeRange) > 0 + ); +} + +/** Offset of a range boundary within a segment's rendered text, or null + * when the boundary sits outside the segment. */ +function boundaryOffsetInSegment( + segment: Element, + range: Range, + edge: "start" | "end", +): number | null { + const node = edge === "start" ? range.startContainer : range.endContainer; + const offset = edge === "start" ? range.startOffset : range.endOffset; + if (!segment.contains(node)) return null; + try { + return getBoundaryOffsetWithin(segment, node, offset); + } catch { + return null; + } +} + +/** Maps a DOM range to canonical source offsets using renderer-produced + * source segments (see markdown-source-segments.tsx). Returns offsets + * within the Markdown string the renderer parsed, or null when the block + * carries no segments the range touches. */ +function mapRangeThroughSourceSegments( + block: Element, + range: Range, +): { start: number; end: number } | null { + const segments = Array.from( + block.querySelectorAll(SOURCE_SEGMENT_SELECTOR), + ).filter((segment) => rangeIntersectsNode(range, segment)); + if (segments.length === 0) return null; + + const firstCoordinates = readSourceSegmentCoordinates(segments[0]); + const lastCoordinates = readSourceSegmentCoordinates( + segments[segments.length - 1], + ); + if (!firstCoordinates || !lastCoordinates) return null; + + // Boundaries inside an exact segment translate directly; boundaries + // outside a segment (or inside a non-exact one) clamp to the segment's + // canonical bounds, keeping the quote lossless rather than guessing. + let start = firstCoordinates.start; + if (firstCoordinates.exact) { + const offset = boundaryOffsetInSegment(segments[0], range, "start"); + if (offset !== null) start = firstCoordinates.start + offset; + } + let end = lastCoordinates.end; + if (lastCoordinates.exact) { + const offset = boundaryOffsetInSegment( + segments[segments.length - 1], + range, + "end", + ); + if (offset !== null) end = lastCoordinates.start + offset; + } + if (end <= start) return null; + return { start, end }; +} + +/** A quoted slice of one text content block, in canonical coordinates. */ +interface MappedBlockQuote { + source: StagedQuoteSourceRange; + excerpt: string; +} + +/** Maps the portion of the selection range that falls inside one rendered + * text block back to that block's canonical source range. Returns null when + * the block's slice of the selection cannot be mapped losslessly. */ +function mapBlockQuote( + blockElement: Element, + range: Range, + messages: readonly Message[], +): MappedBlockQuote | null { + const messageElement = blockElement.closest(QUOTE_MESSAGE_SELECTOR); + const messageId = messageElement?.getAttribute(MESSAGE_ID_ATTRIBUTE); + const blockIndex = Number( + blockElement.getAttribute(CONTENT_BLOCK_INDEX_ATTRIBUTE), + ); + const sourceTextStart = Number( + blockElement.getAttribute(SOURCE_TEXT_START_ATTRIBUTE) ?? "0", + ); + if ( + !messageId || + !Number.isInteger(blockIndex) || + blockIndex < 0 || + !Number.isInteger(sourceTextStart) || + sourceTextStart < 0 + ) + return null; + + const message = messages.find((candidate) => candidate.id === messageId); + const block = message?.content[blockIndex]; + if (!block || block.type !== "text") return null; + + // Clamp the selection to this block: boundaries outside the block snap to + // the block's own edges, so a cross-block selection maps each block's + // actually selected slice. + const blockRange = range.cloneRange(); + if (!blockElement.contains(range.startContainer)) { + blockRange.setStart(blockElement, 0); + } + if (!blockElement.contains(range.endContainer)) { + blockRange.setEnd(blockElement, blockElement.childNodes.length); + } + if (blockRange.collapsed) return null; + + const canonicalText = (block as TextContent).text; + const renderedText = blockElement.textContent ?? ""; + const renderedSourceText = canonicalText.slice( + sourceTextStart, + sourceTextStart + renderedText.length, + ); + + let start: number; + let end: number; + if (renderedText === renderedSourceText) { + // Plain text maps directly because DOM and canonical UTF-16 offsets agree. + try { + start = + sourceTextStart + + getBoundaryOffsetWithin( + blockElement, + blockRange.startContainer, + blockRange.startOffset, + ); + end = + sourceTextStart + + getBoundaryOffsetWithin( + blockElement, + blockRange.endContainer, + blockRange.endOffset, + ); + } catch { + return null; + } + } else { + // Rendered Markdown: the renderer produced canonical source segments for + // every rendered text node (see markdown-source-segments.tsx), so the + // mapper only intersects the DOM range with those segments and reads the + // canonical offsets back. No Markdown syntax knowledge lives here. + const mapped = mapRangeThroughSourceSegments(blockElement, blockRange); + if (mapped) { + start = sourceTextStart + mapped.start; + end = sourceTextStart + mapped.end; + } else { + // Legacy fallback for markdown surfaces that have not enabled source + // segments: a unique verbatim occurrence is still a lossless mapping. + // If the same selection occurs more than once, decline rather than + // guess. + const selectedText = blockRange.toString(); + if (!selectedText.trim()) return null; + const canonicalSlice = canonicalText.slice(sourceTextStart); + const firstMatch = canonicalSlice.indexOf(selectedText); + if (firstMatch < 0) return null; + if (canonicalSlice.indexOf(selectedText, firstMatch + 1) >= 0) + return null; + start = sourceTextStart + firstMatch; + end = start + selectedText.length; + } + } + if (start < 0 || end <= start || end > canonicalText.length) return null; + const excerpt = canonicalText.slice(start, end); + if (!excerpt.trim()) return null; + + return { + excerpt, + source: { + messageId, + role: message.role, + contentBlockIndex: blockIndex, + start, + end, + }, + }; +} + +/** Maps a DOM selection back to canonical source ranges. A selection may + * span multiple text blocks and multiple messages (any roles); each touched + * block contributes one source range, in document order, and non-text + * content between them (tool cards, images) is clamped out rather than + * blocking the quote. */ +export function stagedQuoteFromSelection({ + messages, + root, + selection, + id = crypto.randomUUID(), +}: { + messages: readonly Message[]; + root: HTMLElement; + selection: Selection; + id?: string; +}): StagedQuoteItem | null { + if (selection.isCollapsed || selection.rangeCount !== 1) return null; + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return null; + + // Every touched text block, in document order. querySelectorAll already + // returns document order; include the blocks that contain the boundaries + // even when the boundary sits in a non-text wrapper inside them. + const touchedBlocks = Array.from( + root.querySelectorAll(QUOTE_TEXT_BLOCK_SELECTOR), + ).filter((blockElement) => rangeIntersectsNode(range, blockElement)); + if (touchedBlocks.length === 0) return null; + + const mapped = touchedBlocks + .map((blockElement) => mapBlockQuote(blockElement, range, messages)) + .filter((quote): quote is MappedBlockQuote => quote !== null); + if (mapped.length === 0) return null; + + // A selection is one quote even across messages. Per-block excerpts join + // with a blank line so the quote reads as the passage the user saw. + const excerpt = mapped.map((quote) => quote.excerpt).join("\n\n"); + if (!excerpt.trim()) return null; + + return { + id, + kind: "quote", + excerpt, + sources: mapped.map((quote) => quote.source), + }; +} + +export function getQuoteAffordancePosition( + range: Range, + root: HTMLElement, +): { left: number; top: number } | null { + // A multi-line selection's bounding rect spans full line boxes, so its + // horizontal center can sit far from the swept text. Centering over the + // first visual line keeps the pill above where the selection begins. + // getClientRects returns one rect per inline segment (bold spans, links), + // so several rects can share the first line; union everything whose + // vertical center falls inside the first rect's line box, or the pill + // centers over just the first inline segment instead of the line. + // (getClientRects is missing in some DOM implementations, e.g. jsdom.) + const rects = Array.from( + typeof range.getClientRects === "function" ? range.getClientRects() : [], + ).filter((rect) => rect.width > 0 || rect.height > 0); + let anchor: { left: number; width: number; top: number }; + if (rects.length > 0) { + const firstLine = rects[0]; + let left = firstLine.left; + let right = firstLine.right; + for (const rect of rects) { + const centerY = rect.top + rect.height / 2; + if (centerY < firstLine.top || centerY > firstLine.bottom) continue; + left = Math.min(left, rect.left); + right = Math.max(right, rect.right); + } + anchor = { left, width: right - left, top: firstLine.top }; + } else { + const boundingRect = range.getBoundingClientRect(); + if (boundingRect.width === 0 && boundingRect.height === 0) return null; + anchor = { + left: boundingRect.left, + width: boundingRect.width, + top: boundingRect.top, + }; + } + const rootRect = root.getBoundingClientRect(); + return { + left: Math.min( + Math.max(anchor.left + anchor.width / 2 - rootRect.left, 16), + Math.max(16, rootRect.width - 16), + ), + top: Math.max(anchor.top - rootRect.top - 8, 8), + }; +} diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 811c297b..01a73ec7 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -15,6 +15,7 @@ import { removeWorkspaceAttachment, withWorkspaceBackfill, } from "@/features/chat/lib/workspaceAttachments"; +import { clearSubmittedStagedItems } from "@/features/chat/lib/submittedQuoteProvenance"; import { archiveSession as acpArchiveSession, unarchiveSession as acpUnarchiveSession, @@ -972,6 +973,7 @@ export const useChatSessionStore = create((set, get) => ({ }; }); removePersistedChatWorkspaceMetadata(id); + clearSubmittedStagedItems(id); useSecurityConfirmationStore.getState().cancelAll(id); releaseWindowedSession(id); }, diff --git a/src/features/chat/stores/chatStore.ts b/src/features/chat/stores/chatStore.ts index 3b6b724f..6c0ff7aa 100644 --- a/src/features/chat/stores/chatStore.ts +++ b/src/features/chat/stores/chatStore.ts @@ -4,6 +4,7 @@ import type { ChatAttachmentDraft, Message, MessageContent, + StagedItem, } from "@/shared/types/messages"; import { completeAssistantMessage } from "@/features/chat/lib/messageCompletion"; import { clearReplayBuffer } from "../hooks/replayBuffer"; @@ -18,7 +19,12 @@ import { INITIAL_TOKEN_STATE, } from "@/shared/types/chat"; import type { ChatSkillDraft } from "../types"; -import { loadCachedDrafts, persistDrafts } from "./draftPersistence"; +import { + loadCachedDrafts, + loadCachedStagedItems, + persistDrafts, + persistStagedItems, +} from "./draftPersistence"; import { loadCachedMessageQueues, persistMessageQueues, @@ -397,6 +403,7 @@ interface ChatStoreState { nonEmptyDraftSessionIds: Set; skillDraftsBySession: Record; draftAttachmentsBySession: Record; + stagedItemsBySession: Record; activeSessionId: string | null; recentMessageSessionIds: string[]; isViewingActiveSession: boolean; @@ -519,6 +526,10 @@ interface ChatStoreActions { attachments: ChatAttachmentDraft[], ) => void; clearDraftAttachments: (sessionId: string) => void; + setStagedItems: (sessionId: string, items: StagedItem[]) => void; + addStagedItem: (sessionId: string, item: StagedItem) => void; + removeStagedItem: (sessionId: string, itemId: string) => void; + clearStagedItems: (sessionId: string) => void; setSessionLoading: (sessionId: string, loading: boolean) => void; setScrollTargetMessage: ( sessionId: string, @@ -533,6 +544,7 @@ interface ChatStoreActions { export type ChatStore = ChatStoreState & ChatStoreActions; const cachedDrafts = loadCachedDrafts(); +const cachedStagedItems = loadCachedStagedItems(); const cachedMessageQueues = loadCachedMessageQueues(); const createChatStore: StateCreator< @@ -548,6 +560,7 @@ const createChatStore: StateCreator< nonEmptyDraftSessionIds: buildNonEmptyDraftSessionIds(cachedDrafts), skillDraftsBySession: {}, draftAttachmentsBySession: {}, + stagedItemsBySession: cachedStagedItems, activeSessionId: null, recentMessageSessionIds: [], isViewingActiveSession: false, @@ -1757,6 +1770,37 @@ const createChatStore: StateCreator< return { draftAttachmentsBySession: rest }; }), + setStagedItems: (sessionId, items) => { + set((state) => { + if (items.length === 0) { + const { [sessionId]: _, ...rest } = state.stagedItemsBySession; + return { stagedItemsBySession: rest }; + } + return { + stagedItemsBySession: { + ...state.stagedItemsBySession, + [sessionId]: items, + }, + }; + }); + persistStagedItems(get().stagedItemsBySession); + }, + + addStagedItem: (sessionId, item) => { + const items = get().stagedItemsBySession[sessionId] ?? []; + get().setStagedItems(sessionId, [...items, item]); + }, + + removeStagedItem: (sessionId, itemId) => { + const items = get().stagedItemsBySession[sessionId] ?? []; + get().setStagedItems( + sessionId, + items.filter((item) => item.id !== itemId), + ); + }, + + clearStagedItems: (sessionId) => get().setStagedItems(sessionId, []), + // Session loading (replay) setSessionLoading: (sessionId, loading) => set((state) => { @@ -1813,6 +1857,8 @@ const createChatStore: StateCreator< [draftSessionId]: draftAttachments, ...remainingDraftAttachments } = state.draftAttachmentsBySession; + const { [draftSessionId]: stagedItems, ...remainingStagedItems } = + state.stagedItemsBySession; const { [draftSessionId]: scrollTarget, ...remainingTargets } = state.scrollTargetMessageBySession; const loadingSessionIds = new Set(state.loadingSessionIds); @@ -1848,6 +1894,9 @@ const createChatStore: StateCreator< [backendSessionId]: draftAttachments, } : remainingDraftAttachments, + stagedItemsBySession: stagedItems + ? { ...remainingStagedItems, [backendSessionId]: stagedItems } + : remainingStagedItems, scrollTargetMessageBySession: scrollTarget ? { ...remainingTargets, [backendSessionId]: scrollTarget } : remainingTargets, @@ -1868,6 +1917,7 @@ const createChatStore: StateCreator< backendSessionId, ]); persistDrafts(get().draftsBySession); + persistStagedItems(get().stagedItemsBySession); persistUnreadStateIfChanged( previousSessionStateById, get().sessionStateById, @@ -1896,6 +1946,9 @@ const createChatStore: StateCreator< ...remainingDraftAttachments } = state.draftAttachmentsBySession; void removedDraftAttachments; + const { [sessionId]: removedStagedItems, ...remainingStagedItems } = + state.stagedItemsBySession; + void removedStagedItems; const { [sessionId]: removedTarget, ...remainingTargets } = state.scrollTargetMessageBySession; void removedTarget; @@ -1907,6 +1960,7 @@ const createChatStore: StateCreator< nonEmptyDraftSessionIds, skillDraftsBySession: remainingSkillDrafts, draftAttachmentsBySession: remainingDraftAttachments, + stagedItemsBySession: remainingStagedItems, scrollTargetMessageBySession: remainingTargets, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, @@ -1922,6 +1976,7 @@ const createChatStore: StateCreator< }); persistMessageQueues(get().queuedMessageBySession, [sessionId]); persistDrafts(get().draftsBySession); + persistStagedItems(get().stagedItemsBySession); persistUnreadStateIfChanged( previousSessionStateById, get().sessionStateById, diff --git a/src/features/chat/stores/draftPersistence.test.ts b/src/features/chat/stores/draftPersistence.test.ts new file mode 100644 index 00000000..7da973c6 --- /dev/null +++ b/src/features/chat/stores/draftPersistence.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { StagedItem } from "@/shared/types/messages"; +import { loadCachedStagedItems, persistStagedItems } from "./draftPersistence"; + +const quote: StagedItem = { + id: "quote-1", + kind: "quote", + excerpt: "selected text", + sources: [ + { + messageId: "message-1", + contentBlockIndex: 0, + start: 0, + end: 13, + }, + ], +}; + +describe("staged item draft persistence", () => { + beforeEach(() => window.localStorage.clear()); + + it("round-trips staged items by session", () => { + persistStagedItems({ "session-1": [quote] }); + + expect(loadCachedStagedItems()).toEqual({ "session-1": [quote] }); + }); + + it("drops invalid persisted values without losing valid sessions", () => { + window.localStorage.setItem( + "goose:chat-staged-items:v1", + JSON.stringify({ + valid: [quote], + invalid: [{ id: "bad", kind: "quote", excerpt: "", sources: [] }], + }), + ); + + expect(loadCachedStagedItems()).toEqual({ valid: [quote] }); + }); +}); diff --git a/src/features/chat/stores/draftPersistence.ts b/src/features/chat/stores/draftPersistence.ts index 683a9538..dedddb01 100644 --- a/src/features/chat/stores/draftPersistence.ts +++ b/src/features/chat/stores/draftPersistence.ts @@ -1,4 +1,10 @@ +import type { + StagedItem, + StagedQuoteSourceRange, +} from "@/shared/types/messages"; + const DRAFTS_STORAGE_KEY = "goose:chat-drafts"; +const STAGED_ITEMS_STORAGE_KEY = "goose:chat-staged-items:v1"; export function loadCachedDrafts(): Record { if (typeof window === "undefined") return {}; @@ -36,3 +42,82 @@ export function persistDrafts(drafts: Record): void { // localStorage may be unavailable } } + +function isStagedQuoteSourceRange( + value: unknown, +): value is StagedQuoteSourceRange { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return ( + typeof source.messageId === "string" && + (source.role === undefined || + source.role === "user" || + source.role === "assistant" || + source.role === "system") && + Number.isInteger(source.contentBlockIndex) && + (source.contentBlockIndex as number) >= 0 && + Number.isInteger(source.start) && + (source.start as number) >= 0 && + Number.isInteger(source.end) && + (source.end as number) > (source.start as number) + ); +} + +export function isStagedItem(value: unknown): value is StagedItem { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return ( + item.kind === "quote" && + typeof item.id === "string" && + item.id.length > 0 && + typeof item.excerpt === "string" && + item.excerpt.length > 0 && + Array.isArray(item.sources) && + item.sources.length > 0 && + item.sources.every(isStagedQuoteSourceRange) + ); +} + +export function loadCachedStagedItems(): Record { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(STAGED_ITEMS_STORAGE_KEY); + if (!stored) return {}; + const parsed: unknown = JSON.parse(stored); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).flatMap(([sessionId, value]) => { + if (!Array.isArray(value)) return []; + const items = value.filter(isStagedItem); + return items.length > 0 ? [[sessionId, items]] : []; + }), + ); + } catch { + return {}; + } +} + +export function persistStagedItems( + stagedItemsBySession: Record, +): void { + if (typeof window === "undefined") return; + try { + const nonEmpty = Object.fromEntries( + Object.entries(stagedItemsBySession).filter( + ([, items]) => items.length > 0, + ), + ); + if (Object.keys(nonEmpty).length === 0) { + window.localStorage.removeItem(STAGED_ITEMS_STORAGE_KEY); + } else { + window.localStorage.setItem( + STAGED_ITEMS_STORAGE_KEY, + JSON.stringify(nonEmpty), + ); + } + } catch { + // localStorage may be unavailable + } +} diff --git a/src/features/chat/transcript/projection/buildTranscriptItems.fragmentCoordinates.test.ts b/src/features/chat/transcript/projection/buildTranscriptItems.fragmentCoordinates.test.ts new file mode 100644 index 00000000..55fbd7b3 --- /dev/null +++ b/src/features/chat/transcript/projection/buildTranscriptItems.fragmentCoordinates.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { buildTranscriptItems } from "./buildTranscriptItems"; +import type { TranscriptAssistantContentFragmentItem } from "./transcriptItemTypes"; + +/** + * Fragment source coordinates must stay honest even when a chunk's text + * does not occur verbatim in the canonical source. The known case is an + * unterminated fenced code block at a streaming tail: the chunker + * synthesizes a closing fence, so that chunk's text cannot be located in + * the source. Such a fragment is deliberately unquotable (-1 coordinates), + * and — critically — must not poison the search cursor for later chunks. + */ + +function makeAssistantMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 1, + content: [{ type: "text", text }], + }; +} + +function fragmentItems(messages: Message[]) { + return buildTranscriptItems({ + messages, + streamingMessageId: null, + nowBucket: "2026-08-13", + localeKey: "en", + calendarRevisionToken: "test", + }).filter( + (item): item is TranscriptAssistantContentFragmentItem => + item.kind === "assistant-content-fragment", + ); +} + +describe("assistant fragment source coordinates", () => { + it("maps every fragment to its verbatim source range", () => { + // Fragmentation engages at 60+ lines; blank lines between paragraphs + // count, so 40 paragraphs produce 79 lines. + const text = Array.from( + { length: 40 }, + (_, i) => `Paragraph ${i} with some content to fill the line.`, + ).join("\n\n"); + const items = fragmentItems([makeAssistantMessage("m1", text)]); + + expect(items.length).toBeGreaterThan(1); + for (const item of items) { + const { sourceTextStart, sourceTextEnd } = item.fragment; + expect(sourceTextStart).toBeGreaterThanOrEqual(0); + expect(text.slice(sourceTextStart, sourceTextEnd)).toBe( + item.fragment.content[0].type === "text" + ? item.fragment.content[0].text + : "", + ); + } + }); + + it("marks a synthesized-fence chunk unquotable instead of carrying bogus coordinates", () => { + // An unterminated fence (streaming tail) swallows all remaining lines + // and gets a synthesized closing fence, so the chunk's text does not + // occur verbatim in the source. Paragraphs come first so the message + // still fragments into multiple chunks. + const paragraphs = Array.from( + { length: 15 }, + (_, i) => `Paragraph ${i} before the code block starts.`, + ).join("\n\n"); + const codeLines = Array.from( + { length: 10 }, + (_, i) => `const line${i} = ${i};`, + ).join("\n"); + const text = `${paragraphs}\n\n\`\`\`ts\n${codeLines}`; + const items = fragmentItems([makeAssistantMessage("m1", text)]); + + expect(items.length).toBeGreaterThan(1); + const synthesized = items.filter( + (item) => item.fragment.sourceTextStart < 0, + ); + const located = items.filter((item) => item.fragment.sourceTextStart >= 0); + + // The unterminated-fence chunk cannot be located in the source. + expect(synthesized.length).toBe(1); + // It is explicitly unquotable: both coordinates are -1, never a bogus + // "start of -1 plus text length" range (the pre-fix behavior). + expect(synthesized[0].fragment.sourceTextEnd).toBe(-1); + + // Every locatable fragment still slices back to its own text. + expect(located.length).toBeGreaterThan(0); + for (const item of located) { + const { sourceTextStart, sourceTextEnd } = item.fragment; + expect(text.slice(sourceTextStart, sourceTextEnd)).toBe( + item.fragment.content[0].type === "text" + ? item.fragment.content[0].text + : "", + ); + } + }); +}); diff --git a/src/features/chat/transcript/projection/buildTranscriptItems.ts b/src/features/chat/transcript/projection/buildTranscriptItems.ts index 2d6c5582..a41e2ea9 100644 --- a/src/features/chat/transcript/projection/buildTranscriptItems.ts +++ b/src/features/chat/transcript/projection/buildTranscriptItems.ts @@ -517,8 +517,20 @@ function buildAssistantTextFragmentItems({ isStreaming, }); + let sourceTextStart = 0; return textChunks.map((chunk, fragmentIndex) => { const { text, isCodeContinuationChunk, startsWithHeading } = chunk; + // Chunk text usually occurs verbatim in the source, but not always: + // an unterminated fenced code block (streaming tail) gets a synthesized + // closing fence. Such a chunk is deliberately unquotable (-1 coordinates, + // which the quote mapper rejects) and must not poison the cursor for + // any chunks that follow. + const chunkStart = sourceText.indexOf(text, sourceTextStart); + const chunkFound = chunkStart >= 0; + const chunkEnd = chunkFound ? chunkStart + text.length : -1; + if (chunkFound) { + sourceTextStart = chunkEnd; + } const isStreamingTail = isStreaming && fragmentIndex === lastIndex; const fragmentId = useStreamingFragmentIds ? fragmentIndex === lastIndex @@ -569,6 +581,9 @@ function buildAssistantTextFragmentItems({ fragmentCount: textChunks.length, role: getAssistantFragmentRole(fragmentIndex, textChunks.length), content: fragmentContent, + sourceContentBlockIndex: message.content.indexOf(visibleContent[0]), + sourceTextStart: chunkStart, + sourceTextEnd: chunkEnd, isStreamingTail, messageScrollTarget: isStreaming ? isStreamingTail diff --git a/src/features/chat/transcript/projection/transcriptItemTypes.ts b/src/features/chat/transcript/projection/transcriptItemTypes.ts index 68c9d84d..f32bef73 100644 --- a/src/features/chat/transcript/projection/transcriptItemTypes.ts +++ b/src/features/chat/transcript/projection/transcriptItemTypes.ts @@ -136,6 +136,10 @@ export interface TranscriptAssistantContentFragmentPayload { fragmentCount: number; role: TranscriptAssistantContentFragmentRole; content: readonly MessageContent[]; + /** Canonical coordinates of this rendered fragment in message.content. */ + sourceContentBlockIndex: number; + sourceTextStart: number; + sourceTextEnd: number; isStreamingTail: boolean; messageScrollTarget: boolean; isCodeContinuationChunk: boolean; diff --git a/src/features/chat/transcript/virtual/react/useTranscriptVirtualTimeline.test.tsx b/src/features/chat/transcript/virtual/react/useTranscriptVirtualTimeline.test.tsx index 51793076..3862c46f 100644 --- a/src/features/chat/transcript/virtual/react/useTranscriptVirtualTimeline.test.tsx +++ b/src/features/chat/transcript/virtual/react/useTranscriptVirtualTimeline.test.tsx @@ -959,6 +959,9 @@ function row( fragmentCount: 1, role: "single", content: [], + sourceContentBlockIndex: 0, + sourceTextStart: 0, + sourceTextEnd: 0, isStreamingTail: overrides.anchorPriority === "streaming", messageScrollTarget: true, isCodeContinuationChunk: false, diff --git a/src/features/chat/transcript/virtual/transcriptStreamingHeightFloor.validation.test.ts b/src/features/chat/transcript/virtual/transcriptStreamingHeightFloor.validation.test.ts index 872015b1..55229b5e 100644 --- a/src/features/chat/transcript/virtual/transcriptStreamingHeightFloor.validation.test.ts +++ b/src/features/chat/transcript/virtual/transcriptStreamingHeightFloor.validation.test.ts @@ -160,6 +160,9 @@ function row( fragmentCount: 1, role: "single", content: [], + sourceContentBlockIndex: 0, + sourceTextStart: 0, + sourceTextEnd: 0, isStreamingTail: overrides.anchorPriority === "streaming", messageScrollTarget: true, isCodeContinuationChunk: false, diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 8c72ca59..ebcbaae5 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -6,6 +6,7 @@ import type { ChatAttachmentDraft, MessageChip, MessageMetadata, + StagedItem, } from "@/shared/types/messages"; import type { ChatSessionReasoningEffortConfig } from "./stores/chatSessionStore"; import type { QueuedMessagePayload } from "./stores/chatStore"; @@ -183,6 +184,14 @@ export interface ChatInputControls { autoFocus?: boolean; fileMentions?: boolean; projectPicker?: boolean; + /** + * Whether this composer participates in transcript quoting: displays + * staged quote chips and includes staged quotes in sends. Surfaces that + * disable it (read-only views, Home, the automations builder) must feed + * the same decision to their transcript's quote affordance — quoting is + * one capability, not two independent switches. + */ + quotes?: boolean; skills?: boolean; voice?: boolean; } @@ -191,6 +200,8 @@ export interface ChatInputProps { composerActions: ChatInputComposerActions; initialValue?: string; initialAttachments?: ChatAttachmentDraft[]; + stagedItems?: StagedItem[]; + onRemoveStagedItem?: (itemId: string) => void; placeholder?: string; onDraftChange?: (text: string) => void; /** Mirrors the live composer attachments so a remounted chat can restore them. */ diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index c1c70566..4834ae40 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -59,9 +59,11 @@ import { useChatInputAttachments } from "../hooks/useChatInputAttachments"; import { useChatInputFilePicker } from "../hooks/useChatInputFilePicker"; import { ChatInputAttachments } from "./ChatInputAttachments"; import { ChatInputSelectionChips } from "./ChatInputSelectionChips"; +import { ChatInputStagedItems } from "./ChatInputStagedItems"; import { useChatInputSubmit } from "../hooks/useChatInputSubmit"; import { useVoiceDictation } from "../hooks/useVoiceDictation"; import { resolveDisplayModelLabel } from "../lib/modelDisplayLabel"; +import { stagedItemSnapshotsMatch } from "../lib/stagedQuoteSend"; import { personaIntentFromComposer, type PersonaIntent, @@ -200,6 +202,8 @@ export function ChatInput({ composerActions, initialValue = "", initialAttachments, + stagedItems: stagedItemsProp = [], + onRemoveStagedItem, placeholder, onDraftChange, onDraftAttachmentsChange, @@ -295,6 +299,7 @@ export function ChatInput({ autoFocus: controls?.autoFocus ?? true, fileMentions: controls?.fileMentions ?? true, projectPicker: controls?.projectPicker ?? true, + quotes: controls?.quotes ?? true, skills: controls?.skills ?? true, voice: controls?.voice ?? true, }; @@ -508,11 +513,21 @@ export function ChatInput({ observer.observe(content); return () => observer.disconnect(); }); + // One quote capability decision: a composer without quotes neither shows + // staged quote chips nor includes staged quotes in sends. + const stagedItems = scopedControls.quotes ? stagedItemsProp : []; const hasDraftContext = (scopedControls.attachments && attachments.length > 0) || visibleSelectedSkills.length > 0; + const stagedItemsRef = useRef(stagedItems); + stagedItemsRef.current = stagedItems; + // Staged quotes are draft context but cannot form a message by + // themselves: a quote-only send would dispatch an empty ACP prompt, + // which breaks replay provenance matching (and asks the agent to answer + // nothing). The user must say something about the quoted passage. const hasComposedMessage = text.trim().length > 0 || hasDraftContext; - const hasDraftContent = text.length > 0 || hasDraftContext; + const hasDraftContent = + text.length > 0 || hasDraftContext || stagedItems.length > 0; const canQueueMessage = hasComposedMessage && !disabled && !sendDisabled && !attachmentWorkPending; const canSteerCurrentMessage = @@ -693,6 +708,7 @@ export function ChatInput({ const { submitChatInputMessage, handleVoiceAutoSubmit } = useChatInputSubmit({ attachmentsRef, selectedSkillsRef, + stagedItemsRef, selectedChipsRef: selectedMessageChipsRef, selectedPersonaId, skillProviderId, @@ -788,6 +804,7 @@ export function ChatInput({ const submittedText = submittedTextOverride ?? text; const submittedSkills = visibleSelectedSkills; + const submittedStagedItems = stagedItemsRef.current; const submittedAttachments = scopedControls.attachments ? attachmentsRef.current : []; @@ -826,6 +843,7 @@ export function ChatInput({ submittedText, submittedAttachments, submittedSkills, + submittedStagedItems, submitHandler, ); if (!accepted) { @@ -852,6 +870,14 @@ export function ChatInput({ if (attachmentsStillMatchSubmission) { clearAttachments(); } + if ( + onRemoveStagedItem && + stagedItemSnapshotsMatch(stagedItemsRef.current, submittedStagedItems) + ) { + for (const item of submittedStagedItems) { + onRemoveStagedItem(item.id); + } + } if (textareaRef.current) { textareaRef.current.style.height = "auto"; } @@ -861,6 +887,7 @@ export function ChatInput({ clearAttachments, editingQueuedRecordId, onUpdateQueue, + onRemoveStagedItem, scopedControls.attachments, scopedControls.voice, setEditingQueuedRecord, @@ -991,6 +1018,7 @@ export function ChatInput({ submittedText, submittedAttachments, submittedSkills, + stagedItemsRef.current, steerMessage, ); } @@ -1004,6 +1032,11 @@ export function ChatInput({ setText(""); setSelectedSkills([]); clearAttachments(); + if (onRemoveStagedItem) { + for (const item of stagedItemsRef.current) { + onRemoveStagedItem(item.id); + } + } if (textareaRef.current) { textareaRef.current.style.height = "auto"; } @@ -1013,6 +1046,7 @@ export function ChatInput({ dictation, editingQueuedRecordId, onCancelQueueEdit, + onRemoveStagedItem, onSteerMessage, restoredQueuedSendOptions, scopedControls.attachments, @@ -1675,6 +1709,11 @@ export function ChatInput({ onRemove={removeAttachment} /> + onRemoveStagedItem?.(itemId)} + /> + void; +}) { + if (items.length === 0) return null; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx index 1eca851a..968e885f 100644 --- a/src/features/chat/ui/ChatView.tsx +++ b/src/features/chat/ui/ChatView.tsx @@ -579,6 +579,10 @@ export function ChatView({ | "streaming" | "waiting" | "compacting"; + // The single quote-capability decision for this view: it governs both the + // transcript's quote affordance and the composer's staged-quote handling. + // Do not add a second, independently drifting switch. + const quotesEnabled = !isReadOnly; const chatInputControls = useMemo(() => { if (isReadOnly) { return { @@ -587,6 +591,7 @@ export function ChatView({ autoFocus: false, fileMentions: false, projectPicker: false, + quotes: quotesEnabled, skills: false, voice: false, }; @@ -600,7 +605,12 @@ export function ChatView({ } return undefined; - }, [composerHandoffActive, controller.skillsEnabled, isReadOnly]); + }, [ + composerHandoffActive, + controller.skillsEnabled, + isReadOnly, + quotesEnabled, + ]); const shouldStageTranscript = shouldStageInitialTranscript( controller.messages, controller.isLoadingHistory, @@ -844,6 +854,8 @@ export function ChatView({ onAttachmentDragOverChange={setConversationAttachmentDragOver} initialValue={controller.draftValue} initialAttachments={controller.draftAttachments} + stagedItems={controller.stagedItems} + onRemoveStagedItem={controller.handleRemoveStagedItem} onDraftChange={controller.handleDraftChange} onDraftAttachmentsChange={controller.handleDraftAttachmentsChange} selectedSkills={controller.selectedSkills} @@ -934,6 +946,7 @@ export function ChatView({ = { file: "bg-chip-file-bg text-chip-file-fg hover:bg-chip-file-bg", + quote: "bg-chip-chat-bg text-chip-chat-fg hover:bg-chip-chat-bg", agent: "bg-chip-agent-bg text-chip-agent-fg hover:bg-chip-agent-bg", skill: "bg-chip-skill-bg text-chip-skill-fg hover:bg-chip-skill-bg", automation: @@ -16,10 +22,14 @@ const toneClasses: Record = { interface ComposerChipProps { tone: ComposerChipTone; label: string; - removeLabel: string; - onRemove: () => void; + removeLabel?: string; + onRemove?: () => void; leading?: ReactNode; - title?: string; + title?: ReactNode; + /** Rich preview panel shown on hover in place of the plain tooltip. + * Rendered on the dark tooltip surface; unlike a Tooltip it stays open + * while the pointer moves into it, so it can host scrollable content. */ + details?: ReactNode; className?: string; } @@ -30,34 +40,58 @@ export function ComposerChip({ onRemove, leading, title, + details, className, }: ComposerChipProps) { - return ( - - - + {onRemove && removeLabel ? ( + - {label} + {leading ? ( + + {leading} + + ) : null} + + + ) : leading ? ( + + {leading} - + ) : null} + {label} + + ); + + if (details) { + return ( + + {chip} + + {details} + + + ); + } + + return ( + + {chip} {title ?? label} ); diff --git a/src/features/chat/ui/MessageBubble.tsx b/src/features/chat/ui/MessageBubble.tsx index e80052d9..8e4330da 100644 --- a/src/features/chat/ui/MessageBubble.tsx +++ b/src/features/chat/ui/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, @@ -45,6 +45,10 @@ import { resolveImageContentSrc } from "./resolveImageContentSrc"; import { McpAppView } from "./McpAppView"; import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler"; import { detectProviderErrorNotice } from "@/features/chat/lib/providerErrorNotice"; +import { + quoteMessageAttributes, + quoteTextBlockAttributes, +} from "@/features/chat/lib/transcriptQuoteSelection"; import type { CustomRenderer } from "streamdown"; import { RUNNABLE_SHELL_LANGUAGES } from "@/shared/lib/runnableShellCommand"; import type { @@ -64,6 +68,7 @@ import { Button } from "@/shared/ui/button"; import { LinkifiedText } from "@/shared/ui/LinkifiedText"; import { MessageBubbleActions } from "./MessageBubbleActions"; import { MessageMetadataChip } from "./MessageMetadataChip"; +import { MessageStagedQuotes } from "./MessageStagedQuotes"; import { couldOverflowUserMessagePreview, UserMessageClamp, @@ -342,6 +347,8 @@ interface MessageBubbleProps { actionsAlwaysVisible?: boolean; animateEntry?: boolean; contentOverride?: readonly MessageContent[]; + /** Canonical coordinates for a projected text fragment. */ + quoteSource?: { contentBlockIndex: number; textStart: number }; contentContext?: readonly MessageContent[]; actionMessageId?: string; fragmentRole?: "single" | "start" | "middle" | "end"; @@ -365,6 +372,7 @@ interface ContentSection { key: string; type: "single" | "toolChain"; items: MessageContent[] | ToolChainItem[]; + contentBlockIndex?: number; } function filterUserVisibleContent(content: MessageContent[]): MessageContent[] { @@ -453,6 +461,7 @@ function groupContentSections(content: MessageContent[]): ContentSection[] { key: `${block.type}-${"id" in block ? String(block.id) : index}`, type: "single", items: [block], + contentBlockIndex: index, }); } @@ -573,6 +582,7 @@ function renderContentBlock( options.onRunShellCommand ? options.runItCodeRenderers : undefined } imageRenderer={MarkdownImage} + sourceSegments > {displayText} @@ -695,6 +705,7 @@ export const MessageBubble = memo(function MessageBubble({ actionsAlwaysVisible = false, animateEntry = true, contentOverride, + quoteSource, contentContext, actionMessageId = message.id, fragmentRole, @@ -813,6 +824,14 @@ export const MessageBubble = memo(function MessageBubble({ ), [attachedImageContentIndexes, content], ); + const sourceContentBlockIndex = useCallback( + (block: MessageContent, renderedIndex: number) => { + if (quoteSource) return quoteSource.contentBlockIndex; + const canonicalIndex = rawContent.indexOf(block); + return canonicalIndex >= 0 ? canonicalIndex : renderedIndex; + }, + [quoteSource, rawContent], + ); const messageChips = message.metadata?.chips ?? []; // Skip empty user bubbles (all blocks filtered as assistant-only). @@ -831,7 +850,11 @@ export const MessageBubble = memo(function MessageBubble({ : textContent; if (role === "system") { return ( -
+
{content.map((c, i) => renderContentBlock(c, i, { @@ -929,6 +952,7 @@ export const MessageBubble = memo(function MessageBubble({ )} data-role={isUser ? "user-message" : "assistant-message"} data-message-fragment-role={fragmentRole} + {...quoteMessageAttributes(message.id)} {...rowRootAttributes} > {showPersonaGutterAvatar && showLeadingAssistantChrome ? ( @@ -1028,6 +1052,9 @@ export const MessageBubble = memo(function MessageBubble({ ) : null}
) : null} + {isUser && message.metadata?.stagedItems ? ( + + ) : null} {isUser && messageChips.length > 0 && (
{messageChips.map((chip) => ( @@ -1055,21 +1082,41 @@ export const MessageBubble = memo(function MessageBubble({ const block = section.items[0] as MessageContent; if (isUser && block.type === "text") { if (!block.text.trim()) return null; - return couldOverflowUserMessagePreview(block.text) ? ( - - ) : ( - + {...quoteTextBlockAttributes( + sourceContentBlockIndex( + block, + section.contentBlockIndex ?? 0, + ), + quoteSource?.textStart ?? 0, + )} + > + {couldOverflowUserMessagePreview(block.text) ? ( + + ) : ( + + )} +
); } return ( -
+
{renderContentBlock( block, sectionIdx, diff --git a/src/features/chat/ui/MessageStagedQuotes.tsx b/src/features/chat/ui/MessageStagedQuotes.tsx new file mode 100644 index 00000000..eaeca990 --- /dev/null +++ b/src/features/chat/ui/MessageStagedQuotes.tsx @@ -0,0 +1,19 @@ +import type { StagedItem } from "@/shared/types/messages"; +import { StagedQuoteChip } from "./StagedQuoteChip"; + +export function MessageStagedQuotes({ + items, +}: { + items: readonly StagedItem[]; +}) { + const quotes = items.filter((item) => item.kind === "quote"); + if (quotes.length === 0) return null; + + return ( +
+ {quotes.map((quote) => ( + + ))} +
+ ); +} diff --git a/src/features/chat/ui/MessageTimeline.tsx b/src/features/chat/ui/MessageTimeline.tsx index 7df321a4..09946385 100644 --- a/src/features/chat/ui/MessageTimeline.tsx +++ b/src/features/chat/ui/MessageTimeline.tsx @@ -23,6 +23,7 @@ import { } from "@/features/chat/transcript/projection"; import { useResponseStartGutterPreference } from "@/features/chat/lib/responseStartGutterPreference"; import { VirtualTranscriptRow } from "./VirtualTranscriptRow"; +import { TranscriptQuoteAffordance } from "./TranscriptQuoteAffordance"; import { ASSISTIVE_UX_RULES } from "@/shared/assistive-ux/registry"; import { hasAssistiveMomentBeenShown, @@ -66,6 +67,8 @@ const GUTTER_RESPONSE_START_THRESHOLD_PX = 16; interface MessageTimelineProps extends MessageTimelineBubbleCallbacks { messages: Message[]; + sessionId?: string; + quoteEnabled?: boolean; streamingMessageId?: string | null; scrollTargetMessageId?: string | null; scrollTargetQuery?: string | null; @@ -113,6 +116,8 @@ function formatRowDateSeparator( export function MessageTimeline({ messages, + sessionId, + quoteEnabled = true, streamingMessageId, scrollTargetMessageId, scrollTargetQuery, @@ -1440,6 +1445,14 @@ export function MessageTimeline({ className, )} > + {quoteEnabled ? ( + + ) : null} {hasFooter ? (