| author | |
| committer | |
| log | 6f83358ae6d85b5dee7d3280e645e0e9a0aeff62 |
| tree | 1f65d1623fb0f6354a3bae8f5e16fe169722a64d |
| parent | 0f09627a111f7e9adc22c256c725565672676f1a |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 78 insertions(+), 17 deletions(-)
apps/web/src/components/chat/AssistantCitationChip.tsx+5-3| ... | @@ -1,5 +1,8 @@ | ... | @@ -1,5 +1,8 @@ |
| 1 | import type { AssistantCitation } from "@t3tools/contracts"; | 1 | import type { AssistantCitation } from "@t3tools/contracts"; |
| 2 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | 2 | import { |
| 3 | assistantCitationLabel, | ||
| 4 | serializeAssistantCitation, | ||
| 5 | } from "@t3tools/shared/assistantCitations"; | ||
| 3 | import { Link, useNavigate } from "@tanstack/react-router"; | 6 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | import { PencilIcon, QuoteIcon } from "lucide-react"; | 7 | import { PencilIcon, QuoteIcon } from "lucide-react"; |
| 5 | import { | 8 | import { |
| ... | @@ -94,8 +97,7 @@ export function AssistantCitationChip({ | ... | @@ -94,8 +97,7 @@ export function AssistantCitationChip({ |
| 94 | }, | 97 | }, |
| 95 | } | 98 | } |
| 96 | : undefined; | 99 | : undefined; |
| 97 | const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); | 100 | const label = assistantCitationLabel(citation); |
| 98 | const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; | ||
| 99 | const sourceLinkProps = { | 101 | const sourceLinkProps = { |
| 100 | to: "/$environmentId/$threadId" as const, | 102 | to: "/$environmentId/$threadId" as const, |
| 101 | params: { environmentId: citation.environmentId, threadId: citation.threadId }, | 103 | params: { environmentId: citation.environmentId, threadId: citation.threadId }, |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+36-1| ... | @@ -21,12 +21,14 @@ import { | ... | @@ -21,12 +21,14 @@ import { |
| 21 | import { makeStreamingTimelineFixture } from "../../test-fixtures"; | 21 | import { makeStreamingTimelineFixture } from "../../test-fixtures"; |
| 22 | import type { TurnDiffSummary } from "../../types"; | 22 | import type { TurnDiffSummary } from "../../types"; |
| 23 | import { describe, expect, it } from "vite-plus/test"; | 23 | import { describe, expect, it } from "vite-plus/test"; |
| 24 | import { MessageId, RunId } from "@t3tools/contracts"; | 24 | import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; |
| 25 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | ||
| 25 | import { | 26 | import { |
| 26 | computeStableMessagesTimelineRows, | 27 | computeStableMessagesTimelineRows, |
| 27 | computeMessageDurationStart, | 28 | computeMessageDurationStart, |
| 28 | deriveMessagesTimelineRows, | 29 | deriveMessagesTimelineRows, |
| 29 | deriveMessagesTimelineRowsWithState, | 30 | deriveMessagesTimelineRowsWithState, |
| 31 | shouldCollapseUserMessage, | ||
| 30 | liveWorkEntryLabel, | 32 | liveWorkEntryLabel, |
| 31 | normalizeCompactToolLabel, | 33 | normalizeCompactToolLabel, |
| 32 | resolveAssistantMessageCopyState, | 34 | resolveAssistantMessageCopyState, |
| ... | @@ -5162,3 +5164,36 @@ describe("live subagents after their parent turn settles", () => { | ... | @@ -5162,3 +5164,36 @@ describe("live subagents after their parent turn settles", () => { |
| 5162 | expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); | 5164 | expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]); |
| 5163 | }); | 5165 | }); |
| 5164 | }); | 5166 | }); |
| 5167 | |||
| 5168 | describe("shouldCollapseUserMessage", () => { | ||
| 5169 | it("measures a quote chip by its label, not its encoded link", () => { | ||
| 5170 | const quote = "A long assistant paragraph that the user quoted. ".repeat(40); | ||
| 5171 | const citation = serializeAssistantCitation({ | ||
| 5172 | version: 1, | ||
| 5173 | environmentId: EnvironmentId.make("environment"), | ||
| 5174 | threadId: ThreadId.make("thread"), | ||
| 5175 | messageId: MessageId.make("source"), | ||
| 5176 | text: quote, | ||
| 5177 | comment: "Why does this matter?", | ||
| 5178 | start: 0, | ||
| 5179 | end: quote.length, | ||
| 5180 | prefix: "", | ||
| 5181 | suffix: "", | ||
| 5182 | }); | ||
| 5183 | |||
| 5184 | expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); | ||
| 5185 | expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); | ||
| 5186 | }); | ||
| 5187 | |||
| 5188 | it("measures file links and context chips by their label", () => { | ||
| 5189 | const links = Array.from( | ||
| 5190 | { length: 8 }, | ||
| 5191 | (_, index) => | ||
| 5192 | `[file${index}.ts](/workspace/projects/example/packages/some/deeply/nested/directory/file${index}.ts)`, | ||
| 5193 | ); | ||
| 5194 | const text = `Compare ${links.join(", ")} with [terminal 1](t3-context://v1/terminal/${"a".repeat(36)}).`; | ||
| 5195 | |||
| 5196 | expect(text.length).toBeGreaterThan(600); | ||
| 5197 | expect(shouldCollapseUserMessage(text)).toBe(false); | ||
| 5198 | }); | ||
| 5199 | }); |
apps/web/src/components/chat/MessagesTimeline.logic.ts+31| ... | @@ -2,6 +2,10 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu | ... | @@ -2,6 +2,10 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu |
| 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; | 2 | export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; |
| 3 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; | 3 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 4 | import * as Equal from "effect/Equal"; | 4 | import * as Equal from "effect/Equal"; |
| 5 | import { | ||
| 6 | assistantCitationLabel, | ||
| 7 | collectAssistantCitations, | ||
| 8 | } from "@t3tools/shared/assistantCitations"; | ||
| 5 | import { shallow } from "zustand/vanilla/shallow"; | 9 | import { shallow } from "zustand/vanilla/shallow"; |
| 6 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 10 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 7 | import { | 11 | import { |
| ... | @@ -2226,3 +2230,30 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -2226,3 +2230,30 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 2226 | } | 2230 | } |
| 2227 | } | 2231 | } |
| 2228 | } | 2232 | } |
| 2233 | |||
| 2234 | const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; | ||
| 2235 | const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; | ||
| 2236 | const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; | ||
| 2237 | |||
| 2238 | function visibleUserMessageText(text: string): string { | ||
| 2239 | const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); | ||
| 2240 | let visible = ""; | ||
| 2241 | let cursor = 0; | ||
| 2242 | for (const match of collectAssistantCitations(text)) { | ||
| 2243 | visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); | ||
| 2244 | cursor = match.end; | ||
| 2245 | } | ||
| 2246 | return visible + linkLabels(text.slice(cursor)); | ||
| 2247 | } | ||
| 2248 | |||
| 2249 | export function shouldCollapseUserMessage(text: string): boolean { | ||
| 2250 | const visible = visibleUserMessageText(text); | ||
| 2251 | if (visible.trim().length === 0) { | ||
| 2252 | return false; | ||
| 2253 | } | ||
| 2254 | |||
| 2255 | return ( | ||
| 2256 | visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || | ||
| 2257 | visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES | ||
| 2258 | ); | ||
| 2259 | } |
apps/web/src/components/chat/MessagesTimeline.tsx+1-13| ... | @@ -207,6 +207,7 @@ import { | ... | @@ -207,6 +207,7 @@ import { |
| 207 | resolveTimelineMinimapNavigationInteractive, | 207 | resolveTimelineMinimapNavigationInteractive, |
| 208 | resolveTimelineMinimapTopPercent, | 208 | resolveTimelineMinimapTopPercent, |
| 209 | resolveWorkGroupScrollIndex, | 209 | resolveWorkGroupScrollIndex, |
| 210 | shouldCollapseUserMessage, | ||
| 210 | shouldFollowWorkGroupAppend, | 211 | shouldFollowWorkGroupAppend, |
| 211 | shouldPreserveAssistantLineBreaks, | 212 | shouldPreserveAssistantLineBreaks, |
| 212 | threadReadLabelPrefix, | 213 | threadReadLabelPrefix, |
| ... | @@ -4522,22 +4523,9 @@ function UserMessageContextReferenceChip(props: { | ... | @@ -4522,22 +4523,9 @@ function UserMessageContextReferenceChip(props: { |
| 4522 | }); | 4523 | }); |
| 4523 | } | 4524 | } |
| 4524 | 4525 | ||
| 4525 | const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; | ||
| 4526 | const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; | ||
| 4527 | const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; | 4526 | const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; |
| 4528 | const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; | 4527 | const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; |
| 4529 | 4528 | ||
| 4530 | function shouldCollapseUserMessage(text: string): boolean { | ||
| 4531 | if (text.trim().length === 0) { | ||
| 4532 | return false; | ||
| 4533 | } | ||
| 4534 | |||
| 4535 | return ( | ||
| 4536 | text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || | ||
| 4537 | text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES | ||
| 4538 | ); | ||
| 4539 | } | ||
| 4540 | |||
| 4541 | const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { | 4529 | const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { |
| 4542 | text: string; | 4530 | text: string; |
| 4543 | renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; | 4531 | renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; |
packages/shared/src/assistantCitations.ts+5| ... | @@ -98,6 +98,11 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu | ... | @@ -98,6 +98,11 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu |
| 98 | } | 98 | } |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | export function assistantCitationLabel(citation: AssistantCitation): string { | ||
| 102 | const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); | ||
| 103 | return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; | ||
| 104 | } | ||
| 105 | |||
| 101 | export function serializeAssistantCitation(citation: AssistantCitation): string { | 106 | export function serializeAssistantCitation(citation: AssistantCitation): string { |
| 102 | return `[Assistant quote](${formatAssistantCitationHref(citation)})`; | 107 | return `[Assistant quote](${formatAssistantCitationHref(citation)})`; |
| 103 | } | 108 | } |