Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 (
<div class="group/session relative flex h-7 min-w-0 items-center rounded-sm">
Expand All @@ -1096,14 +1101,9 @@ function SessionDropdownRow(props: {
class={SESSION_DROPDOWN_ROW}
onClick={() => props.onOpen(props.session)}
>
<SessionTabStatusDot
status={sessionTabStatus({
loading: status.loading(),
needsAttention: status.needsAttention(),
hasError: status.hasError(),
unread: status.unread(),
})}
/>
<Show when={props.isOpenTab}>
<SessionTabStatusDot status={dotStatus()} />
</Show>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap">
{title()}
</span>
Expand Down
48 changes: 14 additions & 34 deletions packages/app/src/design-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
18 changes: 9 additions & 9 deletions packages/app/src/pages/layout/session-tab-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import { describe, expect, test } from "bun:test"
import { sessionTabStatus } from "./session-tab-status"

describe("sessionTabStatus precedence", () => {
test("blocked beats busya spinner must not hide a question", () => {
expect(sessionTabStatus({ loading: true, needsAttention: true, unread: true, hasError: true })).toBe("attention")
test("error is loudestred 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")
})
})
40 changes: 11 additions & 29 deletions packages/app/src/pages/layout/session-tab-status.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,31 @@
// 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
needsAttention: boolean
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<SessionTabStatus, { color: string; label: string }> = {
// 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
Expand All @@ -64,8 +48,6 @@ export function StatusDot(props: { color: string; label: string; status?: string
<span
aria-hidden="true"
class="block size-1.5 rounded-full"
// No ring. Each tone is picked to clear 3:1 against its own ground
// unaided, so an edge would only mute the hue that carries the meaning.
style={{ background: props.color }}
/>
</span>
Expand Down
Loading