diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c7d61d8..ee16ddc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,28 +21,28 @@ jobs: lint: name: Lint runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - run: npm ci + - run: npm ci --prefer-offline --no-audit --no-fund - run: npm run lint typecheck: name: Type Check runs-on: ubuntu-latest needs: lint - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - run: npm ci + - run: npm ci --prefer-offline --no-audit --no-fund - run: npm run typecheck build: @@ -56,7 +56,7 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - run: npm ci + - run: npm ci --prefer-offline --no-audit --no-fund - run: npm run build - uses: actions/upload-artifact@v7 with: @@ -89,7 +89,7 @@ jobs: # "loopback: Failed RTM_NEWADDR: Operation not permitted". sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true bwrap --ro-bind / / --unshare-net --dev /dev --proc /proc -- /bin/sh -lc 'echo sandbox-ok' - - run: npm ci + - run: npm ci --prefer-offline --no-audit --no-fund # Coverage thresholds live in vitest.config.ts (ratcheted floor below the # measured baseline — see that file's comment). Previously `npm test` had # no --coverage flag anywhere in CI, so the threshold was configured but @@ -109,7 +109,7 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - run: npm ci + - run: npm ci --prefer-offline --no-audit --no-fund - run: npm test -- --run src/adapters/processTree.windows.test.ts loc-gate: diff --git a/src/issues/issueBoardHtml.ts b/src/issues/issueBoardHtml.ts index b0a1691d..7ead27b3 100644 --- a/src/issues/issueBoardHtml.ts +++ b/src/issues/issueBoardHtml.ts @@ -10,26 +10,30 @@ export const ISSUE_BOARD_HTML = ` OpenSwarm :: Issues + + + + 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 00000000..798d6d9f Binary files /dev/null and b/web/static/fonts/inter-latin-wght-normal.woff2 differ 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…
-
-