Skip to content
Source
Sign inSign up
Open History to see the last commit

CollapsedComposerPromptPreview.tsx

128 lines4.4 KB

1import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
2import type { ServerProviderSkill } from "@t3tools/contracts";
3import { assistantCitationLabel } from "@t3tools/shared/assistantCitations";
4import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
5import { fileBasename } from "@t3tools/shared/path";
6import {
7 CircleDashedIcon,
8 ImageIcon,
9 MessageCircleIcon,
10 MessagesSquareIcon,
11 MonitorIcon,
12 MousePointerClickIcon,
13 QuoteIcon,
14 TerminalIcon,
15} from "lucide-react";
16import type { ReactElement } from "react";
17​
18import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
19import { composerFileNeedsReattach } from "~/composerDraftStore";
20import type {
21 ComposerDraftContextRecord,
22 ComposerDraftContextRecords,
23} from "../composerContextPresentation";
24import { ContextChip, ContextChipLabel } from "../ContextChip";
25import { ContextChipShell } from "../contextChipParts";
26import { PierreEntryIcon } from "./PierreEntryIcon";
27import { SkillChipIcon } from "./SkillInlineText";
28​
29type Theme = "light" | "dark";
30​
31// Upload progress and PR state stay with the editor, so the row shows each reference by kind.
32const 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.
46function 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;
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 */
61export 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 });