Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
427aa6e
fix(web): collapsed composer shows quoted text instead of the citatio…
EtanHey Oct 20, 2026
a8d3209
test(web): cover quote comments and multiple quotes in the collapsed …
EtanHey Oct 20, 2026
b4e6db6
fix(web): collapsed composer shows chips instead of raw links
EtanHey Oct 20, 2026
26f0d11
refactor(web): trim collapsed composer preview
EtanHey Oct 20, 2026
5d5d9a9
Merge remote-tracking branch 'upstream/main' into fix/resting-compose…
EtanHey Oct 20, 2026
4734d91
test(web): use disposable render helper
EtanHey Oct 20, 2026
06e10eb
Merge remote-tracking branch 'upstream/main' into fix/resting-compose…
EtanHey Oct 21, 2026
b5614f9
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Oct 22, 2026
8cf6dae
Merge upstream main into collapsed composer citation preview
EtanHey Oct 27, 2026
94c61a5
fix(web): recognize environment chips in the collapsed composer
EtanHey Oct 27, 2026
4399c38
test(web): explain router-free collapsed preview fixtures
EtanHey Oct 27, 2026
1366714
fix(web): shorten Windows paths in collapsed composer chips
EtanHey Oct 27, 2026
6490323
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Oct 29, 2026
f38d13d
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Oct 29, 2026
d931b28
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Oct 29, 2026
f95f3ee
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Nov 3, 2026
0a584e5
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Nov 4, 2026
8458b59
Merge branch 'main' into fix/collapsed-composer-chip-text
EtanHey Nov 5, 2026
5a34075
Merge remote-tracking branch 'origin/main' into fix/collapsed-compose…
juliusmarminge Nov 11, 2026
8446caf
fix(web): collapsed composer shows model mentions as typed text
juliusmarminge Nov 11, 2026
cea4d5e
Merge commit '034f3ba918c986da6abe9cdb4dc9715b7150dc1a' into HEAD
EtanHey Nov 11, 2026
5773e3a
Merge remote-tracking branch 'origin/main' into fix/collapsed-compose…
juliusmarminge Nov 11, 2026
c35b164
Merge branch 'fix/collapsed-composer-chip-text' of https://github.com…
juliusmarminge Nov 11, 2026
8b80feb
fix(web): collapsed composer marks references without a draft record …
juliusmarminge Nov 11, 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
15 changes: 13 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
} from "./composerContextUndo";
import { ComposerBanner } from "./ComposerBanner";
import { ComposerSurface } from "./ComposerSurface";
import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview";
import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
import {
attachmentsToReleaseOnUploadCapabilityLoss,
Expand Down Expand Up @@ -3323,8 +3324,18 @@ 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}
contextRecords={composerContextRecords}
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,100 @@
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 {
environmentContextRecord,
environmentContextReference,
} from "~/lib/composerContextRecords";
import {
composerContextRecordsFromDraft,
EMPTY_COMPOSER_CONTEXT_RECORDS,
} from "../composerContextPresentation";
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 buildHost = environmentContextRecord(EnvironmentId.make("environment:build"), "Build host");
// No draft record backs this one, so the editor shows it as unresolved and so must the row.
const terminal = formatInlineContextReference({
kind: "terminal",
contextId: "terminal_1",
label: "Terminal 1",
});
const environment = formatInlineContextReference(environmentContextReference(buildHost));
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 },
]}
contextRecords={composerContextRecordsFromDraft({
terminalContexts: [],
environmentContexts: [buildHost],
})}
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
.filter((chip) => chip.getAttribute("data-state") === "unresolved")
.map((c) => c.textContent),
).toEqual(["Terminal 1"]);
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={[]}
contextRecords={EMPTY_COMPOSER_CONTEXT_RECORDS}
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");
});
128 changes: 128 additions & 0 deletions apps/web/src/components/chat/CollapsedComposerPromptPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills";
import type { 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 { composerFileNeedsReattach } from "~/composerDraftStore";
import type {
ComposerDraftContextRecord,
ComposerDraftContextRecords,
} from "../composerContextPresentation";
import { ContextChip, ContextChipLabel } from "../ContextChip";
import { ContextChipShell } from "../contextChipParts";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { SkillChipIcon } from "./SkillInlineText";

type Theme = "light" | "dark";

// Upload progress and PR state stay with the editor, so the row shows each reference by kind.
const CONTEXT_REFERENCE_ICONS: Record<
ComposerDraftContextRecord["kind"],
(label: string, theme: Theme) => ReactElement
> = {
image: () => <ImageIcon />,
file: (label, theme) => <PierreEntryIcon pathValue={label} kind="file" theme={theme} />,
terminal: () => <TerminalIcon />,
"preview-annotation": () => <MousePointerClickIcon />,
"review-comment": () => <MessageCircleIcon />,
thread: () => <MessagesSquareIcon />,
environment: () => <MonitorIcon />,
};

// Mirrors the editor's chip: only a draft record of the same kind resolves a reference.
function resolvedContextRecord(
records: ComposerDraftContextRecords,
kind: string,
contextId: string,
): ComposerDraftContextRecord | null {
const entry = records.get(contextId);
if (entry === undefined || entry.kind !== kind) return null;
if (entry.kind === "file" && composerFileNeedsReattach(entry.record)) return null;
return entry;
}

/**
* 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>;
contextRecords: ComposerDraftContextRecords;
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;
// The editor's model chip is a popover trigger, which cannot nest in the row's button.
case "model":
return segment.source;
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": {
const entry = resolvedContextRecord(props.contextRecords, segment.kind, segment.contextId);
return entry ? (
<ContextChipShell
key={key}
kind={entry.kind}
icon={CONTEXT_REFERENCE_ICONS[entry.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">
<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 @@ -144,7 +144,7 @@ export function SkillChipPopover(props: {
);
}

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