diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index edf489bc2..98452c872 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -22,8 +22,6 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer, ServerConnection } from "@/context/server" import { useSettings } from "@/context/settings" -import { SessionTabStatusDot, sessionTabStatus } from "@/pages/layout/session-tab-status" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { useSync } from "@/context/sync" import { sessionHasOpenTab, useTabs } from "@/context/tabs" import { useTerminal } from "@/context/terminal" @@ -34,6 +32,8 @@ import { decode64 } from "@/utils/base64" import { fileManagerApp } from "@/utils/file-manager" import { Persist, persisted } from "@/utils/persist" import { sessionTitle } from "@/utils/session-title" +import { SessionTabStatusDot, sessionTabStatus } from "@/pages/layout/session-tab-status" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { statusTriggerVisibility } from "../status-popover-model" import { useServerSync } from "@/context/server-sync" @@ -1078,15 +1078,20 @@ function SessionDropdownRow(props: { }) { const language = useLanguage() const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) - // The dot used to mean "this session is an open tab" and was therefore always - // the same green. It now reports the SAME status the tab strip shows, computed - // from the same hook, so a session reads identically in both places. const rowServer = useServer() const status = useSessionTabAvatarState( () => rowServer.key, () => props.session.directory, () => props.session.id, ) + const dotStatus = createMemo(() => + sessionTabStatus({ + loading: status.loading(), + needsAttention: status.needsAttention(), + hasError: status.hasError(), + unread: status.unread(), + }), + ) return (
@@ -1096,14 +1101,9 @@ function SessionDropdownRow(props: { class={SESSION_DROPDOWN_ROW} onClick={() => props.onOpen(props.session)} > - + + + {title()} diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index d927050f0..4f16cabdf 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -99,48 +99,28 @@ } /* ── session status dots ── - SEMANTIC, not brand: a status dot's job is to say what state a session is in, - so it sits outside the yellow system. Brand yellow was tried for "running" and - is unusable here — 1.27:1 on white, invisible without a crutch. No rings - either: each tone clears >=3:1 against its own ground unaided, and an edge - would only mute the hue that carries the meaning. - - Chosen by simulating deuteranopia, protanopia and tritanopia (Vienot 1999) - over a candidate search. Two constraints beyond contrast: - - 1. worst pairwise CIELAB separation is maximised across all three CVD types; - 2. idle and done must differ in LIGHTNESS, not just chroma. An earlier set had - them at L* 48 vs 44 — same lightness, separated only by saturation, which - is precisely what reads as "a slightly green grey". They now sit ~23 L* - apart in both schemes. - - Colour is never the only signal: each dot carries role="img" plus an - aria-label and title naming the state. - - light: worst CVD dE 13.7 · idle/done dE 73.9 (dL* 24) · min contrast 3.19:1 - dark: worst CVD dE 31.3 · idle/done dE 72.3 (dL* 22) · min contrast 3.71:1 */ + Four states: green (done, unread), grey (done, seen), yellow (running), + red (error). Colour is never the only signal: each dot carries + role="img" + aria-label. */ :root, [data-color-scheme="light"] { - --status-idle: #52525b; /* L* 35 · 7.73:1 — neutral, no hue, so it stays quiet */ - --status-running: #1d4ed8; /* L* 39 · 6.70:1 — blue reads as in-progress */ - --status-attention: #d97706; /* L* 60 · 3.19:1 — amber, needs you */ - --status-error: #ef4444; /* L* 55 · 3.76:1 */ - --status-done: #16a34a; /* L* 59 · 3.30:1 — vivid, and 24 L* clear of idle */ + --status-idle: #a1a1aa; /* grey — done and seen */ + --status-running: #ca8a04; /* yellow-600 — working */ + --status-done: #16a34a; /* green-600 — done, unread */ + --status-error: #dc2626; /* red-600 */ } [data-color-scheme="dark"] { - --status-idle: #8a8a8a; /* L* 57 · 5.56:1 */ - --status-running: #2563eb; /* L* 46 · 3.71:1 */ - --status-attention: #fbbf24; /* L* 81 · 11.49:1 */ - --status-error: #dc2626; /* L* 48 · 3.97:1 */ - --status-done: #4ade80; /* L* 79 · 11.01:1 */ + --status-idle: #71717a; /* grey */ + --status-running: #eab308; /* yellow-500 */ + --status-done: #4ade80; /* green-400 */ + --status-error: #ef4444; /* red-500 */ } @media (prefers-color-scheme: dark) { :root:not([data-color-scheme="light"]) { - --status-idle: #8a8a8a; - --status-running: #2563eb; - --status-attention: #fbbf24; - --status-error: #dc2626; + --status-idle: #71717a; + --status-running: #eab308; --status-done: #4ade80; + --status-error: #ef4444; } } diff --git a/packages/app/src/pages/layout/session-tab-status.test.ts b/packages/app/src/pages/layout/session-tab-status.test.ts index 762be7090..4e4e89ba9 100644 --- a/packages/app/src/pages/layout/session-tab-status.test.ts +++ b/packages/app/src/pages/layout/session-tab-status.test.ts @@ -2,19 +2,19 @@ import { describe, expect, test } from "bun:test" import { sessionTabStatus } from "./session-tab-status" describe("sessionTabStatus precedence", () => { - test("blocked beats busy — a spinner must not hide a question", () => { - expect(sessionTabStatus({ loading: true, needsAttention: true, unread: true, hasError: true })).toBe("attention") + test("error is loudest — red even when running", () => { + expect(sessionTabStatus({ loading: true, needsAttention: true, unread: true, hasError: true })).toBe("error") }) - test("running beats error and unread", () => { - expect(sessionTabStatus({ loading: true, needsAttention: false, unread: true, hasError: true })).toBe("running") + test("running when no error — yellow", () => { + expect(sessionTabStatus({ loading: true, needsAttention: false, unread: false, hasError: false })).toBe("running") }) - test("error beats plain unread, which would otherwise swallow it", () => { - expect(sessionTabStatus({ loading: false, needsAttention: false, unread: true, hasError: true })).toBe("error") - }) - test("unread when nothing louder", () => { + test("done when unread — green", () => { expect(sessionTabStatus({ loading: false, needsAttention: false, unread: true, hasError: false })).toBe("done") }) - test("idle", () => { + test("done when needs attention — green", () => { + expect(sessionTabStatus({ loading: false, needsAttention: true, unread: false, hasError: false })).toBe("done") + }) + test("idle when done and seen — grey", () => { expect(sessionTabStatus({ loading: false, needsAttention: false, unread: false, hasError: false })).toBe("idle") }) }) diff --git a/packages/app/src/pages/layout/session-tab-status.tsx b/packages/app/src/pages/layout/session-tab-status.tsx index c7c426d36..8e2bc988c 100644 --- a/packages/app/src/pages/layout/session-tab-status.tsx +++ b/packages/app/src/pages/layout/session-tab-status.tsx @@ -1,15 +1,9 @@ // AMICODE: the per-session tab status dot. // -// Replaces the Amico mark that used to sit on every tab. The mark was identical -// on all of them, so it cost a slot and carried no information; a dot in that -// slot says what the session is doing at a glance down the whole tab strip. -// -// The states are the ones the session store can actually report. There is no -// per-session error signal today — `globalStore.error` is server-wide — so an -// "error" tone is deliberately absent rather than added as a dot that never -// lights. When a per-session failure signal exists, add it here as `danger`. +// Four states: green (done, unread), grey (done, seen), yellow (running), +// red (error). -export type SessionTabStatus = "idle" | "running" | "attention" | "error" | "done" +export type SessionTabStatus = "idle" | "running" | "done" | "error" export function sessionTabStatus(input: { loading: boolean @@ -17,31 +11,21 @@ export function sessionTabStatus(input: { unread: boolean hasError?: boolean }): SessionTabStatus { - // Blocked beats busy: a permission or question needs the user before anything - // else can happen, so it must not be masked by a spinner. An error outranks - // plain unread, which would otherwise swallow it — both come from the same - // unseen-notification index. - if (input.needsAttention) return "attention" - if (input.loading) return "running" + // Error is loudest — red dot demands action. if (input.hasError) return "error" - if (input.unread) return "done" + // Running means the agent is working — yellow. + if (input.loading) return "running" + // Done + unread (or needs attention) — green. + if (input.needsAttention || input.unread) return "done" + // Done + already seen — grey. return "idle" } -// These come from --status-* in design-polish.css: a SEMANTIC scale, deliberately -// outside the brand yellow system. A dot's job is to say what state a session is -// in, and it needs 3:1 (graphical object), not the 4.5:1 body-text minimum that -// made the earlier tones muddy. The values were chosen by colour-blindness -// simulation — see the comment on --status-idle. const TONE: Record = { - // quiet on purpose — it still holds the slot so the strip stays aligned - idle: { color: "var(--status-idle)", label: "Idle" }, - // blue, the conventional in-progress signal — NOT the brand yellow, which is - // 1.27:1 on white and cannot be made to work as a dot there + idle: { color: "var(--status-idle)", label: "Done" }, running: { color: "var(--status-running)", label: "Working" }, - attention: { color: "var(--status-attention)", label: "Needs you" }, + done: { color: "var(--status-done)", label: "Done — unread" }, error: { color: "var(--status-error)", label: "Error" }, - done: { color: "var(--status-done)", label: "Finished — unread" }, } /** The dot itself. Exported so every surface that shows session state — the tab @@ -64,8 +48,6 @@ export function StatusDot(props: { color: string; label: string; status?: string