Skip to content

Commit e6afd2a

Browse files
EtanHeyclaudecodexjuliusmarminge
authored
fix(web): render citation chips in the collapsed composer (#22148)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: t3codeCodex running gpt-6.1-sol <noreply@openai.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent 1278628 commit e6afd2a

4 files changed

Lines changed: 242 additions & 3 deletions

File tree

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
} from "./composerContextUndo";
117117
import { ComposerBanner } from "./ComposerBanner";
118118
import { ComposerSurface } from "./ComposerSurface";
119+
import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview";
119120
import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
120121
import {
121122
attachmentsToReleaseOnUploadCapabilityLoss,
@@ -3327,8 +3328,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33273328
onClick={expandMobileComposerStable}
33283329
aria-label="Expand composer"
33293330
>
3330-
{prompt.trim() ||
3331-
(showProviderUnavailable ? "Enable a provider in Settings" : "Ask anything...")}
3331+
{prompt.trim() ? (
3332+
<CollapsedComposerPromptPreview
3333+
prompt={prompt}
3334+
skills={selectedProviderSkills}
3335+
contextRecords={composerContextRecords}
3336+
theme={resolvedTheme}
3337+
/>
3338+
) : showProviderUnavailable ? (
3339+
"Enable a provider in Settings"
3340+
) : (
3341+
"Ask anything..."
3342+
)}
33323343
</button>
33333344
{collapsedComposerImagePreviews}
33343345
<button
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import "~/index.css";
2+
3+
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
4+
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
5+
import { expect, it } from "vite-plus/test";
6+
7+
import { formatInlineContextReference } from "~/lib/composerContextReferences";
8+
import {
9+
environmentContextRecord,
10+
environmentContextReference,
11+
} from "~/lib/composerContextRecords";
12+
import {
13+
composerContextRecordsFromDraft,
14+
EMPTY_COMPOSER_CONTEXT_RECORDS,
15+
} from "../composerContextPresentation";
16+
import { render } from "../../../test/render";
17+
import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview";
18+
19+
function quote(text: string, comment?: string): string {
20+
return serializeAssistantCitation({
21+
version: 1,
22+
environmentId: EnvironmentId.make("environment:test"),
23+
threadId: ThreadId.make("thread:test"),
24+
messageId: MessageId.make("message:assistant"),
25+
text,
26+
start: 0,
27+
end: text.length,
28+
prefix: "",
29+
suffix: "",
30+
...(comment === undefined ? {} : { comment }),
31+
});
32+
}
33+
34+
it("shows quotes, file mentions, context references and skills as chips", async () => {
35+
const buildHost = environmentContextRecord(EnvironmentId.make("environment:build"), "Build host");
36+
// No draft record backs this one, so the editor shows it as unresolved and so must the row.
37+
const terminal = formatInlineContextReference({
38+
kind: "terminal",
39+
contextId: "terminal_1",
40+
label: "Terminal 1",
41+
});
42+
const environment = formatInlineContextReference(environmentContextReference(buildHost));
43+
const prompt = `${quote("Cache the parsed config.")} vs ${quote("Retry on 503.", "Also on 429?")} in [package.json](package.json) and [file.txt](C:\\dir\\file.txt) per ${terminal} on ${environment} with $review-pr please`;
44+
45+
await using screen = await render(
46+
<CollapsedComposerPromptPreview
47+
prompt={prompt}
48+
skills={[
49+
{ name: "review-pr", displayName: "Review PR", path: "/skills/review-pr", enabled: true },
50+
]}
51+
contextRecords={composerContextRecordsFromDraft({
52+
terminalContexts: [],
53+
environmentContexts: [buildHost],
54+
})}
55+
theme="light"
56+
/>,
57+
// The inert preview has no router-dependent children.
58+
{ router: false },
59+
);
60+
const { container } = screen;
61+
62+
const chips = [...container.querySelectorAll('[data-slot="context-chip"]')];
63+
expect(chips.map((chip) => chip.textContent)).toEqual([
64+
"Cache the parsed config.",
65+
"Also on 429?",
66+
"package.json",
67+
"file.txt",
68+
"Terminal 1",
69+
"Build host",
70+
"Review PR",
71+
]);
72+
expect(
73+
chips
74+
.filter((chip) => chip.getAttribute("data-state") === "unresolved")
75+
.map((c) => c.textContent),
76+
).toEqual(["Terminal 1"]);
77+
expect(chips.every((chip) => chip.querySelector("svg") !== null)).toBe(true);
78+
expect(container.textContent).toBe(
79+
"Cache the parsed config. vs Also on 429? in package.json and file.txt per Terminal 1 on Build host with Review PR please",
80+
);
81+
// The row is one tap target that expands the composer, so chips stay inert.
82+
expect(container.querySelector("button, a, [tabindex]")).toBeNull();
83+
});
84+
85+
it("leaves unknown skills as the text the user typed", async () => {
86+
await using screen = await render(
87+
<CollapsedComposerPromptPreview
88+
prompt="run $missing now"
89+
skills={[]}
90+
contextRecords={EMPTY_COMPOSER_CONTEXT_RECORDS}
91+
theme="dark"
92+
/>,
93+
// The inert preview has no router-dependent children.
94+
{ router: false },
95+
);
96+
const { container } = screen;
97+
98+
expect(container.querySelector('[data-slot="context-chip"]')).toBeNull();
99+
expect(container.textContent).toBe("run $missing now");
100+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
2+
import type { ServerProviderSkill } from "@t3tools/contracts";
3+
import { assistantCitationLabel } from "@t3tools/shared/assistantCitations";
4+
import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
5+
import { fileBasename } from "@t3tools/shared/path";
6+
import {
7+
CircleDashedIcon,
8+
ImageIcon,
9+
MessageCircleIcon,
10+
MessagesSquareIcon,
11+
MonitorIcon,
12+
MousePointerClickIcon,
13+
QuoteIcon,
14+
TerminalIcon,
15+
} from "lucide-react";
16+
import type { ReactElement } from "react";
17+
18+
import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
19+
import { composerFileNeedsReattach } from "~/composerDraftStore";
20+
import type {
21+
ComposerDraftContextRecord,
22+
ComposerDraftContextRecords,
23+
} from "../composerContextPresentation";
24+
import { ContextChip, ContextChipLabel } from "../ContextChip";
25+
import { ContextChipShell } from "../contextChipParts";
26+
import { PierreEntryIcon } from "./PierreEntryIcon";
27+
import { SkillChipIcon } from "./SkillInlineText";
28+
29+
type Theme = "light" | "dark";
30+
31+
// Upload progress and PR state stay with the editor, so the row shows each reference by kind.
32+
const CONTEXT_REFERENCE_ICONS: Record<
33+
ComposerDraftContextRecord["kind"],
34+
(label: string, theme: Theme) => ReactElement
35+
> = {
36+
image: () => <ImageIcon />,
37+
file: (label, theme) => <PierreEntryIcon pathValue={label} kind="file" theme={theme} />,
38+
terminal: () => <TerminalIcon />,
39+
"preview-annotation": () => <MousePointerClickIcon />,
40+
"review-comment": () => <MessageCircleIcon />,
41+
thread: () => <MessagesSquareIcon />,
42+
environment: () => <MonitorIcon />,
43+
};
44+
45+
// Mirrors the editor's chip: only a draft record of the same kind resolves a reference.
46+
function resolvedContextRecord(
47+
records: ComposerDraftContextRecords,
48+
kind: string,
49+
contextId: string,
50+
): ComposerDraftContextRecord | null {
51+
const entry = records.get(contextId);
52+
if (entry === undefined || entry.kind !== kind) return null;
53+
if (entry.kind === "file" && composerFileNeedsReattach(entry.record)) return null;
54+
return entry;
55+
}
56+
57+
/**
58+
* The draft as the editor's chips, drawn inert: the collapsed row is a single button that
59+
* expands the composer, so these chips carry no popovers, links or focus stops.
60+
*/
61+
export function CollapsedComposerPromptPreview(props: {
62+
prompt: string;
63+
skills: ReadonlyArray<ServerProviderSkill>;
64+
contextRecords: ComposerDraftContextRecords;
65+
theme: Theme;
66+
}) {
67+
let offset = 0;
68+
return splitPromptIntoComposerSegments(props.prompt).map((segment) => {
69+
const key = offset;
70+
offset += segment.type === "text" ? segment.text.length : segment.source.length;
71+
switch (segment.type) {
72+
case "text":
73+
return segment.text;
74+
// The editor's model chip is a popover trigger, which cannot nest in the row's button.
75+
case "model":
76+
return segment.source;
77+
case "citation":
78+
return (
79+
<ContextChip key={key} kind="citation">
80+
<QuoteIcon aria-hidden="true" />
81+
<ContextChipLabel className="max-w-[16em]">
82+
{assistantCitationLabel(segment.citation)}
83+
</ContextChipLabel>
84+
</ContextChip>
85+
);
86+
case "mention":
87+
return (
88+
<ContextChipShell
89+
key={key}
90+
kind="mention"
91+
icon={<PierreEntryIcon pathValue={segment.path} kind="file" theme={props.theme} />}
92+
label={fileBasename(segment.path)}
93+
/>
94+
);
95+
case "context-reference": {
96+
const entry = resolvedContextRecord(props.contextRecords, segment.kind, segment.contextId);
97+
return entry ? (
98+
<ContextChipShell
99+
key={key}
100+
kind={entry.kind}
101+
icon={CONTEXT_REFERENCE_ICONS[entry.kind](segment.label, props.theme)}
102+
label={segment.label}
103+
/>
104+
) : (
105+
<ContextChipShell
106+
key={key}
107+
state="unresolved"
108+
icon={<CircleDashedIcon />}
109+
label={segment.label}
110+
/>
111+
);
112+
}
113+
case "skill": {
114+
const skill = props.skills.find(
115+
(candidate) => candidate.name === segment.name && isProviderSkillUserInvocable(candidate),
116+
);
117+
return skill ? (
118+
<ContextChip key={key} kind="skill">
119+
<SkillChipIcon />
120+
<ContextChipLabel>{formatProviderSkillDisplayName(skill)}</ContextChipLabel>
121+
</ContextChip>
122+
) : (
123+
segment.source
124+
);
125+
}
126+
}
127+
});
128+
}

‎apps/web/src/components/chat/SkillInlineText.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export function SkillChipPopover(props: {
144144
);
145145
}
146146

147-
function SkillChipIcon() {
147+
export function SkillChipIcon() {
148148
return (
149149
<span
150150
aria-hidden="true"

0 commit comments

Comments
 (0)