diff --git a/apps/web/src/features/breakpoints.test.ts b/apps/web/src/features/breakpoints.test.ts index e949c38cb4..b5b2e4c2cd 100644 --- a/apps/web/src/features/breakpoints.test.ts +++ b/apps/web/src/features/breakpoints.test.ts @@ -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) => @@ -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", @@ -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", ]); }); diff --git a/apps/web/src/features/chat/chat-composer.alignment.test.tsx b/apps/web/src/features/chat/chat-composer.alignment.test.tsx index 102f85b8ca..be3f581b78 100644 --- a/apps/web/src/features/chat/chat-composer.alignment.test.tsx +++ b/apps/web/src/features/chat/chat-composer.alignment.test.tsx @@ -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(); + 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("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); + }); }); diff --git a/apps/web/src/features/chat/chat-session-controls.tsx b/apps/web/src/features/chat/chat-session-controls.tsx index e42879022c..9e686d314f 100644 --- a/apps/web/src/features/chat/chat-session-controls.tsx +++ b/apps/web/src/features/chat/chat-session-controls.tsx @@ -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; @@ -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}`; -} diff --git a/apps/web/src/features/chat/chat-workspace.integration.test.tsx b/apps/web/src/features/chat/chat-workspace.integration.test.tsx index 85e4a45aec..9acc7abebc 100644 --- a/apps/web/src/features/chat/chat-workspace.integration.test.tsx +++ b/apps/web/src/features/chat/chat-workspace.integration.test.tsx @@ -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)"); diff --git a/apps/web/src/features/chat/chat-workspace.tsx b/apps/web/src/features/chat/chat-workspace.tsx index 0cfb2e44f4..dcef48837f 100644 --- a/apps/web/src/features/chat/chat-workspace.tsx +++ b/apps/web/src/features/chat/chat-workspace.tsx @@ -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 } }), @@ -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 { diff --git a/apps/web/src/features/chat/queued-message-strip.tsx b/apps/web/src/features/chat/queued-message-strip.tsx index 52fed1a0e7..0f8b8d0235 100644 --- a/apps/web/src/features/chat/queued-message-strip.tsx +++ b/apps/web/src/features/chat/queued-message-strip.tsx @@ -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 @@ -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} /> diff --git a/apps/web/src/features/chat/session-surfaces.alignment.test.tsx b/apps/web/src/features/chat/session-surfaces.alignment.test.tsx index 44153ab2e5..4be3ec3d21 100644 --- a/apps/web/src/features/chat/session-surfaces.alignment.test.tsx +++ b/apps/web/src/features/chat/session-surfaces.alignment.test.tsx @@ -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 = [ diff --git a/apps/web/src/features/chat/tool-call-list.tsx b/apps/web/src/features/chat/tool-call-list.tsx index 1bdea171f2..f167f5cfdf 100644 --- a/apps/web/src/features/chat/tool-call-list.tsx +++ b/apps/web/src/features/chat/tool-call-list.tsx @@ -123,7 +123,7 @@ function ToolCallRow({
  • {controls.onPreview ? (