Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { EnvironmentId, MessageId, ThreadId, type ComposerContextId } from "@t3tools/contracts";
import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
import { describe, expect, it } from "vite-plus/test";

import {
Expand Down Expand Up @@ -28,6 +31,35 @@ describe("threadQueueControlPresentation", () => {
expect(controls.dismissAccessibilityLabel).toBe(REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL);
});

it("shows context, mention and quote chips as their labels", () => {
const file = formatComposerContextReference({
kind: "mention",
contextId: "context-1" as ComposerContextId,
label: "README.md",
});
const quote = serializeAssistantCitation({
version: 1,
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
messageId: MessageId.make("message"),
text: "Use the cache",
start: 0,
end: 13,
prefix: "",
suffix: "",
});
const controls = resolveThreadQueueRowControls({
busy: false,
canPromoteToSteer: false,
canReorder: false,
index: 0,
queuedCount: 1,
text: `${quote} in ${file} and [api.ts](src/api.ts)`,
});

expect(controls.displayText).toBe("Use the cache in README.md and api.ts");
});

it("disables edge reorder controls and busy dismissal", () => {
const first = resolveThreadQueueRowControls({
busy: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations";
import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";

export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message";

Expand Down Expand Up @@ -35,7 +38,12 @@ export function resolveThreadQueueRowControls(input: {
canMoveUp: mutationEnabled && input.canReorder && input.index > 0,
canSteer: mutationEnabled && input.canPromoteToSteer && !isEditing,
dismissAccessibilityLabel: REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
displayText: input.text,
// Chips read as their labels, matching the composer.
displayText: composerFileMentionsToLabels(
assistantCitationsToLabels(
replaceComposerContextReferences(input.text, (reference) => reference.label),
),
).trim(),
isEditing,
};
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper
as="span"
className="inline-flex min-w-0 max-w-full select-none"
className="inline-flex min-w-0 max-w-full select-none align-middle"
contentEditable={false}
spellCheck={false}
data-composer-citation-chip="true"
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/ContextChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ import { cn } from "~/lib/utils";
* A consumer may override `--context-chip-accent` through `style` for a color that comes
* from content, such as an image's average color.
*
* align-middle centers the pill on the x-height, which reads low beside capitals; the 0.1em lift
* centers it on the cap height instead. Wrappers around the chip should be inline or align-middle
* too, so the chip keeps that alignment.
*
* Renders a span by default. Render it as a button, link or popover/tooltip trigger to make it
* interactive; the hover tint follows from that, not from a prop. Anything focusable (including a
* span with tabIndex for a tooltip) gets the focus outline.
*/
const contextChipVariants = cva(
"inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default",
"inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] -translate-y-[0.1em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default",
{
defaultVariants: { kind: "neutral" },
variants: {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { requestCustomSnooze } from "./CustomSnoozeDialog";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
import { useAtomValue } from "@effect/atom-react";
import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations";
import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import * as Schema from "effect/Schema";
import {
Expand Down Expand Up @@ -1022,7 +1024,11 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
}) {
const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
const promptPreview =
replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
composerFileMentionsToLabels(
assistantCitationsToLabels(
replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label),
),
)
.trim()
.split("\n", 1)[0] ?? "";
// images mirrors persistedAttachments once rehydration finishes; before
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/chat/ComposerStashMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { assistantCitationsToLabels } from "@t3tools/shared/assistantCitations";
import { composerFileMentionsToLabels } from "@t3tools/shared/composerInlineTokens";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";

import { formatRelativeTimeLabel } from "../../timestampFormat";
import { cn } from "~/lib/utils";
Expand All @@ -15,7 +17,13 @@ function missingImageCount(entry: PromptStashEntry): number {
}

function stashEntrySnippet(entry: PromptStashEntry): string {
const trimmed = assistantCitationsToPlainText(entry.prompt).trim().replace(/\s+/g, " ");
const trimmed = composerFileMentionsToLabels(
assistantCitationsToLabels(
replaceComposerContextReferences(entry.prompt, (reference) => reference.label),
),
)
.trim()
.replace(/\s+/g, " ");
if (trimmed.length > 0) {
return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed;
}
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/components/chat/InlineChipText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
assistantCitationLabel,
collectAssistantCitations,
} from "@t3tools/shared/assistantCitations";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
BotIcon,
FileIcon,
FolderGit2Icon,
FolderIcon,
ImageIcon,
MessageCircleIcon,
MessageSquareTextIcon,
MessagesSquareIcon,
MousePointerClickIcon,
QuoteIcon,
TerminalIcon,
} from "lucide-react";
import type { ReactNode } from "react";

import { useTheme } from "../../hooks/useTheme";
import { basenameOfPath } from "../../pierre-icons";
import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
import { GitHubIcon, GranolaIcon, LinearIcon, NotionIcon, SlackIcon } from "../Icons";
import { FileTagChipContent } from "./FileTagChip";
import { SkillChipIcon } from "./SkillInlineText";

const CHIP_ICONS: Partial<Record<ContextChipKind, ReactNode>> = {
image: <ImageIcon />,
file: <FileIcon />,
mention: <FileIcon />,
terminal: <TerminalIcon />,
element: <MousePointerClickIcon />,
"preview-annotation": <MousePointerClickIcon />,
"review-comment": <MessageCircleIcon />,
skill: <SkillChipIcon />,
thread: <MessagesSquareIcon />,
"thread-tab": <MessagesSquareIcon />,
subagent: <BotIcon />,
"prompt-link": <MessageSquareTextIcon />,
"linear-issue": <LinearIcon />,
"github-issue": <GitHubIcon />,
"slack-thread": <SlackIcon />,
"notion-page": <NotionIcon />,
"granola-note": <GranolaIcon />,
repository: <FolderGit2Icon />,
checkout: <FolderIcon />,
citation: <QuoteIcon />,
};

type Segment = {
start: number;
end: number;
kind: ContextChipKind;
label: string;
path?: string;
};

/**
* One-line message text with its context references, file mentions and assistant quotes drawn as static
* chips, for previews that have no records to open (queued messages). `hideImages` drops
* image references a surface already shows as thumbnails.
*/
export function InlineChipText({ text, hideImages }: { text: string; hideImages?: boolean }) {
const { resolvedTheme } = useTheme();
const segments: Segment[] = [
// Mentions only match before whitespace; the pad lets one end the text.
...collectComposerInlineTokens(`${text} `)
.filter((token) => token.type === "mention")
.map((token) => ({
start: token.start,
end: token.end,
kind: "mention" as const,
label: basenameOfPath(token.value),
path: token.value,
})),
...collectComposerContextReferences(text).map((reference) => ({
start: reference.start,
end: reference.end,
kind: reference.kind in CHIP_ICONS ? (reference.kind as ContextChipKind) : "neutral",
label: hideImages && reference.kind === "image" ? "" : reference.label,
})),
...collectAssistantCitations(text).map((match) => ({
start: match.start,
end: match.end,
kind: "citation" as const,
label: assistantCitationLabel(match.citation),
})),
].toSorted((a, b) => a.start - b.start);

const parts: ReactNode[] = [];
let cursor = 0;
for (const segment of segments) {
if (segment.start < cursor) continue;
parts.push(text.slice(cursor, segment.start));
if (segment.label) {
parts.push(
<ContextChip key={segment.start} kind={segment.kind}>
{segment.path === undefined ? (
<>
{CHIP_ICONS[segment.kind]}
<ContextChipLabel>{segment.label}</ContextChipLabel>
</>
) : (
<FileTagChipContent path={segment.path} label={segment.label} theme={resolvedTheme} />
)}
</ContextChip>,
);
}
cursor = segment.end;
}
parts.push(text.slice(cursor));
return <>{parts}</>;
}
12 changes: 6 additions & 6 deletions apps/web/src/components/chat/QueuedRunsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { deriveThreadQueueWorkflowState } from "@t3tools/client-runtime/state/thread-workflows";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import type {
ChatAttachment as ContractChatAttachment,
EnvironmentId,
Expand All @@ -24,6 +23,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { isImageAttachment, type ChatMessage } from "../../types";
import { cn } from "~/lib/utils";
import { ComposerBanner } from "./ComposerBanner";
import { InlineChipText } from "./InlineChipText";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

Expand Down Expand Up @@ -272,9 +272,9 @@ export function QueuedRunsControl({
<ComposerBanner.Scroll className={cn("max-h-32", !expanded && "hidden")}>
<ComposerBanner.Children render={<ol />} id={queueListId}>
{items.map((item) => {
const previewText = replaceComposerContextReferences(item.text, (reference) =>
reference.kind === "image" && item.thumbnails.length > 0 ? "" : reference.label,
).trim();
const chipText = (
<InlineChipText text={item.text.trim()} hideImages={item.thumbnails.length > 0} />
);
const rowRunId = item.runId;
const rowServerIndex = item.serverIndex;
const isEditing = rowRunId !== null && rowRunId === props.editingRunId;
Expand Down Expand Up @@ -396,10 +396,10 @@ export function QueuedRunsControl({
) : null}
<Tooltip>
<TooltipTrigger render={<span className="min-w-0 flex-1 truncate" />}>
{previewText}
{chipText}
</TooltipTrigger>
<TooltipPopup side="top" className="max-w-96 break-words">
{previewText}
{chipText}
</TooltipPopup>
</Tooltip>
</ComposerBanner.Content>
Expand Down
11 changes: 11 additions & 0 deletions packages/shared/src/assistantCitations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type AssistantCitation,
} from "@t3tools/contracts";
import {
assistantCitationsToLabels,
assistantCitationsToPlainText,
collectAssistantCitations,
expandAssistantCitationsForProvider,
Expand Down Expand Up @@ -110,10 +111,20 @@ describe("assistant citation references", () => {
const prompt = `[Assistant quote](${href})`;
expect(collectAssistantCitations(prompt)).toEqual([]);
expect(assistantCitationsToPlainText(prompt)).toBe(prompt);
expect(assistantCitationsToLabels(prompt)).toBe(prompt);
expect(expandAssistantCitationsForProvider(prompt)).toBe(prompt);
expect(renderAssistantCitationsAsText(prompt)).toBe(prompt);
});

it("previews each citation as its chip label", () => {
const commented = serializeAssistantCitation({ ...citation, comment: " Why\nthis?" });
expect(
assistantCitationsToLabels(
`About ${serializeAssistantCitation(legacyCitation)} and ${commented}`,
),
).toBe("About A quote & a newline. and Why this?");
});

it("bounds selected text and surrounding context", () => {
expect(
parseAssistantCitationHref(
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/assistantCitations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ export function collectAssistantCitations(text: string) {
return citations;
}

/** One-line previews show each citation as its chip label, the way the composer draws it. */
export function assistantCitationsToLabels(prompt: string): string {
return prompt.replace(CITATION_LINK, (source: string, href: string) => {
const citation = parseAssistantCitationHref(href);
return citation ? assistantCitationLabel(citation) : source;
});
}

/** Titles and previews include the selected text and user comment without Markdown escaping. */
export function assistantCitationsToPlainText(prompt: string): string {
return prompt.replace(CITATION_LINK, (source: string, href: string) => {
Expand Down
15 changes: 14 additions & 1 deletion packages/shared/src/composerInlineTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vite-plus/test";

import { collectComposerInlineTokens } from "./composerInlineTokens.ts";
import {
collectComposerInlineTokens,
composerFileMentionsToLabels,
} from "./composerInlineTokens.ts";

describe("collectComposerInlineTokens", () => {
it("collects file links, mentions, and skills with source ranges", () => {
Expand Down Expand Up @@ -191,3 +194,13 @@ describe("collectComposerInlineTokens", () => {
expect(performance.now() - started).toBeLessThan(1_000);
});
});

describe("composerFileMentionsToLabels", () => {
it("previews file-link mentions as their file names", () => {
expect(
composerFileMentionsToLabels(
"Check [README.md](README.md) and [a b.ts](src/a%20b.ts)\nthen @src/c.ts [docs](https://example.com) [x.ts](src/x.ts)",
),
).toBe("Check README.md and a b.ts\nthen @src/c.ts [docs](https://example.com) x.ts");
});
});
18 changes: 18 additions & 0 deletions packages/shared/src/composerInlineTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,21 @@ export function collectComposerInlineTokens(

return [...matches].sort((left, right) => left.start - right.start);
}

/** One-line previews show each file-link mention as its file name, the way the composer chip reads. */
export function composerFileMentionsToLabels(text: string): string {
// Mentions only match before whitespace; the pad lets one end the text.
const mentions = collectMentionTokens(`${text} `).filter((token) => token.source.startsWith("["));
let result = "";
let cursor = 0;
// File-link matches come back in text order and never overlap.
for (const mention of mentions) {
const separatorIndex = Math.max(
mention.value.lastIndexOf("/"),
mention.value.lastIndexOf("\\"),
);
result += text.slice(cursor, mention.start) + mention.value.slice(separatorIndex + 1);
cursor = mention.end;
}
return result + text.slice(cursor);
}