From b5f556f3a453f7ae00d2dbd826cb890eedfe098e Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:23:24 +0100 Subject: [PATCH 01/16] Derive the expanded document from the desktop Chat choice Co-Authored-By: Claude Opus 5.5 --- apps/web/src/workspace-model.test.ts | 13 +++++++++++++ apps/web/src/workspace-model.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index c95c9e080..27f9efa0e 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -140,6 +140,19 @@ describe("adaptive workspace", () => { separatorVisible: false, }); }); + + it("expands the document only when split Chat is hidden", () => { + let hidden: WorkspaceState = { chatOpen: false, desktopChatOpen: false }; + let shown: WorkspaceState = { chatOpen: false, desktopChatOpen: true }; + let unset: WorkspaceState = { chatOpen: false, desktopChatOpen: undefined }; + + expect(presentWorkspace(hidden, "split", "plan").documentExpanded).toBe(true); + expect(presentWorkspace(shown, "split", "plan").documentExpanded).toBe(false); + expect(presentWorkspace(unset, "split", "plan").documentExpanded).toBe(false); + expect(presentWorkspace(hidden, "compact", "plan").documentExpanded).toBe(false); + expect(presentWorkspace({ ...hidden, chatOpen: true }, "split", "plan").documentExpanded) + .toBe(false); + }); }); it("opens Chat by default and accepts only an explicit saved choice", () => { diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index ec7a9a7e7..83e953b7b 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -107,6 +107,7 @@ export function presentWorkspace( return { documentView, documentVisible, + documentExpanded: mode === "split" && !chatVisible, chatVisible, separatorVisible: mode === "split" && chatVisible, }; From 04964a88ea8a1ed2e4e44f8f7bdd0d4127d24ac1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:57 +0100 Subject: [PATCH 02/16] Replace the Chat header with an expand control on the tab row Co-Authored-By: Claude Opus 5.5 --- apps/web/src/hosted.test.ts | 63 ++++++++ apps/web/src/icon-assets.test.ts | 6 +- apps/web/src/theme.css | 41 ------ apps/web/src/theme.test.ts | 6 - apps/web/src/workspace-feedback.test.ts | 91 ++++-------- apps/web/src/workspace.tsx | 185 +++++++++--------------- packages/icons/src/index.ts | 2 + packages/icons/src/line.tsx | 24 +++ 8 files changed, 189 insertions(+), 229 deletions(-) diff --git a/apps/web/src/hosted.test.ts b/apps/web/src/hosted.test.ts index 1df52f010..8cdbe405c 100644 --- a/apps/web/src/hosted.test.ts +++ b/apps/web/src/hosted.test.ts @@ -65,6 +65,12 @@ function detail( }; } +let toolbarOf = (markup: string) => + markup.slice( + markup.indexOf('data-document-toolbar="true"'), + markup.indexOf('data-document-view="plan"'), + ); + describe("hosted routes", () => { it("recognizes canonical document routes and rejects the removed research route", () => { expect(hostedRoute("/")).toEqual({ page: "repositories" }); @@ -348,6 +354,63 @@ describe("anchored child lifecycle", () => { expect(markup.indexOf(" { + let markup = renderToStaticMarkup(createElement(Workspace, { + ...splitProps, + state: { chatOpen: false, desktopChatOpen: true }, + })); + let toolbar = toolbarOf(markup); + + expect(markup).not.toContain("data-chat-header"); + expect(markup).not.toContain("data-chat-identity"); + expect(markup).not.toContain('aria-label="Hide chat"'); + expect(markup).toMatch(/

Chat<\/h2>/); + expect(toolbar).toContain('aria-label="Expand document"'); + expect(toolbar.indexOf("Expand document")).toBeLessThan(toolbar.indexOf("Document controls")); + expect(toolbar.indexOf("Document controls")).toBeLessThan(toolbar.indexOf("People here: ana")); + expect(markup).toContain('aria-label="Resize chat"'); + expect(markup.indexOf("Room header")).toBeLessThan(markup.indexOf("workspace-frame")); + expect(markup).not.toContain("data-document-expanded"); + expect(markup).toMatch(/class="workspace-root[^"]*"[^>]*style="--chat-width:\d+px"/); + }); + + it("trades Chat and its top bar for a Chat button when expanded", () => { + let markup = renderToStaticMarkup(createElement(Workspace, { + ...splitProps, + state: { chatOpen: false, desktopChatOpen: false }, + })); + let toolbar = toolbarOf(markup); + let slot = markup.match(/]*class="workspace-header-slot[^"]*"[^>]*>/)?.[0]; + + expect(markup).toContain('data-document-expanded="true"'); + expect(slot).toContain('inert=""'); + expect(slot).toContain('aria-hidden="true"'); + expect(toolbar).toContain('aria-label="Show chat, Planner working"'); + expect(toolbar).toContain(">Chat"); + expect(toolbar).not.toContain("Expand document"); + expect(toolbar.indexOf("Show chat")).toBeLessThan(toolbar.indexOf("Document controls")); + expect(markup).not.toContain('aria-label="Resize chat"'); + }); + it("gives a compact child all three workspace destinations", () => { let markup = renderToStaticMarkup(createElement(Workspace, { chat: createElement("div", null, "Child chat"), diff --git a/apps/web/src/icon-assets.test.ts b/apps/web/src/icon-assets.test.ts index bc779ae91..050c5125f 100644 --- a/apps/web/src/icon-assets.test.ts +++ b/apps/web/src/icon-assets.test.ts @@ -18,12 +18,14 @@ function sourceFiles(directory: string, found: string[] = []): string[] { return found; } -test("the sidebar and Chat share one panel-close asset", () => { +test("the sidebar owns the panel-close asset and the workspace uses line icons", () => { let sidebar = readFileSync(join(root, "project-sidebar.tsx"), "utf8"); let workspace = readFileSync(join(root, "workspace.tsx"), "utf8"); expect(sidebar).toContain("assets/icons/panel-close.svg"); - expect(workspace).toContain("assets/icons/panel-close.svg"); + expect(workspace).not.toContain("assets/icons/panel-close.svg"); + expect(workspace).toContain("ExpandIcon"); + expect(workspace).toContain("CollapseIcon"); expect(existsSync(join(root, "assets/figma/navigation/collapse.svg"))).toBe(false); expect(existsSync(join(root, "assets/icons/chat-close.svg"))).toBe(false); }); diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index af649b754..74a8a285f 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -501,10 +501,6 @@ body { } } -.chat-toggle-icon-sidebar { - opacity: 0; -} - .panel-header { block-size: var(--panel-header-height); } @@ -513,35 +509,6 @@ body { margin-inline-start: calc(-1 * var(--panel-header-control-offset)); } -.chat-toggle:is(:hover, :focus-visible) .chat-toggle-icon-default { - opacity: 0; -} - -.chat-toggle:is(:hover, :focus-visible) .chat-toggle-icon-sidebar { - opacity: 1; -} - -@media (hover: hover) and (pointer: fine) { - .chat-header-control { - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-out); - } - - .chat-header:is(:hover, :focus-within) .chat-header-control { - opacity: 1; - } -} - -@media (any-pointer: coarse) { - .chat-header-control { - opacity: 1; - } -} - -:root[data-motion-input="pointer"] .chat-toggle-icon { - transition: opacity var(--duration-fast) var(--ease-out); -} - :root[data-motion-input="pointer"] .motion-picker-option { transition: background-color var(--duration-fast) var(--ease-out), @@ -1723,14 +1690,6 @@ button.question-add:disabled { transition: none; } - .chat-header-control { - transition: none; - } - - :root[data-motion-input="pointer"] .chat-toggle-icon { - transition: none; - } - [data-chat-enter] { transition: none; } diff --git a/apps/web/src/theme.test.ts b/apps/web/src/theme.test.ts index ad0807ff0..57319a26a 100644 --- a/apps/web/src/theme.test.ts +++ b/apps/web/src/theme.test.ts @@ -341,12 +341,6 @@ describe("feedback motion", () => { expect(THEME).not.toContain("@keyframes feedback-"); }); - it("uses the short control transition for Chat hover glyphs", () => { - expect(THEME).toMatch( - /:root\[data-motion-input="pointer"\] \.chat-toggle-icon\s*{[^}]*opacity var\(--duration-fast\) var\(--ease-out\)/s, - ); - }); - it("leaves feedback opacity to the component", () => { for (let kind of ["icon", "count", "alert"]) { let settled = new RegExp( diff --git a/apps/web/src/workspace-feedback.test.ts b/apps/web/src/workspace-feedback.test.ts index 8a080ef0f..986a92815 100644 --- a/apps/web/src/workspace-feedback.test.ts +++ b/apps/web/src/workspace-feedback.test.ts @@ -2,67 +2,38 @@ import { expect, test } from "bun:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { ChatToggle } from "./workspace"; - -test("chat state swaps use purposeful icon feedback", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: false, - }), - ); - +import { SurfaceToggle } from "./workspace"; + +let render = (expanded: boolean, activity = { busy: false, unread: 0 }) => + renderToStaticMarkup(createElement(SurfaceToggle, { + activity, + controls: "chat", + expanded, + onToggle: () => {}, + })); + +test("the expand control is an icon button that names the document", () => { + let markup = render(false); + + expect(markup).toContain('aria-label="Expand document"'); + expect(markup).toContain('data-tooltip="Expand document"'); + expect(markup).toContain('aria-controls="chat"'); + expect(markup).not.toContain("aria-expanded"); + expect(markup).toContain("btn-icon"); expect(markup).toContain('data-motion-feedback="icon"'); - expect(markup).toContain("motion-feedback"); -}); - -test("the open chat header swaps its glyph without display changes", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: true, - swapOnHover: true, - }), - ); - - expect(markup.match(/class="chat-toggle-icon/g)).toHaveLength(2); - expect(markup).toContain('aria-label="Hide chat"'); - expect(markup).toContain("chat-toggle-icon-sidebar"); - expect(markup).toContain("rotate-180"); - expect(markup).not.toContain("group-hover:hidden"); - expect(markup).not.toContain("group-hover:block"); + expect(markup).not.toContain("panel-close.svg"); + expect(markup).not.toContain(">Chat<"); }); -test("the open chat control has a distinct close action", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: false, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: true, - }), - ); - - expect(markup).toContain('aria-label="Hide chat"'); - expect(markup).toContain('data-tooltip="Hide chat"'); - expect(markup).toContain('data-tooltip-verbatim=""'); - expect(markup).toContain("panel-close.svg"); -}); - -test("live busy feedback stays immediate", () => { - let markup = renderToStaticMarkup( - createElement(ChatToggle, { - activity: { busy: true, unread: 0 }, - controls: "chat", - onToggle: () => {}, - open: false, - }), - ); - - expect(markup).toContain('aria-label="Show chat, Planner working"'); - expect(markup).not.toContain('data-motion-feedback="count"'); +test("the restore control shows a Chat label and reports Planner activity", () => { + let busy = render(true, { busy: true, unread: 0 }); + let unread = render(true, { busy: false, unread: 2 }); + + expect(busy).toContain('aria-label="Show chat, Planner working"'); + expect(busy).toContain('aria-expanded="false"'); + expect(busy).toContain(">Chat"); + expect(busy).not.toContain('data-motion-feedback="count"'); + expect(unread).toContain('aria-label="Show chat, 2 unread"'); + expect(unread).toContain('data-motion-feedback="count"'); + expect(unread.match(/