diff --git a/DESIGN.md b/DESIGN.md index bf3f671..43f17a9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -57,7 +57,7 @@ 2. Active view content. 3. Main view: a Claude | Codex usage summary (limits plus 14-day tokens), runtime session counts, a `내 응답 대기 | 실행 중 | 턴 종료 · 검토` kanban board, then a `WORKSPACES` inventory for session-less, unknown and inactive tasks. Every card shows IDE/Terminal/Finder launchers and repo/branch Git facts without expanding; expanding adds last commits and session request/activity/response excerpts. Base repositories remain in a supporting disclosure. 4. Usage view: per-agent limits and today's token mix, one daily chart with both agents on a shared scale, and a last-7-days table. - 5. Settings view: launch-at-login, menu bar display, font, text size, agent theme, and screen saver (switch, idle minutes, `Show now` preview). + 5. Settings view: five category tabs, each one numbered page. `01 · General` opens with a Status group (the check's outcome with the one apply button, then CLI and Companion versions in the order the update runs, the update log, and a one-line CLI/agent connection summary linking to Connections), then launch-at-login and Live Claude Limits; `02 · Connections` CLI install and agent connections, right after General because the other features build on it; `03 · Menu Bar` the tray title items and percent basis; `04 · Screen Saver` the switch, idle minutes and `Show now` preview; `05 · Appearance` agent theme, font, text size and a live preview. 6. Screen-reader live status in the agent shell; routine `Updated`/`Ready` text stays out of the visible header. ## Design principles @@ -80,22 +80,22 @@ - shared `AgentShell` that applies `claude` or `codex` to all views, - shared expanded `AgentHeader` for Main, Usage, and Settings with one theme-neutral text anatomy, - update control before quit: a text `↓` inside a 1.5px circular border (`arrow.down.circle`, download the new version) so it pairs with the outline `⏻`; after a check finds an installed package outdated it turns `--notify` with a 6px dot and its label becomes `Updates available`, - - `UpdatePanel` below the header (opened by the update control, closed explicitly): CLI and Companion rows with installed/latest versions, the running Companion version, one update action whose label names the restart, and the shared connection log disclosure, + - the update control opens Settings › General and starts a check; the Status group there shows CLI and Companion rows with installed/latest versions, the running Companion version, one update action whose label names the restart, and the update log disclosure, - `AgentTabs` as an inset floating bottom terminal navigation, - shared `TerminalPanel`, `PromptLine`, and `StatusToken` primitives, - compact global inventory summary limited to project and task counts, - `StageBoard` renders runtime state as kanban columns: waiting (`내 응답 대기`), running (`실행 중`) and finished (`턴 종료 · 검토`). Unknown/session-less and inactive tasks move to the `WORKSPACES` inventory below the board. Cards remount when they change columns, so expanded keys and the open note draft live in `StageBoard` state and survive column moves, - top toolbar with icon-only update/quit controls and screen-reader-only live status, - - Settings view preferences panel, - - Settings preference sections always use the Claude Code `fieldset`/`legend` anatomy in both themes; the selected theme still owns colors and control state, + - Settings view: `SettingsNav` icon tabs in five equal columns (a `--notify` dot marks General while an update is available and Connections while the CLI is not ready), then one `SettingsPage` with a numbered eyebrow (`01 · General`), title and description, + - Settings layout primitives in `SettingsLayout`: `SettingsGroup` (small label above a 10px-radius `--surface-1` card whose rows are split by thin rules), `SettingsRow` (bold title and muted description on the left, control on the right, or below when stacked), `SettingsNote` (icon status line with an optional action), `SettingsBadge` and `SegmentedControl`; both themes share the anatomy and the theme owns colors, - Usage detail view, - - switch row for launch-at-login, - - font select row, + - switches are native checkboxes with `role="switch"` drawn as a 38×22 pill that fills with `--emphasis` when on, + - font select row with an overlay chevron, and Text Size as a four-way segmented control sharing one live font/size preview, - agent theme segmented control (`Claude Code`, `Codex`) with Claude Code as the default and migration target, - `RuntimeCard` (variants `card` and `row`) puts the project name (ellipsized) beside the icon-only launcher group on line one, then the task name on its own full-width line, the latest request, a state line (pill for the lead state such as `권한 승인 대기`, `실행 중`, `턴 종료`, `중단됨`/`오류로 종료`; plain faint `+N ` / `관측 불명 N` text for other sessions; one provider icon per distinct provider, lead session first, and the observation age pushed to the right), and one excerpt line (tool activity, or the last response on review cards). The repo block names a shared branch once with a branch icon, then one row per repo with name, compact facts and the note button; repos on different branches show their branch under their own row. The disclosure adds last commits and per-session details. Single click toggles the disclosure; double-click opens the IDE for repo-bearing tasks; the card carries no hover tooltip, - `StageRepoRow` containing repo/branch identity, compact Git facts (`●N` dirty files, `↑N`, `↓N`, and `CLEAN` only when none apply, with the full `DIRTY N FILES · BEHIND N` text in title/aria-label); in cards, repo names wrap at hyphens instead of truncating, last-commit relative time when expanded, and an inline note editor persisted in `companion-notes.json` by `repo:branch` key. - `BaseRepoRow` inside `StageBoard`, showing base branch, dirty and cached origin/base-branch differences. Quiet dots identify clean rows; notify/blocked tokens identify warning/problem rows. PULL/PUSH/CHECK pills are non-interactive guidance, not execution controls. Dirty + behind is warn/CHECK until clean, then PULL; ahead-only is PUSH, dirty-only has no pill. Missing, mismatch, divergence, missing remote and inspection errors use CHECK. Inspection errors show UNAVAILABLE with a safe reason, never sentinel CLEAN/0 facts, and do not hide healthy sibling repositories or tasks. - - `UsageSummary` above the stage board: always two columns, one per agent (`ProviderIcon`, name, plan such as `max 20x`, and `Nh 전 관측`) with compact limit rows (`label | bar | % | remaining · Day HH:MM`; under 560px the reset text drops below the bar) and a `today · 7d` foot. A 5h row appears only when the agent reports a 5h window (Claude today); a window whose reset passed reads 0%, a never-started one reads `미시작`. Below the columns, a `DAILY TOKENS · 7d` chart pairs both agents per day for the last seven days ending today: each day is a tinted slot (weekends darker, today outlined) over a two-line axis of day number (with the month on the first day and on the 1st) and Korean weekday initial, and each day lists both agents' totals in their colors. A `Usage 탭에서 자세히 ›` link opens the Usage view. It is the first block in Main, above the setup onboarding panel, and absent until the first snapshot loads. + - `UsageSummary` above the stage board: always two columns, one per agent (`ProviderIcon`, name, plan such as `max 20x`, and `Nh 전 관측`) with compact limit rows (`label | bar | % | remaining · Day HH:MM`; under 560px the reset text drops below the bar) and a `today · 7d` foot. A 5h row appears only when the agent reports a 5h window (Claude today); a window whose reset passed reads 0%, a never-started one reads `미시작`. Below the columns, a `DAILY TOKENS · 7d` chart pairs both agents per day for the last seven days ending today: each day is a tinted slot (weekends darker, today outlined) over a two-line axis of day number (with the month on the first day and on the 1st) and Korean weekday initial, and each agent's total sits in its color directly under its own bar, above the day axis; when the summary is narrower than about 46em, too narrow for two totals side by side, the totals stack under the day instead. A `Usage 탭에서 자세히 ›` link opens the Usage view. It is the first block in Main, above the setup onboarding panel, and absent until the first snapshot loads. - `UsageView`: the same columns with detail limit rows (large %, full-width bar) and a TODAY block (total, `in · out`, `cache r · w`, 7d; `오늘 사용 없음 · 어제 N` when idle), a `DAILY TOKENS · 14d` chart pairing both agents per day on one scale, a `LAST 7 DAYS` table, and a footnote that only local files are read. - Variants and states: running/waiting/finished/idle with independent observed/uncertain/stale confidence. Unknown or unobserved workspaces remain visible. - Shared header anatomy: Claude Code and Codex use the same text-only title block: `Workbranch Companion` above `projects · tasks`. The top banner contains no Workbranch mark, product icon, or Claude/Codex prompt prefix; theme identity comes from surrounding color tokens rather than different header geometry. Task metadata rows may retain their theme-specific prompt and action accents. @@ -194,6 +194,9 @@ - 2026-10-06 (setup progress): Setup and update buttons now show their own running state (spinner plus `연결 중…`-style label) because the only progress line sat at the bottom of a long panel and scrolled out of view, so clicks looked ignored. Disabled buttons became visibly muted after a missing-agent `연결` looked enabled yet did nothing. An action's result moved beside its button for the same reason. Agent lookup also reads the login shell PATH: a Finder launch had reported a version-manager-installed Codex as `Agent 미설치`. - 2026-10-07 (local usage): Replaced the brief-fed Activity calendar and the manual, time-only `WEEKLY LIMITS` gauge. Main now opens with a Claude | Codex usage summary (limits plus 14-day token bars) and the third tab became Usage (per-agent detail, shared daily chart, 7-day table). Data comes only from the agents' local files — Claude transcripts and its cached `/usage` in `~/.claude.json`, Codex rollouts — so nothing asks for OAuth or Keychain access; the cost is freshness, which every limit row states. HTML comparison rounds chose the side-by-side Usage B layout and the chart-below-limits Main summary; Codex shows no 5h row because its plan reports none. - 2026-10-07 (menu bar icon and usage title): The tray icon became an outline of the app icon's ghost (`icons/tray-template.svg`, shipped as the 36px template so it stays sharp on Retina). Settings › Menu Bar composes the text beside it: toggles for Claude 5h, Claude weekly, Codex weekly and today's tokens, each with a one-line description, plus a Used % | Remaining % switch, under a menu bar preview drawn in the system font with sample numbers. The result reads `CL 42·63 CO 18 · 31.0M`: `CL`/`CO` name the agent because a template title is plain text with no colour, an agent's windows join with `·` in 5h → weekly order, and turning every item off leaves the icon alone. A Rust thread recomputes it every minute from the same local usage files and parse cache as the Usage view, so it stays current while the window is hidden; a reading past its stale window shows `–` and an agent with no reading is left out. Earlier rounds tried four fixed presets (highest limit, per-agent highest, limit + tokens) in a dropdown and then as preview cards; review replaced them because which limits matter, and whether remaining or used reads better, differs per person. The HTML comparison also covered gauges drawn inside the ghost; plain text won because it reads exactly and needs no per-tick icon rendering. +- 2026-10-17 (Main onboarding visibility): The setup panel used to open on Main whenever any connection result, busy state or error existed, and while the first check ran. So a `다시 연결` in Settings › Connections left the whole setup panel under the Main header even with setup complete, and every launch flashed it. Visibility now comes from `needsOnboarding` (setup state plus where the action started), and the panel keeps a 12px gap above the board. +- 2026-10-17 (daily totals under their bars): The Main `DAILY TOKENS · 7d` totals moved from a centered stack under the day label to one total under each bar, between the bars and the day axis, so each number reads against the bar it measures. The totals row reuses the bar slot's padding and gap in equal parts. The fallback width is in `em`, so it follows Text Size. +- 2026-10-17 (settings tabs and grouped rows): Settings moved from one scrolling column of `fieldset`/`legend` panels to five category tabs (General, Connections, Menu Bar, Screen Saver, Appearance), each a numbered page of labelled cards with title/description rows, switches and segmented controls. The reference was a desktop settings panel with icon tabs and grouped rows. An HTML canvas compared one long page with jump chips, the tabs, and a sidebar with search and a status overview; tabs won because each page stays short at 460px, and search was left out because there are about fifteen settings. The overview idea survives as General's Status group. The `UpdatePanel` above every view was removed: the header `↓` now opens that group and starts the same click-only check. Text Size became a segmented control because its four steps fit on one line. A follow-up review fixed the order: Connections moved from last to second, next to the General status that links to it, and the Status group lists CLI before Companion as the banner and the update itself do, with every check outcome on top and the update log beside the version rows. - 2026-10-08 (menu bar units and window labels): The menu bar title now reads `CL 5h 42% · W 63% CO W 18% · 31.0M`. Bare numbers (`CL 42·63`) gave neither a unit nor which window each was, so every fresh reading carries `%` and every window its label (`5h`, `W`), joined with ` · ` inside an agent; `–` for a stale reading keeps its label (`5h –`). This follows CodexBar's `5h 12% · W 45%`, the most common layout among the menu bar usage apps surveyed (CodexBar, Claude-Usage-Tracker, clabar, cli-usage-bar). None of them mark used versus remaining in the text, so that stays with the Used % | Remaining % setting. ## Agent runtime surface (0060) @@ -217,4 +220,4 @@ A full-screen, view-only window on every display, shown by the opt-in screensave The first-run connection panel and Settings share the same two steps: prepare Workbranch CLI/collector through Homebrew, then connect individual agents. Provider rows are Claude Code, Codex and Grok Build; missing agent executables disable connection and explain the prerequisite. Agent executables are looked up on the GUI-safe PATH and then on the PATH of the user's interactive login shell (`$SHELL -ilc`, resolved once per launch in its own session with a 10s deadline), because Finder and login-item launches inherit only launchd's sparse PATH and agents installed through version managers would otherwise read as missing. Installation progress is visible on the clicked button, with bounded detailed logs in a disclosure. The action's result (error or next step) appears inside the CLI step or agent row that ran it; status-check failures stay at the top of the panel. No package or hook configuration is changed merely by viewing the panel. -Configured, disabled, trust-required, first-event waiting and historical receipts are distinct. Only an event newer than the current connection verification baseline earns a receipt confirmation. A successful connect keeps its trust/first-event instructions visible instead of immediately dismissing onboarding. Existing terminal panel anatomy, font tokens, theme colors and 460px minimum width apply. +Configured, disabled, trust-required, first-event waiting and historical receipts are distinct. Only an event newer than the current connection verification baseline earns a receipt confirmation. A successful connect keeps its trust/first-event instructions visible instead of immediately dismissing onboarding. Main shows the panel only once the first check reports an unready CLI or no agent with an observed event, or whenever a status check fails, even with an earlier ready status cached, because setup may have broken, as after a CLI update. It stays hidden while the first check runs, so it never flashes on launch. An action's progress and result keep it open only when the action was started on Main; results of actions run from Settings › Connections stay there. On Main the first-run panel keeps the terminal panel frame; in Settings the same steps are the `02 · Connections` page with the re-check beside its heading. Both use the Settings group/row anatomy, font tokens, theme colors and the 460px minimum width. diff --git a/README.ko.md b/README.ko.md index 421541e..2cb74ed 100644 --- a/README.ko.md +++ b/README.ko.md @@ -226,9 +226,9 @@ Workbranch Companion은 Main, Usage, Settings view를 제공하는 macOS menu ba - Main: agent runtime 상태를 칸반 보드로 표시. `내 응답 대기`는 agent가 권한 승인, 질문 답변, plan 승인, 입력을 기다리는 상태, `실행 중`은 작업 중인 상태, `턴 종료 · 검토`는 agent의 턴이 끝나 내가 검토할 차례라는 뜻이며 task 완료를 뜻하지 않습니다. task는 대표 세션의 열에 한 번만 표시됩니다. 세션이 없거나, 관측 불명(stale)이거나, 비활성 세션만 있는 task는 보드 아래 `WORKSPACES` 목록에 둡니다. 보드 위 usage 요약은 Claude Code와 Codex를 나란히 놓고 플랜 한도(Claude 5h·weekly, Codex weekly)와 reset까지 남은 시간, 오늘 token을 표시하고, 그 아래 최근 7일을 날짜별로 두 agent의 합계와 함께 표시 - Task card: 펼치지 않아도 IDE/Terminal/Finder 실행 버튼, repo별 branch와 Git 상태(`●N` 변경 파일, `↑N` ahead, `↓N` behind, 또는 `CLEAN`), 최근 요청, 상태, provider 아이콘(Claude Code, Codex, Grok Build), 관측 시각을 표시. 클릭하면 최근 commit과 세션 상세를 펼치고, 더블클릭하면 IDE로 task를 엽니다. repo 줄마다 note를 남길 수 있습니다 -- Usage: agent별 상세 — 한도, 오늘의 input/output/cache token, 두 agent를 같은 축에 놓은 14일 일별 차트, 최근 7일 표. Companion은 로그인 없이 로컬 파일만 읽습니다: `~/.claude/projects`의 Claude Code transcript와 `~/.claude.json`에 캐시된 `/usage` 결과, `~/.codex/sessions`의 Codex rollout. 한도는 각 agent가 마지막으로 관측한 값이라 관측 시각을 함께 보여주고 오래되면 흐리게 표시합니다. Claude 한도는 Claude Code가 `/usage`를 갱신할 때 바뀝니다. Settings > Claude Limits > Live Claude Limits를 켜면 Claude Code 세션이 열려 있는 동안 실시간으로 바뀝니다(opt-in: `~/.claude/settings.json`의 `statusLine`을 statusline 입력의 `rate_limits`를 저장하는 relay로 바꾸고, 기존 statusline은 그대로 실행합니다. 끄면 원래대로 되돌립니다) -- Settings: login 시 자동 실행, menu bar icon 옆 표시(Claude 5h, Claude weekly, Codex weekly, 오늘 token 중 원하는 항목을 고르고 한도를 사용 % 또는 남은 %로 표시. 예: `CL 5h 42% · W 63% CO W 18% · 31.0M`. 1분마다 갱신하고 오래된 값은 `–`로 표시), interface font와 글자 크기, Claude Code 또는 Codex theme, 화면보호기, CLI/agent 연결 설정 -- 업데이트: header의 업데이트 버튼으로 업데이트 패널을 엽니다. Homebrew 확인은 요청할 때만 실행하고, 업데이트 버튼 하나로 CLI를 먼저 올린 뒤 Companion을 업데이트합니다. 설치·연결·업데이트 버튼은 실행 중 spinner를 보여주고, 결과는 해당 버튼 옆에 표시합니다 +- Usage: agent별 상세 — 한도, 오늘의 input/output/cache token, 두 agent를 같은 축에 놓은 14일 일별 차트, 최근 7일 표. Companion은 로그인 없이 로컬 파일만 읽습니다: `~/.claude/projects`의 Claude Code transcript와 `~/.claude.json`에 캐시된 `/usage` 결과, `~/.codex/sessions`의 Codex rollout. 한도는 각 agent가 마지막으로 관측한 값이라 관측 시각을 함께 보여주고 오래되면 흐리게 표시합니다. Claude 한도는 Claude Code가 `/usage`를 갱신할 때 바뀝니다. Settings > General > Live Claude Limits를 켜면 Claude Code 세션이 열려 있는 동안 실시간으로 바뀝니다(opt-in: `~/.claude/settings.json`의 `statusLine`을 statusline 입력의 `rate_limits`를 저장하는 relay로 바꾸고, 기존 statusline은 그대로 실행합니다. 끄면 원래대로 되돌립니다) +- Settings: General(업데이트·연결 상태, login 시 자동 실행, Live Claude Limits), Connections, Menu Bar, Screen Saver, Appearance 다섯 탭으로 나눕니다. login 시 자동 실행, menu bar icon 옆 표시(Claude 5h, Claude weekly, Codex weekly, 오늘 token 중 원하는 항목을 고르고 한도를 사용 % 또는 남은 %로 표시. 예: `CL 5h 42% · W 63% CO W 18% · 31.0M`. 1분마다 갱신하고 오래된 값은 `–`로 표시), interface font와 글자 크기, Claude Code 또는 Codex theme, 화면보호기, CLI/agent 연결 설정 +- 업데이트: header의 업데이트 버튼은 Settings > General로 이동해 업데이트를 확인하고, Status 그룹에 설치·최신 버전을 표시합니다. Homebrew 확인은 요청할 때만 실행하고, 업데이트 버튼 하나로 CLI를 먼저 올린 뒤 Companion을 업데이트합니다. 설치·연결·업데이트 버튼은 실행 중 spinner를 보여주고, 결과는 해당 버튼 옆에 표시합니다 - 화면보호기(opt-in, 기본 꺼짐): Settings > Screen Saver > While agents work 를 켜면 실행 중이거나 내 응답을 기다리는 세션이 있는 동안 화면이 잠겨 있어도 Mac 이 idle 잠자기에 들어가지 않습니다. 화면이 잠기지 않은 채 정한 시간(Dashboard after, 기본 5분) 동안 아무도 Mac 을 건드리지 않으면 macOS 화면보호기 대신 모든 모니터에 아래 dashboard 를 띄웁니다. 잠금이 가능한 환경에서는 입력 시 macOS 잠금을 요청하며, 잠긴 뒤에는 macOS 암호나 Touch ID 로 돌아옵니다. 잠금이 불가능하면 잠금 표시를 숨기고 입력 시 잠금 없이 dashboard 만 닫습니다. 잠금 요청이 3회(약 6초) 모두 실패해도 잠금 없이 dashboard 만 닫습니다. 실행 중·대기 세션이 없어지면 잠자기·화면보호기·잠금은 다시 macOS 설정을 따릅니다. Show now 는 잠금 없이 dashboard 를 미리 보여 줍니다. 추가 권한을 요청하지 않습니다

