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
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node ../scripts/run-suites.mjs",
"test:render": "node test/statusMark.render.test.mjs",
"test:render": "node test/statusMark.render.test.mjs && node test/stateLogo.render.test.mjs",
"preview": "vite preview"
},
"dependencies": {
Expand Down
5 changes: 2 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import SettingsView from './components/SettingsView';
import NewSession from './components/NewSession';
import LayoutPicker from './components/LayoutPicker';
import ShareDialog from './components/ShareDialog';
import Logo from './components/Logo';
import StateLogo from './components/StateLogo';
import Overview from './components/Overview';
import Locked from './components/Locked';
import BackupBanner from './components/BackupBanner';
Expand Down Expand Up @@ -1122,8 +1122,7 @@ export default function App() {
<div className="mchips">
{groupSessions.map((s, i) => (
<button key={s.id} className={`mchip${i === page ? ' on' : ''}`} title={s.name} onClick={() => setPage(i)}>
<Logo cli={s.cli} size={13} tint={cliMap[s.cli]?.color} />
<span className={`status ${s.state}`} />
<StateLogo cli={s.cli} state={s.state} size={13} tint={cliMap[s.cli]?.color} />
</button>
))}
</div>
Expand Down
16 changes: 8 additions & 8 deletions web/src/components/Locked.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from 'react';
import type { SessionState } from '../types';
import { SlidersGlyph, SunGlyph, GridGlyph, PlusGlyph, AmMark } from './icons';
import Logo from './Logo';
import StateLogo from './StateLogo';

// A frozen, slightly dimmed replica of the real sidebar so the install page
// looks like the product it unlocks. Pure decoration: no handlers, no state.
type MockSession = { name: string; cli: string; tint: string; state: string };
type MockSession = { name: string; cli: string; tint: string; state: SessionState };
type MockItem = MockSession | { group: string; members: MockSession[] };
const MOCK: MockItem[] = [
{ group: 'research', members: [
Expand All @@ -19,8 +21,7 @@ const MOCK: MockItem[] = [
function MockRow({ s, nested }: { s: MockSession; nested?: boolean }) {
return (
<div className={`row session${nested ? ' nested' : ''}`}>
<span className={`status ${s.state}`} />
<Logo cli={s.cli} size={12} tint={s.tint} />
<StateLogo cli={s.cli} state={s.state} size={12} tint={s.tint} />
<span className="name">{s.name}</span>
</div>
);
Expand All @@ -39,7 +40,6 @@ function MockSidebar() {
</div>
<div className="ov-fixed">
<div className="row ov-row">
<span className="status ov-spacer" />
<span className="ov-tile"><GridGlyph /></span>
<span className="name">overview</span>
</div>
Expand All @@ -63,10 +63,10 @@ function MockSidebar() {
<span className="btn-ghost"><Logo cli="files" size={14} /> Files</span>
</div>
<div className="legend">
<span><span className="status working" /> working</span>
<span><span className="status waiting" /> your turn</span>
<span><span className="status idle" /> idle</span>
<span><span className="status stopped" /> stopped</span>
<span><StateLogo cli="shell" state="working" size={12} tint="var(--muted)" /> working</span>
<span><StateLogo cli="shell" state="waiting" size={12} tint="var(--muted)" /> your turn</span>
<span><StateLogo cli="shell" state="idle" size={12} tint="var(--muted)" /> idle</span>
<span><StateLogo cli="shell" state="stopped" size={12} tint="var(--muted)" /> stopped</span>
</div>
</aside>
);
Expand Down
8 changes: 7 additions & 1 deletion web/src/components/Logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ import { FolderGlyph, ListGlyph, RemoteGlyph } from './icons';
const INVERT_DARK = new Set(['shell', 'opencode']);
const INVERT_LIGHT = new Set(['hermes']);

// A state frame must follow the tile exactly, so expose the two pieces of tile
// geometry rather than making that component repeat Logo's sizing arithmetic.
export const logoTilePadding = (size: number, tint?: string) =>
tint ? Math.max(3, Math.round(size * 0.18)) : 0;
export const logoTileRadius = (size: number) => Math.max(4, Math.round(size * 0.28));

export default function Logo({ cli, size = 18, tint }: { cli: string; size?: number; tint?: string }) {
// `tint` (the CLI's brand color) wraps the glyph in a softly tinted tile so
// rows/panes read as "Claude / Codex / Gemini" at a glance. `size` stays the
// glyph size; the tile adds its own padding around it.
const tile = tint
? { padding: Math.max(3, Math.round(size * 0.18)), background: `color-mix(in srgb, ${tint} 13%, transparent)`, borderRadius: Math.max(4, Math.round(size * 0.28)) }
? { padding: logoTilePadding(size, tint), background: `color-mix(in srgb, ${tint} 13%, transparent)`, borderRadius: logoTileRadius(size) }
: undefined;

// Files uses the same minimal folder glyph as the file tree.
Expand Down
5 changes: 2 additions & 3 deletions web/src/components/RemotePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { RemoteInfo, RemoteMessage, Session } from '../types';
import { REMOTE_STATE_LABEL } from '../types';
import * as api from '../api';
import Logo from './Logo';
import StateLogo from './StateLogo';
import { renderMarkdown } from '../lib/markdown';
import { groupLabel, sessionTitle } from '../lib/sessionTitle';
import { BackGlyph, CloseGlyph, StopGlyph, PlayGlyph, ShareGlyph, AckGlyph } from './icons';
Expand Down Expand Up @@ -222,8 +222,7 @@ export default function RemotePane({
<BackGlyph />
</button>
)}
<Logo cli="remote" size={16} tint="#5ec2e0" />
<span className={`status ${state}`} title={stateLabel} />
<StateLogo cli="remote" state={state} size={16} tint="#5ec2e0" title={stateLabel} />
</div>
{editing ? (
<input
Expand Down
21 changes: 12 additions & 9 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { Cli, MoveTarget, Group, Session, Tree } from '../types';
import { STATE_LABEL, REMOTE_STATE_LABEL, isPassive, isRemote } from '../types';
import Logo from './Logo';
import StateLogo from './StateLogo';
import NewSession from './NewSession';
import FolderPicker from './FolderPicker';
import Attachments from './Attachments';
Expand Down Expand Up @@ -461,10 +462,12 @@ export default function Sidebar({
onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, s.name); }}
title={s.path ? `${s.name} · ${s.path}` : s.name}
>
{/* The same three lights, but for a remote agent they mean connection,
not process: working / listening / not connected. */}
<span className={`status ${s.state}`} title={(isRemote(s.cli) ? REMOTE_STATE_LABEL : STATE_LABEL)[s.state]} />
<Logo cli={s.cli} size={12} tint={colorOf[s.cli]} />
{/* State rides the CLI tile itself. For a remote agent it means
connection, not process: working / listening / not connected. */}
<StateLogo
cli={s.cli} state={s.state} size={12} tint={colorOf[s.cli]}
title={(isRemote(s.cli) ? REMOTE_STATE_LABEL : STATE_LABEL)[s.state]}
/>
{editing ? (
<input
autoFocus className="rename" value={editName}
Expand Down Expand Up @@ -787,15 +790,14 @@ export default function Sidebar({
{/* Overview: pinned above the tree with the row anatomy (tile · name) so
it reads as clickable. It used to carry an "N waiting" count in a
right slot; the rows directly below already show each of those agents
with its own state mark, so the number was a second, vaguer telling of
with state on its tile, so the number was a second, vaguer telling of
what the list says exactly. */}
<div className="ov-fixed">
<div
className={`row session ov-row${activeRef === 'overview' ? ' active' : ''}`}
onClick={() => onActivate('overview')}
title="All agents: digests, states, replies"
>
<span className="status ov-spacer" />
<span className="ov-tile"><GridGlyph /></span>
<span className="name">overview</span>
</div>
Expand Down Expand Up @@ -837,9 +839,10 @@ export default function Sidebar({
</div>

<div className="legend">
<span><span className="status working" /> working</span>
<span><span className="status waiting" /> idle</span>
<span><span className="status stopped" /> stopped</span>
<span><StateLogo cli="shell" state="working" size={12} tint="var(--muted)" /> working</span>
<span><StateLogo cli="shell" state="waiting" size={12} tint="var(--muted)" /> your turn</span>
<span><StateLogo cli="shell" state="idle" size={12} tint="var(--muted)" /> idle</span>
<span><StateLogo cli="shell" state="stopped" size={12} tint="var(--muted)" /> stopped</span>
{archived.size > 0 && (
<label className="legend-arch" title="Sessions with no activity beyond the archive window (Settings)">
<input type="checkbox" checked={showArchived} onChange={onToggleArchived} />
Expand Down
48 changes: 48 additions & 0 deletions web/src/components/StateLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { SessionState } from '../types';
import Logo, { logoTilePadding, logoTileRadius } from './Logo';

/**
* The CLI tile and the agent state are one mark. The 96-unit path is divided
* into four exact 20 + 4 cells, so the working dash loop has no remainder (and
* therefore no visible jump) at the rounded rectangle's origin.
*/
export default function StateLogo({
cli, state, size = 18, tint, title,
}: {
cli: string;
state: SessionState;
size?: number;
tint?: string;
title?: string;
}) {
const padding = logoTilePadding(size, tint);
const frameSize = size + padding * 2;

return (
<span
className={`state-logo ${state}`}
style={{ width: frameSize, height: frameSize }}
title={title}
>
<Logo cli={cli} size={size} tint={tint} />
<svg
className="state-logo-frame"
viewBox={`0 0 ${frameSize} ${frameSize}`}
aria-hidden="true"
>
{state === 'working' && (
<rect
className="state-logo-track"
x="0.5" y="0.5" width={frameSize - 1} height={frameSize - 1}
rx={logoTileRadius(size)} pathLength="96"
/>
)}
<rect
className={state === 'working' ? 'state-logo-run' : 'state-logo-static'}
x="0.5" y="0.5" width={frameSize - 1} height={frameSize - 1}
rx={logoTileRadius(size)} pathLength="96"
/>
</svg>
</span>
);
}
10 changes: 5 additions & 5 deletions web/src/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import type { Cli, Session } from '../types';
import { STATE_LABEL, isRemote } from '../types';
import Logo from './Logo';
import StateLogo from './StateLogo';
import TraceInfo from './TraceInfo';
import ConversationView from './conversation/ConversationView';
import { isPassive } from '../types';
Expand Down Expand Up @@ -1105,7 +1105,10 @@ export default function TerminalPane({
<BackGlyph />
</button>
)}
<Logo cli={session.cli} size={16} tint={tint} />
<StateLogo
cli={session.cli} state={session.state} size={16} tint={tint}
title={`${STATE_LABEL[session.state]} · ${conn}`}
/>
{/* Where the agent runs, beside what it is. It was on the right, in
among the controls; it is not a control. Still hidden on a phone —
moving it did not create room — where the `i` panel carries it
Expand All @@ -1129,9 +1132,6 @@ export default function TerminalPane({
title={`${sessionTitle(session.name, groupName)} · ${pathLabel} · double-click to rename`}
onDoubleClick={() => { setDraft(session.name); setEditing(true); }}
>
{/* The state mark reads as part of the name now: what this agent is
doing, immediately left of who it is, both centred together. */}
<span className={`status ${session.state}`} title={`${STATE_LABEL[session.state]} · ${conn}`} />
{group && <span className="ph-group">[{group}]</span>}
<span className="ph-name">{session.name}</span>
</span>
Expand Down
29 changes: 20 additions & 9 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ body {
[data-theme='dark'] .cli-logo.inv-dark img { filter: invert(1); }
[data-theme='light'] .cli-logo.inv-light img { filter: invert(1); }

/* Agent state follows the CLI tile instead of occupying a second mark slot.
The 0.5px inset centres a 1px SVG stroke on the pixel boundary, so its top
and bottom edges paint at the same weight. Working uses four exact 20 + 4
cells on a normalised 96-unit perimeter: the final gap ends exactly where
the first dash starts, with no remainder to jump at the path origin.
Waiting uses sixteen exact 2 + 4 cells on that path: static points make
"your turn" distinct from both the long working segments and solid idle. */
.state-logo { position: relative; flex: none; display: inline-flex; color: var(--accent); }
.state-logo-frame { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.state-logo-frame rect { fill: none; stroke: currentColor; stroke-width: 1; vector-effect: non-scaling-stroke; stroke-linecap: butt; stroke-linejoin: round; }
.state-logo-track { opacity: 0.22; }
.state-logo-run { stroke-dasharray: 20 4; animation: state-logo-trace 0.92s linear infinite; }
.state-logo.waiting .state-logo-static { stroke-dasharray: 2 4; stroke-linecap: round; }
.state-logo.waiting, .state-logo.idle { color: var(--accent); }
.state-logo.stopped { color: color-mix(in srgb, var(--muted) 50%, transparent); }
@keyframes state-logo-trace { to { stroke-dashoffset: -24; } }

/* header */
.brand { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 13px 14px; border-bottom: 1px solid var(--border); }
.brand .logo { display: flex; align-items: center; gap: 9px; flex: 1; min-width: 0; }
Expand Down Expand Up @@ -274,7 +291,6 @@ body {
/* overview: pinned sidebar row (session-row anatomy) + cards view */
.ov-fixed { padding: 5px 8px 6px; border-bottom: 1px solid var(--border); }
.ov-row { cursor: pointer; padding-top: 6px; padding-bottom: 6px; }
.ov-row .status.ov-spacer { visibility: hidden; }
.ov-tile { flex: none; box-sizing: content-box; width: 12px; height: 12px; padding: 3px; border-radius: 4px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); display: inline-flex; align-items: center; justify-content: center; }
.ov-tile svg { width: 100%; height: 100%; display: block; }
.ov-row .name { color: var(--text); }
Expand Down Expand Up @@ -521,10 +537,8 @@ body {
.row.drop-before::after { top: -1px; }
.row.drop-after::after { bottom: -1px; }
.row.drop-on { background: var(--drop); outline: 1px solid var(--accent); outline-offset: -1px; }
/* No size override here any more: the mark is em-based, so a sidebar row's own
type size already makes it a size down from a card's. See .status. */
/* mono for THINGS (session names, counts), sans for commentary. Member names
speak in the muted voice — state (dot) and place (frame) carry the color. */
speak in the muted voice — the CLI tile's state frame carries the color. */
.row .name { flex: 1; font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.active .name, .row:hover .name { color: var(--text); }
.row .rename { flex: 1; font: inherit; font-family: var(--font-mono); font-size: 11.5px; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
Expand Down Expand Up @@ -635,7 +649,7 @@ body {
.controls .seg button { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 8px; }

.legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 10px 16px; border-top: 1px solid var(--border); font-size: 10.5px; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 5px; }
.legend > span { display: inline-flex; align-items: center; gap: 5px; }
/* archived toggle: a quiet custom checkbox speaking the app's language */
.legend-arch { position: relative; display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.legend-arch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
Expand All @@ -644,7 +658,7 @@ body {
.legend-arch input:checked ~ .lbox { background: var(--accent); border-color: var(--accent); }
.legend-arch input:checked ~ .lbox::after { content: ''; width: 5px; height: 2.5px; border-left: 1.5px solid var(--accent-fg); border-bottom: 1.5px solid var(--accent-fg); transform: rotate(-45deg) translateY(-1px); }
/* archived rows/tiles: present but visibly parked */
.row.archived .name, .row.archived .cli-logo { opacity: 0.45; }
.row.archived .name, .row.archived .state-logo, .row.archived > .cli-logo { opacity: 0.45; }
.ovt-tile.archived .ovt-head { opacity: 0.55; }
.arch-note { font-size: 10.5px; color: var(--muted); font-style: italic; text-align: center; padding: 14px 12px 4px; margin-top: auto; }

Expand Down Expand Up @@ -812,9 +826,6 @@ body {
server/reader-info.test.mjs — all four, in both views. */
.pane-head .ph-btn::before { content: ''; position: absolute; inset: -4px -4px; }
.pane-head .ph-path { min-width: 0; max-width: min(24vw, 220px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; }
/* The state mark leads the name, inside the centred title: it never shrinks and
never wraps, so the name gives way first (see .ph-name). */
.pane-head .ph-title > .status { flex: none; margin-right: 1px; }
.pane-head .ph-title-input { width: 100%; text-align: center; font: inherit; font-family: var(--font-mono); font-weight: 600; padding: 1px 6px; border: 1px solid var(--accent); border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); min-width: 0; }
.pane-head .ph-close { justify-self: end; }
/* The conversation's facts, behind one `i` in the pane header — the same place
Expand Down
Loading