Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
52f59a2
feat(chat): stage transcript quotes in composer
morgmart Aug 9, 2026
f80ffbd
fix(chat): detect transcript selection in WebKit
morgmart Aug 9, 2026
f28ada8
fix(chat): map rendered markdown quote selections
morgmart Aug 9, 2026
d7777e3
feat(chat): send staged quote callbacks
morgmart Aug 10, 2026
8347c41
refactor(chat): unify staged quote presentation
morgmart Aug 10, 2026
66cf64a
refactor(chat): use Tabler quote icon
morgmart Aug 10, 2026
856d794
test(chat): expect role provenance in quote selection sources
tulsi-builder Aug 12, 2026
539df6e
feat(chat): map quote selections through renderer source segments
tulsi-builder Aug 13, 2026
9422446
feat(chat): persist submitted quote provenance across replay
tulsi-builder Aug 13, 2026
dc4644e
feat(chat): map selections across blocks and messages into one quote
tulsi-builder Aug 13, 2026
9283bd5
feat(chat): serialize staged quotes at the authoritative send attempt
tulsi-builder Aug 13, 2026
1df6e66
feat(chat): govern quoting with one composer control policy
tulsi-builder Aug 13, 2026
2c40a83
fix(chat): infer source segments for list subcontent after hard breaks
tulsi-builder Aug 13, 2026
4d6f960
fix(chat): show quote pill after selection ends, centered on first line
tulsi-builder Aug 13, 2026
90839d9
feat(chat): unify quote preview into one scrollable hover panel
tulsi-builder Aug 13, 2026
62cba6f
refactor(chat): harden quote dispatch, coordinates, and send policy
tulsi-builder Aug 13, 2026
e46f5ae
fix(chat): center quote pill over the full first line of inline segments
tulsi-builder Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/features/automations/ui/AutomationBuilderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 9 additions & 1 deletion src/features/chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -456,7 +461,10 @@ export function useChat(
const buffer = getAndDeleteReplayBuffer(sessionId);
if (buffer) {
setMessages(sessionId, [
...sanitizeReplayMessages(buffer),
...withRestoredStagedItems(
sessionId,
sanitizeReplayMessages(buffer),
),
createCompactionConfirmationMessage(),
]);
} else {
Expand Down
12 changes: 11 additions & 1 deletion src/features/chat/hooks/useChatInputSubmit.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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";

interface UseChatInputSubmitOptions {
attachmentsRef: RefObject<ChatAttachmentDraft[]>;
selectedSkillsRef: RefObject<ChatSkillDraft[]>;
stagedItemsRef: RefObject<StagedItem[]>;
selectedChipsRef: RefObject<MessageChip[]>;
skillProviderId?: string | null;
selectedPersonaId?: string | null;
Expand All @@ -21,6 +26,7 @@ interface UseChatInputSubmitOptions {
export function useChatInputSubmit({
attachmentsRef,
selectedSkillsRef,
stagedItemsRef,
selectedChipsRef,
skillProviderId,
selectedPersonaId,
Expand All @@ -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,
Expand All @@ -62,6 +70,7 @@ export function useChatInputSubmit({
submittedText,
submittedAttachments,
submittedSkills,
stagedItemsRef.current,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Clear quotes after voice submission (blocking)

Voice auto-submit includes stagedItemsRef.current in the send at this line, but its accepted path clears only selected skills. useVoiceDictation subsequently clears text and attachments, and this path bypasses submitCurrentMessage, which contains the staged-item cleanup used by manual sends. The accepted quote consequently remains staged.

User effect: The sent quote chip remains in the empty composer and is silently included again in the next typed or dictated message unless the user notices and removes it, causing the agent to answer against stale context.

Recommended fix: Apply the same snapshot-aware staged-item cleanup to voice auto-submit as manual submission: after acceptance, remove only the submitted items if the current staged snapshot still matches, preserving any quote added while the send was pending.

Test: Voice-auto-submit valid text with one staged quote, assert the send receives it, resolve acceptance, and assert the submitted chip is removed. Send a second message and assert the first quote is absent. Add a race case where a different quote is staged before acceptance and must remain.

);
if (
accepted &&
Expand All @@ -74,6 +83,7 @@ export function useChatInputSubmit({
[
attachmentsRef,
selectedSkillsRef,
stagedItemsRef,
setSelectedSkills,
submitChatInputMessage,
],
Expand Down
16 changes: 15 additions & 1 deletion src/features/chat/hooks/useChatSessionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 } = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2634,6 +2640,12 @@ export function useChatSessionController({
},
[stateSessionId],
);
const handleRemoveStagedItem = useCallback(
(itemId: string) => {
useChatStore.getState().removeStagedItem(stateSessionId, itemId);
},
[stateSessionId],
);

useEffect(() => {
const previousSelection = agentBuilderSkillSelectionRef.current;
Expand Down Expand Up @@ -3087,6 +3099,8 @@ export function useChatSessionController({
handleDraftChange,
draftAttachments,
handleDraftAttachmentsChange,
stagedItems,
handleRemoveStagedItem,
selectedSkills,
handleSkillsChange,
skillProjectDirs,
Expand Down
16 changes: 15 additions & 1 deletion src/features/chat/lib/sendCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/features/chat/lib/sessionActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions src/features/chat/lib/stagedItemPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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");
});
});
47 changes: 47 additions & 0 deletions src/features/chat/lib/stagedItemPresentation.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading