Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 12 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
import type { ThreadSyncPhase } from "../../threadSync";
import { ComposerBanner } from "./ComposerBanner";
import { ComposerSurface } from "./ComposerSurface";
import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
import {
attachmentsToReleaseOnUploadCapabilityLoss,
Expand Down Expand Up @@ -3209,8 +3210,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onClick={expandMobileComposerStable}
aria-label="Expand composer"
>
{prompt.trim() ||
(showProviderUnavailable ? "Enable a provider in Settings" : "Ask anything...")}
{prompt.trim() ? (
<CollapsedComposerPromptPreview
prompt={prompt}
skills={selectedProviderSkills}
theme={resolvedTheme}
/>
) : showProviderUnavailable ? (
"Enable a provider in Settings"
) : (
"Ask anything..."
)}
</button>
{collapsedComposerImagePreviews}
<button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import "~/index.css";

import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import { expect, it } from "vite-plus/test";

import { formatInlineContextReference } from "~/lib/composerContextReferences";
import { render } from "../../../test/render";
import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview";

function quote(text: string, comment?: string): string {
return serializeAssistantCitation({
version: 1,
environmentId: EnvironmentId.make("environment:test"),
threadId: ThreadId.make("thread:test"),
messageId: MessageId.make("message:assistant"),
text,
start: 0,
end: text.length,
prefix: "",
suffix: "",
...(comment === undefined ? {} : { comment }),
});
}

it("shows quotes, file mentions, context references and skills as chips", async () => {
const terminal = formatInlineContextReference({
kind: "terminal",
contextId: "terminal_1",
label: "Terminal 1",
});
const environment = formatInlineContextReference({
kind: "environment",
contextId: "environment_1",
label: "Build host",
});
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`;

await using screen = await render(
<CollapsedComposerPromptPreview
prompt={prompt}
skills={[
{ name: "review-pr", displayName: "Review PR", path: "/skills/review-pr", enabled: true },
]}
theme="light"
/>,
// The inert preview has no router-dependent children.
{ router: false },
);
const { container } = screen;

const chips = [...container.querySelectorAll('[data-slot="context-chip"]')];
expect(chips.map((chip) => chip.textContent)).toEqual([
"Cache the parsed config.",
"Also on 429?",
"package.json",
"file.txt",
"Terminal 1",
"Build host",
"Review PR",
]);
expect(chips.every((chip) => chip.getAttribute("data-state") !== "unresolved")).toBe(true);
expect(chips.every((chip) => chip.querySelector("svg") !== null)).toBe(true);
expect(container.textContent).toBe(
"Cache the parsed config. vs Also on 429? in package.json and file.txt per Terminal 1 on Build host with Review PR please",
);
// The row is one tap target that expands the composer, so chips stay inert.
expect(container.querySelector("button, a, [tabindex]")).toBeNull();
});

it("leaves unknown skills as the text the user typed", async () => {
await using screen = await render(
<CollapsedComposerPromptPreview prompt="run $missing now" skills={[]} theme="dark" />,
// The inert preview has no router-dependent children.
{ router: false },
);
const { container } = screen;

expect(container.querySelector('[data-slot="context-chip"]')).toBeNull();
expect(container.textContent).toBe("run $missing now");
});
113 changes: 113 additions & 0 deletions apps/web/src/components/chat/CollapsedComposerPromptPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
import type { KnownComposerContextKind, ServerProviderSkill } from "@t3tools/contracts";
import { assistantCitationLabel } from "@t3tools/shared/assistantCitations";
import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
import { fileBasename } from "@t3tools/shared/path";
import {
CircleDashedIcon,
ImageIcon,
MessageCircleIcon,
MessagesSquareIcon,
MonitorIcon,
MousePointerClickIcon,
QuoteIcon,
TerminalIcon,
} from "lucide-react";
import type { ReactElement } from "react";

import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
import { ContextChip, ContextChipLabel } from "../ContextChip";
import { ContextChipShell } from "../contextChipParts";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { SkillChipIcon } from "./SkillInlineText";

type Theme = "light" | "dark";

// Draft records (PR state, upload progress) live with the editor, so the row shows each
// reference by its kind alone.
const CONTEXT_REFERENCE_ICONS: Record<
KnownComposerContextKind,
(label: string, theme: Theme) => ReactElement
> = {
image: () => <ImageIcon />,
file: (label, theme) => <PierreEntryIcon pathValue={label} kind="file" theme={theme} />,
terminal: () => <TerminalIcon />,
element: () => <MousePointerClickIcon />,
"preview-annotation": () => <MousePointerClickIcon />,
"review-comment": () => <MessageCircleIcon />,
mention: (label, theme) => <PierreEntryIcon pathValue={label} kind="file" theme={theme} />,
skill: () => <SkillChipIcon />,
thread: () => <MessagesSquareIcon />,
environment: () => <MonitorIcon />,
};

function isKnownContextKind(kind: string): kind is KnownComposerContextKind {
return Object.hasOwn(CONTEXT_REFERENCE_ICONS, kind);
}

/**
* The draft as the editor's chips, drawn inert: the collapsed row is a single button that
* expands the composer, so these chips carry no popovers, links or focus stops.
*/
export function CollapsedComposerPromptPreview(props: {
prompt: string;
skills: ReadonlyArray<ServerProviderSkill>;
theme: Theme;
}) {
let offset = 0;
return splitPromptIntoComposerSegments(props.prompt).map((segment) => {
const key = offset;
offset += segment.type === "text" ? segment.text.length : segment.source.length;
switch (segment.type) {
case "text":
return segment.text;
case "citation":
return (
<ContextChip key={key} kind="citation">
<QuoteIcon aria-hidden="true" />
<ContextChipLabel className="max-w-[16em]">
{assistantCitationLabel(segment.citation)}
</ContextChipLabel>
</ContextChip>
);
case "mention":
return (
<ContextChipShell
key={key}
kind="mention"
icon={<PierreEntryIcon pathValue={segment.path} kind="file" theme={props.theme} />}
label={fileBasename(segment.path)}
/>
);
case "context-reference":
return isKnownContextKind(segment.kind) ? (
<ContextChipShell
key={key}
kind={segment.kind}
icon={CONTEXT_REFERENCE_ICONS[segment.kind](segment.label, props.theme)}
label={segment.label}
/>
) : (
<ContextChipShell
key={key}
state="unresolved"
icon={<CircleDashedIcon />}
label={segment.label}
/>
);
case "skill": {
const skill = props.skills.find(
(candidate) => candidate.name === segment.name && isProviderSkillUserInvocable(candidate),
);
return skill ? (
<ContextChip key={key} kind="skill" data-markdown-copy={segment.source}>
<SkillChipIcon />
<ContextChipLabel>{formatProviderSkillDisplayName(skill)}</ContextChipLabel>
</ContextChip>
) : (
segment.source
);
}
}
});
}
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/SkillInlineText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function SkillChipPopover(props: {
);
}

function SkillChipIcon() {
export function SkillChipIcon() {
return (
<span
aria-hidden="true"
Expand Down
Loading