diff --git a/DESIGN.md b/DESIGN.md index d6e905f6b..10e4bef78 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,6 +12,16 @@ reimplementing it in this repo. The shell has exactly one nav surface: the sidebar. There is no second nav column and no collapse affordance — it is always present, at a fixed width. +Conversation rows keep a fixed avatar footprint and two compact text lines: +the name with its timestamp aligned on the right, then the latest message +preview. Selection uses a quiet neutral fill; the name stays in the normal +foreground color. While an agent works, a thin orange orbit surrounds the +avatar, including when that conversation is selected. A completed, unread +agent reply replaces the orbit with a small green check labeled "Reply +ready". Opening the conversation clears that check; idle, read, failed, +and cancelled turns show no completion badge. Reduced motion keeps the +working indicator as a static orange arc. + Top to bottom: 1. **Brand row** — logo mark and a "New workbench" button (`+`) that diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 2fd9883ad..be59b278d 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1464,100 +1464,100 @@ select:disabled, color: var(--shell-panel-faint); } -/* Mock .ch rows in the sidebar: pad 0.32/0.42, square. */ .shell-ch-row { display: grid; - grid-template-columns: auto 1fr auto; - gap: 0.55rem; - align-items: center; + grid-template-columns: 2rem minmax(0, 1fr) auto; + column-gap: 0.65rem; + align-items: start; width: 100%; - padding: 0.32rem 0.42rem; - border-radius: 0; - color: var(--muted-foreground, var(--ui-fg-muted)); + min-height: 3.75rem; + padding: 0.65rem 0.6rem; + border-radius: var(--radius); + color: var(--foreground); text-align: left; border: 0; background: transparent; cursor: pointer; - transition: - background 120ms ease, - color 120ms ease; + transition: background var(--duration-micro) var(--ease-out); } .shell-ch-row:hover { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - color: var(--foreground, var(--ui-fg)); + background: color-mix(in srgb, var(--foreground) 4%, transparent); } -/* Brand rule: grey is for text/structure, not container fills — the active - row stays on ground and reads through a 2px orange left edge instead of a - band. */ .shell-ch-row[aria-current="true"], .shell-ch-row[data-active="true"] { - background: var(--background); - color: var(--shell-accent); - box-shadow: inset 2px 0 0 0 var(--shell-accent); + background: color-mix(in srgb, var(--foreground) 6%, transparent); } .shell-ch-row[aria-current="true"] .shell-ch-name, -.shell-ch-row[data-active="true"] .shell-ch-name { +.shell-ch-row[data-unread="true"] .shell-ch-name { font-weight: 650; } -/* A 5% foreground tint is a much quieter cue on a near-black row than the - same percentage on a near-white one perceptually, so dark mode's hover - gets a stronger mix to read at the same visible weight. */ -.dark .shell-ch-row:hover { - background: color-mix(in srgb, var(--foreground) 9%, transparent); -} - -.shell-ch-stack { - display: flex; - width: 1.35rem; - height: 1.4rem; +.shell-ch-avatar { position: relative; - flex-shrink: 0; + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + align-self: center; } -.shell-ch-stack span { - position: absolute; - width: 1.2rem; - height: 1.2rem; - border-radius: 50%; - border: 1.5px solid var(--background); +.shell-ch-initial { display: grid; place-items: center; - font-size: 0.55rem; - font-weight: 700; - /* --muted sits within ~1.2:1 of --background in dark (near-black on - pure black), leaving the chip indistinguishable from the page. A - foreground tint reads at the same visible strength in both themes - since the page it's mixed against is pure white/black either way. */ + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; background: color-mix(in srgb, var(--foreground) 10%, transparent); - color: var(--foreground); + font-size: 0.75rem; + font-weight: 650; } -.shell-ch-stack span:nth-child(1) { - left: 0; - top: 0.2rem; - z-index: 3; +.shell-ch-orbit { + position: absolute; + inset: 0; + border: 1.5px solid transparent; + border-top-color: var(--primary); + border-right-color: var(--primary); + border-bottom-color: var(--primary); + border-radius: 50%; + animation: shell-ch-orbit 1.4s linear infinite; } -.shell-ch-stack span:nth-child(2) { - left: 0.45rem; - top: 0.2rem; - z-index: 2; - background: color-mix(in srgb, var(--foreground) 16%, transparent); +@keyframes shell-ch-orbit { + to { + transform: rotate(360deg); + } +} + +.shell-ch-completion { + position: absolute; + right: -0.05rem; + bottom: -0.05rem; + display: grid; + place-items: center; + width: 0.85rem; + height: 0.85rem; + border: 2px solid var(--background); + border-radius: 50%; + background: var(--ok); + color: var(--background); } -.shell-ch-stack span:nth-child(3) { - left: 0.9rem; - top: 0.2rem; - z-index: 1; +.shell-ch-completion svg { + width: 0.6rem; + height: 0.6rem; } -.shell-ch-stack span[data-agent="true"] { - background: var(--primary); - color: var(--primary-foreground); +@media (prefers-reduced-motion: reduce) { + .shell-ch-orbit { + animation: none; + } + .shell-ch-row { + transition: none; + } } /* Agent-DM identity avatar: zero radius per brand rule, distinct from the @@ -1606,7 +1606,7 @@ select:disabled, } .shell-ch-name { - font-weight: 580; + font-weight: 550; font-size: 0.86rem; overflow: hidden; text-overflow: ellipsis; @@ -1622,27 +1622,9 @@ select:disabled, color: var(--muted-foreground, var(--ui-fg-muted)); } -.shell-ch-live { - width: 0.4rem; - height: 0.4rem; - border-radius: 0; - background: var(--ok); - flex-shrink: 0; - animation: shell-ch-pulse 2s ease infinite; -} - -@keyframes shell-ch-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 40%, transparent); - } - 50% { - box-shadow: 0 0 0 4px transparent; - } -} - .shell-ch-right { display: flex; + min-width: 1.6rem; flex-direction: row; align-items: center; gap: 0.35rem; @@ -1650,6 +1632,8 @@ select:disabled, } .shell-ch-time { + line-height: 1.3rem; + white-space: nowrap; font-size: 0.66rem; color: var(--muted-foreground, var(--ui-fg-muted)); } @@ -1658,6 +1642,7 @@ select:disabled, workbench's message preview, or an unreachable agent's join caption. */ .shell-ch-preview { display: block; + margin-top: 0.15rem; font-size: 0.72rem; color: var(--muted-foreground, var(--ui-fg-muted)); overflow: hidden; @@ -1666,9 +1651,9 @@ select:disabled, } .shell-ch-row-wrap { + position: relative; display: flex; align-items: stretch; - gap: 0.15rem; width: 100%; } @@ -1678,17 +1663,26 @@ select:disabled, } .shell-ch-row-menu { + position: absolute; + top: 0.5rem; + right: 0.6rem; opacity: 0; - flex-shrink: 0; - align-self: center; transition: opacity 120ms ease; } .shell-ch-row-wrap:hover .shell-ch-row-menu, -.shell-ch-row-wrap:focus-within .shell-ch-row-menu { +.shell-ch-row-wrap:focus-within .shell-ch-row-menu, +.shell-ch-row-wrap:has([aria-expanded="true"]) .shell-ch-row-menu, +.shell-ch-row-menu .chat-sidebar-row-menu-trigger { opacity: 1; } +.shell-ch-row-wrap:hover .shell-ch-time, +.shell-ch-row-wrap:focus-within .shell-ch-time, +.shell-ch-row-wrap:has([aria-expanded="true"]) .shell-ch-time { + visibility: hidden; +} + /* Mock v2 .search: 2rem, square, on the ground — shared by every band. */ .shell-panel-search { display: flex; diff --git a/apps/web/src/shell/workbench-list.test.ts b/apps/web/src/shell/workbench-list.test.ts index 463bdb6e0..6c42751be 100644 --- a/apps/web/src/shell/workbench-list.test.ts +++ b/apps/web/src/shell/workbench-list.test.ts @@ -49,14 +49,23 @@ describe("renamePayload", () => { }); describe("workbenchRowSignals", () => { + test("opening clears a ready reply but keeps an active orbit", () => { + expect( + workbenchRowSignals(workbench({ live: "reply-ready" }), true).live, + ).toBe("idle"); + expect(workbenchRowSignals(workbench({ live: "working" }), true).live).toBe( + "working", + ); + }); + test("passes through only the signals the platform actually sent", () => { expect(workbenchRowSignals(workbench(), false)).toEqual({}); const signals = workbenchRowSignals( - workbench({ unreadCount: 3, live: true, sharedLabel: "Acme" }), + workbench({ unreadCount: 3, live: "working", sharedLabel: "Acme" }), false, ); expect(signals.unread).toBe(3); - expect(signals.live).toBe(true); + expect(signals.live).toBe("working"); expect(signals.sharedLabel).toBe("Acme"); }); diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index 0491101b2..3d482d8e8 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -26,6 +26,7 @@ import type { Workbench } from "@corbits/chat-ui"; import { useQueryClient } from "@tanstack/react-query"; import { ChatCircle, + Check, DotsThree, Hash, MagnifyingGlass, @@ -102,7 +103,7 @@ export function renamePayload( */ export type WorkbenchRowSignals = { readonly sharedLabel?: string; - readonly live?: boolean; + readonly live?: Workbench["live"]; readonly time?: string; readonly unread?: number; }; @@ -125,7 +126,14 @@ export function workbenchRowSignals( ...(workbench.sharedLabel !== undefined ? { sharedLabel: workbench.sharedLabel } : {}), - ...(workbench.live !== undefined ? { live: workbench.live } : {}), + ...(workbench.live !== undefined + ? { + live: + isOpen && workbench.live === "reply-ready" + ? "idle" + : workbench.live, + } + : {}), ...(workbench.lastActivityAt !== undefined ? { time: formatRelativeTime(workbench.lastActivityAt) } : {}), @@ -299,6 +307,12 @@ function WorkbenchRow({ displayWorkbenchTitle(title, workbench.id) || CHAT_STRINGS.unnamedWorkbench; const { sharedLabel, live, time, unread } = signals; const hasUnread = typeof unread === "number" && unread > 0; + const liveLabel = + live === "working" + ? "Agent working" + : live === "reply-ready" + ? "Reply ready" + : ""; return (
- {/* The workbench's own initial for multi-party channels, or - the Corbit avatar for agent DM conversations. */} -