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
15 changes: 9 additions & 6 deletions apps/web/src/features/breakpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@ function scanned(): string[] {
}

/** The viewport breakpoint variants the guard looks for, alone or after other variants. */
const viewportVariants = new Set(["sm", "md", "max-sm", "max-md"]);
const viewportVariants = new Set(["sm", "md", "min-sm", "min-md", "max-sm", "max-md"]);

/**
* The classes in a source text that carry a viewport `sm:` / `md:` variant,
* in any position (`dark:sm:flex`, `[&>svg]:md:size-4`) and in its `max-`
* form. A container query (`@md:`, `@max-md:`) reads its container, not the
* viewport, so it passes.
* in any position (`dark:sm:flex`, `[&>svg]:md:size-4`) and in its `min-`
* and `max-` forms (`min-sm:` compiles to the same media query as `sm:`). A
* container query (`@md:`, `@max-md:`) reads its container, not the viewport,
* so it passes.
*/
function breakpointClasses(text: string): string[] {
return text.split(/[\s"'`{}()]+/u).filter((token) =>
Expand All @@ -75,10 +76,10 @@ function breakpointClasses(text: string): string[] {
}

describe("the breakpoint guard", () => {
it("finds a viewport breakpoint behind any variant, and in its max- form", () => {
it("finds a viewport breakpoint behind any variant, and in its min- and max- forms", () => {
expect(
breakpointClasses(
'cn("sm:flex", "dark:md:grid hover:sm:block", "[&>svg]:sm:size-4 max-sm:hidden max-md:p-2")',
'cn("sm:flex", "dark:md:grid hover:sm:block", "[&>svg]:sm:size-4 max-sm:hidden max-md:p-2 min-sm:flex min-md:grid")',
),
).toEqual([
"sm:flex",
Expand All @@ -87,6 +88,8 @@ describe("the breakpoint guard", () => {
"[&>svg]:sm:size-4",
"max-sm:hidden",
"max-md:p-2",
"min-sm:flex",
"min-md:grid",
]);
});

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/features/chat/chat-composer.alignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -847,4 +847,18 @@ describe("QueuedMessageStrip", () => {
await user.click(await screen.findByRole("menuitem", { name: "Delete" }));
expect(onDelete).toHaveBeenCalledWith("q2");
});

it("does not save an edit when Enter commits an IME composition", async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(<QueuedMessageStrip items={items} onDelete={vi.fn()} onEdit={onEdit} />);
await user.click(screen.getByRole("button", { name: "Actions for queued message 1" }));
await user.click(await screen.findByRole("menuitem", { name: "Edit" }));
const field = screen.getByRole<HTMLInputElement>("textbox", { name: "Edit queued message 1" });
fireEvent.keyDown(field, { isComposing: true, key: "Enter" });
fireEvent.keyDown(field, { key: "Enter", keyCode: 229 });
expect(onEdit).not.toHaveBeenCalled();
fireEvent.keyDown(field, { key: "Enter" });
expect(onEdit).toHaveBeenCalledTimes(1);
});
});
13 changes: 1 addition & 12 deletions apps/web/src/features/chat/chat-session-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { GitFork, Minimize2 } from "lucide-react";
import { cn } from "../../lib/utils";
import { GHOST_TRIGGER_CLASS } from "./composer-option-menu";
import { contextUtilization } from "./context-usage";
import { formatTokens } from "./turn-stats";

interface SessionUsageControlsProps {
compacting: boolean;
Expand Down Expand Up @@ -169,15 +170,3 @@ function humanize(value: string) {
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}

function formatTokens(value: string) {
const count = BigInt(value);
if (count < 1_000n) return count.toString();
if (count < 1_000_000n) return `${compact(count, 1_000n)}k`;
return `${compact(count, 1_000_000n)}m`;
}

function compact(value: bigint, divisor: bigint) {
const tenths = (value * 10n) / divisor;
return tenths % 10n === 0n ? (tenths / 10n).toString() : `${tenths / 10n}.${tenths % 10n}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ describe("mounted chat workspace BFF boundary", () => {
}),
);
});
fireEvent.click(await screen.findByRole("button", { name: "Open Read details" }));
fireEvent.click(await screen.findByRole("button", { name: "Open Read details, running" }));
const panel = screen.getByRole("complementary", { name: "Read result" });
expect(panel.textContent).toContain("(still running)");

Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/features/chat/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
setForkStarting(true);
setError(undefined);
setNotice("Forking this conversation…");
const viewedAtStart = viewedSessionId.current;
try {
const detail = await queryClient.fetchQuery(
getSessionDetailOptions({ path: { sessionId: row.id } }),
Expand All @@ -1323,7 +1324,8 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
path: { sessionId: row.id },
});
await queryClient.invalidateQueries({ queryKey: listSessionsQueryKey() });
await selectSession(successor.id);
// Someone who went to another chat while the fork ran is not pulled away; the fork is in the list.
if (viewedSessionId.current === viewedAtStart) await selectSession(successor.id);
} catch (caught) {
setError(errorMessage(caught));
} finally {
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/features/chat/queued-message-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
import { Input } from "../../components/ui/input";
import type { QueuedMessage } from "./chat-queue";

/** Whether a key event belongs to an IME composition (`keyCode` 229 covers Safari's commit Enter). */
function isCompositionKey(event: KeyboardEvent) {
return event.isComposing || event.keyCode === 229;
}

/**
* Messages held above the composer as one divided group, from the prototype's
* `QueuedMessageStrip` (`stack-08`): a header with the count, then one row per
Expand Down Expand Up @@ -83,7 +88,10 @@ export function QueuedMessageStrip({
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setEditingId(undefined);
if (event.key === "Enter") save(item.id);
// Enter that commits an IME composition must not save a half-typed edit.
if (event.key === "Enter" && !isCompositionKey(event.nativeEvent)) {
save(item.id);
}
}}
value={draft}
/>
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/features/chat/session-surfaces.alignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,40 @@ describe("chat list row menu", () => {
);
});

it("leaves someone who went to another chat during the fork where they are", async () => {
const bff = new Bff();
bff.rows = [
row("chat"),
{ ...row("other"), title: "Other chat" },
row("third"),
row("successor"),
];
let release!: () => void;
bff.heldDetail = { id: "other", release: new Promise((done) => (release = done)) };
const mounted = await mount(bff);
const user = userEvent.setup();
await screen.findByText("Saved answer");
await user.click(
screen.getByRole("button", { name: "Options for chat: Other chat", hidden: true }),
);
const item = await screen.findByRole("menuitem", { name: "Fork chat" });
item.focus();
await user.keyboard("{Enter}");
await waitFor(() => expect(bff.calls("/api/v1/sessions/other")).toHaveLength(1));

// The fork is still reading its model; the reader moves to another chat.
await act(async () => {
await mounted.router.navigate({ search: { sessionId: "third" }, to: "/workspace/chat" });
});
await act(async () => {
release();
});
await waitFor(() => expect(bff.calls("/api/v1/sessions/other/fork", "POST")).toHaveLength(1));
// The fork exists, and the reader stays on the chat they chose.
await waitFor(() => expect(bff.calls("/api/v1/sessions", "GET").length).toBeGreaterThan(1));
expect(mounted.router.state.location.search).toMatchObject({ sessionId: "third" });
});

it("forks only once while a fork is in flight", async () => {
const bff = new Bff();
bff.rows = [
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/chat/tool-call-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function ToolCallRow({
<li className="min-w-0">
{controls.onPreview ? (
<button
aria-label={`Open ${head.display} details`}
aria-label={`Open ${head.display} details, ${status}`}
className="-mx-1 flex w-full min-w-0 items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-brand"
onClick={() => controls.onPreview?.(tool)}
type="button"
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/features/chat/tool-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ describe("clampChars", () => {
it("clamps with a trailing ellipsis", () => {
expect(clampChars("abcdef", 4)).toBe("abc…");
});

it("counts code points, so a text of astral characters is not cut early", () => {
const text = "😀".repeat(10); // 20 UTF-16 units, 10 code points
expect(clampChars(text, 10)).toBe(text);
expect(clampChars(`${text}x`, 10)).toBe(`${"😀".repeat(9)}…`);
});

it("clamps a huge text without splitting a pair at the cut", () => {
const clamped = clampChars("😀".repeat(2_000_000), 5);
expect(clamped).toBe(`${"😀".repeat(4)}…`);
});
});

describe("summarizeArgs", () => {
Expand Down Expand Up @@ -62,6 +73,41 @@ describe("summarizeResult", () => {
});
});

describe("previews of huge inputs", () => {
it("reads only a bounded head of a huge plain-text output", () => {
const output = `${"word ".repeat(2_000_000)}tail`;
const summary = summarizeResult(output);
expect(summary.length).toBeLessThanOrEqual(120);
expect(summary.endsWith("…")).toBe(true);
expect(summary.startsWith("word word")).toBe(true);
});

it("says a cut text was cut even when what was read was mostly whitespace", () => {
expect(summarizeResult(`x${" ".repeat(10_000)}y`)).toBe("x…");
});

it("summarizes a huge JSON output as text instead of parsing it", () => {
const output = JSON.stringify(Array.from({ length: 200_000 }, (_, index) => ({ index })));
expect(output.length).toBeGreaterThan(256 * 1024);
const summary = summarizeResult(output);
expect(summary).not.toBe("200000 items");
expect(summary.length).toBeLessThanOrEqual(120);
});

it("clamps a huge string argument and a huge non-JSON argument", () => {
const huge = "x".repeat(5_000_000);
expect(summarizeArgs(JSON.stringify({ path: huge })).length).toBeLessThanOrEqual(
"path: ".length + 60,
);
expect(summarizeArgs(huge).length).toBeLessThanOrEqual(60);
});

it("leaves normal-size outputs exactly as before", () => {
expect(summarizeResult(" a\n\nb c ")).toBe("a b c");
expect(summarizeResult("[1,2,3]")).toBe("3 items");
});
});

describe("friendlyToolName", () => {
it("passes a built-in tool name through unchanged", () => {
expect(friendlyToolName("Edit")).toEqual({
Expand Down
44 changes: 35 additions & 9 deletions apps/web/src/features/chat/tool-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,43 @@ const BODY_KEYS: ReadonlySet<string> = new Set(["content", "new_string", "old_st
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

/** Clamps to `max` code points with a trailing ellipsis (never splits a pair). */
/**
* How much of a tool's args or output a one-line preview ever looks at, in
* UTF-16 units. A row renders this on every mount, and an MCP tool's output
* has no upstream size cap, so a preview must not walk a multi-megabyte string.
*/
const SCAN_LIMIT = 4096;

/** Largest text parsed as JSON for a shape summary; bigger payloads read as text. */
const JSON_PARSE_LIMIT = 256 * 1024;

/**
* Clamps to `max` code points with a trailing ellipsis (never splits a pair).
* Only a bounded head of the text is split into code points.
*/
export function clampChars(text: string, max: number): string {
const chars = [...text];
if (chars.length <= max) return text;
return `${chars.slice(0, Math.max(0, max - 1)).join("")}${ELLIPSIS}`;
if (text.length <= max) return text;
// A code point is at most two units, so more than `2 * max + 2` units is
// certainly more than `max` code points; a shorter text is checked exactly.
const head = [...text.slice(0, 2 * max + 2)];
if (head.length <= max) return text;
return `${head.slice(0, Math.max(0, max - 1)).join("")}${ELLIPSIS}`;
}

/** Folds every line break and run of whitespace into one space. */
function flatten(text: string): string {
return text.replace(/\s+/g, " ").trim();
}

/** Flattens and clamps a long text, reading only its first `SCAN_LIMIT` units. */
function preview(text: string, max: number): string {
if (text.length <= SCAN_LIMIT) return clampChars(flatten(text), max);
const flat = flatten(text.slice(0, SCAN_LIMIT));
const clamped = clampChars(flat, max);
// More text follows what was read, so the preview always says it was cut.
return clamped === flat ? `${clampChars(flat, Math.max(0, max - 1))}${ELLIPSIS}` : clamped;
}

/** "N lines" for a body argument — the count the TUI's card shows. */
function lineCount(text: string): number {
if (text === "") return 0;
Expand All @@ -56,14 +81,15 @@ export function summarizeArgs(
const maxKeys = options.maxKeys ?? 4;
const maxValueChars = options.maxValueChars ?? 60;
if (!rawArgs || rawArgs.trim() === "") return "";
if (rawArgs.length > JSON_PARSE_LIMIT) return preview(rawArgs, maxValueChars);
let parsed: unknown;
try {
parsed = JSON.parse(rawArgs);
} catch {
return clampChars(flatten(rawArgs), maxValueChars);
return preview(rawArgs, maxValueChars);
}
if (!isRecord(parsed)) {
return clampChars(flatten(rawArgs), maxValueChars);
return preview(rawArgs, maxValueChars);
}
const entries = Object.entries(parsed);
const shown = entries.slice(0, maxKeys).map(([key, value]) => {
Expand All @@ -72,7 +98,7 @@ export function summarizeArgs(
return `${key}: <${lines} line${lines === 1 ? "" : "s"}>`;
}
if (typeof value === "string") {
return `${key}: ${clampChars(flatten(value), maxValueChars)}`;
return `${key}: ${preview(value, maxValueChars)}`;
}
let rendered: string;
try {
Expand Down Expand Up @@ -108,7 +134,7 @@ export function summarizeResult(
if (!output) return "";
const trimmed = output.trim();
if (trimmed === "") return "";
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
if (trimmed.length <= JSON_PARSE_LIMIT && (trimmed.startsWith("{") || trimmed.startsWith("["))) {
try {
const parsed: unknown = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
Expand All @@ -125,7 +151,7 @@ export function summarizeResult(
// Not JSON after all — fall through to the text preview.
}
}
return clampChars(flatten(trimmed), maxChars);
return preview(trimmed, maxChars);
}

export interface FriendlyToolName {
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/features/chat/transcript-alignment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,21 @@ describe("tool call list", () => {
window.localStorage.setItem("studio.profile.expand-details", "expanded");
const preview = vi.fn();
render(<ToolCallList onPreview={preview} tools={[mcp]} />);
fireEvent.click(screen.getByRole("button", { name: "Open GitHub · Issue write details" }));
fireEvent.click(
screen.getByRole("button", { name: "Open GitHub · Issue write details, running" }),
);
expect(preview).toHaveBeenCalledWith(mcp);
});

it("names a call's status in its label, so a failed call is not announced like a finished one", () => {
window.localStorage.setItem("studio.profile.expand-details", "expanded");
render(
<ToolCallList onPreview={vi.fn()} tools={[{ ...mcp, isError: true, output: "denied" }]} />,
);
expect(
screen.getByRole("button", { name: "Open GitHub · Issue write details, failed" }),
).toBeTruthy();
});
});

describe("tool panel", () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/features/chat/turn-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ export const TURN_STAT_CACHE_FLOOR = 0.1;

/**
* A model's token count as the compact live-usage label ("1.2k", "3.4M");
* under 1,000 the exact integer. The daemon reports token counts as
* arbitrary-precision strings; converting through BigInt first avoids a
* silent precision loss before handing off to the shared number formatter.
* under 1,000 the exact integer. The daemon reports token counts as strings.
* `BigInt` rejects anything that is not an integer, so a malformed count
* fails loudly; counts beyond 2^53 lose precision in the `Number`, which a
* compact label does not need. The one token formatter for the chat UI.
*/
export function formatTokens(value: string): string {
return formatTokensNumber(Number(BigInt(value)));
Expand Down
Loading
Loading