diff --git a/README.md b/README.md index 644807e..3d7dbf0 100644 --- a/README.md +++ b/README.md @@ -226,9 +226,9 @@ Workbranch Companion is a macOS menu bar app with Main, Usage, and Settings view - Main: a kanban board of agent runtime state. `내 응답 대기` (waiting on you) means the agent needs your permission, answer, plan approval, or input; `실행 중` (running) means it is working; `턴 종료 · 검토` (turn ended, review) means its turn ended and it is your turn to review, not that the task is done. A task appears once, in its lead session's column. Tasks with no session, an unknown (stale) observation, or only inactive sessions are listed under `WORKSPACES` below the board. A usage summary above the board shows Claude Code and Codex side by side: plan limits (Claude 5h and weekly, Codex weekly) with time to reset and today's tokens, then the last seven days by day with both agents' totals - Task cards: IDE/Terminal/Finder launchers and each repo's branch and Git facts (`●N` dirty files, `↑N` ahead, `↓N` behind, or `CLEAN`) are visible without expanding, next to the latest request, state, provider icon (Claude Code, Codex, or Grok Build), and observation age. Click a card for last commits and session details; double-click opens the task in your IDE. Each repo row keeps a note -- Usage: per-agent detail — limits, today's input/output/cache tokens, a shared 14-day daily chart, and a last-7-days table. Companion reads only local files and never signs in: Claude Code transcripts in `~/.claude/projects` and its cached `/usage` result in `~/.claude.json`, and Codex rollouts in `~/.codex/sessions`. Limits are the agents' last observation, so each shows its age and dims when stale; Claude's limits refresh when Claude Code refreshes `/usage`, or live while a Claude Code session is open once Settings > Claude Limits > Live Claude Limits is on (opt-in: it points `statusLine` in `~/.claude/settings.json` at a relay that saves the status line's `rate_limits` and still runs your previous status line; turning it off restores it) -- Settings: controls launch at login, the text beside the menu bar icon (pick any of Claude 5h, Claude weekly, Codex weekly and today's tokens, shown as used or remaining percent, for example `CL 5h 42% · W 63% CO W 18% · 31.0M`; refreshed every minute, `–` when a reading is stale), the interface font and text size, the Claude Code or Codex theme, the screen saver, and CLI/agent connections -- Updates: the header's update button opens an update panel. It checks Homebrew only when you ask, and one update action upgrades the CLI before Companion. Setup and update buttons show a spinner while they run, and each result appears next to the button that ran it +- Usage: per-agent detail — limits, today's input/output/cache tokens, a shared 14-day daily chart, and a last-7-days table. Companion reads only local files and never signs in: Claude Code transcripts in `~/.claude/projects` and its cached `/usage` result in `~/.claude.json`, and Codex rollouts in `~/.codex/sessions`. Limits are the agents' last observation, so each shows its age and dims when stale; Claude's limits refresh when Claude Code refreshes `/usage`, or live while a Claude Code session is open once Settings > General > Live Claude Limits is on (opt-in: it points `statusLine` in `~/.claude/settings.json` at a relay that saves the status line's `rate_limits` and still runs your previous status line; turning it off restores it) +- Settings: five tabs — General (update and connection status, launch at login, Live Claude Limits), Connections, Menu Bar, Screen Saver and Appearance. They control launch at login, the text beside the menu bar icon (pick any of Claude 5h, Claude weekly, Codex weekly and today's tokens, shown as used or remaining percent, for example `CL 5h 42% · W 63% CO W 18% · 31.0M`; refreshed every minute, `–` when a reading is stale), the interface font and text size, the Claude Code or Codex theme, the screen saver, and CLI/agent connections +- Updates: the header's update button opens Settings > General and checks for updates; its Status group shows the installed and latest versions. It checks Homebrew only when you ask, and one update action upgrades the CLI before Companion. Setup and update buttons show a spinner while they run, and each result appears next to the button that ran it - Screen saver (opt-in, off by default): with Settings > Screen Saver > While agents work on, the Mac stays out of idle sleep while a session is running or waiting on you, even with the screen locked. If the screen is unlocked and nobody touches the Mac for the chosen time (Dashboard after, 5 minutes by default), every display shows the dashboard below instead of the macOS screen saver. When locking is available, input requests the macOS lock screen; after it locks, your macOS password or Touch ID is required. If locking is unavailable, the lock indicator is hidden and input only closes the dashboard without locking. If lock requests fail after three attempts (about 6 seconds), the dashboard also closes without locking. Once no session is running or waiting, sleep, screen saver and lock follow macOS settings again. Show now previews the dashboard without locking. No extra permissions are requested

diff --git a/apps/agent-runtime/src/store.rs b/apps/agent-runtime/src/store.rs index bbbd54a..5b4e501 100644 --- a/apps/agent-runtime/src/store.rs +++ b/apps/agent-runtime/src/store.rs @@ -33,8 +33,15 @@ pub struct Session { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub completed_tools: Vec, } +/// How long a hook writer waits for another writer's commit. Hooks from +/// several sessions serialize on one database, and each commit fsyncs, so on +/// a busy disk the queue outlasts a short timeout and the event is lost. +/// Snapshot readers keep the short timeout in `open_read` and retry on the +/// next poll instead. +const WRITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); + fn initialize(db: &Connection) -> Result<()> { - db.busy_timeout(Duration::from_millis(150))?; + db.busy_timeout(WRITE_BUSY_TIMEOUT)?; let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; if version > 1 { return Err("Runtime database is newer; update workbranch".into()); diff --git a/apps/agent-runtime/tests/runtime.rs b/apps/agent-runtime/tests/runtime.rs index 57cc75c..b57df81 100644 --- a/apps/agent-runtime/tests/runtime.rs +++ b/apps/agent-runtime/tests/runtime.rs @@ -114,6 +114,36 @@ fn concurrent_sessions_do_not_lose_updates() { ); } +#[test] +fn hook_writer_waits_out_a_slow_concurrent_commit() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("runtime/state.sqlite3"); + let mut holder = store::open(&path).unwrap(); + let transaction = holder + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .unwrap(); + + // Another hook writes while this one holds the lock longer than a fast + // disk would, as fsync on a busy machine does. + let writer = { + let path = path.clone(); + std::thread::spawn(move || { + let mut db = store::open(&path)?; + event::ingest(&mut db, "/p/t", "claude", &json!({"session_id":"s","hook_event_name":"UserPromptSubmit","prompt":"safe request"}), 100) + }) + }; + std::thread::sleep(std::time::Duration::from_millis(400)); + transaction.commit().unwrap(); + + writer.join().unwrap().unwrap(); + assert_eq!( + store::snapshot(&store::open(&path).unwrap(), 100) + .unwrap() + .len(), + 1 + ); +} + #[test] fn migration_refuses_symlinked_archive_and_preserves_runtime() { let root = tempfile::tempdir().unwrap(); diff --git a/apps/companion/src/App.tsx b/apps/companion/src/App.tsx index 6acdad8..b923854 100644 --- a/apps/companion/src/App.tsx +++ b/apps/companion/src/App.tsx @@ -1,6 +1,6 @@ import { isTauri } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { SetupAction } from "./application/connections"; +import { needsOnboarding, type SetupAction } from "./application/connections"; import { buildMainViewModel, buildMenuModel, @@ -35,11 +35,12 @@ import { AgentHeader } from "./ui/AgentHeader"; import { AgentTabs, type CompanionView } from "./ui/AgentTabs"; import { ConnectionsPanel } from "./ui/ConnectionsPanel"; import { ProgressButton } from "./ui/ProgressButton"; +import type { SettingsCategory } from "./ui/SettingsNav"; import { SettingsView } from "./ui/SettingsView"; import { StageBoard } from "./ui/StageBoard"; import { StatusAlert } from "./ui/StatusAlert"; +import { StatusSettings } from "./ui/StatusSettings"; import type { TaskActionKind } from "./ui/TaskRow"; -import { UpdatePanel } from "./ui/UpdatePanel"; import { UsageSummary } from "./ui/UsageSummary"; import { UsageView } from "./ui/UsageView"; @@ -67,6 +68,8 @@ export function App() { const [visibleError, setVisibleError] = useState(); const [setupError, setSetupError] = useState(false); const [currentView, setCurrentView] = useState("main"); + const [settingsCategory, setSettingsCategory] = + useState("general"); const [selectedStageTask, setSelectedStageTask] = useState< string | undefined >(undefined); @@ -95,6 +98,7 @@ export function App() { const main = buildMainViewModel(state, sessions); const connections = useConnections(tauriRuntimeAvailable); const [onboardingDismissed, setOnboardingDismissed] = useState(false); + const [setupStartedOnMain, setSetupStartedOnMain] = useState(false); const usage = useUsage(tauriRuntimeAvailable); const showStatus = useCallback((message: string) => { @@ -185,13 +189,15 @@ export function App() { void refresh(); void connections.refresh(); }); - const [updatePanelOpen, setUpdatePanelOpen] = useState(false); + const updateAvailable = updateTargets(updates.state.status).length > 0; + // The header control opens the update status on Settings › General. const handleCheckUpdates = useCallback(() => { if (!tauriRuntimeAvailable) { showStatus(TAURI_RUNTIME_UNAVAILABLE); return; } - setUpdatePanelOpen(true); + setCurrentView("settings"); + setSettingsCategory("general"); void updates.check(); }, [showStatus, tauriRuntimeAvailable, updates.check]); @@ -300,7 +306,8 @@ export function App() { } }; - const handleSetupAction = (action: SetupAction) => { + const handleSetupAction = (action: SetupAction, onMain: boolean) => { + setSetupStartedOnMain(onMain); void connections.run(action).then((ok) => { if (ok) void refresh(); }); @@ -311,23 +318,15 @@ export function App() { state={connections.state} sessions={sessions} onRefresh={() => void connections.refresh()} - onAction={handleSetupAction} + onAction={(action) => handleSetupAction(action, onboarding)} onboarding={onboarding} onDismiss={() => setOnboardingDismissed(true)} /> ); - const needsOnboarding = - connections.state.busy !== null || - connections.state.notice !== null || - connections.state.error !== null || - connections.state.status === null || - connections.state.status.cli.state !== "ready" || - !connections.state.status.agents.some( - (a) => - a.state === "configured" && - ((a.lastObservedAt ?? 0) > 0 || - sessions.some((s) => s.provider === a.provider)), - ); + const showOnboarding = + tauriRuntimeAvailable && + !onboardingDismissed && + needsOnboarding(connections.state, sessions, setupStartedOnMain); return (

0} + updateAvailable={updateAvailable} onQuit={handleQuit} /> - {updatePanelOpen ? ( - void updates.check()} - onApply={() => void updates.apply()} - onClose={() => setUpdatePanelOpen(false)} - /> - ) : null} ) : null} - {tauriRuntimeAvailable && !onboardingDismissed && needsOnboarding - ? connectionPanel(true) - : null} + {showOnboarding ? connectionPanel(true) : null} void updates.apply()} + onCheckUpdates={() => void updates.check()} + onOpenConnections={() => setSettingsCategory("connections")} + updates={updates.state} + /> + } claudeLimitRelay={{ status: claudeLimitRelay.status, loading: claudeLimitRelay.loading, diff --git a/apps/companion/src/application/connections.ts b/apps/companion/src/application/connections.ts index ba6eae0..b0cc29f 100644 --- a/apps/companion/src/application/connections.ts +++ b/apps/companion/src/application/connections.ts @@ -241,3 +241,38 @@ export function receiptState( if (observedAt <= 0) return "waiting"; return after !== undefined && observedAt > after ? "verified" : "historical"; } + +/** + * Whether Main shows the setup panel. It stays hidden while the first check + * runs, so it never flashes on launch. A failed status check shows it with + * the retry whoever ran the check, even when an earlier ready status is + * still cached: the setup itself may have broken, as after a CLI update. + * Once setup is known, an unready CLI or no agent with an observed event + * keeps it open. An action's progress and result keep it open only when the + * action was started there; results of actions run from Settings › + * Connections stay in Settings. + */ +export function needsOnboarding( + state: ConnectionState, + sessions: readonly { readonly provider: Provider }[], + actionStartedOnMain: boolean, +): boolean { + // A check failure clears `lastAction`; an action's failure keeps it. + if (state.error !== null && state.lastAction === null) return true; + if ( + actionStartedOnMain && + (state.busy !== null || state.notice !== null || state.error !== null) + ) + return true; + const { status } = state; + if (status === null) return false; + return ( + status.cli.state !== "ready" || + !status.agents.some( + (agent) => + agent.state === "configured" && + ((agent.lastObservedAt ?? 0) > 0 || + sessions.some((session) => session.provider === agent.provider)), + ) + ); +} diff --git a/apps/companion/src/styles/agent-shell.css b/apps/companion/src/styles/agent-shell.css index 06b2466..4113ebf 100644 --- a/apps/companion/src/styles/agent-shell.css +++ b/apps/companion/src/styles/agent-shell.css @@ -246,13 +246,3 @@ top: 3px; width: 6px; } - -.update-panel { - margin-bottom: 16px; - word-break: keep-all; -} - -.update-panel .connections-heading .connection-actions { - flex: 0 0 auto; - flex-wrap: nowrap; -} diff --git a/apps/companion/src/styles/motion.css b/apps/companion/src/styles/motion.css index bec7ffd..fb13da0 100644 --- a/apps/companion/src/styles/motion.css +++ b/apps/companion/src/styles/motion.css @@ -12,4 +12,9 @@ .button-spinner { animation: none; } + + .settings-switch, + .settings-switch::before { + transition: none; + } } diff --git a/apps/companion/src/styles/settings.css b/apps/companion/src/styles/settings.css index 9d5b038..5b2eadc 100644 --- a/apps/companion/src/styles/settings.css +++ b/apps/companion/src/styles/settings.css @@ -1,60 +1,329 @@ -.settings-panel { +/* Settings: a row of category tabs, then one page of grouped cards. */ +.settings-view { + align-content: start; display: grid; - gap: 9px; - margin-bottom: 12px; + margin-inline: auto; + max-width: 720px; + min-width: 0; + width: 100%; + word-break: keep-all; +} + +/* Five equal columns keep every tab on one row down to the 460px window. */ +.settings-nav { + border-bottom: 1px solid var(--line); + display: grid; + gap: 2px; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.settings-nav-tab { + align-items: center; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + color: var(--muted); + display: inline-flex; + flex-direction: column; + font-size: var(--fs-label); + gap: 4px; + justify-content: center; + min-height: 56px; + min-width: 0; + padding: 8px 4px 6px; + position: relative; + text-align: center; +} + +.settings-nav-tab > span:not(.settings-nav-dot) { + line-height: 1.25; + max-width: 100%; + overflow-wrap: anywhere; } -.settings-panel-header { - align-items: start; +.settings-nav-tab:hover:not(:disabled) { + background: var(--button-bg-hover); + border-color: transparent; + color: var(--text); +} + +.settings-nav-tab[data-active="true"] { + background: var(--surface-1); + border-bottom-color: var(--emphasis); + color: var(--emphasis); + font-weight: 700; +} + +.settings-nav-dot { + background: var(--notify); + border-radius: 50%; + height: 6px; + left: calc(50% + 8px); + position: absolute; + top: 7px; + width: 6px; +} + +.settings-icon { + flex: none; + height: 18px; + width: 18px; +} + +.settings-page { + display: grid; + gap: 22px; + min-width: 0; + padding: 20px 8px 8px; +} + +.settings-page button, +.settings-page select { + font-size: var(--fs-meta); +} + +.settings-page-header { + align-items: center; + border-bottom: 1px solid var(--line); display: flex; - gap: 10px; + gap: 12px; justify-content: space-between; + padding-bottom: 16px; } -.settings-panel h2 { +.settings-page-heading { + display: grid; + gap: 4px; + min-width: 0; +} + +.settings-page-eyebrow { + color: var(--emphasis); + font-size: var(--fs-meta); + letter-spacing: 0.04em; +} + +.settings-page-header h2 { color: var(--text); font-size: var(--fs-title); font-weight: 700; + line-height: 1.3; } -.settings-panel p, +.settings-page-description, .settings-hint { color: var(--muted); font-size: var(--fs-meta); } -.settings-row { +.settings-group { + display: grid; + gap: 8px; + min-width: 0; +} + +.settings-group-header { align-items: center; display: flex; gap: 10px; justify-content: space-between; + min-height: 24px; + padding-left: 2px; +} + +.settings-group-header h3 { + color: var(--muted); + font-size: var(--fs-meta); + font-weight: 600; + margin: 0; +} + +.settings-group-header button { + min-height: 28px; + padding: 3px 10px; +} + +.settings-card { + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: 10px; + min-width: 0; + overflow: hidden; +} + +.settings-card > * + * { + border-top: 1px solid var(--line); +} + +.settings-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + min-width: 0; + padding: 12px 14px; +} + +.settings-row-copy { + display: grid; + flex: 1 1 240px; + gap: 3px; + min-width: 0; } -.settings-row label { +.settings-row-title { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-row-title strong, +.settings-row-title label { color: var(--text); - font-size: var(--fs-ui); + font-size: var(--fs-body); + font-weight: 700; } -.settings-row input[type="checkbox"] { - accent-color: var(--emphasis); - height: 18px; - width: 18px; +.settings-row-description { + color: var(--muted); + display: grid; + font-size: var(--fs-meta); + gap: 2px; + line-height: 1.5; + overflow-wrap: anywhere; } -.settings-row select { - min-width: 156px; +.settings-row-control { + align-items: center; + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: 6px; + max-width: 100%; } -.settings-row-select { +.settings-row[data-stacked="true"] .settings-row-control { + flex: 1 0 100%; +} + +.settings-row-extra { + display: grid; + flex: 1 0 100%; + gap: 8px; + min-width: 0; +} + +.settings-row-extra:empty { + display: none; +} + +/* A checkbox drawn as a switch; the native input keeps its semantics. */ +.settings-switch { + appearance: none; + background: var(--surface-3); + border: 1px solid var(--line-strong); + border-radius: 999px; + cursor: pointer; + flex: none; + height: 22px; + margin: 0; position: relative; + transition: + background-color 150ms ease, + border-color 150ms ease; + width: 38px; } -.settings-row-select select { - margin-left: auto; +.settings-switch::before { + background: var(--muted); + border-radius: 50%; + content: ""; + height: 16px; + left: 2px; + position: absolute; + top: 2px; + transition: + transform 150ms ease, + background-color 150ms ease; + width: 16px; +} + +.settings-switch:checked { + background: var(--emphasis); + border-color: var(--emphasis); +} + +.settings-switch:checked::before { + background: var(--surface-0); + transform: translateX(16px); +} + +.settings-switch:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.settings-segmented { + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: 8px; + display: inline-flex; + gap: 2px; + margin: 0; + min-width: 0; + padding: 3px; +} + +.settings-segmented[data-stretch="true"] { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + width: 100%; +} + +.settings-segmented:disabled { + opacity: 0.55; +} + +.settings-segment { + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--muted); + min-height: 28px; + min-width: 0; + overflow: hidden; + padding: 3px 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-segment:hover:not(:disabled) { + background: var(--button-bg-hover); + border-color: transparent; + color: var(--text); +} + +.settings-segment[data-active="true"] { + background: var(--surface-3); + border-color: var(--line-strong); + color: var(--text); + font-weight: 700; +} + +.settings-select { + flex: none; + position: relative; +} + +.settings-select select { + min-width: 140px; padding-right: 28px; } -.settings-row-select::after { +.settings-select::after { color: var(--faint); content: "⌄"; font-size: var(--fs-ui); @@ -62,94 +331,153 @@ position: absolute; right: 10px; top: 50%; - transform: translateY(-50%); + transform: translateY(-60%); } -.font-preview { - background: var(--surface-2); +.settings-badge { border: 1px solid var(--line); - border-radius: 4px; - color: var(--text); - display: grid; - font-size: var(--fs-body); - gap: 5px; - line-height: 1.45; - margin-top: 8px; - padding: 8px; + border-radius: 999px; + color: var(--muted); + font-size: var(--fs-label); + padding: 0 8px; + white-space: nowrap; } -.font-preview code { - color: var(--accent-strong); - font: inherit; +.settings-badge[data-tone="success"], +.settings-note[data-tone="success"] { + color: var(--done); } -.font-preview-label { - color: var(--muted); - font-size: var(--fs-label); +.settings-badge[data-tone="success"] { + background: var(--done-soft); + border-color: transparent; } -.font-preview-meta { - color: var(--faint); +.settings-badge[data-tone="notice"], +.settings-note[data-tone="notice"] { + background: var(--notify-soft); + border-color: transparent; + color: var(--notify); +} + +.settings-badge[data-tone="danger"], +.settings-note[data-tone="danger"] { + background: var(--blocked-soft); + border-color: transparent; + color: var(--blocked); +} + +.settings-note { + align-items: center; + color: var(--muted); + display: flex; + flex-wrap: wrap; font-size: var(--fs-meta); + gap: 8px 10px; + padding: 10px 14px; } -.agent-theme-picker { +.settings-note .settings-icon { + height: 16px; + width: 16px; +} + +.settings-note-text { + flex: 1 1 200px; + min-width: 0; + overflow-wrap: anywhere; +} + +.settings-row-extra .settings-note, +.settings-page > .settings-note { border: 1px solid var(--line); - border-radius: 4px; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin-bottom: 8px; + border-radius: 6px; + padding: 8px 10px; } -.agent-theme-button { - background: transparent; - border: 0; - border-radius: 0; +.settings-details { color: var(--muted); - font-size: var(--fs-body); - font-weight: 700; + font-size: var(--fs-meta); + min-width: 0; + padding: 10px 14px; } -.agent-theme-button + .agent-theme-button { - border-left: 1px solid var(--line); +.settings-details summary { + cursor: pointer; } -.agent-theme-button:hover:not(:disabled) { - background: var(--button-bg-hover); +.settings-details dl { + display: grid; + gap: 4px 14px; + grid-template-columns: max-content minmax(0, 1fr); + margin: 10px 0 2px; +} + +.settings-details dt { + color: var(--faint); +} + +.settings-details dd { color: var(--text); + margin: 0; + overflow-wrap: anywhere; } -.agent-theme-button[data-active="true"] { - background: var(--emphasis-soft); - color: var(--emphasis); +.settings-details pre { + font-size: var(--fs-label); + max-height: 180px; + overflow: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; } -.menu-bar-settings { +.settings-preview { display: grid; gap: 8px; + min-width: 0; + padding: 12px 14px; } -.menu-bar-preview { - align-items: center; - display: flex; - gap: 10px; +.settings-preview-hint { + color: var(--faint); + font-size: var(--fs-meta); +} + +.font-preview { + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--text); + display: grid; + font-size: var(--fs-body); + gap: 5px; + line-height: 1.45; + padding: 10px 12px; } -.menu-bar-preview-label { +.font-preview code { + color: var(--accent-strong); + font: inherit; +} + +.font-preview-label { color: var(--muted); - flex: none; font-size: var(--fs-label); } +.font-preview-meta { + color: var(--faint); + font-size: var(--fs-meta); +} + /* A slice of the macOS menu bar: system font and size, not the app's mono. */ .menu-bar-sample { align-items: center; background: var(--surface-2); border: 1px solid var(--line); - border-radius: 3px; + border-radius: 6px; color: var(--text); display: flex; - flex: 1; font: 13px / 1 -apple-system, BlinkMacSystemFont, @@ -157,11 +485,11 @@ sans-serif; font-variant-numeric: tabular-nums; gap: 4px; - height: 28px; + height: 30px; justify-content: flex-end; min-width: 0; overflow: hidden; - padding: 0 10px; + padding: 0 12px; white-space: pre; } @@ -176,145 +504,41 @@ text-overflow: ellipsis; } -.menu-bar-items { - border: 0; - display: grid; - gap: 8px; - grid-template-columns: repeat(2, minmax(0, 1fr)); - margin: 0; - min-width: 0; - padding: 0; -} - -.menu-bar-item { - align-items: start; - background: transparent; - border-radius: 4px; - color: var(--muted); - display: flex; - gap: 8px; - min-width: 0; - padding: 8px; - text-align: left; -} - -.menu-bar-item:hover:not(:disabled) { - color: var(--text); -} - -.menu-bar-item[data-active="true"] { - background: var(--emphasis-soft); - border-color: var(--emphasis); - color: var(--emphasis); -} - -.menu-bar-item-check { - align-items: center; - border: 1px solid currentColor; - border-radius: 3px; - display: inline-flex; - flex: none; - font-size: var(--fs-label); - height: 14px; - justify-content: center; - margin-top: 2px; - width: 14px; -} - -.menu-bar-item-text { - display: grid; - gap: 2px; - min-width: 0; -} - -.menu-bar-item-label { - font-size: var(--fs-body); - font-weight: 700; -} - -.menu-bar-item-detail { - color: var(--muted); - font-size: var(--fs-meta); -} - -.menu-bar-percent { - margin: 0; - padding: 0; -} - -.menu-bar-percent:disabled { - opacity: 0.55; +.connection-observation { + color: var(--accent); } +/* First-run panel on Main; Settings › Connections uses the page anatomy. */ .connections-panel { display: grid; gap: 14px; + margin-bottom: 12px; min-width: 0; } -.connections-heading, -.connection-step header, -.agent-connection header { - display: flex; + +.connections-heading { align-items: center; - justify-content: space-between; + display: flex; gap: 10px; + justify-content: space-between; } + .connections-heading p { margin: 0; } -.connection-step { - min-width: 0; - display: grid; - gap: 10px; -} -.connection-step h3 { - font-size: var(--fs-body); - margin: 0; -} -.connection-step p { - font-size: var(--fs-meta); - margin: 0; - line-height: 1.6; -} -.connection-badge { - font-size: var(--fs-label); - color: var(--muted); - white-space: nowrap; -} + .connection-actions { display: flex; flex-wrap: wrap; gap: 8px; } -.connection-actions button { - min-height: 32px; -} -.agent-connection { - padding: 12px; + +.grok-trust-prompt { border: 1px solid var(--line); border-radius: 6px; display: grid; gap: 8px; + margin: 0; min-width: 0; -} -.connection-step details { - font-size: var(--fs-label); -} -.connection-step dd { - margin: 0 0 6px; - overflow-wrap: anywhere; - color: var(--muted); -} -.connection-observation { - color: var(--accent); -} -.connection-logs { - min-width: 0; -} -.connection-logs pre { - max-height: 180px; - overflow: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - font-size: var(--fs-label); + padding: 10px 12px; } diff --git a/apps/companion/src/styles/usage.css b/apps/companion/src/styles/usage.css index ed6a51a..686b1b7 100644 --- a/apps/companion/src/styles/usage.css +++ b/apps/companion/src/styles/usage.css @@ -370,14 +370,34 @@ .usage-values { font-size: var(--fs-label); font-variant-numeric: tabular-nums; - margin-top: 4px; + margin-bottom: 4px; text-align: center; } +/* Each total sits under its own bar: the day slot's padding and gap, + split into the same equal parts as the bars. */ .usage-values-day { - display: grid; + display: flex; + gap: 1px; line-height: 1.35; min-width: 0; + padding: 0 2px; +} + +.usage-values-day > [data-provider] { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + +/* Too narrow for two totals side by side (about five characters each at + the label size, so em tracks Text Size): stack them under the day. */ +@container usage (max-width: 46em) { + .usage-values-day { + display: grid; + gap: 0; + padding: 0; + } } .usage-values-day [data-provider="claude"] { diff --git a/apps/companion/src/ui/AgentThemePicker.tsx b/apps/companion/src/ui/AgentThemePicker.tsx index 4eb7c1d..a6ce131 100644 --- a/apps/companion/src/ui/AgentThemePicker.tsx +++ b/apps/companion/src/ui/AgentThemePicker.tsx @@ -2,31 +2,26 @@ import { COMPANION_THEME_OPTIONS, type CompanionTheme, } from "../application/preferences"; +import { SegmentedControl } from "./SettingsLayout"; type AgentThemePickerProps = { readonly value: CompanionTheme; readonly onChange: (theme: CompanionTheme) => void; }; +const THEME_SEGMENTS = COMPANION_THEME_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + ariaLabel: `Use ${option.label} theme`, +})); + export function AgentThemePicker({ value, onChange }: AgentThemePickerProps) { return ( -
- {COMPANION_THEME_OPTIONS.map((option) => { - const selected = option.value === value; - return ( - - ); - })} -
+ ); } diff --git a/apps/companion/src/ui/ClaudeLimitRelaySettings.tsx b/apps/companion/src/ui/ClaudeLimitRelaySettings.tsx index 0791c95..a14bdd8 100644 --- a/apps/companion/src/ui/ClaudeLimitRelaySettings.tsx +++ b/apps/companion/src/ui/ClaudeLimitRelaySettings.tsx @@ -1,4 +1,5 @@ import type { ClaudeLimitRelayStatus } from "../infrastructure/tauriClient"; +import { SettingsRow } from "./SettingsLayout"; export type ClaudeLimitRelayProps = { readonly status: ClaudeLimitRelayStatus | undefined; @@ -20,18 +21,21 @@ function hint({ status, loading }: ClaudeLimitRelayProps): string { export function ClaudeLimitRelaySettings(props: ClaudeLimitRelayProps) { const { status, loading, onChange } = props; return ( - <> -
- - onChange(event.currentTarget.checked)} - type="checkbox" - /> -
-

{hint(props)}

- + + onChange(event.currentTarget.checked)} + role="switch" + type="checkbox" + /> + ); } diff --git a/apps/companion/src/ui/ConnectionsPanel.tsx b/apps/companion/src/ui/ConnectionsPanel.tsx index b9e11e2..204908a 100644 --- a/apps/companion/src/ui/ConnectionsPanel.tsx +++ b/apps/companion/src/ui/ConnectionsPanel.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import type { ConnectionState, Provider, SetupAction, + SetupStatus, } from "../application/connections"; import { cliSetupAction, @@ -13,10 +14,24 @@ import { import type { CompanionTheme } from "../application/preferences"; import type { AgentSession } from "../domain/model"; import { ProgressButton } from "./ProgressButton"; +import { + SettingsBadge, + SettingsGroup, + SettingsNote, + SettingsPage, + SettingsRow, + type SettingsTone, +} from "./SettingsLayout"; import { formatRelativeTime } from "./TaskRow"; import { TerminalPanel } from "./TerminalPanel"; -const names = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" }; +type Agent = SetupStatus["agents"][number]; + +export const AGENT_NAMES: Record = { + claude: "Claude Code", + codex: "Codex", + grok: "Grok Build", +}; const pendingLabels: Record = { installCli: "CLI 설치 중…", updateCli: "CLI 업데이트 중…", @@ -24,6 +39,35 @@ const pendingLabels: Record = { connectAgent: "연결 중…", disconnectAgent: "연결 해제 중…", }; + +export function cliStateLabel(cli: SetupStatus["cli"]): string { + if (cli.state === "ready") return "준비됨"; + return cli.state === "missing" ? "미설치" : "확인 필요"; +} + +export function agentStateLabel(state: Agent["state"]): string { + switch (state) { + case "notInstalled": + return "Agent 미설치"; + case "disabled": + return "비활성"; + case "trustRequired": + return "신뢰 승인 필요"; + case "configured": + return "설정됨"; + case "unknown": + return "확인 필요"; + case "disconnected": + return "미연결"; + } +} + +function agentTone(state: Agent["state"]): SettingsTone { + if (state === "configured") return "success"; + if (state === "trustRequired" || state === "unknown") return "notice"; + return "neutral"; +} + function SetupOutcome({ error, notice, @@ -34,11 +78,15 @@ function SetupOutcome({ return ( <> {error ? ( -

+ {error} -

+ + ) : null} + {notice ? ( + + {notice} + ) : null} - {notice ?

{notice}

: null} ); } @@ -63,10 +111,7 @@ export function GrokTrustPrompt({ readonly onCancel: () => void; }) { return ( -
+
Grok plugin을 신뢰하고 설치할까요?

Plugin에 포함된 hook·MCP·skill이 이 컴퓨터에서 실행될 수 있습니다. 아래 @@ -110,65 +155,162 @@ export function ConnectionsPanel({ ) : null; const ready = status?.cli.state === "ready"; + const lastSeen = (agent: Agent) => + Math.max( + agent.lastObservedAt ?? 0, + ...sessions + .filter((s) => s.provider === agent.provider) + .map((s) => s.updatedAt), + ); const verified = status?.agents.some( (agent) => agent.state === "configured" && receiptState( - Math.max( - agent.lastObservedAt ?? 0, - ...sessions - .filter((s) => s.provider === agent.provider) - .map((s) => s.updatedAt), - ), + lastSeen(agent), state.verificationAfter[agent.provider], ) === "verified", ) ?? false; - return ( - -

-

CLI를 준비하고 사용할 agent를 연결하세요.

+ 다시 확인 + + ); + + const agentRow = (agent: Agent): ReactNode => { + const seen = lastSeen(agent); + const configured = agent.state === "configured"; + const receipt = receiptState(seen, state.verificationAfter[agent.provider]); + const disabled = !ready || !agent.executablePath || busy !== null; + const missingTrustTarget = + agent.provider === "grok" && !agent.installSource; + const now = Math.floor(Date.now() / 1000); + return ( + + {agentStateLabel(agent.state)} + + } + description={ + <> + {agent.message} + {configured ? ( + + {receipt === "verified" + ? `수신 확인됨 · 마지막 관측 ${formatRelativeTime(seen, now)} 전` + : receipt === "historical" + ? `이전 수신 기록 · ${formatRelativeTime(seen, now)} 전 · 새 이벤트 확인 대기` + : "첫 이벤트 수신 대기 · 설치만으로 수집 완료를 뜻하지 않습니다."} + + ) : null} + + } + extra={ + <> + {outcome(agent.provider)} + {missingTrustTarget ? ( +

+ Grok 설치 대상을 확인하려면 CLI를 업데이트하고 다시 확인하세요. +

+ ) : null} + {agent.provider === "grok" && trustSource !== null ? ( + setTrustSource(null)} + onApprove={() => { + if (disabled || agent.installSource !== trustSource) return; + const approvedSource = trustSource; + setTrustSource(null); + onAction({ + kind: "connectAgent", + provider: "grok", + approvedSource, + }); + }} + /> + ) : null} + {configured || agent.state === "trustRequired" ? ( +

+ Agent의 /hooks에서 연결과 신뢰 상태를 확인하세요. 기존 session은 + 다시 시작해야 할 수 있습니다. +

+ ) : null} + + } + key={agent.provider} + title={AGENT_NAMES[agent.provider]} + > { + if (agent.provider === "grok" && agent.installSource) { + setTrustSource(agent.installSource); + } else onAction({ kind: "connectAgent", provider: agent.provider }); + }} > - 다시 확인 + {configured || agent.state === "disabled" ? "다시 연결" : "연결"} -
+ {agent.state !== "notInstalled" && agent.state !== "disconnected" ? ( + + onAction({ kind: "disconnectAgent", provider: agent.provider }) + } + > + 연결 해제 + + ) : null} + + ); + }; + + const content = ( + <> {outcomeTarget === null ? ( ) : null} {status ? ( <> -
-
- 1 · Workbranch CLI - - {ready - ? "준비됨" - : status.cli.state === "missing" - ? "미설치" - : "확인 필요"} - -
-

{status.cli.message}

-
+ + + {cliStateLabel(status.cli)} + + } + description={status.cli.message} + extra={ + <> + {outcome("cli")} + {!status.brewPath ? ( +

+ Homebrew를 찾지 못했습니다. 설치 또는 실행 경로를 + 확인하세요. +

+ ) : null} + + } + title="Workbranch CLI" + > - onAction({ - kind: cliSetupAction(status), - }) - } + onClick={() => onAction({ kind: cliSetupAction(status) })} > {cliSetupAction(status) === "installCli" ? "CLI 설치" @@ -186,14 +328,8 @@ export function ConnectionsPanel({ 설치 복구 ) : null} -
- {outcome("cli")} - {!status.brewPath ? ( -

- Homebrew를 찾지 못했습니다. 설치 또는 실행 경로를 확인하세요. -

- ) : null} -
+ +
버전 및 사용 경로
CLI
@@ -212,142 +348,27 @@ export function ConnectionsPanel({
{status.brewPath || "없음"}
-
-
-

2 · AI Agent 연결

+ + {!ready ? ( -

+ CLI 준비가 끝나면 agent를 연결할 수 있습니다. -

+ ) : null} - {status.agents.map((agent) => { - const seen = Math.max( - agent.lastObservedAt ?? 0, - ...sessions - .filter((s) => s.provider === agent.provider) - .map((s) => s.updatedAt), - ); - const configured = agent.state === "configured"; - const receipt = receiptState( - seen, - state.verificationAfter[agent.provider], - ); - const disabled = !ready || !agent.executablePath || busy !== null; - const missingTrustTarget = - agent.provider === "grok" && !agent.installSource; - const label = - agent.state === "notInstalled" - ? "Agent 미설치" - : agent.state === "disabled" - ? "비활성" - : agent.state === "trustRequired" - ? "신뢰 승인 필요" - : configured - ? "설정됨" - : agent.state === "unknown" - ? "확인 필요" - : "미연결"; - return ( -
-
- {names[agent.provider]} - {label} -
-

{agent.message}

- {configured ? ( -

- {receipt === "verified" - ? `수신 확인됨 · 마지막 관측 ${formatRelativeTime(seen, Math.floor(Date.now() / 1000))} 전` - : receipt === "historical" - ? `이전 수신 기록 · ${formatRelativeTime(seen, Math.floor(Date.now() / 1000))} 전 · 새 이벤트 확인 대기` - : "첫 이벤트 수신 대기 · 설치만으로 수집 완료를 뜻하지 않습니다."} -

- ) : null} -
- { - if (agent.provider === "grok" && agent.installSource) { - setTrustSource(agent.installSource); - } else - onAction({ - kind: "connectAgent", - provider: agent.provider, - }); - }} - > - {configured || agent.state === "disabled" - ? "다시 연결" - : "연결"} - - {agent.state !== "notInstalled" && - agent.state !== "disconnected" ? ( - - onAction({ - kind: "disconnectAgent", - provider: agent.provider, - }) - } - > - 연결 해제 - - ) : null} -
- {outcome(agent.provider)} - {missingTrustTarget ? ( -

- Grok 설치 대상을 확인하려면 CLI를 업데이트하고 다시 - 확인하세요. -

- ) : null} - {agent.provider === "grok" && trustSource !== null ? ( - setTrustSource(null)} - onApprove={() => { - if (disabled || agent.installSource !== trustSource) - return; - const approvedSource = trustSource; - setTrustSource(null); - onAction({ - kind: "connectAgent", - provider: "grok", - approvedSource, - }); - }} - /> - ) : null} - {configured || agent.state === "trustRequired" ? ( -

- Agent의 /hooks에서 연결과 신뢰 상태를 확인하세요. 기존 - session은 다시 시작해야 할 수 있습니다. -

- ) : null} -
- ); - })} -
+ {status.agents.map(agentRow)} + ) : loading ? ( -

설치 상태를 확인하고 있습니다…

+

+ 설치 상태를 확인하고 있습니다… +

) : ( -

설치 상태를 읽을 수 없습니다. 다시 확인하세요.

+

+ 설치 상태를 읽을 수 없습니다. 다시 확인하세요. +

)} {busy ? ( -

+

{busy.kind.endsWith("Agent") ? "Agent 연결 작업 중…" : "Homebrew 작업 중…"}{" "} @@ -355,12 +376,39 @@ export function ConnectionsPanel({

) : null} {state.logs ? ( -
+
상세 로그
{state.logs}
) : null} - {onboarding && onDismiss ? ( + + ); + + if (!onboarding) { + return ( + + {content} + + ); + } + return ( + +
+

CLI를 준비하고 사용할 agent를 연결하세요.

+ {refresh} +
+ {content} + {onDismiss ? ( diff --git a/apps/companion/src/ui/MenuBarSettings.tsx b/apps/companion/src/ui/MenuBarSettings.tsx index c125fd3..edd3a5b 100644 --- a/apps/companion/src/ui/MenuBarSettings.tsx +++ b/apps/companion/src/ui/MenuBarSettings.tsx @@ -5,12 +5,19 @@ import { type MenuBarConfig, menuBarSampleTitle, } from "../application/menuBar"; +import { SegmentedControl, SettingsGroup, SettingsRow } from "./SettingsLayout"; type MenuBarSettingsProps = { readonly value: MenuBarConfig; readonly onChange: (config: MenuBarConfig) => void; }; +const PERCENT_SEGMENTS = MENU_BAR_PERCENT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + ariaLabel: `Show limits as ${option.label}`, +})); + /** Same outline as src-tauri/icons/tray-template.svg. */ function TrayGhost() { return ( @@ -30,74 +37,72 @@ function TrayGhost() { /** * Builds the tray title from items. The preview shows what the picked items - * produce with sample numbers, so each toggle's effect is visible at once. + * produce with sample numbers, so each switch's effect is visible at once. */ export function MenuBarSettings({ value, onChange }: MenuBarSettingsProps) { const title = menuBarSampleTitle(value); const percentDisabled = !value.claude5h && !value.claudeWeekly && !value.codexWeekly; return ( -
-
- Preview - - - {title === "" ? null : ( - {title} - )} - -
-
+ <> + +
+ + + {title === "" ? null : ( + {title} + )} + +

+ {isIconOnly(value) + ? "Nothing picked: the menu bar shows the icon alone." + : "Sample numbers. The menu bar updates every minute; a limit reading too old to trust shows as –."} +

+
+
+ {MENU_BAR_ITEM_OPTIONS.map((option) => { - const selected = value[option.item]; + const id = `menu-bar-${option.item}`; return ( - - ); - })} -
-
- {MENU_BAR_PERCENT_OPTIONS.map((option) => { - const selected = option.value === value.percent; - return ( - + + onChange({ + ...value, + [option.item]: event.currentTarget.checked, + }) + } + role="switch" + type="checkbox" + /> + ); })} -
-

- {isIconOnly(value) - ? "Nothing picked: the menu bar shows the icon alone." - : "Sample numbers. The menu bar updates every minute; a limit reading too old to trust shows as –."} -

-
+ + + + onChange({ ...value, percent })} + options={PERCENT_SEGMENTS} + value={value.percent} + /> + + + ); } diff --git a/apps/companion/src/ui/ScreensaverSettings.tsx b/apps/companion/src/ui/ScreensaverSettings.tsx index 20bf241..ad58e2e 100644 --- a/apps/companion/src/ui/ScreensaverSettings.tsx +++ b/apps/companion/src/ui/ScreensaverSettings.tsx @@ -2,6 +2,7 @@ import { SCREENSAVER_IDLE_MINUTES, type ScreensaverConfig, } from "../application/screensaver"; +import { SettingsGroup, SettingsRow } from "./SettingsLayout"; type ScreensaverSettingsProps = { readonly value: ScreensaverConfig; @@ -22,40 +23,57 @@ export function ScreensaverSettings({ onPreview, }: ScreensaverSettingsProps) { return ( -
-
- + + onChange({ ...value, enabled: event.currentTarget.checked }) } + role="switch" type="checkbox" /> -
-
- - -
-
- Preview + + +
+ +
+
+ -
-

- {value.enabled - ? `Keeps the Mac awake while an agent runs or waits for you; after ${value.idleMinutes} min without input a dashboard covers every screen, and any input locks the Mac.` - : "Off: macOS handles sleep, screen saver and lock as usual."}{" "} - The preview ignores input for two seconds, then any input closes it - without locking. -

-
+ + ); } diff --git a/apps/companion/src/ui/SettingsIcon.tsx b/apps/companion/src/ui/SettingsIcon.tsx new file mode 100644 index 0000000..ef9f595 --- /dev/null +++ b/apps/companion/src/ui/SettingsIcon.tsx @@ -0,0 +1,43 @@ +export type SettingsIconName = + | "general" + | "menu-bar" + | "screen-saver" + | "appearance" + | "connections" + | "download" + | "info" + | "check" + | "alert"; + +/** One stroke path per icon, drawn on a 24px grid. */ +const PATHS: Record = { + general: + "M3 5h10M17 5h4M3 12h4M11 12h10M3 19h12M19 19h2M13 5a2 2 0 1 0 4 0a2 2 0 1 0-4 0M7 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0M15 19a2 2 0 1 0 4 0a2 2 0 1 0-4 0", + "menu-bar": + "M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM3 9h18M15 6.5h3", + "screen-saver": + "M5 4h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM8 20h8M12 16v4", + appearance: "M4 7V5h16v2M9 19h6M12 5v14", + connections: "M9 2v5M15 2v5M7 7h10v4a5 5 0 0 1-10 0V7zM12 16v6", + download: "M12 4v11M7 10l5 5 5-5M5 20h14", + info: "M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0M12 11v6M12 7.5v.5", + check: "M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0M8 12.5l2.5 2.5L16 9.5", + alert: "M12 3l9.5 17h-19L12 3zM12 10v4.5M12 17.5v.5", +}; + +export function SettingsIcon({ name }: { readonly name: SettingsIconName }) { + return ( + + ); +} diff --git a/apps/companion/src/ui/SettingsLayout.tsx b/apps/companion/src/ui/SettingsLayout.tsx new file mode 100644 index 0000000..075d59c --- /dev/null +++ b/apps/companion/src/ui/SettingsLayout.tsx @@ -0,0 +1,199 @@ +import type { ReactNode } from "react"; +import { SettingsIcon, type SettingsIconName } from "./SettingsIcon"; +import { type SettingsCategory, settingsEyebrow } from "./SettingsNav"; + +export type SettingsTone = "neutral" | "success" | "notice" | "danger"; + +type SettingsPageProps = { + readonly category: SettingsCategory; + readonly title: string; + readonly description: string; + /** Page-wide control beside the heading, such as a status re-check. */ + readonly action?: ReactNode; + readonly children: ReactNode; +}; + +/** One Settings tab: numbered heading, then its groups. */ +export function SettingsPage({ + category, + title, + description, + action, + children, +}: SettingsPageProps) { + const eyebrow = settingsEyebrow(category); + return ( +
+
+
+

{eyebrow}

+

{title}

+

{description}

+
+ {action} +
+ {children} +
+ ); +} + +type SettingsGroupProps = { + readonly label: string; + readonly ariaLabel?: string; + readonly action?: ReactNode; + readonly children: ReactNode; +}; + +/** A small label above a rounded card of rows. */ +export function SettingsGroup({ + label, + ariaLabel = label, + action, + children, +}: SettingsGroupProps) { + return ( +
+
+

{label}

+ {action} +
+
{children}
+
+ ); +} + +type SettingsRowProps = { + readonly title: string; + readonly description?: ReactNode; + /** Makes the title the control's label. */ + readonly htmlFor?: string; + readonly badge?: ReactNode; + /** Puts the control on its own full-width line under the copy. */ + readonly stacked?: boolean; + /** Full-width content under the row, such as an action's result. */ + readonly extra?: ReactNode; + readonly children?: ReactNode; +}; + +/** Title and description on the left, the control on the right. */ +export function SettingsRow({ + title, + description, + htmlFor, + badge, + stacked = false, + extra, + children, +}: SettingsRowProps) { + return ( +
+
+
+ {htmlFor === undefined ? ( + {title} + ) : ( + + )} + {badge} +
+ {description === undefined ? null : ( +
{description}
+ )} +
+ {children === undefined ? null : ( +
{children}
+ )} + {extra ?
{extra}
: null} +
+ ); +} + +type SettingsNoteProps = { + readonly icon?: SettingsIconName; + readonly tone?: SettingsTone; + readonly role?: "alert" | "status" | undefined; + readonly action?: ReactNode; + readonly children: ReactNode; +}; + +/** A status line with an optional icon and the button that settles it. */ +export function SettingsNote({ + icon, + tone = "neutral", + role, + action, + children, +}: SettingsNoteProps) { + return ( +
+ {icon ? : null} +
{children}
+ {action} +
+ ); +} + +export function SettingsBadge({ + tone = "neutral", + children, +}: { + readonly tone?: SettingsTone; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export type SegmentOption = { + readonly value: T; + readonly label: string; + readonly ariaLabel?: string; +}; + +type SegmentedControlProps = { + readonly label: string; + readonly options: readonly SegmentOption[]; + readonly value: T; + readonly onChange: (value: T) => void; + readonly disabled?: boolean; + /** Spreads the segments over the full width in equal parts. */ + readonly stretch?: boolean; +}; + +export function SegmentedControl({ + label, + options, + value, + onChange, + disabled = false, + stretch = false, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const selected = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/apps/companion/src/ui/SettingsNav.tsx b/apps/companion/src/ui/SettingsNav.tsx new file mode 100644 index 0000000..cda1d4a --- /dev/null +++ b/apps/companion/src/ui/SettingsNav.tsx @@ -0,0 +1,68 @@ +import { SettingsIcon } from "./SettingsIcon"; + +export type SettingsCategory = + | "general" + | "connections" + | "menu-bar" + | "screen-saver" + | "appearance"; + +export type SettingsAttention = Partial>; + +export const SETTINGS_CATEGORIES: readonly { + readonly category: SettingsCategory; + readonly label: string; +}[] = [ + { category: "general", label: "General" }, + { category: "connections", label: "Connections" }, + { category: "menu-bar", label: "Menu Bar" }, + { category: "screen-saver", label: "Screen Saver" }, + { category: "appearance", label: "Appearance" }, +]; + +/** "01 · General": the page's place in the tab row, then its name. */ +export function settingsEyebrow(category: SettingsCategory): string { + const index = SETTINGS_CATEGORIES.findIndex( + (entry) => entry.category === category, + ); + const label = SETTINGS_CATEGORIES[index]?.label ?? category; + return `${String(index + 1).padStart(2, "0")} · ${label}`; +} + +type SettingsNavProps = { + readonly current: SettingsCategory; + readonly attention: SettingsAttention; + readonly onChange: (category: SettingsCategory) => void; +}; + +export function SettingsNav({ + current, + attention, + onChange, +}: SettingsNavProps) { + return ( + + ); +} diff --git a/apps/companion/src/ui/SettingsPanel.tsx b/apps/companion/src/ui/SettingsPanel.tsx index f127798..fce4bc7 100644 --- a/apps/companion/src/ui/SettingsPanel.tsx +++ b/apps/companion/src/ui/SettingsPanel.tsx @@ -1,9 +1,9 @@ +import type { ReactNode } from "react"; import type { CompanionPreferences } from "../application/preferences"; import { COMPANION_FONT_OPTIONS, COMPANION_FONT_SIZE_OPTIONS, isCompanionFont, - isCompanionFontSize, } from "../application/preferences"; import { AgentThemePicker } from "./AgentThemePicker"; import { @@ -12,9 +12,21 @@ import { } from "./ClaudeLimitRelaySettings"; import { MenuBarSettings } from "./MenuBarSettings"; import { ScreensaverSettings } from "./ScreensaverSettings"; -import { TerminalPanel } from "./TerminalPanel"; +import { + SegmentedControl, + SettingsGroup, + SettingsPage, + SettingsRow, +} from "./SettingsLayout"; +import type { SettingsCategory } from "./SettingsNav"; + +/** Every tab except Connections, which the app shell renders itself. */ +export type PreferenceCategory = Exclude; type Props = { + readonly category: PreferenceCategory; + /** Update and connection status, shown first on General. */ + readonly status?: ReactNode; readonly claudeLimitRelay: ClaudeLimitRelayProps; readonly preferences: CompanionPreferences; readonly launchAtLogin: boolean; @@ -24,15 +36,95 @@ type Props = { readonly onScreensaverPreview: () => void; }; -export function SettingsPanel({ +export function SettingsPanel(props: Props) { + switch (props.category) { + case "general": + return generalPage(props); + case "menu-bar": + return ( + + + props.onPreferencesChange({ ...props.preferences, menuBar }) + } + value={props.preferences.menuBar} + /> + + ); + case "screen-saver": + return ( + + + props.onPreferencesChange({ ...props.preferences, screensaver }) + } + onPreview={props.onScreensaverPreview} + value={props.preferences.screensaver} + /> + + ); + case "appearance": + return appearancePage(props); + } +} + +/** Called, not mounted, so the page's controls stay in the returned tree. */ +function generalPage({ + status, claudeLimitRelay, - preferences, launchAtLogin, launchAtLoginLoading, onLaunchAtLoginChange, - onPreferencesChange, - onScreensaverPreview, }: Props) { + return ( + + {status} + + + + onLaunchAtLoginChange(event.currentTarget.checked) + } + role="switch" + type="checkbox" + /> + + + + + + + ); +} + +function appearancePage({ preferences, onPreferencesChange }: Props) { const fontOption = COMPANION_FONT_OPTIONS.find( (candidate) => candidate.value === preferences.font, ); @@ -44,140 +136,75 @@ export function SettingsPanel({ const fontSizeName = fontSizeOption?.label ?? preferences.fontSize; return ( -
-
-
-

Settings

-

Companion preferences

-
-
- -
- - - onLaunchAtLoginChange(event.currentTarget.checked) - } - type="checkbox" + + + + onPreferencesChange({ ...preferences, theme })} + value={preferences.theme} /> -
-

- {launchAtLoginLoading - ? "Checking login item state" - : launchAtLogin - ? "Opens automatically when you sign in" - : "Opens only when opened manually"} -

-
- - - - - - onPreferencesChange({ ...preferences, menuBar }) - } - /> - - - - onPreferencesChange({ ...preferences, screensaver }) - } - onPreview={onScreensaverPreview} - /> - - -
- - -
-
- Preview · {fontName} - workbranch feat/update-0619 - 1234567890 · RUN 1 · 21/21 -
-

Current font: {fontName}

-
- -
- - { + const nextFont = event.currentTarget.value; + if (isCompanionFont(nextFont)) { + onPreferencesChange({ ...preferences, font: nextFont }); + } + }} + value={preferences.font} + > + {COMPANION_FONT_OPTIONS.map((option) => ( + + ))} + +
+ + + + onPreferencesChange({ ...preferences, fontSize }) + } + options={COMPANION_FONT_SIZE_OPTIONS} + stretch value={preferences.fontSize} - > - {COMPANION_FONT_SIZE_OPTIONS.map((option) => ( - - ))} - - -
- Preview · {fontSizeName} - workbranch feat/update-0619 - - ci: build signed macOS DMGs · 11d - + /> + +
+
+ + Preview · {fontName} · {fontSizeName} + + workbranch feat/update-0619 + 1234567890 · RUN 1 · 21/21 + + ci: build signed macOS DMGs · 11d + +
-

- Scales every text size in Main, Usage, and Settings. -

- - - onPreferencesChange({ ...preferences, theme })} - /> -

- Applies immediately across Main, Usage, and Settings. -

-
-
+ + ); } diff --git a/apps/companion/src/ui/SettingsView.tsx b/apps/companion/src/ui/SettingsView.tsx index 9306f36..e75c4ec 100644 --- a/apps/companion/src/ui/SettingsView.tsx +++ b/apps/companion/src/ui/SettingsView.tsx @@ -1,11 +1,21 @@ import type { ReactNode } from "react"; import type { CompanionPreferences } from "../application/preferences"; import type { ClaudeLimitRelayProps } from "./ClaudeLimitRelaySettings"; +import { + type SettingsAttention, + type SettingsCategory, + SettingsNav, +} from "./SettingsNav"; import { SettingsPanel } from "./SettingsPanel"; type Props = { - readonly claudeLimitRelay: ClaudeLimitRelayProps; + readonly category: SettingsCategory; + readonly onCategoryChange: (category: SettingsCategory) => void; + /** Tabs that hold something to act on get a dot. */ + readonly attention?: SettingsAttention; + readonly status?: ReactNode; readonly connections?: ReactNode; + readonly claudeLimitRelay: ClaudeLimitRelayProps; readonly preferences: CompanionPreferences; readonly launchAtLogin: boolean; readonly launchAtLoginLoading: boolean; @@ -15,8 +25,12 @@ type Props = { }; export function SettingsView({ - claudeLimitRelay, + category, + onCategoryChange, + attention = {}, + status, connections, + claudeLimitRelay, preferences, launchAtLogin, launchAtLoginLoading, @@ -26,16 +40,26 @@ export function SettingsView({ }: Props) { return (
- {connections} - + {category === "connections" ? ( + connections + ) : ( + + )}
); } diff --git a/apps/companion/src/ui/StatusSettings.tsx b/apps/companion/src/ui/StatusSettings.tsx new file mode 100644 index 0000000..a128a94 --- /dev/null +++ b/apps/companion/src/ui/StatusSettings.tsx @@ -0,0 +1,215 @@ +import type { ConnectionState } from "../application/connections"; +import { + companionRestartPending, + type PackageVersion, + type UpdateState, + type UpdateTarget, + updateActionLabel, + updateTargets, +} from "../application/updates"; +import { + AGENT_NAMES, + agentStateLabel, + cliStateLabel, +} from "./ConnectionsPanel"; +import { ProgressButton } from "./ProgressButton"; +import { + SettingsBadge, + SettingsGroup, + SettingsNote, + SettingsRow, + type SettingsTone, +} from "./SettingsLayout"; + +type Props = { + readonly updates: UpdateState; + readonly connections: ConnectionState; + readonly onCheckUpdates: () => void; + readonly onApplyUpdates: () => void; + readonly onOpenConnections: () => void; +}; + +const TARGET_NAMES: Record = { + cli: "CLI", + companion: "Companion", +}; + +function versionBadge(version: PackageVersion): string { + if (version.latest === null) return "확인 불가"; + if (version.installed === null) return "Homebrew 미설치"; + return version.outdated ? "업데이트 있음" : "최신"; +} + +function versionTone(version: PackageVersion): SettingsTone { + if (version.latest === null || version.installed === null) return "neutral"; + return version.outdated ? "notice" : "success"; +} + +function versionLine(version: PackageVersion): string { + const installed = version.installed ?? "없음"; + const latest = version.latest ?? "알 수 없음"; + return version.outdated + ? `설치 ${installed} → 최신 ${latest}` + : `설치 ${installed} · 최신 ${latest}`; +} + +function connectionSummary({ status, loading }: ConnectionState): string { + if (status === null) + return loading + ? "설치 상태를 확인하고 있습니다…" + : "설치 상태를 읽을 수 없습니다."; + const agents = status.agents.map( + (agent) => `${AGENT_NAMES[agent.provider]} ${agentStateLabel(agent.state)}`, + ); + return [`CLI ${cliStateLabel(status.cli)}`, ...agents].join(" · "); +} + +/** + * What needs a decision comes first on General: the check's outcome (with + * the one button that applies updates), then CLI and Companion versions in + * the order the update runs, the update log, and whether agents are ready. + * The check stays click-only because it runs `brew update`. + */ +export function StatusSettings({ + updates, + connections, + onCheckUpdates, + onApplyUpdates, + onOpenConnections, +}: Props) { + const { status, checking, updating, restarting } = updates; + const busy = checking || updating || restarting; + const targets = updateTargets(status); + return ( + + {status === null ? "업데이트 확인" : "다시 확인"} + + } + label="Status" + > + {targets.length > 0 ? ( + + {updateActionLabel(targets)} + + } + icon="download" + tone="notice" + > + 업데이트 있음 ·{" "} + {targets.map((target) => TARGET_NAMES[target]).join(", ")} + + ) : null} + {status?.brewPath && targets.length === 0 && !checking ? ( + + 업데이트할 항목이 없습니다. + + ) : null} + {updates.error ? ( + + {updates.error} + + ) : null} + {updates.notice ? ( + + {updates.notice} + + ) : null} + {updating ? ( + + Homebrew 업데이트 중… 완료될 때까지 기다려 주세요. + + ) : null} + {status?.fetchError ? ( + + Homebrew 정보를 갱신하지 못해 최신 버전이 오래됐을 수 있습니다:{" "} + {status.fetchError} + + ) : null} + {status === null ? ( + + {checking + ? "최신 버전을 확인하고 있습니다…" + : "Homebrew로 설치한 CLI와 Companion의 최신 버전을 확인합니다."} + + ) : status.brewPath === null ? ( + + Homebrew를 찾지 못했습니다. 실행 중인 Companion{" "} + {status.runningVersion} + + ) : ( + <> + + {versionBadge(status.cli)} + + } + description={ + <> + {versionLine(status.cli)} + {status.cli.installed === null && status.cli.latest !== null ? ( + Connections에서 CLI를 설치하세요. + ) : null} + + } + title="Workbranch CLI" + /> + + {versionBadge(status.companion)} + + } + description={ + <> + + 실행 중 {status.runningVersion} ·{" "} + {versionLine(status.companion)} + + {!status.selfUpdateSupported ? ( + + 개발 빌드에서는 Companion을 업데이트하지 않습니다. + + ) : companionRestartPending(status) ? ( + + 새 버전이 설치되어 있습니다. Companion을 다시 시작하면 + 적용됩니다. + + ) : null} + + } + title="Workbranch Companion" + /> + + )} + {updates.logs ? ( +
+ 상세 로그 +
{updates.logs}
+
+ ) : null} + + + +
+ ); +} diff --git a/apps/companion/src/ui/UpdatePanel.tsx b/apps/companion/src/ui/UpdatePanel.tsx deleted file mode 100644 index ea01d89..0000000 --- a/apps/companion/src/ui/UpdatePanel.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import type { CompanionTheme } from "../application/preferences"; -import { - companionRestartPending, - type PackageVersion, - type UpdateState, - updateActionLabel, - updateTargets, -} from "../application/updates"; -import { ProgressButton } from "./ProgressButton"; -import { TerminalPanel } from "./TerminalPanel"; - -type Props = { - readonly theme: CompanionTheme; - readonly state: UpdateState; - readonly onCheck: () => void; - readonly onApply: () => void; - readonly onClose: () => void; -}; - -function versionBadge(version: PackageVersion): string { - if (version.latest === null) return "확인 불가"; - if (version.installed === null) return "Homebrew 미설치"; - return version.outdated ? "업데이트 있음" : "최신"; -} - -function versionLine(version: PackageVersion): string { - const installed = version.installed ?? "없음"; - const latest = version.latest ?? "알 수 없음"; - return version.outdated - ? `설치 ${installed} → 최신 ${latest}` - : `설치 ${installed} · 최신 ${latest}`; -} - -export function UpdatePanel({ - theme, - state, - onCheck, - onApply, - onClose, -}: Props) { - const { status, checking, updating, restarting } = state; - const busy = checking || updating || restarting; - const targets = updateTargets(status); - return ( - -
-

Homebrew로 설치한 CLI와 Companion의 최신 버전을 확인합니다.

-
- - 다시 확인 - - -
-
- {state.error ? ( -

- {state.error} -

- ) : null} - {state.notice ?

{state.notice}

: null} - {status?.fetchError ? ( -

- Homebrew 정보를 갱신하지 못해 최신 버전이 오래됐을 수 있습니다:{" "} - {status.fetchError} -

- ) : null} - {status ? ( - status.brewPath === null ? ( -

- Homebrew를 찾지 못했습니다. 실행 중인 Companion{" "} - {status.runningVersion} -

- ) : ( - <> -
-
- Workbranch CLI - - {versionBadge(status.cli)} - -
-

{versionLine(status.cli)}

- {status.cli.installed === null && status.cli.latest !== null ? ( -

- Settings의 설치 및 agent 연결에서 CLI를 설치하세요. -

- ) : null} -
-
-
- Workbranch Companion - - {versionBadge(status.companion)} - -
-

- 실행 중 {status.runningVersion} ·{" "} - {versionLine(status.companion)} -

- {!status.selfUpdateSupported ? ( -

- 개발 빌드에서는 Companion을 업데이트하지 않습니다. -

- ) : companionRestartPending(status) ? ( -

- 새 버전이 설치되어 있습니다. Companion을 다시 시작하면 - 적용됩니다. -

- ) : null} -
- {targets.length > 0 ? ( -
- - {updateActionLabel(targets)} - -
- ) : !checking ? ( -

업데이트할 항목이 없습니다.

- ) : null} - - ) - ) : checking ? ( -

최신 버전을 확인하고 있습니다…

- ) : null} - {updating ? ( -

Homebrew 업데이트 중… 완료될 때까지 기다려 주세요.

- ) : null} - {state.logs ? ( -
- 상세 로그 -
{state.logs}
-
- ) : null} -
- ); -} diff --git a/apps/companion/src/ui/UsageParts.tsx b/apps/companion/src/ui/UsageParts.tsx index 2feabc6..ce7172e 100644 --- a/apps/companion/src/ui/UsageParts.tsx +++ b/apps/companion/src/ui/UsageParts.tsx @@ -168,7 +168,8 @@ export function UsageDayAxis({ /** * Both agents per day on one scale, so each day's split reads directly. * The last `dayCount` days end with today; `showValues` prints each agent's - * total under its day for the short Main window. + * total right under its own bar, above the day axis, for the short Main + * window. */ export function UsageDailyComparison({ summaries, @@ -235,7 +236,6 @@ export function UsageDailyComparison({ ))} - {showValues ? (
{days.map((day, index) => ( @@ -257,6 +257,7 @@ export function UsageDailyComparison({ ))}
) : null} + ); } diff --git a/apps/companion/tests/app-shell.test.tsx b/apps/companion/tests/app-shell.test.tsx index 99a76fb..6d32edd 100644 --- a/apps/companion/tests/app-shell.test.tsx +++ b/apps/companion/tests/app-shell.test.tsx @@ -346,24 +346,58 @@ describe("App shell settings wiring", () => { ); }); - it("right-aligns the settings font select without letting the chevron take layout space", () => { + it("keeps the settings select chevron an overlay that takes no layout space", () => { // Given the settings stylesheet - // When the font select row CSS contract is inspected + // When the select wrapper CSS contract is inspected const css = readCssContract("src/style.css"); - // Then the select owns the right edge and the chevron is only an overlay - expect(css).toMatch(/\.settings-row-select\s*\{[^}]*position:\s*relative/s); + // Then the chevron sits over the select's own right padding + expect(css).toMatch(/\.settings-select\s*\{[^}]*position:\s*relative/s); expect(css).toMatch( - /\.settings-row-select\s+select\s*\{[^}]*margin-left:\s*auto/s, + /\.settings-select select\s*\{[^}]*padding-right:\s*28px/s, ); expect(css).toMatch( - /\.settings-row-select::after\s*\{[^}]*position:\s*absolute[^}]*right:\s*10px/s, + /\.settings-select::after\s*\{[^}]*pointer-events:\s*none[^}]*position:\s*absolute[^}]*right:\s*10px/s, ); - expect(css).not.toMatch( - /\.settings-row-select::after\s*\{[^}]*margin-left:\s*-32px/s, + }); + + it("draws settings switches from native checkboxes and theme tokens", () => { + const css = readCssContract("src/style.css"); + + expect(css).toMatch(/\.settings-switch\s*\{[^}]*appearance:\s*none/s); + expect(css).toMatch( + /\.settings-switch:checked\s*\{[^}]*background:\s*var\(--emphasis\)/s, + ); + expect(css).toMatch( + /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.settings-switch,\s*\.settings-switch::before\s*\{[^}]*transition:\s*none/s, ); }); + it("decides Main onboarding from setup state and where an action started", () => { + const source = readFileSync("src/App.tsx", "utf8"); + + expect(source).toContain( + "needsOnboarding(connections.state, sessions, setupStartedOnMain)", + ); + expect(source).toContain( + "onAction={(action) => handleSetupAction(action, onboarding)}", + ); + expect(source).not.toMatch(/connections\.state\.notice !== null \|\|/); + }); + + it("opens update status on Settings › General from the header control", () => { + const source = readFileSync("src/App.tsx", "utf8"); + const handler = source.slice( + source.indexOf("const handleCheckUpdates"), + source.indexOf("const handleQuit"), + ); + + expect(source).not.toContain("UpdatePanel"); + expect(handler).toContain('setCurrentView("settings")'); + expect(handler).toContain('setSettingsCategory("general")'); + expect(handler).toContain("updates.check()"); + }); + it("keeps the stage board framed and width-safe at the native boundary", () => { const css = readCssContract("src/style.css"); @@ -431,7 +465,10 @@ describe("App shell settings wiring", () => { expect(css).not.toContain("--faint: #626262"); expect(css).not.toContain("--faint: #5d5d5d"); expect(css).toMatch( - /\.settings-panel p,\s*\.settings-hint\s*\{[^}]*color:\s*var\(--muted\)/s, + /\.settings-page-description,\s*\.settings-hint\s*\{[^}]*color:\s*var\(--muted\)/s, + ); + expect(css).toMatch( + /\.settings-row-description\s*\{[^}]*color:\s*var\(--muted\)/s, ); expect(css).toMatch( /\.font-preview-label\s*\{[^}]*color:\s*var\(--muted\)/s, @@ -474,14 +511,20 @@ describe("App shell settings wiring", () => { /\.error,\s*\.empty\s*\{[^}]*font-size:\s*var\(--fs-ui\)/s, /\.task-action\s*\{[^}]*font-size:\s*var\(--fs-body\)/s, /\.app-error\s*\{[^}]*font-size:\s*var\(--fs-ui\)/s, - /\.settings-panel h2\s*\{[^}]*font-size:\s*var\(--fs-title\)/s, - /\.settings-panel p,\s*\.settings-hint\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, - /\.settings-row label\s*\{[^}]*font-size:\s*var\(--fs-ui\)/s, - /\.settings-row-select::after\s*\{[^}]*font-size:\s*var\(--fs-ui\)/s, + /\.settings-nav-tab\s*\{[^}]*font-size:\s*var\(--fs-label\)/s, + /\.settings-page button,\s*\.settings-page select\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, + /\.settings-page-eyebrow\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, + /\.settings-page-header h2\s*\{[^}]*font-size:\s*var\(--fs-title\)/s, + /\.settings-page-description,\s*\.settings-hint\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, + /\.settings-group-header h3\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, + /\.settings-row-title strong,\s*\.settings-row-title label\s*\{[^}]*font-size:\s*var\(--fs-body\)/s, + /\.settings-row-description\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, + /\.settings-select::after\s*\{[^}]*font-size:\s*var\(--fs-ui\)/s, + /\.settings-badge\s*\{[^}]*font-size:\s*var\(--fs-label\)/s, + /\.settings-note\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, /\.font-preview\s*\{[^}]*font-size:\s*var\(--fs-body\)/s, /\.font-preview-label\s*\{[^}]*font-size:\s*var\(--fs-label\)/s, /\.font-preview-meta\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, - /\.agent-theme-button\s*\{[^}]*font-size:\s*var\(--fs-body\)/s, /\.usage-name\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, /\.usage-limit\s*\{[^}]*font-size:\s*var\(--fs-meta\)/s, /\.usage-limit-detail\s*\{[^}]*font-size:\s*var\(--fs-label\)/s, @@ -607,6 +650,26 @@ describe("App shell settings wiring", () => { expect(baseCss).toMatch(/main\s*\{[^}]*max-width:\s*100%/s); }); + it("aligns each daily total under its own bar", () => { + const css = readCssContract("src/style.css"); + const slot = + /\.usage-compare-day\s*\{\s*gap:\s*1px;\s*padding:\s*0 2px;\s*\}/s; + const values = + /\.usage-values-day\s*\{[^}]*display:\s*flex;[^}]*gap:\s*1px;[^}]*padding:\s*0 2px;/s; + + // The totals row repeats the bar slot's padding and gap in equal parts + expect(css).toMatch(slot); + expect(css).toMatch(values); + expect(css).toMatch( + /\.usage-values-day > \[data-provider\]\s*\{[^}]*flex:\s*1 1 0/s, + ); + expect(css).toMatch(/\.usage-compare-bar\s*\{[^}]*flex:\s*1 1 0/s); + // And stacks under the day once the slots are too narrow for both + expect(css).toMatch( + /@container usage \(max-width: 46em\)\s*\{\s*\.usage-values-day\s*\{[^}]*display:\s*grid/s, + ); + }); + it("defines only the fixed-dark Claude Code and Codex theme tokens", () => { // Given the companion stylesheet // When theme selector contracts are inspected diff --git a/apps/companion/tests/connections.test.tsx b/apps/companion/tests/connections.test.tsx index c429367..8c8eeab 100644 --- a/apps/companion/tests/connections.test.tsx +++ b/apps/companion/tests/connections.test.tsx @@ -4,6 +4,7 @@ import { type ConnectionState, createConnectionController, EMPTY_CONNECTION_STATE, + needsOnboarding, type SetupStatus, sameSetupAction, } from "../src/application/connections"; @@ -292,7 +293,7 @@ it("puts an action result beside the button that ran it", () => { lastAction: { kind: "updateCli" }, notice: "CLI와 수집기 준비가 완료됐습니다.", }); - expect(between(cli, "1 · Workbranch CLI", "Claude Code")).toContain( + expect(between(cli, "Workbranch CLI", "Claude Code")).toContain( "CLI와 수집기 준비가 완료됐습니다.", ); }); @@ -346,3 +347,111 @@ it("matches a running action to its button regardless of the approved source", ( false, ); }); + +it("keeps Main onboarding hidden until the first check knows the setup", () => { + expect(needsOnboarding(EMPTY_CONNECTION_STATE, [], false)).toBe(false); + // A failed first check still offers the retry. + expect( + needsOnboarding( + { + ...EMPTY_CONNECTION_STATE, + loading: false, + error: "설치 상태를 확인하지 못했습니다.", + }, + [], + false, + ), + ).toBe(true); +}); + +it("opens Main onboarding until an agent has been observed", () => { + const ready = { + ...EMPTY_CONNECTION_STATE, + loading: false, + status: readyStatus, + }; + // readyStatus: CLI ready, Grok configured but never observed. + expect(needsOnboarding(ready, [], false)).toBe(true); + expect(needsOnboarding(ready, [{ provider: "grok" }], false)).toBe(false); + const observedStatus: SetupStatus = { + ...readyStatus, + agents: readyStatus.agents.map((agent) => + agent.provider === "grok" ? { ...agent, lastObservedAt: 1 } : agent, + ), + }; + const observed = { ...ready, status: observedStatus }; + expect(needsOnboarding(observed, [], false)).toBe(false); + // A CLI that stops being ready brings the panel back. + expect( + needsOnboarding( + { ...observed, status: { ...observedStatus, cli: status.cli } }, + [], + false, + ), + ).toBe(true); +}); + +it("leaves results of Settings actions out of Main once setup is complete", () => { + const complete: ConnectionState = { + ...EMPTY_CONNECTION_STATE, + loading: false, + status: readyStatus, + lastAction: { kind: "connectAgent", provider: "codex" }, + notice: "설정 후 agent를 다시 시작하세요.", + }; + const sessions = [{ provider: "grok" as const }]; + expect(needsOnboarding(complete, sessions, false)).toBe(false); + expect( + needsOnboarding( + { ...complete, notice: null, error: "실패" }, + sessions, + false, + ), + ).toBe(false); + // An action started on Main keeps its progress and result in place. + expect(needsOnboarding(complete, sessions, true)).toBe(true); + expect( + needsOnboarding( + { ...complete, notice: null, busy: { kind: "updateCli" } }, + sessions, + true, + ), + ).toBe(true); +}); + +it("surfaces a failed status check on Main even with a ready status cached", async () => { + let current = EMPTY_CONNECTION_STATE; + const complete: SetupStatus = { + ...readyStatus, + agents: readyStatus.agents.map((agent) => + agent.provider === "grok" ? { ...agent, lastObservedAt: 1 } : agent, + ), + }; + const inspect = vi.fn(async () => complete); + const controller = createConnectionController( + { + inspect, + run: async () => ({ exit_code: 0, stdout: "", stderr: "" }), + subscribe: async () => () => {}, + id: () => "op", + }, + (value) => { + current = value; + }, + ); + await controller.refresh(); + expect(needsOnboarding(current, [], false)).toBe(false); + + // A later check fails, such as the refresh after a CLI update; the ready + // status stays cached, but the failure and its retry must still show. + inspect.mockRejectedValueOnce(new Error("offline")); + await controller.refresh(); + expect(current.status).toBe(complete); + expect(current.lastAction).toBeNull(); + expect(needsOnboarding(current, [], false)).toBe(true); + + // A passing check clears it again. + await controller.refresh(); + expect(needsOnboarding(current, [], false)).toBe(false); + controller.dispose(); +}); diff --git a/apps/companion/tests/screensaver-settings.test.tsx b/apps/companion/tests/screensaver-settings.test.tsx index 03b13a8..f8f3244 100644 --- a/apps/companion/tests/screensaver-settings.test.tsx +++ b/apps/companion/tests/screensaver-settings.test.tsx @@ -131,6 +131,7 @@ describe("SettingsPanel screen saver section", () => { it("renders a Screen Saver panel", () => { const html = renderToStaticMarkup( { onScreensaverPreview={() => undefined} />, ); - expect(html).toContain("Screen Saver"); + expect(html).toContain("04 · Screen Saver"); + expect(html).toContain('aria-label="Screen Saver"'); expect(html).toContain('id="screensaver-enabled"'); }); it("delegates the edited screensaver config", () => { const calls: CompanionPreferences[] = []; const element = SettingsPanel({ + category: "screen-saver", claudeLimitRelay: { status: { enabled: false, previousCommand: null }, loading: false, @@ -248,6 +251,7 @@ describe("Screen saver preview", () => { it("passes the preview request up from the settings panel", () => { let previews = 0; const element = SettingsPanel({ + category: "screen-saver", claudeLimitRelay: { status: { enabled: false, previousCommand: null }, loading: false, diff --git a/apps/companion/tests/settings-panel.test.tsx b/apps/companion/tests/settings-panel.test.tsx index d120c16..f3d73d5 100644 --- a/apps/companion/tests/settings-panel.test.tsx +++ b/apps/companion/tests/settings-panel.test.tsx @@ -1,4 +1,9 @@ -import { Children, isValidElement, type ReactNode } from "react"; +import { + Children, + isValidElement, + type JSXElementConstructor, + type ReactNode, +} from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { @@ -7,19 +12,27 @@ import { } from "../src/application/menuBar"; import type { CompanionFont, - CompanionFontSize, CompanionPreferences, CompanionTheme, } from "../src/application/preferences"; import { DEFAULT_SCREENSAVER } from "../src/application/screensaver"; import { AgentThemePicker } from "../src/ui/AgentThemePicker"; import { MenuBarSettings } from "../src/ui/MenuBarSettings"; -import { SettingsPanel } from "../src/ui/SettingsPanel"; +import { SegmentedControl } from "../src/ui/SettingsLayout"; +import { + type SettingsCategory, + SettingsNav, + settingsEyebrow, +} from "../src/ui/SettingsNav"; +import { + type PreferenceCategory, + SettingsPanel, +} from "../src/ui/SettingsPanel"; +import { SettingsView } from "../src/ui/SettingsView"; type InputProps = { readonly id?: string; readonly checked?: boolean; - readonly disabled?: boolean; readonly onChange?: (event: { readonly currentTarget: { readonly checked: boolean }; }) => void; @@ -27,72 +40,44 @@ type InputProps = { type SelectProps = { readonly id?: string; - readonly value?: string; readonly onChange?: (event: { readonly currentTarget: { readonly value: string }; }) => void; }; -type TraversableProps = { +type ButtonProps = { readonly children?: ReactNode; + readonly onClick?: () => void; }; -type MenuBarPickerProps = { - readonly value: MenuBarConfig; - readonly onChange: (config: MenuBarConfig) => void; +type SegmentedProps = { + readonly label: string; + readonly onChange: (value: string) => void; }; -type ThemePickerProps = { - readonly value: CompanionTheme; - readonly onChange: (theme: CompanionTheme) => void; +type TraversableProps = { + readonly children?: ReactNode; }; -function collectByType( +/** Walks the returned element tree through `children`, without rendering. */ +function collect( node: ReactNode, - typeName: "button" | "input" | "select", + type: string | JSXElementConstructor, ): readonly TProps[] { - const props: TProps[] = []; + const found: TProps[] = []; const visit = (child: ReactNode): void => { - if (!isValidElement(child)) { - return; - } - if (child.type === typeName) { - props.push(child.props); - } + if (!isValidElement(child)) return; + if (child.type === type) found.push(child.props); Children.forEach(child.props.children, visit); }; visit(node); - return props; + return found; } -function findThemePicker(node: ReactNode): ThemePickerProps | undefined { - let result: ThemePickerProps | undefined; - const visit = (child: ReactNode): void => { - if (!isValidElement(child)) { - return; - } - if (child.type === AgentThemePicker) { - result = child.props; - } - Children.forEach(child.props.children, visit); - }; - visit(node); - return result; -} - -function findMenuBarPicker(node: ReactNode): MenuBarPickerProps | undefined { - let result: MenuBarPickerProps | undefined; - const visit = (child: ReactNode): void => { - if (!isValidElement(child)) { - return; - } - if (child.type === MenuBarSettings) { - result = child.props; - } - Children.forEach(child.props.children, visit); - }; - visit(node); - return result; +function segmented(node: ReactNode, label: string): SegmentedProps | undefined { + return collect(node, SegmentedControl).find( + (props) => props.label === label, + ); } const preferences: CompanionPreferences = { @@ -103,226 +88,183 @@ const preferences: CompanionPreferences = { screensaver: DEFAULT_SCREENSAVER, }; -function renderSettingsPanel({ - currentPreferences = preferences, - launchAtLoginLoading = false, -}: { - readonly currentPreferences?: CompanionPreferences; - readonly launchAtLoginLoading?: boolean; -} = {}): string { +const relay = { + status: { enabled: false, previousCommand: null }, + loading: false, + onChange: () => undefined, +}; + +function panelProps( + category: PreferenceCategory, + overrides: { + readonly preferences?: CompanionPreferences; + readonly launchAtLoginLoading?: boolean; + readonly onLaunchAtLoginChange?: (enabled: boolean) => void; + readonly onPreferencesChange?: (next: CompanionPreferences) => void; + } = {}, +) { + return { + category, + claudeLimitRelay: relay, + preferences: overrides.preferences ?? preferences, + launchAtLogin: false, + launchAtLoginLoading: overrides.launchAtLoginLoading ?? false, + onLaunchAtLoginChange: overrides.onLaunchAtLoginChange ?? (() => undefined), + onPreferencesChange: overrides.onPreferencesChange ?? (() => undefined), + onScreensaverPreview: () => undefined, + }; +} + +function renderPage( + category: PreferenceCategory, + overrides: Parameters[1] = {}, +): string { return renderToStaticMarkup( - undefined, - }} - preferences={currentPreferences} - launchAtLogin={false} - launchAtLoginLoading={launchAtLoginLoading} - onLaunchAtLoginChange={() => undefined} - onPreferencesChange={() => undefined} - onScreensaverPreview={() => undefined} - />, + , ); } describe("SettingsPanel", () => { - it("renders Claude terminal panels and exactly two agent themes", () => { - const html = renderSettingsPanel(); - - expect(html).toContain('data-terminal-panel="claude"'); - expect(html).toContain('data-terminal-panel-anatomy="claude"'); - expect(html).toContain("Startup"); - expect(html).toContain("Claude Limits"); - expect(html).toContain('for="claude-limit-relay"'); - expect(html).toContain("Menu Bar"); - expect(html).toContain("Font"); - expect(html).toContain("Text Size"); - expect(html).toContain("Theme"); - expect(html).not.toContain("Weekly Limits"); - expect(html).toContain('aria-label="Menu bar items"'); + it("opens General with the status slot first, then startup and limit switches", () => { + const html = renderToStaticMarkup( + STATUS SLOT

} />, + ); + + expect(html).toContain('aria-label="01 · General"'); + expect(html).toContain("

Status, startup and limits

"); + expect(html.indexOf("STATUS SLOT")).toBeLessThan( + html.indexOf("Open at Login"), + ); + expect(html).toContain('aria-label="Startup"'); + expect(html).toContain('aria-label="Claude Limits"'); expect(html).toContain('for="launch-at-login"'); - expect(html).toContain('for="companion-font"'); - expect(html).toContain('for="companion-font-size"'); - expect(html).toContain("Open at Login"); - expect(html).toContain("System Mono"); - expect(html).toContain("JetBrains Mono"); - expect(html).toContain("Extra Large"); - expect(html).toContain("Claude Code"); - expect(html).toContain("Codex"); - // Four menu bar items, two percent bases, two themes and the screen - // saver preview are the only buttons. - expect(html.match(/"); - expect(html).not.toContain(">Dark"); - expect(html).not.toContain(">System"); - expect(html).not.toContain("theme mode"); + expect(html).toContain('for="claude-limit-relay"'); + expect(html.match(/role="switch"/g)).toHaveLength(2); + expect(html).not.toContain(" { - const html = renderSettingsPanel({ - currentPreferences: { - font: "menlo", - fontSize: "medium", - theme: "codex", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, + it("gives every preference page its numbered heading", () => { + const pages: readonly [PreferenceCategory, string, string][] = [ + ["menu-bar", "03 · Menu Bar", "What the menu bar shows"], + ["screen-saver", "04 · Screen Saver", "Dashboard while agents work"], + ["appearance", "05 · Appearance", "Theme, font and text size"], + ]; + for (const [category, eyebrow, title] of pages) { + const html = renderPage(category); + expect(html).toContain(`

${eyebrow}

`); + expect(html).toContain(`

${title}

`); + } + }); + + it("offers exactly the two agent themes as a segmented control", () => { + const html = renderPage("appearance", { + preferences: { ...preferences, theme: "codex" }, }); - expect(html).toContain('data-terminal-panel="codex"'); - expect(html).toContain('data-terminal-panel-anatomy="claude"'); - expect(html).toContain("Startup"); - expect(html).toContain("Font"); - expect(html).toContain("Text Size"); - expect(html).toContain("Theme"); - expect(html).not.toContain('class="terminal-panel-heading"'); - expect(html).toContain('aria-label="Use Codex theme"'); - expect(html).toContain('aria-pressed="true"'); + expect(html).toContain('aria-label="Agent theme"'); + expect(html).toContain('aria-label="Use Claude Code theme"'); + expect(html).toMatch(/aria-label="Use Codex theme" aria-pressed="true"/); + expect(html).not.toContain(">Light"); + expect(html).not.toContain(">Dark"); + expect(html).not.toContain(">System"); }); - it("shows the selected font in a live preview sample", () => { - const html = renderSettingsPanel({ - currentPreferences: { - font: "menlo", - fontSize: "medium", - theme: "claude", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, + it("previews the selected font and text size together", () => { + const html = renderPage("appearance", { + preferences: { ...preferences, font: "menlo", fontSize: "large" }, }); expect(html).toContain('class="font-preview"'); expect(html).toContain("font-family:Menlo"); + expect(html).toContain("Preview · Menlo · Large"); expect(html).toContain("workbranch feat/update-0619"); expect(html).toContain("1234567890"); - }); - - it("previews the smallest scaled copy alongside the selected text size", () => { - const html = renderSettingsPanel({ - currentPreferences: { - font: "menlo", - fontSize: "large", - theme: "claude", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, - }); - - expect(html).toContain("Preview · Large"); expect(html).toContain('class="font-preview-meta"'); expect(html).toContain("ci: build signed macOS DMGs · 11d"); - expect(html).toContain('value="large"'); + expect(html).toContain("System Mono"); + expect(html).toContain("JetBrains Mono"); + expect(html).toContain('aria-label="Text size"'); + expect(html).toMatch(/aria-pressed="true"[^>]*>Large<\/button>/); + expect(html).toContain(">Extra Large"); }); it("disables launch-at-login while its state is loading", () => { - const html = renderSettingsPanel({ launchAtLoginLoading: true }); + const html = renderPage("general", { launchAtLoginLoading: true }); - expect(html).toContain('id="launch-at-login"'); - expect(html).toContain('disabled=""'); + expect(html).toMatch(/disabled=""[^>]*id="launch-at-login"/); expect(html).toContain("Checking login item state"); }); it("delegates launch, font, size, and theme updates to app-shell callbacks", () => { const launchCalls: boolean[] = []; const preferenceCalls: CompanionPreferences[] = []; - const element = SettingsPanel({ - preferences, - launchAtLogin: false, - launchAtLoginLoading: false, - onLaunchAtLoginChange: (enabled) => { + const overrides = { + onLaunchAtLoginChange: (enabled: boolean) => { launchCalls.push(enabled); }, - onPreferencesChange: (next) => { + onPreferencesChange: (next: CompanionPreferences) => { preferenceCalls.push(next); }, - onScreensaverPreview: () => undefined, - }); - const launchToggle = collectByType(element, "input").find( - (input) => input.id === "launch-at-login", - ); - const selects = collectByType(element, "select"); - const fontSelect = selects.find((select) => select.id === "companion-font"); - const fontSizeSelect = selects.find( - (select) => select.id === "companion-font-size", - ); - const themePicker = findThemePicker(element); - - launchToggle?.onChange?.({ currentTarget: { checked: true } }); - fontSelect?.onChange?.({ - currentTarget: { value: "menlo" satisfies CompanionFont }, - }); - fontSizeSelect?.onChange?.({ - currentTarget: { value: "large" satisfies CompanionFontSize }, - }); - themePicker?.onChange("codex"); + }; + const general = SettingsPanel(panelProps("general", overrides)); + const appearance = SettingsPanel(panelProps("appearance", overrides)); + + collect(general, "input") + .find((input) => input.id === "launch-at-login") + ?.onChange?.({ currentTarget: { checked: true } }); + collect(appearance, "select") + .find((select) => select.id === "companion-font") + ?.onChange?.({ + currentTarget: { value: "menlo" satisfies CompanionFont }, + }); + segmented(appearance, "Text size")?.onChange("large"); + collect<{ readonly onChange: (theme: CompanionTheme) => void }>( + appearance, + AgentThemePicker, + )[0]?.onChange("codex"); expect(launchCalls).toEqual([true]); expect(preferenceCalls).toEqual([ - { - font: "menlo", - fontSize: "medium", - theme: "claude", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, - { - font: "system-mono", - fontSize: "large", - theme: "claude", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, - { - font: "system-mono", - fontSize: "medium", - theme: "codex", - menuBar: DEFAULT_MENU_BAR, - screensaver: DEFAULT_SCREENSAVER, - }, + { ...preferences, font: "menlo" }, + { ...preferences, fontSize: "large" }, + { ...preferences, theme: "codex" }, ]); }); - it("delegates the edited menu bar config", () => { + it("ignores a font the preference contract does not know", () => { const preferenceCalls: CompanionPreferences[] = []; - const element = SettingsPanel({ - preferences, - launchAtLogin: false, - launchAtLoginLoading: false, - onLaunchAtLoginChange: () => undefined, - onPreferencesChange: (next) => { - preferenceCalls.push(next); - }, - onScreensaverPreview: () => undefined, - }); - const edited = { ...DEFAULT_MENU_BAR, todayTokens: false }; - findMenuBarPicker(element)?.onChange(edited); + const appearance = SettingsPanel( + panelProps("appearance", { + onPreferencesChange: (next) => { + preferenceCalls.push(next); + }, + }), + ); - expect(preferenceCalls).toEqual([{ ...preferences, menuBar: edited }]); + collect(appearance, "select") + .find((select) => select.id === "companion-font") + ?.onChange?.({ currentTarget: { value: "comic-sans" } }); + + expect(preferenceCalls).toEqual([]); }); - it("ignores a text size the preference contract does not know", () => { + it("delegates the edited menu bar config", () => { const preferenceCalls: CompanionPreferences[] = []; - const element = SettingsPanel({ - preferences, - launchAtLogin: false, - launchAtLoginLoading: false, - onLaunchAtLoginChange: () => undefined, - onPreferencesChange: (next) => { - preferenceCalls.push(next); - }, - onScreensaverPreview: () => undefined, - }); - const fontSizeSelect = collectByType(element, "select").find( - (select) => select.id === "companion-font-size", + const element = SettingsPanel( + panelProps("menu-bar", { + onPreferencesChange: (next) => { + preferenceCalls.push(next); + }, + }), ); + const edited = { ...DEFAULT_MENU_BAR, todayTokens: false }; + collect<{ readonly onChange: (config: MenuBarConfig) => void }>( + element, + MenuBarSettings, + )[0]?.onChange(edited); - fontSizeSelect?.onChange?.({ currentTarget: { value: "gigantic" } }); - - expect(preferenceCalls).toEqual([]); + expect(preferenceCalls).toEqual([{ ...preferences, menuBar: edited }]); }); }); @@ -337,7 +279,8 @@ describe("MenuBarSettings", () => { const html = render(DEFAULT_MENU_BAR); expect(html).toContain(">CL 5h 42% · W 63% CO W 18% · 31.0M<"); - expect(html.match(/aria-pressed="true"/g)).toHaveLength(5); + expect(html.match(/aria-checked="true"/g)).toHaveLength(4); + expect(html).toMatch(/aria-pressed="true"[^>]*>Used %<\/button>/); expect(html).toContain("5-hour limit, CL 5h"); expect(html).toContain("7-day limit, CO W"); expect(html).toContain("Both agents since midnight"); @@ -353,7 +296,7 @@ describe("MenuBarSettings", () => { expect(html).toContain(">CL W 37% CO W 82%<"); expect(html).toContain( - 'aria-label="Show Claude 5h in the menu bar" aria-pressed="false"', + 'aria-checked="false" aria-label="Show Claude 5h in the menu bar"', ); }); @@ -381,19 +324,10 @@ describe("MenuBarSettings", () => { calls.push(next); }, }); - const buttons = collectByType<{ - readonly "aria-label"?: string; - readonly onClick?: () => void; - }>(element, "button"); - buttons - .find( - (button) => - button["aria-label"] === "Show Codex weekly in the menu bar", - ) - ?.onClick?.(); - buttons - .find((button) => button["aria-label"] === "Show limits as Remaining %") - ?.onClick?.(); + collect(element, "input") + .find((input) => input.id === "menu-bar-codexWeekly") + ?.onChange?.({ currentTarget: { checked: false } }); + segmented(element, "Limit percent")?.onChange("remaining"); expect(calls).toEqual([ { ...DEFAULT_MENU_BAR, codexWeekly: false }, @@ -401,3 +335,78 @@ describe("MenuBarSettings", () => { ]); }); }); + +describe("SettingsView", () => { + function render( + category: SettingsCategory, + attention: Partial> = {}, + ): string { + return renderToStaticMarkup( + CONNECTIONS PANEL

} + onCategoryChange={() => undefined} + status={

STATUS SLOT

} + />, + ); + } + + it("shows the category tabs and only the current page", () => { + const html = render("menu-bar"); + + expect(html).toContain('aria-label="Settings categories"'); + // Setup comes right after General, whose status links to it. + const order = [ + "General", + "Connections", + "Menu Bar", + "Screen Saver", + "Appearance", + ].map((label) => html.indexOf(`${label}`)); + expect(order.every((index) => index >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(settingsEyebrow("connections")).toBe("02 · Connections"); + expect(html.match(/aria-current="page"/g)).toHaveLength(1); + expect(html).toMatch(/aria-current="page"[^>]*>.*?Menu Bar<\/span>/); + expect(html).toContain("03 · Menu Bar"); + expect(html).not.toContain("01 · General"); + expect(html).not.toContain("STATUS SLOT"); + expect(html).not.toContain("CONNECTIONS PANEL"); + }); + + it("puts status on General and the connections panel on its own tab", () => { + expect(render("general")).toContain("STATUS SLOT"); + const connections = render("connections"); + expect(connections).toContain("CONNECTIONS PANEL"); + expect(connections).not.toContain("STATUS SLOT"); + }); + + it("marks only the tabs that need attention", () => { + const html = render("appearance", { general: true, connections: false }); + + expect(html).toContain('aria-label="General, needs attention"'); + expect(html.match(/settings-nav-dot/g)).toHaveLength(1); + }); + + it("reports the picked tab", () => { + const picked: SettingsCategory[] = []; + const nav = SettingsNav({ + current: "general", + attention: {}, + onChange: (category) => picked.push(category), + }); + collect(nav, "button") + .find((button) => + Children.toArray(button.children).some( + (child) => + isValidElement(child) && + child.props.children === "Connections", + ), + ) + ?.onClick?.(); + + expect(picked).toEqual(["connections"]); + }); +}); diff --git a/apps/companion/tests/updates.test.tsx b/apps/companion/tests/updates.test.tsx index 2a3eb0f..236e5e9 100644 --- a/apps/companion/tests/updates.test.tsx +++ b/apps/companion/tests/updates.test.tsx @@ -1,5 +1,10 @@ +import { Children, isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; +import { + type ConnectionState, + EMPTY_CONNECTION_STATE, +} from "../src/application/connections"; import { companionRestartPending, createUpdateController, @@ -10,7 +15,7 @@ import { updateActionLabel, updateTargets, } from "../src/application/updates"; -import { UpdatePanel } from "../src/ui/UpdatePanel"; +import { StatusSettings } from "../src/ui/StatusSettings"; const current: UpdateStatus = { brewPath: "/opt/homebrew/bin/brew", @@ -43,14 +48,17 @@ function services(overrides: { }; } -function render(state: Partial) { +function render( + state: Partial, + connections: ConnectionState = EMPTY_CONNECTION_STATE, +) { return renderToStaticMarkup( - {}} - onApply={() => {}} - onClose={() => {}} + {}} + onCheckUpdates={() => {}} + onOpenConnections={() => {}} + updates={{ ...EMPTY_UPDATE_STATE, ...state }} />, ); } @@ -174,7 +182,7 @@ describe("update controller", () => { }); }); -describe("update panel", () => { +describe("settings status", () => { it("shows installed and latest versions with one update action", () => { const html = render({ status: outdated }); expect(html).toContain("설치 2.25.1 → 최신 2.26.0"); @@ -199,9 +207,7 @@ describe("update panel", () => { }, }); expect(html).toContain("Homebrew 미설치"); - expect(html).toContain( - "Settings의 설치 및 agent 연결에서 CLI를 설치하세요.", - ); + expect(html).toContain("Connections에서 CLI를 설치하세요."); expect(html).toContain( "개발 빌드에서는 Companion을 업데이트하지 않습니다.", ); @@ -224,4 +230,101 @@ describe("update panel", () => { expect(updating).toMatch(/aria-busy="true"[^>]*>.*업데이트 중…<\/button>/); expect(updating).not.toContain("모두 업데이트 후 재시작"); }); + it("asks for the first check instead of running brew update on open", () => { + const html = render({}); + expect(html).toMatch(/]*>업데이트 확인<\/button>/); + expect(html).toContain( + "Homebrew로 설치한 CLI와 Companion의 최신 버전을 확인합니다.", + ); + expect(html).not.toContain("업데이트 있음"); + }); + + it("puts available updates first with the one button that applies them", () => { + const html = render({ status: outdated }); + expect(html.indexOf("업데이트 있음 · CLI, Companion")).toBeLessThan( + html.indexOf("Workbranch CLI"), + ); + expect(html.match(/모두 업데이트 후 재시작/g)).toHaveLength(1); + }); + + it("orders the check outcome, CLI then Companion, the log, then agents", () => { + const idle = render({ status: current }); + expect(idle.indexOf("업데이트할 항목이 없습니다.")).toBeLessThan( + idle.indexOf("Workbranch CLI"), + ); + const failed = render({ + status: outdated, + error: "업데이트를 완료하지 못했습니다.", + logs: "brew upgrade log", + }); + const positions = [ + "업데이트를 완료하지 못했습니다.", + "Workbranch CLI", + "Workbranch Companion", + "brew upgrade log", + "Agent 연결", + ].map((text) => failed.indexOf(text)); + expect(positions.every((index) => index >= 0)).toBe(true); + expect(positions).toEqual([...positions].sort((a, b) => a - b)); + }); + + it("summarizes CLI and agent readiness with a way to Connections", () => { + const html = render( + { status: current }, + { + ...EMPTY_CONNECTION_STATE, + loading: false, + status: { + brewPath: "/opt/homebrew/bin/brew", + formulaInstalled: true, + cli: { + state: "ready", + path: "/opt/homebrew/bin/workbranch", + version: "2.37.0", + message: "CLI와 수집기를 사용할 수 있습니다.", + }, + agents: [ + { + provider: "claude", + executablePath: "/bin/claude", + state: "configured", + lastObservedAt: 1, + message: "설정됨", + }, + { + provider: "codex", + executablePath: "/bin/codex", + state: "disconnected", + lastObservedAt: null, + message: "연결 필요", + }, + ], + }, + }, + ); + expect(html).toContain("CLI 준비됨 · Claude Code 설정됨 · Codex 미연결"); + + let opened = 0; + const element = StatusSettings({ + connections: EMPTY_CONNECTION_STATE, + onApplyUpdates: () => {}, + onCheckUpdates: () => {}, + onOpenConnections: () => { + opened += 1; + }, + updates: EMPTY_UPDATE_STATE, + }); + const buttons: { children?: ReactNode; onClick?: () => void }[] = []; + const visit = (child: ReactNode): void => { + if ( + !isValidElement<{ children?: ReactNode; onClick?: () => void }>(child) + ) + return; + if (child.type === "button") buttons.push(child.props); + Children.forEach(child.props.children, visit); + }; + visit(element); + buttons.find((button) => button.children === "연결 관리")?.onClick?.(); + expect(opened).toBe(1); + }); }); diff --git a/apps/companion/tests/usage-view.test.tsx b/apps/companion/tests/usage-view.test.tsx index 4657f66..7a1882f 100644 --- a/apps/companion/tests/usage-view.test.tsx +++ b/apps/companion/tests/usage-view.test.tsx @@ -154,6 +154,13 @@ describe("usage surfaces", () => { ).toHaveLength(2); // Each day lists both agents' totals; empty days read as a dash. expect(week.match(/class="usage-values-day"/g)).toHaveLength(7); + // The totals sit right under the bars, and the day axis under them. + expect(week.indexOf('class="usage-compare"')).toBeLessThan( + week.indexOf('class="usage-values"'), + ); + expect(week.indexOf('class="usage-values"')).toBeLessThan( + week.indexOf('class="usage-axis"'), + ); expect(week).toMatch( /data-provider="claude">claude <\/span>338M<\/span>codex <\/span>587k