From 4886583005f0e0a4b1714757816438e0901fb279 Mon Sep 17 00:00:00 2001 From: unohee Date: Fri, 4 Sep 2026 17:13:24 +0900 Subject: [PATCH 1/2] feat(web): retheme every surface on shared design tokens (AGT-4201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One token vocabulary (tokens.css) now paints all seven pages — cockpit, chat, orchestration, threads, warehouse, and the two server-rendered legacy pages — with a light theme as a value swap under [data-theme="light"] stamped by themeBoot.js before first paint, and a toggle on every page. shell.css carries the shared primitives: topbar, buttons, inputs, chips, badges, focus ring, empty/status lines, in-page confirm card, reduced-motion. Chat gets the guideline's composer (autogrow textarea, Enter sends / Shift+Enter newline / IME-safe, empty-disabled, in-place sending state, draft persistence, labelled attachment chips, drop overlay) and stream following (80px slack, "↓ Latest" button, one live-region announcement per batch). Threads' resolve and the warehouse's overwrite ask in the page, naming the target. Role/kind/status colours in the JS colour maps are token references, so the SVG graph re-paints with the theme. tests/web/tokens.test.ts is the gate: every var() resolves, no hex literal outside tokens.css, no inline + + OpenSwarm · Chat room + + + + - -
-

💬 CHAT ROOM

- ← dashboard - orchestration + +
+ 🐝 OpenSwarm + Chat room + +
+
+ +
-
Loading…
-
- - - - -
-
+ +
+
+
Loading the room…
+ + +
+
+ +
+
+
+ + + + +
+
+
+
+ diff --git a/web/static/css/app.css b/web/static/css/app.css index 5586768a..e37e5ccb 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -1,44 +1,44 @@ -/* OpenSwarm issue board components (INT-3388). Consumes tokens.css only. - The shell (body grid, sidebar, views, status bar) lives in cockpit.css — - this file owns the Issues view's own widgets. */ +/* OpenSwarm issue board components (INT-3388, AGT-4201). Consumes tokens.css + + shell.css. The shell (body grid, sidebar, views, status bar) lives in + cockpit.css — this file owns the Issues view's own widgets. */ .repo-picker select { width: 100%; - background: var(--surface2); - color: var(--text); + background: var(--bg-elevated); + color: var(--fg-primary); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 6px 10px; + padding: var(--space-2) var(--space-3); font-size: var(--text-sm); - font-family: var(--font-ui); + font-family: var(--font-sans); } .daemon-status { display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); } /* Provider rate-limit gauge — hidden until the daemon observes usage headers. */ .quota-gauge { font-family: var(--font-mono); font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); white-space: nowrap; } .quota-gauge[hidden] { display: none; } .daemon-status .dot { width: 8px; height: 8px; - border-radius: var(--radius-pill); - background: var(--muted); + border-radius: var(--radius-full); + background: var(--fg-muted); } -.daemon-status .dot[data-state="ok"] { background: var(--ok); } -.daemon-status .dot[data-state="down"] { background: var(--error); } +.daemon-status .dot[data-state="ok"] { background: var(--success); } +.daemon-status .dot[data-state="down"] { background: var(--danger); } .panel { - background: var(--surface); + background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); display: flex; @@ -49,38 +49,31 @@ .panel-head { display: flex; align-items: baseline; - gap: 8px; - padding: 12px 16px; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); } -.panel-head h2 { margin: 0; font-size: var(--text-md); font-weight: 600; } +.panel-head h2 { margin: 0; font-size: var(--text-sm); font-weight: 600; } -.muted { color: var(--muted); font-size: var(--text-xs); } +.muted { color: var(--fg-muted); font-size: var(--text-xs); } .issue-list { flex: 1; overflow-y: auto; - padding: 8px; -} - -.empty { - color: var(--muted); - font-size: var(--text-sm); - padding: 24px; - text-align: center; + padding: var(--space-2); } .issue-row { display: flex; align-items: center; - gap: 10px; - padding: 8px 10px; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); cursor: pointer; user-select: none; } -.issue-row:hover { background: var(--surface2); } -.issue-row.selected { background: var(--accent-soft); } +.issue-row:hover { background: var(--bg-hover); } +.issue-row.selected { background: var(--accent-subtle); } .issue-row input[type="checkbox"] { accent-color: var(--accent); } .issue-row .identifier { font-family: var(--font-mono); @@ -91,10 +84,10 @@ .issue-row .prio { font-size: var(--text-xs); font-family: var(--font-mono); - color: var(--muted); + color: var(--fg-muted); min-width: 22px; } -.issue-row .prio[data-p="1"] { color: var(--error); } +.issue-row .prio[data-p="1"] { color: var(--danger); } .issue-row .prio[data-p="2"] { color: var(--warning); } .issue-row .title { flex: 1; @@ -105,10 +98,10 @@ } .issue-row .state { font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); border: 1px solid var(--border); - border-radius: var(--radius-pill); - padding: 1px 8px; + border-radius: var(--radius-full); + padding: 1px var(--space-2); white-space: nowrap; } @@ -116,45 +109,35 @@ display: flex; align-items: center; justify-content: space-between; - padding: 10px 16px; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border); } -.deploy-btn { - background: var(--accent); - color: #0d1117; - font-weight: 600; - font-size: var(--text-sm); - border: none; - border-radius: var(--radius-md); - padding: 8px 16px; - cursor: pointer; -} -.deploy-btn:disabled { background: var(--surface2); color: var(--muted); cursor: default; } -.deploy-btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } -/* Two-step confirmation state (in-page consent — the Tauri WebView has no JS dialogs). */ -.deploy-btn.arm { background: var(--warning); } -#selection-summary.error { color: var(--error); } +/* Two-step confirmation state (in-page consent — the Tauri WebView has no JS + dialogs): the armed button says what the second click will do. */ +.deploy-btn.arm { background: var(--warning); border-color: var(--warning); color: var(--fg-on-accent); } +#selection-summary.error { color: var(--danger); } .work-cards { flex: 1; overflow-y: auto; - padding: 8px; + padding: var(--space-2); display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .work-card { - background: var(--surface2); + background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: 10px 12px; + padding: var(--space-3); } .work-card .head { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .work-card .identifier { font-family: var(--font-mono); @@ -170,20 +153,20 @@ } .work-card .stage-line { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; - margin-top: 6px; + margin-top: var(--space-2); font-size: var(--text-xs); font-family: var(--font-mono); - color: var(--muted); + color: var(--fg-muted); } .work-card .stage-line .glyph { width: 14px; text-align: center; } -.work-card .stage-line[data-status="complete"] .glyph { color: var(--ok); } -.work-card .stage-line[data-status="fail"] .glyph { color: var(--error); } +.work-card .stage-line[data-status="complete"] .glyph { color: var(--success); } +.work-card .stage-line[data-status="fail"] .glyph { color: var(--danger); } .work-card .stage-line[data-status="start"] .glyph { color: var(--accent); } .work-card .error { - margin-top: 6px; - color: var(--error); + margin-top: var(--space-2); + color: var(--danger); font-size: var(--text-xs); font-family: var(--font-mono); white-space: pre-wrap; @@ -195,22 +178,21 @@ background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); - color: var(--muted); + color: var(--fg-muted); font-size: var(--text-xs); - padding: 2px 8px; + padding: 2px var(--space-2); cursor: pointer; white-space: nowrap; } -.console-toggle:hover { color: var(--text); border-color: var(--muted); } -.console-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.console-toggle:hover { color: var(--fg-primary); border-color: var(--border-strong); } .work-card .console { - margin-top: 8px; + margin-top: var(--space-2); /* Deep ground against the card so the stream reads as a terminal. */ - background: var(--bg); + background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 6px 8px; + padding: var(--space-2); /* Tall enough to show context, capped so several cards stay visible. */ max-height: 260px; overflow-y: auto; @@ -218,7 +200,7 @@ font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.5; - color: var(--text); + color: var(--fg-primary); } .work-card .console:empty { display: none; } .work-card.console-hidden .console { display: none; } @@ -229,7 +211,7 @@ } .work-card .console-stage { color: var(--accent); - margin: 6px 0 2px; + margin: var(--space-2) 0 2px; white-space: nowrap; } .work-card .console-stage:first-child { margin-top: 0; } diff --git a/web/static/css/chat.css b/web/static/css/chat.css new file mode 100644 index 00000000..52180312 --- /dev/null +++ b/web/static/css/chat.css @@ -0,0 +1,204 @@ +/* Chat room (AGT-4201). Consumes tokens.css + shell.css. + One chronological room across every task: durable exchanges as threads, + every line a speaker addressing someone, and a composer that grows. */ + +body.page-chat { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.chat-main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ── Room ────────────────────────────────────────────────────────────── */ + +.room-wrap { position: relative; flex: 1; min-height: 0; } + +.room { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + padding: var(--space-4) var(--space-4) var(--space-6); + /* NEVER smooth: it breaks stick-to-bottom detection. */ + scroll-behavior: auto; +} +.room > .empty { margin: var(--space-12) auto 0; max-width: 40ch; } + +.chat-thread { + --thread-accent: var(--accent); + max-width: 960px; + margin: var(--space-3) auto; + border: 1px solid var(--border); + border-left: 3px solid var(--thread-accent); + border-radius: var(--radius-md); + background: var(--bg-surface); + overflow: hidden; +} +.chat-thread.thread-system { + --thread-accent: var(--warning); + margin-block: var(--space-2); + background: color-mix(in srgb, var(--warning-subtle) 40%, var(--bg-app)); +} + +.thread-head { + min-height: 32px; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); + font-size: var(--text-xs); +} +.thread-system .thread-head { background: transparent; } + +.channel-badge { + flex: none; + padding: 0 var(--space-2); + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + color: var(--accent); + background: var(--accent-subtle); + font-weight: 700; + letter-spacing: 0.08em; + font-size: 10px; + line-height: 18px; +} +.thread-system .channel-badge { color: var(--warning); border-color: var(--warning); background: var(--warning-subtle); } +.thread-kind { margin-left: auto; color: var(--fg-muted); white-space: nowrap; } + +.issue-link { + min-width: 0; + display: inline-flex; + align-items: baseline; + gap: var(--space-2); + color: inherit; + text-decoration: none; + overflow: hidden; +} +a.issue-link:hover .issue-title { color: var(--fg-primary); text-decoration: underline; text-underline-offset: 2px; } +.issue-code { flex: none; color: var(--accent); font-family: var(--font-mono); font-weight: 600; } +.thread-system .issue-code { color: var(--warning); } +.issue-sep { flex: none; color: var(--fg-muted); } +.issue-title { color: var(--fg-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.issue-link-plain .issue-code { color: var(--fg-muted); } + +/* ── Lines ───────────────────────────────────────────────────────────── */ + +.thread-lines { position: relative; } + +.line { + --speaker-color: var(--fg-muted); + position: relative; + padding: var(--space-2) var(--space-4) var(--space-3) calc(var(--space-4) + 18px); + transition: background var(--dur-fast) var(--ease-out); +} +.line + .line { border-top: 1px solid var(--border); } +.line:hover { background: var(--bg-hover); } +/* Speaker dot and the rail joining consecutive lines. */ +.line::before { + content: ""; + position: absolute; + left: var(--space-4); + top: calc(var(--space-2) + 8px); + width: 8px; height: 8px; + border-radius: var(--radius-full); + background: var(--speaker-color); +} +.line:not(:last-child)::after { + content: ""; + position: absolute; + left: calc(var(--space-4) + 3.5px); + top: calc(var(--space-2) + 18px); + bottom: -6px; + width: 1px; + background: var(--border); +} +.thread-system .line { padding-block: var(--space-2); } +.thread-system .line .who, .thread-system .line .tag { color: var(--fg-muted); } +.thread-system .line .text { color: var(--fg-secondary); font-size: var(--text-sm); } + +.line .meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + min-width: 0; + font-size: var(--text-xs); + line-height: 1.55; +} +.line .clock { color: var(--fg-muted); font-family: var(--font-mono); font-variant-numeric: tabular-nums; margin-right: var(--space-2); } +.line .who { font-weight: 600; } +.line .tag { color: var(--fg-muted); margin: 0 var(--space-2) 0 var(--space-1); } +.line .to { color: var(--fg-muted); margin-right: var(--space-1); } +.line .sep { color: var(--fg-muted); } +.line .text { + display: block; + max-width: 80ch; + margin-top: var(--space-1); + color: var(--fg-primary); + white-space: pre-wrap; + font-size: var(--text-base); + line-height: var(--leading-body); +} +.line .text code { color: var(--code-string); } + +.mention { + display: inline; + padding: 0 var(--space-1); + border-radius: var(--radius-sm); + color: var(--accent); + background: var(--accent-subtle); + font-weight: 600; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} +.mention-human { color: var(--danger); background: var(--danger-subtle); } + +/* The operator's own lines: a tinted ground, the room's user bubble (§2.2). */ +.line.from-operator { background: var(--bg-user-bubble); } +.line.from-operator:hover { background: var(--bg-user-bubble); } +.line.pending { box-shadow: inset 0 0 0 1px var(--warning-subtle); } +.line.failed .text { color: var(--danger); } + +/* ── Composer (§2.1) ─────────────────────────────────────────────────── */ + +.composer { + display: grid; + gap: var(--space-2); + padding: var(--space-3) var(--space-4) var(--space-2); + border-top: 1px solid var(--border); + background: var(--bg-surface); +} +.composer-files { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.composer-files:empty { display: none; } +.composer-row { display: flex; align-items: flex-end; gap: var(--space-2); } +.composer-text { + flex: 1; + min-height: 36px; + max-height: 40vh; + resize: none; + overflow-y: hidden; + line-height: 1.5; +} +.composer-status { min-height: 18px; } + +body.dropping .room { outline: 2px dashed var(--accent); outline-offset: -6px; } + +@media (max-width: 760px) { + .room { padding-inline: var(--space-2); } + .chat-thread { margin-block: var(--space-2); } + .thread-kind { display: none; } + .line { padding-left: calc(var(--space-3) + 16px); } + .line::before { left: var(--space-3); } + .line:not(:last-child)::after { left: calc(var(--space-3) + 3.5px); } + .line .text { font-size: var(--text-sm); } + /* Keep the composer above the on-screen keyboard's safe area. */ + .composer { padding-bottom: calc(var(--space-2) + env(safe-area-inset-bottom, 0px)); } +} diff --git a/web/static/css/cockpit.css b/web/static/css/cockpit.css index 726e940a..a12490ef 100644 --- a/web/static/css/cockpit.css +++ b/web/static/css/cockpit.css @@ -1,38 +1,33 @@ -/* Cockpit shell: sidebar + view area + status bar. (INT-3402) - Consumes tokens.css only. Component styling for the Issues view stays in - app.css — this file owns the frame. */ - -* { box-sizing: border-box; } - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: var(--font-ui); - font-size: var(--text-md); +/* Cockpit shell: topbar + sidebar + view area + status bar. (INT-3402, AGT-4201) + Consumes tokens.css + shell.css. Component styling for the Issues view stays + in app.css — this file owns the frame. */ + +body.page-cockpit { height: 100vh; + overflow: hidden; display: grid; grid-template: + "topbar topbar" auto "sidebar main" 1fr "statusbar statusbar" auto / var(--sidebar-w) minmax(0, 1fr); + font-size: var(--text-sm); } +.page-cockpit > .topbar { grid-area: topbar; } /* ── Sidebar ─────────────────────────────────────────────────────────── */ .sidebar { grid-area: sidebar; - background: var(--surface); + background: var(--bg-surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; - padding: 10px 10px 0; - gap: 10px; + padding: var(--space-3) var(--space-3) 0; + gap: var(--space-3); } -.brand { font-weight: 600; font-size: var(--text-lg); padding: 2px 4px; } - .view-nav { display: flex; flex-direction: column; gap: 2px; } .nav-item { @@ -40,32 +35,30 @@ body { background: none; border: none; border-radius: var(--radius-md); - color: var(--muted); + color: var(--fg-muted); font: inherit; font-size: var(--text-sm); - padding: 6px 10px; + padding: var(--space-2) var(--space-3); cursor: pointer; + transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); } -.nav-item:hover { background: var(--surface2); color: var(--text); } -/* Sibling pages (orchestration, chat room) link from the same nav as views. */ -a.nav-item { display: block; text-decoration: none; } -.nav-item.active { background: var(--accent-soft); color: var(--accent); } -.nav-item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } +.nav-item:hover { background: var(--bg-hover); color: var(--fg-primary); } +.nav-item.active { background: var(--accent-subtle); color: var(--accent); } .sidebar-section-head { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.04em; - color: var(--muted); - padding: 0 4px; + color: var(--fg-muted); + padding: 0 var(--space-1); } .session-tree { flex: 1; overflow-y: auto; min-height: 0; - margin: 0 -10px; - padding: 0 10px 10px; + margin: 0 calc(-1 * var(--space-3)); + padding: 0 var(--space-3) var(--space-3); } /* ── Main / views ────────────────────────────────────────────────────── */ @@ -74,32 +67,32 @@ a.nav-item { display: block; text-decoration: none; } /* One attribute on decides which view is mounted; the others stay in the DOM (state, scroll positions, and in-flight renders survive switching). */ -.view { display: none; flex: 1; min-width: 0; min-height: 0; padding: 12px; gap: 12px; } +.view { display: none; flex: 1; min-width: 0; min-height: 0; padding: var(--space-3); gap: var(--space-3); } body[data-view="issues"] .view-issues { display: grid; grid-template-columns: minmax(360px, 1fr) minmax(320px, 1fr); } body[data-view="sessions"] .view-sessions { display: flex; } .session-panel { flex: 1; min-width: 0; } -.session-body { flex: 1; min-height: 0; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; } +.session-body { flex: 1; min-height: 0; padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-2); } /* ── Session tree (sidebar) ──────────────────────────────────────────── */ -.tree-empty { color: var(--muted); font-size: var(--text-xs); padding: 8px 4px; } +.tree-empty { color: var(--fg-muted); font-size: var(--text-xs); padding: var(--space-2) var(--space-1); } .tree-project { display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); font-size: var(--text-xs); - color: var(--muted); - padding: 8px 4px 2px; + color: var(--fg-muted); + padding: var(--space-2) var(--space-1) 2px; text-transform: uppercase; letter-spacing: 0.04em; } .tree-badge { - background: var(--accent-soft); + background: var(--accent-subtle); color: var(--accent); - border-radius: var(--radius-pill); - padding: 0 6px; + border-radius: var(--radius-full); + padding: 0 var(--space-2); font-size: 10px; letter-spacing: 0; } @@ -108,25 +101,24 @@ body[data-view="sessions"] .view-sessions { display: flex; } width: 100%; display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); background: none; border: none; border-radius: var(--radius-md); - color: var(--text); + color: var(--fg-primary); font: inherit; font-size: var(--text-sm); - padding: 4px 8px; + padding: var(--space-1) var(--space-2); cursor: pointer; text-align: left; } -.tree-session:hover { background: var(--surface2); } -.tree-session.selected { background: var(--accent-soft); } -.tree-session:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; } -.tree-session .glyph { width: 12px; text-align: center; color: var(--muted); } +.tree-session:hover { background: var(--bg-hover); } +.tree-session.selected { background: var(--accent-subtle); } +.tree-session .glyph { width: 12px; text-align: center; color: var(--fg-muted); } .tree-session[data-phase="running"] .glyph { color: var(--accent); } -.tree-session[data-phase="completed"] .glyph { color: var(--ok); } -.tree-session[data-phase="failed"] .glyph { color: var(--error); } -.tree-session[data-phase="decomposed"] .glyph { color: var(--purple); } +.tree-session[data-phase="completed"] .glyph { color: var(--success); } +.tree-session[data-phase="failed"] .glyph { color: var(--danger); } +.tree-session[data-phase="decomposed"] .glyph { color: var(--role-review-agent); } .tree-session .label { flex: 1; font-family: var(--font-mono); @@ -135,11 +127,11 @@ body[data-view="sessions"] .view-sessions { display: flex; } overflow: hidden; text-overflow: ellipsis; } -.tree-session .time { font-size: 10px; color: var(--muted); white-space: nowrap; } +.tree-session .time { font-size: 10px; color: var(--fg-muted); white-space: nowrap; } /* ── Session panel ───────────────────────────────────────────────────── */ -.session-header { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.session-header { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; } .session-header .identifier { font-family: var(--font-mono); font-size: var(--text-xs); @@ -154,40 +146,39 @@ body[data-view="sessions"] .view-sessions { display: flex; } } .session-header .phase { font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); border: 1px solid var(--border); - border-radius: var(--radius-pill); - padding: 1px 8px; + border-radius: var(--radius-full); + padding: 1px var(--space-2); white-space: nowrap; } .session-header .phase[data-phase="running"] { color: var(--accent); border-color: var(--accent); } -.session-header .phase[data-phase="completed"] { color: var(--ok); border-color: var(--ok); } -.session-header .phase[data-phase="failed"] { color: var(--error); border-color: var(--error); } +.session-header .phase[data-phase="completed"] { color: var(--success); border-color: var(--success); } +.session-header .phase[data-phase="failed"] { color: var(--danger); border-color: var(--danger); } .session-meta { font-family: var(--font-mono); font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.session-tabs { display: flex; gap: 4px; } +.session-tabs { display: flex; gap: var(--space-1); } .session-tab { background: none; border: 1px solid transparent; border-radius: var(--radius-sm); - color: var(--muted); + color: var(--fg-muted); font: inherit; font-size: var(--text-xs); - padding: 3px 10px; + padding: 3px var(--space-3); cursor: pointer; } -.session-tab:hover { color: var(--text); } -.session-tab.active { color: var(--accent); border-color: var(--border); background: var(--accent-soft); } -.session-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.session-tab:hover { color: var(--fg-primary); } +.session-tab.active { color: var(--accent); border-color: var(--border); background: var(--accent-subtle); } /* ── Diff ────────────────────────────────────────────────────────────── */ @@ -195,24 +186,24 @@ body[data-view="sessions"] .view-sessions { display: flex; } flex: 1; min-height: 0; overflow: auto; - background: var(--bg); + background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 8px 10px; + padding: var(--space-2) var(--space-3); font-family: var(--font-mono); font-size: var(--text-xs); } .diff-pane[hidden] { display: none; } -.diff-summary { color: var(--text); margin-bottom: 6px; } -.diff-file { color: var(--muted); } -.diff-body { margin-top: 8px; } +.diff-summary { color: var(--fg-primary); margin-bottom: var(--space-2); } +.diff-file { color: var(--fg-muted); } +.diff-body { margin-top: var(--space-2); } .diff-line { white-space: pre; line-height: 1.45; } -.diff-line.file { color: var(--purple); margin-top: 8px; } +.diff-line.file { color: var(--role-review-agent); margin-top: var(--space-2); } .diff-line.hunk { color: var(--accent); } -.diff-line.meta { color: var(--muted); } -.diff-line.add { color: var(--ok); } -.diff-line.del { color: var(--error); } -.diff-line.context { color: var(--muted); } +.diff-line.meta { color: var(--fg-muted); } +.diff-line.add { color: var(--success); } +.diff-line.del { color: var(--danger); } +.diff-line.context { color: var(--fg-muted); } /* ── Transcript ──────────────────────────────────────────────────────── */ @@ -221,28 +212,28 @@ body[data-view="sessions"] .view-sessions { display: flex; } min-height: 0; overflow-y: auto; overflow-x: auto; - background: var(--bg); + background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); - padding: 8px 10px; + padding: var(--space-2) var(--space-3); /* NEVER smooth: it breaks stick-to-bottom detection (vega INT-1411). */ scroll-behavior: auto; } .transcript[hidden] { display: none; } -.transcript-stage { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-mono); font-size: var(--text-xs); } +.transcript-stage { color: var(--accent); margin: var(--space-2) 0 var(--space-1); font-family: var(--font-mono); font-size: var(--text-xs); } .transcript-stage:first-child { margin-top: 0; } -.transcript-line { font-size: var(--text-sm); line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-width: 760px; } -.transcript-line.thinking { color: var(--text); } -.transcript-line.plain { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--muted); } +.transcript-line { font-size: var(--text-sm); line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-width: var(--content-max); } +.transcript-line.thinking { color: var(--fg-primary); } +.transcript-line.plain { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--fg-muted); } .activity-row { margin: 2px 0; font-family: var(--font-mono); font-size: var(--text-xs); } -.activity-row > summary { cursor: pointer; color: var(--muted); list-style: none; } -.activity-row > summary::before { content: '▸ '; color: var(--purple); } +.activity-row > summary { cursor: pointer; color: var(--fg-muted); list-style: none; } +.activity-row > summary::before { content: '▸ '; color: var(--role-review-agent); } .activity-row[open] > summary::before { content: '▾ '; } -.activity-row > summary:hover { color: var(--text); } -.activity-line { color: var(--muted); padding-left: 14px; white-space: pre-wrap; word-break: break-word; } +.activity-row > summary:hover { color: var(--fg-primary); } +.activity-line { color: var(--fg-muted); padding-left: 14px; white-space: pre-wrap; word-break: break-word; } /* ── Status bar ──────────────────────────────────────────────────────── */ @@ -250,12 +241,12 @@ body[data-view="sessions"] .view-sessions { display: flex; } grid-area: statusbar; display: flex; align-items: center; - gap: 16px; - padding: 4px 12px; - background: var(--surface); + gap: var(--space-4); + padding: var(--space-1) var(--space-3); + background: var(--bg-surface); border-top: 1px solid var(--border); font-size: var(--text-xs); - color: var(--muted); + color: var(--fg-muted); } .statusbar .spacer { flex: 1; } @@ -265,36 +256,21 @@ body[data-view="sessions"] .view-sessions { display: flex; } .sidebar-toggle { display: none; } @media (max-width: 900px) { - body { grid-template: "main" 1fr "statusbar" auto / minmax(0, 1fr); } + body.page-cockpit { grid-template: "topbar" auto "main" 1fr "statusbar" auto / minmax(0, 1fr); } .sidebar { position: fixed; - inset: 0 auto 0 0; + top: var(--topbar-h); + bottom: 0; + left: 0; width: var(--sidebar-w); - z-index: 10; + z-index: var(--z-dropdown); transform: translateX(-100%); - transition: transform 120ms ease-out; + transition: transform var(--dur-fast) var(--ease-out); + box-shadow: 0 0 0 1px var(--border); } body.sidebar-open .sidebar { transform: none; } body[data-view="issues"] .view-issues { grid-template-columns: minmax(0, 1fr); } - - /* Off-canvas needs a way back on-canvas — without this the repo picker and - session tree are unreachable at narrow widths. */ - .sidebar-toggle { - display: block; - position: fixed; - top: 8px; - left: 8px; - z-index: 11; - background: var(--surface2); - color: var(--text); - border: 1px solid var(--border); - border-radius: var(--radius-md); - font-size: var(--text-md); - line-height: 1; - padding: 6px 10px; - cursor: pointer; - } - .sidebar-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } - /* Keep the first view row clear of the floating button. */ - .view { padding-top: 44px; } + /* Off-canvas needs a way back on-canvas — the toggle lives in the topbar. */ + .sidebar-toggle { display: inline-flex; } + .topbar-nav { display: none; } } diff --git a/web/static/css/orchestration.css b/web/static/css/orchestration.css new file mode 100644 index 00000000..84cb37c5 --- /dev/null +++ b/web/static/css/orchestration.css @@ -0,0 +1,183 @@ +/* Orchestration board (AGT-4201). Consumes tokens.css + shell.css. + Graph on the left, agent detail / event feed / conversation on the right. */ + +body.page-orchestration { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: var(--text-sm); +} + +/* ── Rows under the topbar ───────────────────────────────────────────── */ + +/* The one line on the page an operator can act on, so it sits above the + graph rather than being a sixth chip in the stat row. */ +.waiting { + padding: var(--space-1) var(--space-4); + font-size: var(--text-sm); + color: var(--fg-muted); + border-bottom: 1px solid var(--border); +} +.waiting.is-blocking { color: var(--fg-on-accent); background: var(--warning); font-weight: 600; } + +.orch-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + padding: var(--space-2) var(--space-4); + border-bottom: 1px solid var(--border); + background: var(--bg-surface); +} +#controls { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; } +#controls .input, #controls .select { min-height: 30px; padding: 4px var(--space-2); font-size: var(--text-sm); } +#controls input[type="search"] { min-width: 160px; } +#controls label { display: flex; align-items: center; gap: var(--space-1); font-size: var(--text-sm); color: var(--fg-muted); } +#controls input[type="checkbox"] { accent-color: var(--accent); } +#idle-count { color: var(--fg-muted); } + +#stats { display: flex; gap: var(--space-2); margin-left: auto; flex-wrap: wrap; } +.stat { + display: inline-flex; + align-items: baseline; + gap: var(--space-1); + padding: 2px var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elevated); + font-size: var(--text-xs); + color: var(--fg-muted); +} +.stat b { color: var(--fg-primary); font-size: var(--text-sm); font-variant-numeric: tabular-nums; } +.stat.warn b { color: var(--warning); } + +/* ── Graph ───────────────────────────────────────────────────────────── */ + +.orch-main { flex: 1; display: flex; min-height: 0; } + +#graph-wrap { flex: 1; position: relative; min-width: 0; background: var(--bg-app); } +#graph { width: 100%; height: 100%; display: block; } + +#legend { + position: absolute; + left: var(--space-3); + bottom: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + font-size: var(--text-xs); + color: var(--fg-muted); +} +#legend .row { display: flex; align-items: center; gap: var(--space-2); margin: 2px 0; } +#legend .swatch { width: 10px; height: 10px; border-radius: var(--radius-full); } +#legend .line { width: 14px; height: 2px; } +#legend .legend-section { margin-top: var(--space-2); padding-top: var(--space-1); border-top: 1px solid var(--border); } + +/* SVG paint comes from tokens through classes; JS sets tokens on the few + attributes that vary per node (role fill, edge kind). */ +.tier-label { fill: var(--fg-muted); font-family: var(--font-mono); font-size: 10px; letter-spacing: 2px; font-weight: 700; } +/* A lane header names a task, not a rank: accent like every other task chip, + and unspaced so a real issue key stays readable in the gutter. */ +.lane-label { fill: var(--accent); letter-spacing: 0.5px; } +.tier-count { fill: var(--fg-muted); fill-opacity: 0.7; font-family: var(--font-mono); font-size: 9px; } +.band-stripe { fill: var(--fg-primary); fill-opacity: 0.025; } +.band-rule, .gutter-rule { stroke: var(--border); } +/* Positions are stable (a node's column comes from its address, not its + index), so the rare move a node does make is worth animating. */ +.node { cursor: pointer; transition: transform var(--dur-slow) var(--ease-out); } +.node text { fill: var(--fg-primary); font-family: var(--font-sans); font-size: 10px; text-anchor: middle; pointer-events: none; } +.node .role-label { fill: var(--fg-muted); font-size: 8px; } +.node.dimmed { opacity: 0.25; } +.edge { fill: none; opacity: 0.55; } +.edge.dimmed { opacity: 0.08; } +.pulse { stroke: var(--warning); animation: pulse 1.6s ease-in-out infinite; } +.speaking { stroke: var(--fg-primary); animation: pulse 1.2s ease-in-out infinite; } +.spoken-to { stroke: var(--fg-primary); } +@keyframes pulse { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.9; } } +@media (prefers-reduced-motion: reduce) { + .node { transition: none; } + .pulse, .speaking { animation: none; } +} + +/* ── Right column ────────────────────────────────────────────────────── */ + +.orch-aside { + width: 400px; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--border); + background: var(--bg-surface); +} +.orch-aside > div { min-width: 0; } +.orch-aside h2 { + margin: 0; + padding: var(--space-3) var(--space-3) var(--space-2); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg-muted); +} +.orch-aside .empty { padding: var(--space-4); } + +#detail { min-height: 84px; padding: 0 var(--space-3) var(--space-3); border-bottom: 1px solid var(--border); font-size: var(--text-sm); } +#detail .name { color: var(--accent); font-weight: 700; } +#detail .meta { color: var(--fg-muted); font-size: var(--text-xs); margin-top: 2px; } + +/* Long opaque values (digests, ids) must never widen the panel: the feed + scrolls vertically only, and its rows are free to break anywhere. */ +#feed { flex: 1 1 40%; min-height: 120px; overflow-y: auto; overflow-x: hidden; padding: 0 var(--space-2) var(--space-3); } +.ev { padding: var(--space-2); border-bottom: 1px solid var(--border); border-left: 2px solid transparent; cursor: pointer; } +.ev:hover { background: var(--bg-hover); } +.ev.focused { border-left-color: var(--accent); background: var(--bg-active); } +.ev-head { display: flex; gap: var(--space-2); align-items: center; font-size: var(--text-xs); } +.ev .status { font-weight: 700; } +.task-chip { color: var(--accent); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; font-family: var(--font-mono); font-size: 10px; } +.clock { margin-left: auto; color: var(--fg-muted); font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.ev-line { margin-top: 3px; font-size: var(--text-sm); color: var(--fg-secondary); overflow-wrap: anywhere; } +.ev-line .who { font-weight: 600; color: var(--fg-primary); } +.ev-meta { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; min-width: 0; } +/* Metadata chips are denser than the composer's attachment chips. */ +.ev-meta .chip, .msg .chip { + min-height: 0; + padding: 0 4px; + border-radius: var(--radius-sm); + background: transparent; + font-size: 10px; + color: var(--fg-muted); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; +} +.ev-meta .chip b, .msg .chip b { color: var(--fg-secondary); font-weight: 600; margin-right: 3px; } + +#thread { flex: 1 1 60%; display: flex; flex-direction: column; min-height: 0; border-top: 1px solid var(--border); } +.thread-head { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--border); font-size: var(--text-xs); } +.thread-head .participants { margin-left: auto; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.await-chip { color: var(--fg-on-accent); background: var(--warning); border-radius: var(--radius-sm); padding: 0 4px; font-weight: 700; } +.transcript { flex: 1; overflow-y: auto; overflow-x: hidden; padding: var(--space-2) var(--space-3); scroll-behavior: auto; } +.msg { margin: var(--space-2) 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-elevated); } +.msg.from-operator { border-left: 2px solid var(--role-human); background: var(--bg-user-bubble); } +.msg-head { display: flex; gap: var(--space-2); align-items: baseline; font-size: var(--text-xs); } +.msg .who { font-weight: 700; } +.msg .to { color: var(--fg-muted); } +.role-tag { color: var(--fg-muted); font-size: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 3px; } +.msg-text { margin-top: 3px; font-size: var(--text-sm); color: var(--fg-primary); white-space: pre-wrap; overflow-wrap: anywhere; line-height: var(--leading-body); } + +.composer { display: flex; gap: var(--space-2); align-items: flex-end; padding: var(--space-2) var(--space-3); border-top: 1px solid var(--border); } +.composer .textarea { flex: 1; min-height: 32px; max-height: 40vh; resize: none; overflow-y: hidden; font-size: var(--text-sm); } +.composer-status { min-height: 16px; padding: 0 var(--space-3) var(--space-2); font-size: var(--text-xs); color: var(--fg-muted); } +.composer-status.is-error { color: var(--danger); } + +@media (max-width: 900px) { + .orch-main { flex-direction: column; } + #graph-wrap { min-height: 40vh; } + .orch-aside { width: auto; max-height: 55vh; border-left: 0; border-top: 1px solid var(--border); } + #stats { margin-left: 0; } +} diff --git a/web/static/css/shell.css b/web/static/css/shell.css new file mode 100644 index 00000000..c481439d --- /dev/null +++ b/web/static/css/shell.css @@ -0,0 +1,370 @@ +/* OpenSwarm shared shell primitives (AGT-4201). Consumes tokens.css only. + + Everything more than one page needs lives here: the reset, the top bar, + buttons, inputs, chips, cards, status lines, empty states, the inline + confirm card for destructive actions, and the accessibility baseline + (focus ring, reduced motion, sr-only). Page stylesheets own only their + own widgets. */ + +/* ── Reset & document ────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; } + +html { height: 100%; } + +body { + margin: 0; + min-height: 100%; + background: var(--bg-app); + color: var(--fg-primary); + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-body); + /* Korean/English mixed prose: never break inside a word, but never let an + unbreakable token (a hash, a path) widen the column either (§8.2). */ + word-break: keep-all; + overflow-wrap: anywhere; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, p { margin: 0; } +h1 { font-size: var(--text-xl); line-height: var(--leading-tight); font-weight: 600; } +h2 { font-size: var(--text-lg); line-height: var(--leading-tight); font-weight: 600; } +h3 { font-size: var(--text-base); line-height: var(--leading-tight); font-weight: 600; } + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 2px; } + +code, kbd, pre { font-family: var(--font-mono); font-size: 0.93em; } +code { + background: var(--bg-code); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px var(--space-1); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} +pre { + margin: 0; + background: var(--bg-code); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-3); + overflow: auto; + line-height: 1.5; +} +pre code { background: none; border: 0; padding: 0; } + +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } +button:disabled { cursor: default; } + +/* Thin, self-coloured scrollbars everywhere: the default chrome is heavy + against a dark panel and reads as a defect rather than an affordance. */ +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +*::-webkit-scrollbar { width: 8px; height: 8px; } +*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: var(--radius-full); } +*::-webkit-scrollbar-track { background: transparent; } + +/* ── Accessibility baseline (§8.5) ───────────────────────────────────── */ + +/* One ring for every interactive element; never `outline: none` alone. */ +:focus-visible { outline: none; box-shadow: var(--focus-ring); } +:focus:not(:focus-visible) { outline: none; } + +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Motion is decoration: anyone who asked for less gets none (§0.7). */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* ── Top bar (§1.3) ──────────────────────────────────────────────────── */ + +.topbar { + position: sticky; + top: 0; + z-index: var(--z-dropdown); + display: flex; + align-items: center; + gap: var(--space-4); + min-height: var(--topbar-h); + padding: 0 var(--space-4); + background: color-mix(in srgb, var(--bg-surface) 88%, transparent); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} +.topbar .brand { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--fg-primary); + font-weight: 600; + font-size: var(--text-base); + white-space: nowrap; + text-decoration: none; +} +.topbar .brand:hover { color: var(--fg-primary); text-decoration: none; } +.topbar .page-title { + color: var(--fg-secondary); + font-size: var(--text-sm); + font-weight: 500; + white-space: nowrap; +} +.topbar-nav { + display: flex; + align-items: center; + gap: var(--space-1); + overflow-x: auto; + min-width: 0; +} +.topbar-nav a { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 var(--space-3); + border-radius: var(--radius-md); + color: var(--fg-muted); + font-size: var(--text-sm); + text-decoration: none; + white-space: nowrap; + transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); +} +.topbar-nav a:hover { color: var(--fg-primary); background: var(--bg-hover); text-decoration: none; } +.topbar-nav a[aria-current="page"] { color: var(--accent); background: var(--accent-subtle); } +.topbar-spacer { flex: 1; } +.topbar-actions { display: flex; align-items: center; gap: var(--space-2); } + +/* ── Buttons ─────────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 32px; + padding: 0 var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--fg-primary); + font-size: var(--text-sm); + font-weight: 500; + line-height: 1; + white-space: nowrap; + transition: color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out); +} +.btn:hover { background: var(--bg-active); } +.btn:disabled { opacity: 0.5; } +.btn:disabled:hover { background: var(--bg-elevated); } + +.btn-primary { background: var(--accent); color: var(--fg-on-accent); font-weight: 600; } +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:disabled { background: var(--bg-elevated); color: var(--fg-muted); opacity: 1; } +.btn-primary:disabled:hover { background: var(--bg-elevated); } + +.btn-secondary { background: transparent; border-color: var(--border); } +.btn-secondary:hover { border-color: var(--border-strong); background: var(--bg-hover); } + +.btn-danger { background: transparent; border-color: var(--danger); color: var(--danger); } +.btn-danger:hover { background: var(--danger-subtle); } +/* Armed: the second, committing step of an inline confirm (§3.2). */ +.btn-danger.is-armed, .btn-danger[aria-pressed="true"] { background: var(--danger); color: var(--bg-app); } + +.btn-ghost { background: transparent; color: var(--fg-muted); } +.btn-ghost:hover { color: var(--fg-primary); background: var(--bg-hover); } + +.btn-sm { min-height: 28px; padding: 0 var(--space-2); font-size: var(--text-xs); } + +/* Icon-only: square hit area of at least 32px (§8.3); label comes from aria-label. */ +.btn-icon { width: 32px; min-width: 32px; padding: 0; } +.btn-icon.btn-sm { width: 28px; min-width: 28px; } + +/* ── Inputs ──────────────────────────────────────────────────────────── */ + +.input, .select, .textarea { + width: 100%; + min-height: 36px; + padding: var(--space-2) var(--space-3); + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--fg-primary); + font-size: var(--text-sm); + line-height: 1.4; + transition: border-color var(--dur-fast) var(--ease-out); +} +.input:hover, .select:hover, .textarea:hover { border-color: var(--border-strong); } +.input:focus-visible, .select:focus-visible, .textarea:focus-visible { border-color: var(--accent); } +.input:disabled, .select:disabled, .textarea:disabled { color: var(--fg-muted); background: var(--bg-surface); } +.input::placeholder, .textarea::placeholder { color: var(--fg-muted); } +.textarea { resize: vertical; } +.select { appearance: auto; } +.field { display: grid; gap: var(--space-1); } +.field > label { color: var(--fg-secondary); font-size: var(--text-xs); } + +/* ── Chips & badges ──────────────────────────────────────────────────── */ + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + max-width: 100%; + min-height: 28px; + padding: 0 var(--space-1) 0 var(--space-3); + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-full); + color: var(--fg-primary); + font-size: var(--text-xs); +} +.chip > .chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chip .chip-remove { + width: 22px; height: 22px; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--fg-muted); + line-height: 1; +} +.chip .chip-remove:hover { background: var(--bg-active); color: var(--fg-primary); } + +.badge { + display: inline-flex; + align-items: center; + padding: 0 var(--space-2); + min-height: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + color: var(--fg-muted); + font-size: var(--text-xs); + line-height: 1; + white-space: nowrap; +} +.badge-accent { color: var(--accent); border-color: var(--accent); background: var(--accent-subtle); } +.badge-success { color: var(--success); border-color: var(--success); background: var(--success-subtle); } +.badge-warning { color: var(--warning); border-color: var(--warning); background: var(--warning-subtle); } +.badge-danger { color: var(--danger); border-color: var(--danger); background: var(--danger-subtle); } + +/* ── Cards & panels ──────────────────────────────────────────────────── */ + +/* Border, never shadow: shadows vanish on a dark ground (§4.1). */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} +.card-head { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border); +} +.card-head h2 { font-size: var(--text-sm); font-weight: 600; } +.card-body { flex: 1; min-height: 0; padding: var(--space-3) var(--space-4); } + +/* ── Status, empty, live regions ─────────────────────────────────────── */ + +.status-line { + min-height: 20px; + color: var(--fg-muted); + font-size: var(--text-xs); +} +.status-line.is-error, .is-error { color: var(--danger); } +.status-line.is-success, .is-success { color: var(--success); } +.status-line.is-pending { color: var(--fg-secondary); } + +/* One line of explanation and one action — no illustration (§7.2). */ +.empty { + display: grid; + gap: var(--space-2); + justify-items: center; + padding: var(--space-6); + color: var(--fg-muted); + font-size: var(--text-sm); + text-align: center; +} + +/* Floating "back to latest" affordance for auto-following streams (§2.3). */ +.scroll-latest { + position: absolute; + right: var(--space-4); + bottom: var(--space-4); + z-index: 1; + min-height: 32px; + padding: 0 var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-full); + background: var(--bg-elevated); + color: var(--fg-primary); + font-size: var(--text-xs); + transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out); +} +.scroll-latest[hidden] { display: none; } + +/* ── Inline confirm card (§3.2, §10) ─────────────────────────────────── */ + +.confirm-card { + display: grid; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--danger); + border-radius: var(--radius-md); + background: var(--danger-subtle); + font-size: var(--text-sm); +} +.confirm-card[hidden] { display: none; } +.confirm-card .confirm-text { color: var(--fg-primary); } +.confirm-card .confirm-text b { font-weight: 600; } +.confirm-card .confirm-actions { display: flex; gap: var(--space-2); justify-content: flex-end; } + +/* ── Drop overlay (§2.1) ─────────────────────────────────────────────── */ + +.drop-overlay { + position: absolute; + inset: 0; + z-index: 2; + display: grid; + place-items: center; + border: 2px dashed var(--accent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-app) 82%, transparent); + color: var(--accent); + font-size: var(--text-base); + font-weight: 500; + pointer-events: none; +} +.drop-overlay[hidden] { display: none; } + +/* ── Theme toggle ────────────────────────────────────────────────────── */ + +.theme-toggle .when-dark, .theme-toggle .when-light { display: none; } +:root[data-theme="dark"] .theme-toggle .when-dark { display: inline; } +:root[data-theme="light"] .theme-toggle .when-light { display: inline; } + +/* ── Utilities ───────────────────────────────────────────────────────── */ + +.muted { color: var(--fg-muted); font-size: var(--text-xs); } +.mono { font-family: var(--font-mono); } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[hidden] { display: none !important; } diff --git a/web/static/css/threads.css b/web/static/css/threads.css new file mode 100644 index 00000000..90a4bcbb --- /dev/null +++ b/web/static/css/threads.css @@ -0,0 +1,96 @@ +/* Repository threads (AGT-4201). Consumes tokens.css + shell.css. + Durable decisions per repository: a list on the left, one thread's + history and reply box on the right. */ + +body.page-threads { + min-height: 100vh; + display: flex; + flex-direction: column; + font-size: var(--text-sm); +} + +.topbar #repo { min-width: 240px; max-width: 40vw; } + +.threads-status { + min-height: 28px; + padding: var(--space-1) var(--space-4); + border-bottom: 1px solid var(--border); +} +#status.error { color: var(--danger); } + +.threads-main { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(280px, 36%) 1fr; + height: calc(100vh - var(--topbar-h) - 28px); +} + +/* ── List ────────────────────────────────────────────────────────────── */ + +.threads-list { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg-surface); } +.toolbar { display: flex; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--border); } +.toolbar .select { flex: 1; } +#threads { flex: 1; overflow: auto; } + +.thread-card { + width: 100%; + padding: var(--space-3); + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + color: var(--fg-primary); + font: inherit; + text-align: left; + cursor: pointer; +} +.thread-card:hover { background: var(--bg-hover); } +.thread-card.selected { background: var(--accent-subtle); box-shadow: inset 3px 0 var(--accent); } +.thread-card:focus-visible { box-shadow: inset 3px 0 var(--accent), var(--focus-ring); } +.subject { display: flex; gap: var(--space-2); align-items: center; font-weight: 600; } +.thread-card .badge { font-size: 10px; } +.badge.open { color: var(--success); border-color: var(--success); background: var(--success-subtle); } +.badge.resolved { color: var(--fg-muted); } +.thread-card .meta, .detail-head .meta { margin-top: var(--space-1); color: var(--fg-muted); font-size: var(--text-xs); overflow-wrap: anywhere; } + +.new-thread { display: grid; gap: var(--space-2); padding: var(--space-3); border-top: 1px solid var(--border); } +.new-thread h2 { margin: 0; font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-muted); } +.new-thread .textarea { min-height: 62px; resize: vertical; } +.two { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); } + +/* ── Detail ──────────────────────────────────────────────────────────── */ + +.threads-detail { min-width: 0; display: flex; flex-direction: column; } +#empty { padding: var(--space-6); } +#detail { display: none; min-height: 0; flex: 1; flex-direction: column; } +#detail.visible { display: flex; } +.detail-head { padding: var(--space-4); border-bottom: 1px solid var(--border); } +.detail-head h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); } +.detail-actions { display: flex; gap: var(--space-2); margin-top: var(--space-3); flex-wrap: wrap; } +.detail-head .confirm-card { margin-top: var(--space-3); } + +#messages { flex: 1; overflow: auto; padding: var(--space-3) var(--space-4); } +.message { margin: var(--space-2) 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-surface); max-width: var(--content-max); } +.message-head { color: var(--fg-muted); font-size: var(--text-xs); } +.message-body { margin-top: var(--space-2); color: var(--fg-primary); white-space: pre-wrap; overflow-wrap: anywhere; line-height: var(--leading-body); } +.message-body .inline-code { color: var(--code-string); background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1px 4px; } +.message-body pre { margin: var(--space-2) 0 2px; padding: var(--space-3); overflow: auto; color: var(--fg-primary); background: var(--bg-code); border: 1px solid var(--border); border-radius: var(--radius-sm); line-height: 1.55; white-space: pre; } +.message-body code { font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.55; } +.message-body .token-comment { color: var(--code-comment); font-style: italic; } +.message-body .token-keyword { color: var(--code-keyword); } +.message-body .token-string { color: var(--code-string); } +.message-body .token-number { color: var(--code-number); } +.message-body .token-property { color: var(--code-property); } +.message-body .token-punctuation { color: var(--code-punctuation); } + +.reply-form { display: grid; grid-template-columns: 1fr auto; gap: var(--space-2); align-items: end; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border); background: var(--bg-surface); } +.reply-form .textarea { min-height: 54px; resize: vertical; } + +@media (max-width: 800px) { + .threads-main { grid-template-columns: 1fr; height: auto; } + .threads-list { border-right: 0; max-height: 50vh; } + .threads-detail { min-height: 50vh; } + .topbar #repo { max-width: 100%; } + .two { grid-template-columns: 1fr; } +} diff --git a/web/static/css/tokens.css b/web/static/css/tokens.css index a4ba2c1f..5c5b5416 100644 --- a/web/static/css/tokens.css +++ b/web/static/css/tokens.css @@ -1,52 +1,235 @@ -/* OpenSwarm design tokens — single source (INT-3388). - GitHub-dark values carried over from the VEGA-derived dashboard palette, - renamed to semantic vocabulary. Dark-only by design: no - prefers-color-scheme forks. Legacy aliases (--green meaning blue, etc.) - deliberately not reintroduced. */ +/* OpenSwarm design tokens — the single source every page consumes (AGT-4201). + + Semantic names only (§8.1 of the chat-ui guidelines): a rule says what a + colour is FOR (`--fg-muted`), never what it looks like (`--gray-400`). + Dark is the default; light is a value swap under `[data-theme="light"]`, + set on by /static/js/themeBoot.js before first paint. Every hex + literal on the web surface lives in this file — tokens.test.ts fails the + build if one appears anywhere else under web/static. + + Legacy aliases at the bottom keep the names the earlier cockpit CSS used + (`--bg`, `--surface`, `--text`, …) resolving to the same tokens, so a rule + that has not been migrated still renders on-system instead of falling back + to the browser default. */ + +@font-face { + font-family: 'Inter Variable'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('/static/fonts/inter-latin-wght-normal.woff2') format('woff2-variations'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} :root { - /* Surfaces */ - --bg: #0d1117; - --surface: #161b22; - --surface2: #1c2128; - --border: #30363d; + color-scheme: dark; - /* Text */ - --text: #c9d1d9; - --muted: #8b949e; + /* ── Surfaces ─────────────────────────────────────────────────────── */ + --bg-app: #0d1117; /* outermost page ground */ + --bg-surface: #161b22; /* sidebar, panels, top bar */ + --bg-elevated: #1c2128; /* cards, popovers, hovered rows */ + --bg-user-bubble: #1a2536; /* the operator's own lines */ + --bg-code: #0b0f14; /* code blocks, terminal panes */ + --bg-hover: rgba(255, 255, 255, 0.05); + --bg-active: rgba(255, 255, 255, 0.09); + --bg-scrim: rgba(1, 4, 9, 0.7); - /* Accent */ - --accent: #58a6ff; - --accent-soft: rgba(88, 166, 255, 0.12); - --focus: #79c0ff; + /* ── Foreground ───────────────────────────────────────────────────── */ + --fg-primary: #e6edf3; /* body text, ≈14:1 on --bg-app */ + --fg-secondary: #b1bac4; /* supporting text, ≈8:1 */ + --fg-muted: #8b949e; /* labels, placeholders, ≈5.7:1 */ + --fg-on-accent: #0d1117; /* text on a filled accent button */ - /* Status — never the sole indicator; pair with text/glyph */ - --ok: #3fb950; + /* ── Accent & status ──────────────────────────────────────────────── */ + --accent: #58a6ff; + --accent-hover: #79c0ff; + --accent-subtle: rgba(88, 166, 255, 0.12); + --danger: #f85149; + --danger-subtle: rgba(248, 81, 73, 0.14); --warning: #d29922; - --error: #f85149; + --warning-subtle: rgba(210, 153, 34, 0.14); + --success: #3fb950; + --success-subtle: rgba(63, 185, 80, 0.14); + --info: #79c0ff; - /* Metric hues — carried from the same palette so cost/speed/risk read - apart from status at a glance without inventing new colors. (INT-3402) */ - --purple: #d2a8ff; - --orange: #ffa657; - --risk: #ff7b72; - --on-fill: #ffffff; + /* ── Border & focus ───────────────────────────────────────────────── */ + --border: #30363d; + --border-strong: #484f58; + --focus-ring: 0 0 0 2px var(--bg-app), 0 0 0 4px var(--accent); - /* Metrics */ + /* ── Radius ───────────────────────────────────────────────────────── */ --radius-sm: 6px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; - --radius-pill: 999px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 9999px; - /* Cockpit layout */ - --sidebar-w: 260px; - - --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; + /* ── Spacing (4pt grid) ───────────────────────────────────────────── */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + /* ── Typography ───────────────────────────────────────────────────── */ + --font-sans: 'Inter Variable', Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'Apple SD Gothic Neo', Pretendard, 'Noto Sans KR', 'Malgun Gothic', sans-serif; + --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; --text-xs: 12px; - --text-sm: 13px; - --text-md: 14px; - --text-lg: 16px; + --text-sm: 14px; + --text-base: 15px; + --text-lg: 17px; + --text-xl: 20px; + --leading-body: 1.6; + --leading-tight: 1.3; + + /* ── Motion ───────────────────────────────────────────────────────── */ + --dur-fast: 120ms; + --dur-base: 180ms; + --dur-slow: 300ms; + --ease-out: cubic-bezier(0.2, 0, 0, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + /* ── Z-index ──────────────────────────────────────────────────────── */ + --z-dropdown: 100; + --z-pip: 200; + --z-modal: 300; + --z-toast: 400; + + /* ── Code highlight ───────────────────────────────────────────────── */ + --code-keyword: #ff7b72; + --code-string: #a5d6ff; + --code-comment: #8b949e; + --code-number: #79c0ff; + --code-function: #d2a8ff; + --code-property: #7ee787; + --code-punctuation: #c9d1d9; + + /* ── Charts ───────────────────────────────────────────────────────── */ + --chart-1: #58a6ff; + --chart-2: #3fb950; + --chart-3: #d29922; + --chart-4: #d2a8ff; + --chart-5: #ff7b72; + --chart-6: #79c0ff; + + /* ── Speakers — one hue per agent role, shared by the chat room, the + orchestration graph, and the legend so an agent reads the same colour + on every page. Never the sole indicator: always paired with a label. */ + --role-worker: #58a6ff; + --role-reviewer: #3fb950; + --role-orchestrator: #d29922; + --role-review-agent: #d2a8ff; + --role-daemon: #8b949e; + --role-human: #f85149; + --role-agent: #8b93a5; + + /* ── Interaction kinds — graph edge colours by family. */ + --kind-request: var(--warning); + --kind-response: var(--success); + --kind-human-question: var(--danger); + --kind-human-answer: var(--accent); + --kind-route: var(--role-review-agent); + --kind-plumbing: var(--fg-muted); + + /* ── Exchange status ──────────────────────────────────────────────── */ + --status-pending: var(--warning); + --status-failed: var(--danger); + --status-done: var(--success); + + /* ── Metric hues (cost / speed / risk read apart from status) ─────── */ + --metric-orange: #ffa657; + --metric-risk: #ff7b72; + + /* ── Layout ───────────────────────────────────────────────────────── */ + --sidebar-w: 272px; + --content-max: 800px; /* readable measure for prose columns (§1.1) */ + --topbar-h: 48px; +} + +/* Light: a value swap, never a reversal of the dark palette (§8.1). */ +[data-theme="light"] { + color-scheme: light; + + --bg-app: #ffffff; + --bg-surface: #f6f8fa; + --bg-elevated: #ffffff; + --bg-user-bubble: #ddf0ff; + --bg-code: #f6f8fa; + --bg-hover: rgba(0, 0, 0, 0.04); + --bg-active: rgba(0, 0, 0, 0.08); + --bg-scrim: rgba(27, 31, 36, 0.5); + + --fg-primary: #1f2328; + --fg-secondary: #424a53; + --fg-muted: #656d76; + --fg-on-accent: #ffffff; + + --accent: #0969da; + --accent-hover: #0550ae; + --accent-subtle: rgba(9, 105, 218, 0.1); + --danger: #cf222e; + --danger-subtle: rgba(207, 34, 46, 0.1); + --warning: #9a6700; + --warning-subtle: rgba(154, 103, 0, 0.12); + --success: #1a7f37; + --success-subtle: rgba(26, 127, 55, 0.12); + --info: #0969da; + + --border: #d0d7de; + --border-strong: #8c959f; + + --code-keyword: #cf222e; + --code-string: #0a3069; + --code-comment: #6e7781; + --code-number: #0550ae; + --code-function: #8250df; + --code-property: #116329; + --code-punctuation: #24292f; + + --chart-1: #0969da; + --chart-2: #1a7f37; + --chart-3: #9a6700; + --chart-4: #8250df; + --chart-5: #cf222e; + --chart-6: #0550ae; + + --role-worker: #0969da; + --role-reviewer: #1a7f37; + --role-orchestrator: #9a6700; + --role-review-agent: #8250df; + --role-daemon: #656d76; + --role-human: #cf222e; + --role-agent: #57606a; + + --metric-orange: #bc4c00; + --metric-risk: #cf222e; +} + +/* ── Legacy aliases ──────────────────────────────────────────────────── + Names the pre-AGT-4201 stylesheets used. Kept as indirections (not + values) so both vocabularies always agree; new rules use the semantic + names above. */ +:root { + --bg: var(--bg-app); + --surface: var(--bg-surface); + --surface2: var(--bg-elevated); + --text: var(--fg-primary); + --muted: var(--fg-muted); + --accent-soft: var(--accent-subtle); + --focus: var(--accent-hover); + --ok: var(--success); + --error: var(--danger); + --purple: var(--role-review-agent); + --orange: var(--metric-orange); + --risk: var(--metric-risk); + --on-fill: var(--fg-on-accent); + --radius-xl: var(--radius-lg); + --radius-pill: var(--radius-full); + --font-ui: var(--font-sans); + --text-md: var(--text-sm); } diff --git a/web/static/css/warehouse.css b/web/static/css/warehouse.css index ca6426e7..e795057e 100644 --- a/web/static/css/warehouse.css +++ b/web/static/css/warehouse.css @@ -1,87 +1,108 @@ -:root { - color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #0d1117; - color: #e6edf3; -} - -* { box-sizing: border-box; } -body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, #182235, #0d1117 44%); } -button, input { font: inherit; } -button, a { color: inherit; } +/* Warehouse (AGT-4201). Consumes tokens.css + shell.css. + A file browser on the left, upload and access controls on the right. */ -.topbar { - display: flex; - align-items: center; - gap: 28px; - min-height: 92px; - padding: 20px clamp(20px, 5vw, 72px); - border-bottom: 1px solid #293241; - background: rgba(13, 17, 23, .82); - backdrop-filter: blur(18px); -} -.brand { color: #f5c451; font-weight: 800; text-decoration: none; white-space: nowrap; } -h1, h2, p { margin: 0; } -h1 { font-size: 22px; } -.topbar p { margin-top: 4px; color: #8b98a9; font-size: 13px; } +body.page-warehouse { min-height: 100vh; } -main { +.warehouse-main { display: grid; grid-template-columns: minmax(0, 1fr) 320px; - gap: 22px; + gap: var(--space-5); max-width: 1440px; margin: 0 auto; - padding: 28px clamp(20px, 5vw, 72px); + padding: var(--space-6) clamp(var(--space-4), 5vw, var(--space-12)); } -.browser, .upload { border: 1px solid #293241; border-radius: 14px; background: rgba(18, 24, 33, .92); overflow: hidden; } -.toolbar { display: flex; align-items: center; gap: 10px; min-height: 58px; padding: 10px 14px; border-bottom: 1px solid #293241; } -.toolbar > button, .breadcrumbs button { - border: 1px solid #354052; - border-radius: 8px; - padding: 7px 10px; - background: #192230; - cursor: pointer; + +.browser, .upload { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-surface); + overflow: hidden; +} + +/* ── Browser ─────────────────────────────────────────────────────────── */ + +.toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 56px; + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--border); } -.toolbar > button:disabled { opacity: .4; cursor: default; } -.breadcrumbs { display: flex; align-items: center; gap: 7px; flex: 1; overflow-x: auto; } -.breadcrumbs span { color: #667386; } +.breadcrumbs { display: flex; align-items: center; gap: var(--space-1); flex: 1; overflow-x: auto; } +.breadcrumbs span { color: var(--fg-muted); } .table-wrap { overflow: auto; min-height: 520px; } table { width: 100%; border-collapse: collapse; } -th, td { padding: 13px 16px; border-bottom: 1px solid #222c3a; text-align: left; white-space: nowrap; } -th { color: #8b98a9; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; } -td { font-size: 13px; color: #b9c4d1; } -td:first-child { width: 100%; color: #edf3fa; } -.file-name { border: 0; padding: 0; background: none; color: inherit; cursor: default; } +th, td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; } +th { color: var(--fg-muted); font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; } +td { font-size: var(--text-sm); color: var(--fg-secondary); } +td:first-child { width: 100%; color: var(--fg-primary); } +.file-name { border: 0; padding: 0; background: none; color: inherit; font: inherit; cursor: default; } button.file-name { cursor: pointer; } -td a, .download { border: 0; padding: 0; background: none; color: #79b8ff; text-decoration: none; cursor: pointer; } -.empty { padding: 48px; text-align: center; color: #738096; } -.error { color: #ff8f8f !important; } +button.file-name:hover { color: var(--accent); } +td a, .download { border: 0; padding: 0; background: none; color: var(--accent); font: inherit; text-decoration: none; cursor: pointer; } +td a:hover, .download:hover { text-decoration: underline; } +td.empty { padding: var(--space-12); text-align: center; white-space: normal; } +.error { color: var(--danger) !important; } + +/* ── Upload ──────────────────────────────────────────────────────────── */ + +.upload { align-self: start; padding: var(--space-5); } +.upload h2 { margin: 0 0 var(--space-2); font-size: var(--text-lg); } +.upload > p { margin: 0; color: var(--fg-muted); font-size: var(--text-xs); } +.upload code { color: var(--code-string); } -.upload { align-self: start; padding: 20px; } -.upload h2 { margin-bottom: 8px; font-size: 17px; } -.upload > p { color: #8b98a9; font-size: 12px; } -code { color: #9dd7ff; } -.access { margin: -20px -20px 22px; padding: 20px; border-bottom: 1px solid #293241; background: #111923; } -.access p { margin-bottom: 14px; color: #8b98a9; font-size: 12px; line-height: 1.45; } -.access label { display: block; margin-bottom: 6px; color: #a9b6c7; font-size: 12px; } -.token-row { display: flex; gap: 8px; } -.token-row input { min-width: 0; flex: 1; border: 1px solid #354052; border-radius: 8px; padding: 8px; background: #0d141d; color: #edf3fa; } -.token-row button { border: 1px solid #49627f; border-radius: 8px; padding: 8px 11px; background: #192c42; cursor: pointer; } -.text-button { margin-top: 9px; border: 0; padding: 0; background: none; color: #79b8ff; font-size: 12px; cursor: pointer; } -.dropzone { display: grid; place-items: center; min-height: 130px; margin: 20px 0 14px; border: 1px dashed #4b5d74; border-radius: 12px; background: #111923; color: #a9b6c7; cursor: pointer; text-align: center; padding: 20px; } -.dropzone input { display: none; } -.overwrite { display: flex; gap: 8px; align-items: center; color: #a9b6c7; font-size: 13px; } -.primary { width: 100%; margin-top: 18px; border: 0; border-radius: 9px; padding: 11px; background: #2f81f7; color: white; font-weight: 700; cursor: pointer; } -.primary:disabled { opacity: .45; cursor: default; } -.status { min-height: 20px; margin-top: 12px !important; } -.success { color: #5ed58a !important; } -.status.error { color: #ff8f8f !important; } -.rules { display: grid; gap: 7px; margin-top: 20px; padding-top: 18px; border-top: 1px solid #293241; color: #8492a6; font-size: 12px; line-height: 1.45; } -.rules strong { color: #d9e2ec; } +.access { + margin: calc(-1 * var(--space-5)) calc(-1 * var(--space-5)) var(--space-5); + padding: var(--space-5); + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); +} +.access p { margin: 0 0 var(--space-3); color: var(--fg-muted); font-size: var(--text-xs); line-height: 1.45; } +.access label { display: block; margin-bottom: var(--space-1); color: var(--fg-secondary); font-size: var(--text-xs); } +.token-row { display: flex; gap: var(--space-2); } +.token-row .input { min-width: 0; flex: 1; } +.text-button { margin-top: var(--space-2); padding: 0; border: 0; background: none; color: var(--accent); font: inherit; font-size: var(--text-xs); cursor: pointer; } +.text-button:hover { text-decoration: underline; } + +.dropzone { + display: grid; + place-items: center; + min-height: 130px; + margin: var(--space-5) 0 var(--space-3); + padding: var(--space-5); + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-elevated); + color: var(--fg-secondary); + text-align: center; + cursor: pointer; +} +.dropzone:hover, .dropzone:focus-within { border-color: var(--accent); } +.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.overwrite { display: flex; gap: var(--space-2); align-items: center; color: var(--fg-secondary); font-size: var(--text-sm); } +.overwrite input { accent-color: var(--accent); } +.upload .btn-primary { width: 100%; margin-top: var(--space-4); } +.upload .btn-primary.is-armed { background: var(--danger); border-color: var(--danger); color: var(--fg-on-accent); } +.status { min-height: 20px; margin: var(--space-3) 0 0; } +.status-line.is-warning { color: var(--warning); } +.success { color: var(--success) !important; } + +.rules { + display: grid; + gap: var(--space-2); + margin-top: var(--space-5); + padding-top: var(--space-4); + border-top: 1px solid var(--border); + color: var(--fg-muted); + font-size: var(--text-xs); + line-height: 1.45; +} +.rules strong { color: var(--fg-primary); } @media (max-width: 860px) { - main { grid-template-columns: 1fr; } + .warehouse-main { grid-template-columns: 1fr; } .upload { order: -1; } th:nth-child(2), td:nth-child(2), th:nth-child(4), td:nth-child(4) { display: none; } } diff --git a/web/static/fonts/LICENSE.inter b/web/static/fonts/LICENSE.inter new file mode 100644 index 00000000..046fc664 --- /dev/null +++ b/web/static/fonts/LICENSE.inter @@ -0,0 +1,93 @@ +Google Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/static/fonts/inter-latin-wght-normal.woff2 b/web/static/fonts/inter-latin-wght-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..798d6d9f6cabc933d82f43e47a58b43835e4b631 GIT binary patch literal 48444 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 N2D$e+|L^@6X8`S>M5F)! literal 0 HcmV?d00001 diff --git a/web/static/js/chatView.mjs b/web/static/js/chatView.mjs index 4498523a..b34c45a9 100644 --- a/web/static/js/chatView.mjs +++ b/web/static/js/chatView.mjs @@ -10,8 +10,15 @@ import { buildChatThreads, latestAddressable, openQuestionFor } from './conversationModel.mjs'; import { ROLE_COLORS } from './orchestrationView.mjs'; +import { autogrow, bindDraft, bindEnterToSubmit, setSendEnabled, setSendingState } from './composer.mjs'; +import { createScrollFollow } from './scrollFollow.mjs'; + +// The pin/release rule lives with the other stream-following behaviour now; +// re-exported so the room's callers (and its tests) keep one import. +export { isNearBottom } from './scrollFollow.mjs'; const PENDING = new Set(['open', 'waiting', 'running']); +const DRAFT_KEY = 'openswarm.chat.draft'; function escapeHtml(text) { return String(text ?? '').replace(/[&<>"']/g, (ch) => ( @@ -23,15 +30,6 @@ function clockOf(timestamp) { return new Date(timestamp).toLocaleTimeString(); } -/** - * Whether the room should stay pinned to the newest line. Reading scrollback - * must survive a redraw, so the pin releases as soon as the operator scrolls - * meaningfully away from the bottom. - */ -export function isNearBottom({ scrollHeight, scrollTop, clientHeight }, slack = 40) { - return scrollHeight - scrollTop - clientHeight <= slack; -} - function mentionMarkup(name, role = '') { const classes = `mention${role === 'human' ? ' mention-human' : ''}`; return `@'${escapeHtml(name)}'`; @@ -217,11 +215,15 @@ export function renderThread(thread, mentionTargets = [], taskReferences = new M } /** Entry point: backfill from the trace, render, keep live via SSE + polling. */ -export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 } = {}) { +export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000, storage } = {}) { const fetcher = fetchImpl ?? ((url, init) => fetch(url, init)); const byId = new Map(); let taskReferences = new Map(); - let stick = true; + // Whether the first load has failed and nothing is on screen yet: the empty + // state then says "unreachable", not "silent" (§7.1 keeps partial results; + // here there are none to keep, so the message is the whole surface). + let loadFailed = false; + let renderedLines = 0; const room = doc.getElementById('room'); const form = doc.getElementById('composer'); @@ -230,7 +232,15 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 const fileInput = doc.getElementById('composer-file'); const attachButton = doc.getElementById('composer-attach'); const fileRow = doc.getElementById('composer-files'); + // Optional shell affordances — absent in the bare test shell and in older + // embeds, so every use is guarded. + const dropOverlay = doc.getElementById('drop-overlay'); + const follow = createScrollFollow(room, { + button: doc.getElementById('scroll-latest'), + liveRegion: doc.getElementById('room-live'), + }); let sending = false; + let addressable = false; // Files the operator has staged but not yet sent. Held until the message goes // out so a refused send does not silently discard them (AGT-4026's rule, // applied to attachments). @@ -242,16 +252,31 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 // a new one. const uploaded = new Map(); + /** Recompute whether Send is possible from what the box and the room hold now. */ + const syncSendState = () => { + setSendEnabled(button, { + text: input?.value ?? '', + files: pendingFiles.length, + addressable, + sending, + }); + }; + const renderFiles = () => { if (!fileRow) return; fileRow.innerHTML = ''; pendingFiles.forEach((file, index) => { const chip = doc.createElement('span'); chip.className = 'chip'; - chip.textContent = `${file.name} (${Math.max(1, Math.round(file.size / 1024))} KB)`; + const label = doc.createElement('span'); + label.className = 'chip-label'; + label.textContent = `${file.name} (${Math.max(1, Math.round(file.size / 1024))} KB)`; + chip.appendChild(label); const drop = doc.createElement('button'); drop.type = 'button'; + drop.className = 'chip-remove'; drop.textContent = '×'; + drop.setAttribute('aria-label', `Remove ${file.name}`); drop.title = `Remove ${file.name}`; drop.addEventListener('click', () => { uploaded.delete(file); @@ -261,6 +286,7 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 chip.appendChild(drop); fileRow.appendChild(chip); }); + syncSendState(); }; const stageFiles = (list) => { @@ -319,12 +345,18 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 sending = pending; line.textContent = pending ? 'Sending…' : message; line.classList.toggle('is-error', !pending && !!message); - const addressable = !!latestAddressable([...byId.values()]); + addressable = !!latestAddressable([...byId.values()]); if (input) input.disabled = pending || !addressable; - if (button) button.disabled = pending || !addressable; + setSendingState(button, pending); + syncSendState(); }; - room.addEventListener('scroll', () => { stick = isNearBottom(room); }); + const emptyState = () => { + if (loadFailed) { + return '
Could not reach the daemon — retrying…
'; + } + return '
No one has said anything yet.
'; + }; const redraw = () => { const events = [...byId.values()]; @@ -341,10 +373,11 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 ]; room.innerHTML = threads.length ? threads.map((thread) => renderThread(thread, mentionTargets, taskReferences)).join('') - : '
No one has said anything yet.
'; + : emptyState(); // The composer can only address an agent that exists; without one the // POST would be unroutable (the API requires repository/taskId/recipient). const target = latestAddressable(events); + addressable = !!target; const question = target ? openQuestionFor(events, target.actor, { repository: target.repository, taskId: target.taskId }) : null; @@ -361,8 +394,11 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 ? `Answer ${target.actorName || target.actor}: ${(question.summary || '').slice(0, 60)}…` : `Message ${target.actorName || target.actor}…`; } - if (button) button.disabled = !target || sending; - if (stick) room.scrollTop = room.scrollHeight; + syncSendState(); + // One announcement per batch of arrivals, not one per line (§8.5). + if (lines.length > renderedLines) follow.announce(lines.length - renderedLines); + renderedLines = lines.length; + follow.follow(); }; const absorb = (event) => { @@ -454,23 +490,32 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 async function backfill() { try { const response = await fetcher('/api/coordination/history?limit=500'); - if (!response.ok) return; + if (!response.ok) throw new Error(`history ${response.status}`); const snapshot = await response.json(); + loadFailed = false; let changed = false; for (const event of snapshot.events ?? []) changed = absorb(event) || changed; if (changed || byId.size === 0) redraw(); - } catch { /* transient; the poll below retries via the board */ } + } catch { + // Transient; the poll below retries via the board. Until something + // lands, say the daemon is unreachable rather than showing an empty room. + if (byId.size === 0) { loadFailed = true; redraw(); } + } } async function refresh() { try { const response = await fetcher('/api/coordination'); - if (!response.ok) return; + if (!response.ok) throw new Error(`board ${response.status}`); const snapshot = await response.json(); + const recovered = loadFailed; + loadFailed = false; let changed = false; for (const event of snapshot.events ?? []) changed = absorb(event) || changed; - if (changed) redraw(); - } catch { /* transient; the next poll retries */ } + if (changed || recovered) redraw(); + } catch { + if (byId.size === 0 && !loadFailed) { loadFailed = true; redraw(); } + } } /** Load issue titles/links once from the daemon's local projections. */ @@ -526,9 +571,14 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 doc.addEventListener('dragover', swallowStrayDrop); doc.addEventListener('drop', swallowStrayDrop); - // Drag-and-drop onto the room, the way an operator expects it to work. + // Drag-and-drop onto the room, the way an operator expects it to work. The + // overlay says what a drop will do (§2.1); the body class keeps the older + // outline styling working where the overlay element is absent. if (room) { - const highlight = (on) => doc.body?.classList.toggle('dropping', on); + const highlight = (on) => { + doc.body?.classList.toggle('dropping', on); + if (dropOverlay) dropOverlay.hidden = !on; + }; room.addEventListener('dragover', (dragEvent) => { dragEvent.preventDefault(); highlight(true); }); room.addEventListener('dragleave', () => highlight(false)); room.addEventListener('drop', (dropEvent) => { @@ -538,6 +588,13 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 }); } + // Composer behaviours: grow with the text, Enter sends (never mid-IME), + // and an unsent draft survives a reload. + const draft = bindDraft(input, { storage: storage ?? doc.defaultView?.localStorage, key: DRAFT_KEY }); + const regrow = autogrow(input); + bindEnterToSubmit(input, form); + input?.addEventListener('input', syncSendState); + if (form) { form.addEventListener('submit', async (submitEvent) => { submitEvent.preventDefault(); @@ -550,7 +607,12 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 setComposerStatus('', { pending: true }); const failure = await send(text); setComposerStatus(failure ?? '', { pending: false }); - if (!failure) input.value = ''; + if (!failure) { + input.value = ''; + draft.clear(); + regrow(); + syncSendState(); + } }); } @@ -559,6 +621,7 @@ export function startChatView(doc, { fetchImpl, eventSourceImpl, pollMs = 30_000 stop: () => { clearInterval(timer); source?.close(); + follow.stop(); doc.removeEventListener('dragover', swallowStrayDrop); doc.removeEventListener('drop', swallowStrayDrop); }, diff --git a/web/static/js/composer.mjs b/web/static/js/composer.mjs new file mode 100644 index 00000000..7871f1a0 --- /dev/null +++ b/web/static/js/composer.mjs @@ -0,0 +1,90 @@ +// Composer behaviours (§2.1): the parts of "type a message" that every chat +// surface needs and that are easy to get subtly wrong. Each helper binds one +// behaviour to elements it is handed, so a page composes only what it has — +// the orchestration reply box and the chat room share these without sharing +// markup. (AGT-4201) + +const MAX_GROW_RATIO = 0.4; // ≈ viewport 40% before the box scrolls internally + +/** Grow a textarea with its content, up to a share of the viewport. */ +export function autogrow(input, { win = input.ownerDocument?.defaultView } = {}) { + if (!input || input.tagName !== 'TEXTAREA') return () => {}; + const resize = () => { + input.style.height = 'auto'; + const max = win ? Math.floor(win.innerHeight * MAX_GROW_RATIO) : Infinity; + const wanted = input.scrollHeight; + input.style.height = `${Math.min(wanted, max)}px`; + input.style.overflowY = wanted > max ? 'auto' : 'hidden'; + }; + input.addEventListener('input', resize); + resize(); + return resize; +} + +/** + * Enter sends, Shift+Enter breaks the line, and Enter during an IME + * composition does neither — committing a Korean/Japanese syllable must never + * fire the message (§2.1). Native inputs already submit on Enter, so for them + * only the composition guard applies. + */ +export function bindEnterToSubmit(input, form) { + if (!input || !form) return; + input.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + if (event.isComposing || event.keyCode === 229) { + event.preventDefault(); + return; + } + if (event.shiftKey) return; // newline (textarea) — leave the default alone + if (input.tagName !== 'TEXTAREA') return; // an submits natively + event.preventDefault(); + if (typeof form.requestSubmit === 'function') form.requestSubmit(); + else form.dispatchEvent(new (input.ownerDocument.defaultView.Event)('submit', { bubbles: true, cancelable: true })); + }); +} + +/** + * Keep an unsent draft across reloads and navigation (§2.1). Only text the + * operator actually typed is saved — a value set by script is not a draft. + */ +export function bindDraft(input, { storage, key }) { + if (!input || !storage || !key) return { clear() {} }; + const read = () => { try { return storage.getItem(key) ?? ''; } catch { return ''; } }; + const write = (value) => { + try { + if (value) storage.setItem(key, value); + else storage.removeItem(key); + } catch { /* storage blocked — the draft lives for this page only */ } + }; + if (!input.value) { + const saved = read(); + if (saved) { + input.value = saved; + input.dispatchEvent(new (input.ownerDocument.defaultView.Event)('input', { bubbles: true })); + } + } + input.addEventListener('input', () => write(input.value)); + return { clear: () => write('') }; +} + +/** Send is possible only with something to send, someone to send it to, and no send in flight. */ +export function canSend({ text = '', files = 0, addressable = true, sending = false }) { + return !sending && addressable && (String(text).trim().length > 0 || files > 0); +} + +/** Reflect the send affordance on the button (disabled) and the box (aria). */ +export function setSendEnabled(button, state) { + if (!button) return; + button.disabled = !canSend(state); +} + +/** + * While a send is in flight the button becomes the in-progress indicator, in + * the same place and size (§2.1 "same position, same size"), and says so to + * assistive tech through aria-busy. + */ +export function setSendingState(button, sending, { idleLabel = 'Send', busyLabel = 'Sending…' } = {}) { + if (!button) return; + button.setAttribute('aria-busy', String(!!sending)); + button.textContent = sending ? busyLabel : idleLabel; +} diff --git a/web/static/js/main.mjs b/web/static/js/main.mjs index 10a2d06e..528e63a7 100644 --- a/web/static/js/main.mjs +++ b/web/static/js/main.mjs @@ -15,6 +15,9 @@ import { SessionPanel } from './sessionPanel.mjs'; import { DiffPanel } from './diffPanel.mjs'; import { Nav } from './nav.mjs'; import { resolveSelection } from './sessionSelection.mjs'; +import { installThemeToggle } from './theme.mjs'; + +installThemeToggle(document, document.getElementById('theme-toggle')); const statusDot = document.querySelector('#daemon-status .dot'); const statusLabel = document.querySelector('#daemon-status .label'); diff --git a/web/static/js/orchestrationModel.mjs b/web/static/js/orchestrationModel.mjs index 011e4e02..ebc86048 100644 --- a/web/static/js/orchestrationModel.mjs +++ b/web/static/js/orchestrationModel.mjs @@ -296,17 +296,19 @@ export function shortTaskLabel(taskId) { /** Per-kind edge colors — one hue per interaction family, stable for the legend. */ export const KIND_COLORS = { - 'advice-request': '#e8b339', - 'advice-response': '#4cc38a', - 'delegation-request': '#e8b339', - 'delegation-result': '#4cc38a', - 'human-question': '#e5484d', - 'human-answer': '#3b9eff', - 'adapter-route': '#9d7cd8', - 'review-run': '#4cc38a', - 'mcp-audit': '#9d7cd8', - 'thread-update': '#3b9eff', - 'instruction-snapshot': '#6c7086', + // Token references, not colours: the theme (tokens.css) decides what an + // exchange kind looks like, and the light palette gets its own values. + 'advice-request': 'var(--kind-request)', + 'advice-response': 'var(--kind-response)', + 'delegation-request': 'var(--kind-request)', + 'delegation-result': 'var(--kind-response)', + 'human-question': 'var(--kind-human-question)', + 'human-answer': 'var(--kind-human-answer)', + 'adapter-route': 'var(--kind-route)', + 'review-run': 'var(--kind-response)', + 'mcp-audit': 'var(--kind-route)', + 'thread-update': 'var(--kind-human-answer)', + 'instruction-snapshot': 'var(--kind-plumbing)', }; /** Dominant kind of an edge — the color it is drawn with. */ diff --git a/web/static/js/orchestrationView.mjs b/web/static/js/orchestrationView.mjs index 00e39778..112ac39f 100644 --- a/web/static/js/orchestrationView.mjs +++ b/web/static/js/orchestrationView.mjs @@ -15,23 +15,41 @@ import { layoutTiers } from './tierLayout.mjs'; import { buildThreads, chatLineOf, isUtterance, metadataPairs, openQuestionFor, taskLabelOf, threadFor, } from './conversationModel.mjs'; +import { autogrow, bindEnterToSubmit, setSendEnabled, setSendingState } from './composer.mjs'; +// Token references, not colours (AGT-4201): every role's paint is declared +// once in tokens.css, so the light theme and the chat room agree with the graph. export const ROLE_COLORS = { - worker: '#3b9eff', - reviewer: '#4cc38a', - orchestrator: '#e8b339', - 'review-agent': '#9d7cd8', - daemon: '#6c7086', - human: '#e5484d', - agent: '#8b93a5', + worker: 'var(--role-worker)', + reviewer: 'var(--role-reviewer)', + orchestrator: 'var(--role-orchestrator)', + 'review-agent': 'var(--role-review-agent)', + daemon: 'var(--role-daemon)', + human: 'var(--role-human)', + agent: 'var(--role-agent)', }; const PENDING = new Set(['open', 'waiting', 'running']); const SVG_NS = 'http://www.w3.org/2000/svg'; +/** + * Apply an SVG paint. A custom-property reference in a presentation attribute + * resolves in current engines, but the inline style is what the cascade is + * guaranteed to read — so a token is written to both, and the attribute stays + * inspectable. + */ +function paint(node, key, value) { + const text = String(value); + node.setAttribute(key, text); + if (text.startsWith('var(') && typeof node.style?.setProperty === 'function') node.style.setProperty(key, text); +} + function el(doc, tag, attrs = {}) { const node = doc.createElementNS(SVG_NS, tag); - for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value)); + for (const [key, value] of Object.entries(attrs)) { + if (key === 'fill' || key === 'stroke') paint(node, key, value); + else node.setAttribute(key, String(value)); + } return node; } @@ -181,13 +199,13 @@ export function renderGraph(doc, model, layout, selected, onSelect, spotlight = if (index % 2 === 1) { bandLayer.appendChild(el(doc, 'rect', { x: 0, y: band.y0, width, height: band.y1 - band.y0, - fill: '#ffffff', 'fill-opacity': 0.025, 'data-band': band.id, + class: 'band-stripe', 'data-band': band.id, })); } if (index > 0) { bandLayer.appendChild(el(doc, 'line', { x1: 0, y1: band.y0, x2: width, y2: band.y0, - stroke: '#1f2633', 'stroke-dasharray': band.kind === 'lane' ? '2 4' : '4 6', + class: 'band-rule', 'stroke-dasharray': band.kind === 'lane' ? '2 4' : '4 6', })); } const label = el(doc, 'text', { @@ -208,7 +226,7 @@ export function renderGraph(doc, model, layout, selected, onSelect, spotlight = }); bandLayer.appendChild(el(doc, 'line', { x1: labelGutter - 14, y1: bands[0].y0, x2: labelGutter - 14, y2: bands[bands.length - 1].y1, - stroke: '#1f2633', + class: 'gutter-rule', })); const neighbors = new Set(); @@ -246,7 +264,7 @@ export function renderGraph(doc, model, layout, selected, onSelect, spotlight = const my = (a.y + b.y) / 2 + (a.x - b.x) * 0.12; path.setAttribute('d', `M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`); path.setAttribute('class', `edge${selected && edge.from !== selected && edge.to !== selected ? ' dimmed' : ''}`); - path.setAttribute('stroke', KIND_COLORS[dominantKind(edge)] ?? '#6c7086'); + paint(path, 'stroke', KIND_COLORS[dominantKind(edge)] ?? 'var(--kind-plumbing)'); path.setAttribute('stroke-width', String(Math.min(6, 1 + Math.log2(1 + edge.count)))); path.querySelector('title').textContent = `${nameOf(edge.from)} → ${nameOf(edge.to)}: ${edge.count} (${Object.entries(edge.kinds).map(([k, n]) => `${k}×${n}`).join(', ')})`; @@ -273,17 +291,17 @@ export function renderGraph(doc, model, layout, selected, onSelect, spotlight = const radius = nodeRadius(node); if (node.pendingCount > 0) { - group.appendChild(el(doc, 'circle', { r: radius + 6, fill: 'none', stroke: '#e8b339', 'stroke-width': 2, class: 'pulse' })); + group.appendChild(el(doc, 'circle', { r: radius + 6, fill: 'none', stroke: 'var(--warning)', 'stroke-width': 2, class: 'pulse' })); } if (node.id === speaking) { group.setAttribute('data-speaking', 'true'); group.appendChild(el(doc, 'circle', { - r: radius + 10, fill: 'none', stroke: '#e6e9ef', 'stroke-width': 2.5, class: 'speaking', + r: radius + 10, fill: 'none', stroke: 'var(--fg-primary)', 'stroke-width': 2.5, class: 'speaking', })); } else if (node.id === spokenTo) { group.setAttribute('data-spoken-to', 'true'); group.appendChild(el(doc, 'circle', { - r: radius + 10, fill: 'none', stroke: '#e6e9ef', 'stroke-width': 1.5, + r: radius + 10, fill: 'none', stroke: 'var(--fg-primary)', 'stroke-width': 1.5, 'stroke-opacity': 0.45, 'stroke-dasharray': '3 4', class: 'spoken-to', })); } @@ -291,7 +309,7 @@ export function renderGraph(doc, model, layout, selected, onSelect, spotlight = r: radius, fill: ROLE_COLORS[node.role] ?? ROLE_COLORS.agent, 'fill-opacity': node.active ? 0.9 : 0.35, - stroke: node.id === selected ? '#e6e9ef' : 'transparent', + stroke: node.id === selected ? 'var(--fg-primary)' : 'transparent', 'stroke-width': 2, })); const label = el(doc, 'text', { y: radius + 12 }); @@ -307,7 +325,7 @@ export function renderLegend(doc) { const rows = [ ...Object.entries(ROLE_COLORS).filter(([role]) => role !== 'agent') .map(([role, color]) => `
${role}
`), - '
edges
', + '
edges
', `
advice / delegation
`, `
human question
`, `
route / mcp
`, @@ -394,8 +412,8 @@ export function renderDetail(doc, model, events, selected) { } function statusColor(status) { - if (PENDING.has(status)) return '#e8b339'; - return status === 'failed' || status === 'expired' ? '#e5484d' : '#4cc38a'; + if (PENDING.has(status)) return 'var(--status-pending)'; + return status === 'failed' || status === 'expired' ? 'var(--status-failed)' : 'var(--status-done)'; } function clockOf(timestamp) { @@ -439,7 +457,8 @@ function applyComposerState(doc, form, pending) { input.disabled = active; if (pending?.text !== undefined) input.value = pending.text; } - if (button) button.disabled = active; + setSendingState(button, active); + setSendEnabled(button, { text: input?.value ?? '', addressable: true, sending: active }); } function dialogueLine(event, max = 140) { @@ -524,9 +543,9 @@ export function renderThread(doc, thread, onSend, pending = null) { const target = thread.replyTo ? escapeHtml(thread.replyTo.name) : null; const composer = target ? `
- - + +
` : '
No agent in this exchange can be addressed
'; @@ -544,6 +563,14 @@ export function renderThread(doc, thread, onSend, pending = null) { // therefore lives in the caller and is re-applied on every render below, // rather than being held in DOM nodes this handler captured. applyComposerState(doc, form, pending); + // The box is rebuilt on every render, so its behaviours are re-bound here: + // grow with the reply, Enter sends (never mid-IME), Send lights up with text. + const box = doc.getElementById('composer-text'); + autogrow(box); + bindEnterToSubmit(box, form); + box?.addEventListener('input', () => { + setSendEnabled(form.querySelector('button'), { text: box.value, addressable: true, sending: !!pending?.active }); + }); form.addEventListener('submit', (submitEvent) => { submitEvent.preventDefault(); diff --git a/web/static/js/scrollFollow.mjs b/web/static/js/scrollFollow.mjs new file mode 100644 index 00000000..9dc89a8a --- /dev/null +++ b/web/static/js/scrollFollow.mjs @@ -0,0 +1,66 @@ +// Auto-follow for a growing stream (§2.3): stay pinned while the reader is at +// the bottom, release the moment they scroll up to read, and offer one button +// to come back. New arrivals while released are announced once per batch to +// assistive tech — never one utterance per line. (AGT-4201) + +/** + * Whether the pane is close enough to the bottom to keep following. 80px of + * slack absorbs the jitter of a line landing mid-scroll without treating a + * deliberate scroll-up as "still at the bottom". + */ +export function isNearBottom({ scrollHeight, scrollTop, clientHeight }, slack = 80) { + return scrollHeight - scrollTop - clientHeight <= slack; +} + +export function createScrollFollow(pane, { button = null, liveRegion = null } = {}) { + if (!pane) { + return { get stick() { return true; }, follow() {}, announce() {}, stop() {} }; + } + let stick = true; + let unseen = 0; + + const paintButton = () => { + if (!button) return; + button.hidden = stick; + if (!stick && unseen > 0) button.textContent = `↓ ${unseen} new`; + else if (!stick) button.textContent = '↓ Latest'; + }; + + const jumpToLatest = () => { + pane.scrollTop = pane.scrollHeight; + stick = true; + unseen = 0; + paintButton(); + }; + + const onScroll = () => { + const near = isNearBottom(pane); + if (near && !stick) unseen = 0; + stick = near; + paintButton(); + }; + + pane.addEventListener('scroll', onScroll); + button?.addEventListener('click', jumpToLatest); + paintButton(); + + return { + get stick() { return stick; }, + /** Call after content changed: keeps the pin if it was set. */ + follow() { + if (stick) pane.scrollTop = pane.scrollHeight; + paintButton(); + }, + /** Announce a batch of arrivals; counts unseen ones for the button. */ + announce(count) { + if (!(count > 0)) return; + if (!stick) unseen += count; + paintButton(); + if (liveRegion) liveRegion.textContent = `${count} new message${count === 1 ? '' : 's'}`; + }, + stop() { + pane.removeEventListener('scroll', onScroll); + button?.removeEventListener('click', jumpToLatest); + }, + }; +} diff --git a/web/static/js/theme.mjs b/web/static/js/theme.mjs new file mode 100644 index 00000000..7c0f671a --- /dev/null +++ b/web/static/js/theme.mjs @@ -0,0 +1,77 @@ +// Theme state: resolve, apply, persist, toggle. (AGT-4201, §1.1/§6.4) +// +// themeBoot.js already stamped `data-theme` on before first paint; +// this module owns everything after that — the toggle button, following the +// OS while no explicit choice is stored, and remembering the choice. + +export const THEME_KEY = 'openswarm.theme'; +export const THEMES = ['dark', 'light']; + +/** Explicit stored choice wins; otherwise the OS preference; otherwise dark. */ +export function resolveTheme({ stored, prefersLight }) { + if (THEMES.includes(stored)) return stored; + return prefersLight ? 'light' : 'dark'; +} + +export function applyTheme(doc, theme) { + doc.documentElement.setAttribute('data-theme', theme); +} + +function readStored(storage) { + try { return storage?.getItem(THEME_KEY) ?? null; } catch { return null; } +} + +function writeStored(storage, theme) { + try { storage?.setItem(THEME_KEY, theme); } catch { /* storage blocked — the choice lives for this page only */ } +} + +/** + * Wire a toggle button. The button carries `aria-pressed` = "light mode is + * on" and a spoken label that names the mode it will switch TO, so a screen + * reader hears an action, not a state. + */ +export function installThemeToggle(doc, button, { + storage = doc.defaultView?.localStorage, + matchMedia = doc.defaultView?.matchMedia?.bind(doc.defaultView), +} = {}) { + const query = matchMedia ? matchMedia('(prefers-color-scheme: light)') : null; + + const current = () => doc.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; + + const paint = () => { + const theme = current(); + if (!button) return; + button.setAttribute('aria-pressed', String(theme === 'light')); + button.setAttribute('aria-label', theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme'); + button.title = button.getAttribute('aria-label'); + }; + + const set = (theme) => { + applyTheme(doc, theme); + paint(); + }; + + // Settle the initial state from the same rule the boot script used, so a + // page that loaded without themeBoot.js (tests, embedded hosts) still lands + // on the right theme. + set(resolveTheme({ stored: readStored(storage), prefersLight: !!query?.matches })); + + button?.addEventListener('click', () => { + const next = current() === 'light' ? 'dark' : 'light'; + writeStored(storage, next); + set(next); + }); + + // Follow the OS only while the operator has not chosen explicitly. + const onSystemChange = (event) => { + if (THEMES.includes(readStored(storage))) return; + set(event.matches ? 'light' : 'dark'); + }; + query?.addEventListener?.('change', onSystemChange); + + return { + get theme() { return current(); }, + set, + stop() { query?.removeEventListener?.('change', onSystemChange); }, + }; +} diff --git a/web/static/js/themeBoot.js b/web/static/js/themeBoot.js new file mode 100644 index 00000000..9e4af5cc --- /dev/null +++ b/web/static/js/themeBoot.js @@ -0,0 +1,18 @@ +// Theme bootstrap — a classic (non-module) script so it runs synchronously in +// , before first paint. A deferred module would let the dark default +// flash for one frame on a light-preferring machine. (AGT-4201, §1.1/§6.4) +// +// Resolution: an explicit choice stored by theme.mjs wins; otherwise follow +// the OS. tokens.css only knows `[data-theme="light"]` — dark is the base. +(function () { + var theme = 'dark'; + try { + var stored = window.localStorage.getItem('openswarm.theme'); + if (stored === 'light' || stored === 'dark') { + theme = stored; + } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + theme = 'light'; + } + } catch (_) { /* storage blocked — stay on the dark default */ } + document.documentElement.setAttribute('data-theme', theme); +})(); diff --git a/web/static/js/threadBoard.mjs b/web/static/js/threadBoard.mjs index de508c15..2bea2604 100644 --- a/web/static/js/threadBoard.mjs +++ b/web/static/js/threadBoard.mjs @@ -152,19 +152,29 @@ export function startThreadBoard(doc, { fetchImpl = globalThis.fetch, pollMs = 5 const reply = doc.getElementById('reply-form'); const follow = doc.getElementById('follow'); const resolve = doc.getElementById('resolve'); + // Optional in-page confirmation for resolving (§3.2). Shells without it + // (older embeds, the bare test shell) resolve on the first click. + const confirmCard = doc.getElementById('resolve-confirm'); + const confirmText = doc.getElementById('resolve-confirm-text'); + const confirmButton = doc.getElementById('resolve-confirm-btn'); + const cancelButton = doc.getElementById('resolve-cancel'); const state = { repository: '', threads: [], selectedId: null, detail: null, stopped: false, generation: 0 }; const say = (message, error = false) => { status.textContent = message; - status.className = error ? 'error' : ''; + status.className = error ? 'status-line threads-status is-error error' : 'status-line threads-status'; }; + const hideConfirm = () => { if (confirmCard) confirmCard.hidden = true; }; + const repositoryForThread = (threadId = state.selectedId) => state.threads .find((thread) => thread.id === threadId)?.repository ?? state.repository; const repositoryQuery = (threadId) => `repository=${encodeURIComponent(repositoryForThread(threadId))}`; async function loadDetail(threadId = state.selectedId) { if (!threadId || state.stopped) return; + // A pending "resolve X?" must not outlive the thread it named. + if (threadId !== state.detail?.thread?.id) hideConfirm(); try { const detail = await request(fetchImpl, `/api/coordination/threads/${encodeURIComponent(threadId)}?${repositoryQuery(threadId)}&messageLimit=200`); if (state.stopped || threadId !== state.selectedId) return; @@ -317,8 +327,9 @@ export function startThreadBoard(doc, { fetchImpl = globalThis.fetch, pollMs = 5 } }); - resolve.addEventListener('click', async () => { + const performResolve = async () => { if (!state.selectedId || !state.detail) return; + hideConfirm(); try { await request(fetchImpl, `/api/coordination/threads/${encodeURIComponent(state.selectedId)}/resolve`, { method: 'POST', body: JSON.stringify({ @@ -329,7 +340,21 @@ export function startThreadBoard(doc, { fetchImpl = globalThis.fetch, pollMs = 5 } catch (error) { say(error instanceof Error ? error.message : String(error), true); } + }; + + resolve.addEventListener('click', () => { + if (!state.selectedId || !state.detail) return; + // Resolving is one-way for the agents on the thread, so the card names + // the thread and the version and asks once — in the page, since the + // Tauri WebView has no window.confirm. + if (!confirmCard || !confirmButton) { void performResolve(); return; } + const { subject, version } = state.detail.thread; + if (confirmText) confirmText.textContent = `Resolve “${subject}” at version ${version}? Agents can no longer post to it.`; + confirmCard.hidden = false; + confirmButton.focus(); }); + confirmButton?.addEventListener('click', () => { void performResolve(); }); + cancelButton?.addEventListener('click', () => { hideConfirm(); resolve.focus(); }); void loadRepositories(); const timer = pollMs > 0 ? setInterval(() => { void loadThreads(); }, pollMs) : null; diff --git a/web/static/js/warehouse.mjs b/web/static/js/warehouse.mjs index eb3a23d8..4710fabc 100644 --- a/web/static/js/warehouse.mjs +++ b/web/static/js/warehouse.mjs @@ -1,3 +1,5 @@ +import { installThemeToggle } from './theme.mjs'; + const entriesBody = document.querySelector('#entries'); const breadcrumbs = document.querySelector('#breadcrumbs'); const upButton = document.querySelector('#up'); @@ -50,10 +52,21 @@ async function api(url, options) { } function setStatus(message, kind = '') { - status.className = `status${kind ? ` ${kind}` : ''}`; + status.className = `status-line status${kind ? ` is-${kind} ${kind}` : ''}`; status.textContent = message; } +// Replacing a file is the one thing here an operator cannot take back, so the +// checkbox alone is not consent: the first click names the file that would be +// replaced and asks once more, in the page (§3.2 — no window.confirm). +let armedOverwrite = null; + +function disarmOverwrite() { + armedOverwrite = null; + uploadButton.classList.remove('is-armed'); + uploadButton.textContent = '업로드'; +} + function renderBreadcrumbs() { breadcrumbs.replaceChildren(); const root = document.createElement('button'); @@ -146,14 +159,28 @@ fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; fileLabel.textContent = file ? `${file.name} · ${formatBytes(file.size)}` : '파일을 선택하세요'; uploadButton.disabled = !file; + disarmOverwrite(); +}); + +overwriteInput.addEventListener('change', () => { + if (!overwriteInput.checked) disarmOverwrite(); }); uploadButton.addEventListener('click', async () => { const file = fileInput.files?.[0]; if (!file) return; - uploadButton.disabled = true; - setStatus('업로드 중…'); const target = joinPath(currentPath, file.name); + if (overwriteInput.checked && armedOverwrite !== target) { + armedOverwrite = target; + uploadButton.classList.add('is-armed'); + uploadButton.textContent = '덮어쓰기 확인'; + setStatus(`/${target} 을(를) 새 파일로 바꿉니다. 한 번 더 누르면 실행합니다.`, 'warning'); + return; + } + disarmOverwrite(); + uploadButton.disabled = true; + uploadButton.setAttribute('aria-busy', 'true'); + setStatus('업로드 중…', 'pending'); try { const overwrite = overwriteInput.checked ? '&overwrite=true' : ''; await api(`/api/warehouse/file?path=${encodeURIComponent(target)}${overwrite}`, { @@ -169,6 +196,7 @@ uploadButton.addEventListener('click', async () => { } catch (error) { setStatus(error.message, 'error'); } finally { + uploadButton.removeAttribute('aria-busy'); uploadButton.disabled = !fileInput.files?.length; } }); @@ -191,4 +219,5 @@ clearTokenButton.addEventListener('click', () => { upButton.addEventListener('click', () => loadTree(parts(currentPath).slice(0, -1).join('/'))); refreshButton.addEventListener('click', () => loadTree(currentPath)); +installThemeToggle(document, document.querySelector('#theme-toggle')); loadTree(''); diff --git a/web/static/orchestration.html b/web/static/orchestration.html index 356421f8..cd3669bc 100644 --- a/web/static/orchestration.html +++ b/web/static/orchestration.html @@ -3,155 +3,75 @@ - OpenSwarm Orchestration - + OpenSwarm · Orchestration + + + + - -
-

⚙ ORCHESTRATION

- ← dashboard - chat room → - repository threads → -
+ +
+ 🐝 OpenSwarm + Orchestration + +
+
+ +
+
-
- - - - + +
+
+ + + + +
+
-
+ +
-
+
-
+ diff --git a/web/static/threads.html b/web/static/threads.html index 4750c0f7..99e065f5 100644 --- a/web/static/threads.html +++ b/web/static/threads.html @@ -3,119 +3,106 @@ - OpenSwarm Repository Threads - + OpenSwarm · Repository threads + + + + - -
-

▦ REPOSITORY THREADS

- orchestration - chat - + +
+ 🐝 OpenSwarm + Repository threads + +
+
+ + +
-
Loading durable threads across all repositories…
-
-