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