From 9a2c3b44a1bbc2b9d36857d880a7e3383e7331c2 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 13:23:35 +0545 Subject: [PATCH 1/6] Show agent work and unread replies around sidebar avatars --- apps/web/src/app.css | 144 ++++++------ apps/web/src/shell/bench-activity.ts | 2 + apps/web/src/shell/workbench-list.test.ts | 14 +- apps/web/src/shell/workbench-list.tsx | 63 ++++-- apps/web/test/workbench-list.test.tsx | 35 +++ packages/chat-ui/src/api.ts | 2 +- .../chat-ui/src/workbench-timeline-view.ts | 20 +- packages/chat/src/agent-turns.ts | 49 +++- packages/chat/src/room-messages.ts | 27 +++ packages/chat/src/routes.ts | 25 ++- packages/chat/src/workbench-activity.test.ts | 23 -- packages/chat/src/workbench-activity.ts | 14 -- packages/chat/src/workbench-reply-activity.ts | 46 ++++ .../chat/test/agent-turns.drizzle.test.ts | 89 ++++++++ packages/chat/test/chat-orchestrator.test.ts | 1 + packages/chat/test/routes.test.ts | 8 +- .../test/workbench-reply-activity.test.ts | 212 ++++++++++++++++++ 17 files changed, 618 insertions(+), 156 deletions(-) delete mode 100644 packages/chat/src/workbench-activity.test.ts delete mode 100644 packages/chat/src/workbench-activity.ts create mode 100644 packages/chat/src/workbench-reply-activity.ts create mode 100644 packages/chat/test/workbench-reply-activity.test.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 73ca57f03..f5596b3d2 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-stack span:nth-child(3) { - left: 0.9rem; - top: 0.2rem; - z-index: 1; +.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[data-agent="true"] { - background: var(--primary); - color: var(--primary-foreground); +.shell-ch-completion svg { + width: 0.6rem; + height: 0.6rem; +} + +@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,25 +1622,6 @@ 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; flex-direction: row; @@ -1650,6 +1631,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 +1641,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; diff --git a/apps/web/src/shell/bench-activity.ts b/apps/web/src/shell/bench-activity.ts index e25956a14..f4ff52242 100644 --- a/apps/web/src/shell/bench-activity.ts +++ b/apps/web/src/shell/bench-activity.ts @@ -74,11 +74,13 @@ export function useBenchActivity(tenantId: string | null): BenchActivityQuery { queryKey: tenantKeys.workbenches(key, "workbench"), enabled, queryFn: () => listWorkbenches(key, "workbench"), + refetchInterval: 2000, }); const chatsQuery = useQuery({ queryKey: tenantKeys.workbenches(key, "chat"), enabled, queryFn: () => listWorkbenches(key, "chat"), + refetchInterval: 2000, }); const routinesQuery = useQuery({ queryKey: tenantKeys.topLevelRuns(key), diff --git a/apps/web/src/shell/workbench-list.test.ts b/apps/web/src/shell/workbench-list.test.ts index 463bdb6e0..56c55c50e 100644 --- a/apps/web/src/shell/workbench-list.test.ts +++ b/apps/web/src/shell/workbench-list.test.ts @@ -49,14 +49,24 @@ describe("renamePayload", () => { }); describe("workbenchRowSignals", () => { + test("opening clears a ready reply but keeps an active orbit", () => { + expect( + workbenchRowSignals(workbench({ activity: "reply-ready" }), true) + .activity, + ).toBe("idle"); + expect( + workbenchRowSignals(workbench({ activity: "working" }), true).activity, + ).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, activity: "working", sharedLabel: "Acme" }), false, ); expect(signals.unread).toBe(3); - expect(signals.live).toBe(true); + expect(signals.activity).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..4c83c30bc 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, @@ -94,15 +95,15 @@ export function renamePayload( } /** - * Optional row signals (shared / live / time / unread). The platform's - * listing carries `unreadCount`/`lastActivityAt`/`live` when a mailbox + * Optional row signals (shared / activity / time / unread). The platform's + * listing carries `unreadCount`/`lastActivityAt`/`activity` when a mailbox * could be resolved, and `sharedLabel` for a conversation projected in via * bilateral trust. Render only when present; never invent counts or * timestamps. */ export type WorkbenchRowSignals = { readonly sharedLabel?: string; - readonly live?: boolean; + readonly activity?: Workbench["activity"]; readonly time?: string; readonly unread?: number; }; @@ -117,7 +118,7 @@ export type WorkbenchRowSignals = { export function workbenchRowSignals( workbench: Pick< Workbench, - "unreadCount" | "lastActivityAt" | "live" | "sharedLabel" + "unreadCount" | "lastActivityAt" | "activity" | "sharedLabel" >, isOpen: boolean, ): WorkbenchRowSignals { @@ -125,7 +126,14 @@ export function workbenchRowSignals( ...(workbench.sharedLabel !== undefined ? { sharedLabel: workbench.sharedLabel } : {}), - ...(workbench.live !== undefined ? { live: workbench.live } : {}), + ...(workbench.activity !== undefined + ? { + activity: + isOpen && workbench.activity === "reply-ready" + ? "idle" + : workbench.activity, + } + : {}), ...(workbench.lastActivityAt !== undefined ? { time: formatRelativeTime(workbench.lastActivityAt) } : {}), @@ -297,7 +305,7 @@ function WorkbenchRow({ const displayTitle = displayWorkbenchTitle(title, workbench.id) || CHAT_STRINGS.unnamedWorkbench; - const { sharedLabel, live, time, unread } = signals; + const { sharedLabel, activity, time, unread } = signals; const hasUnread = typeof unread === "number" && unread > 0; return ( @@ -312,25 +320,34 @@ function WorkbenchRow({ className="shell-ch-row" aria-current={active ? "true" : undefined} data-active={active ? "true" : undefined} + data-unread={hasUnread ? "true" : undefined} onClick={onSelect} > - {/* The workbench's own initial for multi-party channels, or - the Corbit avatar for agent DM conversations. */} -