From 763d628fc90012899078b0da23bf6d5bf2741ee1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 26 Jun 2026 11:31:50 +0200 Subject: [PATCH] feat(claude): shared account budget in Team tab + meta-sidebar card facelift (T-158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements T-158 option A: the forwarded /usage budget (already parsed for the Activity tab, T-415) now also renders in the Team tab as a single ACCOUNT card, captioned "shared across the team". Usage is per-account — every team session shares one ~/.claude login — so it's shown once, not split per member (which would just repeat identical numbers). Facelift: the Activity, Team, and Config tabs now render their sections as elevated cards (panelHeader fill, dividerColor hairline, 6px radius) under small-caps mono headers, matching the settings overlay's card design (settings_category_view). Shared helpers — metaSectionHeader / metaCard / metaCardRow — live in meta_sidebar/models.dart; the SESSION control strip and the pinned Config SETTINGS block adopt the same card. The shared ClideAccordion (also used by decisions/tickets) is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 188 ++++++++++++++++++ CHANGELOG.md | 6 + assets/i18n/en_us/builtin.claude.json | 2 + assets/i18n/nl_nl/builtin.claude.json | 2 + .../claude/src/claude_meta_sidebar.dart | 8 +- .../claude/src/meta_sidebar/activity_tab.dart | 102 +++++----- .../claude/src/meta_sidebar/config_tab.dart | 73 ++++--- .../claude/src/meta_sidebar/models.dart | 77 ++++--- .../claude/src/meta_sidebar/team_tab.dart | 89 ++++++--- test/builtin/claude/team_tab_test.dart | 75 +++++++ 11 files changed, 483 insertions(+), 141 deletions(-) create mode 100644 test/builtin/claude/team_tab_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 6c9d5cb6..7eda7ff5 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -6220,3 +6220,5 @@ own budget view rather than send the user to the Activity tab. Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection + thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, en/nl). This resolves Q-34. Ready to pick up; not yet started.', NULL, '2026-06-26 07:01:13', '2026-06-26 07:01:13.929', '2026-06-26 07:01:13.929', NULL, 'b94a593c3db76b3a9705d8111f3d6b9b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'status', 'ready', 'in_progress', NULL, '2026-06-26 07:09:18', '2026-06-26 07:09:18.285', '2026-06-26 07:09:18.285', NULL, 'fae5181a90e789f07eab3a12939d08f4', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'status', 'in_progress', 'done', NULL, '2026-06-26 09:31:41', '2026-06-26 09:31:41.267', '2026-06-26 09:31:41.267', NULL, 'b1bc1ec1f57601ffd2c4cee1a756b610', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index c2f4f38c..25e3ddf3 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -7671,3 +7671,191 @@ own budget view rather than send the user to the Activity tab. Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection + thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, en/nl). This resolves Q-34. Ready to pick up; not yet started.', 'ready', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-26 07:01:13.929', NULL, '5232c82a5d4727de012507305f87626a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'task', NULL, 'Team sidebar: account/team token budget (blocked on upstream)', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''. + +2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path). + +UNBLOCKED (2026-06-12, T-415): probed claude 2.1.175 — a forwarded /usage IS +answered headless in stream-json (free, num_turns 0) with parseable text +(session %, week % all-models, week % Sonnet). The Activity tab now renders it +via parseUsageText + a user-initiated refresh control. Remaining scope for this +ticket would be per-member/team budget split, if still wanted. + +───────────────────────────────────────────── +REFINED 2026-06-26 + +## Grounding (current state) +- T-415 SHIPPED the account-budget display. `/usage` is forwarded headless + (free, num_turns 0), parsed by `parseUsageText` → `ClaudeUsage` (session %, + week all-models %, week Sonnet %) in + lib/builtin/claude/src/claude_status.dart, rendered in the Activity tab + (meta_sidebar/activity_tab.dart, USAGE `MetaSection`) with a user-initiated + refresh that publishes `/usage` on the `builtin.claude/command` channel. + claude_meta_sidebar.dart binds the PRIMARY session''s synthetic text items and + parses them into `_usage`. + +## KEY FINDING — a literal "per-member split" is not meaningful today +- Claude''s usage budget is per-ACCOUNT (subscription/OAuth), not per-session. + Every clide session in a workspace (primary, secondaries, managed teammates) + spawns against the SAME `~/.claude` login — session_orchestrator spawns with + no per-session CLAUDE_* auth override and all transcripts live under one + project dir. So the whole team draws down ONE shared 5h/weekly budget. +- Rendering the same numbers on each roster row would mislead, not inform. True + per-member budgets need per-SESSION account login, which even T-476 (per-REPO + login) does NOT provide — within one workspace/team the account is shared. + Treat per-member-different-account as out of scope unless per-session + multi-account auth ever exists. + +## RECOMMENDED SCOPE (shippable, honest) — resolves Q-34 +Surface the existing account-wide budget in the TEAM tab as a single "Account" +section (NOT per-member): the team sidebar then answers "how much budget is left +for the account the whole team shares." Satisfies the ticket title without the +false split. + +Implementation: +1. meta_sidebar/team_tab.dart (TeamTabView.build): add a `MetaSection` + (header i18n key `team.section.usage`) rendering the same 3 `MetaRow`s the + Activity tab uses (session / week-all / week-sonnet), shown only when usage + != null, with a one-line caption that it is account-wide / shared across the + team (so it isn''t read as per-member). +2. claude_meta_sidebar.dart: pass the existing `_usage` (ClaudeUsage?) into + TeamTabView (it already passes `members`). No new fetch — the primary''s + `/usage` already IS the account budget. +3. Optional: a refresh control in the team section mirroring the Activity tab + (publish `/usage` on `builtin.claude/command`), or just rely on the Activity + tab''s refresh keeping `_usage` current. + +Files to touch: meta_sidebar/team_tab.dart, claude_meta_sidebar.dart (thread +usage), meta_sidebar/models.dart (reuse MetaSection/MetaRow), +assets/i18n/en_us + nl_nl/builtin.claude.json (new `team.section.usage` [+ +caption] key — keep en/nl parity for the a11y i18n gate). + +## ALTERNATIVE PATHS (pick one before building) +A. Shared-account section in the team tab (recommended above). +B. Close T-158 as substantially DONE by T-415 — the budget is already visible in + the Activity tab; a team-tab duplicate may be redundant. Q-34 answered either + way. +C. Keep the true per-member split as a FUTURE item, explicitly blocked on a + (currently non-existent) per-session multi-account login; park T-158 behind + it. + +## ACCEPTANCE (for option A) +- Team tab shows ONE account-budget section (session / week / week-sonnet %) + when a `/usage` result is available, clearly labelled account-wide / shared. +- No per-member duplication of the same numbers. +- No new network/headless call beyond the existing user-initiated `/usage` + refresh (D-64 / POLICY: explicit user action only). +- en/nl parity for any new i18n keys. + +## REFERENCES +T-415 (usage parse + Activity render); Q-34 (this resolves it); T-476 (per-REPO, +not per-session, account login — does NOT enable per-member budgets); +`parseUsageText` / `ClaudeUsage` (claude_status.dart); activity_tab.dart USAGE +block; team_tab.dart roster; claude_meta_sidebar.dart usage binding. + +───────────────────────────────────────────── +DECISION 2026-06-26: go with option A. + +Build the single shared-account budget section in the TEAM tab (account-wide, +clearly labelled — NOT per-member). Options B (close as done-by-T-415) and C +(per-member, deferred behind per-session multi-account) are declined: per-member +is not meaningful under one shared account, and the team tab should carry its +own budget view rather than send the user to the Activity tab. + +Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection + +thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, +en/nl). This resolves Q-34. Ready to pick up; not yet started.', 'in_progress', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-26 07:09:18.285', NULL, 'c1e4e9e1c6c7f410c641359902fcbc65', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM62FKQQD0B9B80PFY4', 'task', NULL, 'Team sidebar: account/team token budget (blocked on upstream)', 'Show the account/subscription usage budget (5-hour + weekly limits, % used, reset times) in the Claude meta sidebar (T-141). BLOCKED: this data is not programmatically exposed under subscription (OAuth) auth as of claude 2.1.150 — verified empirically + via docs (2026-05-23). /usage is TUI-only (headless ''claude -p /usage'' returns only a one-liner); stats-cache.json has activity counts only; the stream-json rate_limit_event is undocumented + needs a billed turn; ''claude auth status --json'' shows plan only. A ''claude usage --json'' + a /v1/organizations/{org}/usage/subscription endpoint are an OPEN, unshipped feature request (GitHub anthropics/claude-code#44328). Revisit when #44328 ships or an API-key usage path exists. See project memory ''claude-usage-budget-not-exposed''. + +2026-06-09: detached from T-132 (which is otherwise complete) and made the RESOLVER ticket for Q-34 (how + when to surface the budget given upstream doesn''t expose it). Stays in the backlog; revisit when a viable data path lands (upstream claude usage --json / endpoint per anthropics/claude-code#44328, or an API-key usage path). + +UNBLOCKED (2026-06-12, T-415): probed claude 2.1.175 — a forwarded /usage IS +answered headless in stream-json (free, num_turns 0) with parseable text +(session %, week % all-models, week % Sonnet). The Activity tab now renders it +via parseUsageText + a user-initiated refresh control. Remaining scope for this +ticket would be per-member/team budget split, if still wanted. + +───────────────────────────────────────────── +REFINED 2026-06-26 + +## Grounding (current state) +- T-415 SHIPPED the account-budget display. `/usage` is forwarded headless + (free, num_turns 0), parsed by `parseUsageText` → `ClaudeUsage` (session %, + week all-models %, week Sonnet %) in + lib/builtin/claude/src/claude_status.dart, rendered in the Activity tab + (meta_sidebar/activity_tab.dart, USAGE `MetaSection`) with a user-initiated + refresh that publishes `/usage` on the `builtin.claude/command` channel. + claude_meta_sidebar.dart binds the PRIMARY session''s synthetic text items and + parses them into `_usage`. + +## KEY FINDING — a literal "per-member split" is not meaningful today +- Claude''s usage budget is per-ACCOUNT (subscription/OAuth), not per-session. + Every clide session in a workspace (primary, secondaries, managed teammates) + spawns against the SAME `~/.claude` login — session_orchestrator spawns with + no per-session CLAUDE_* auth override and all transcripts live under one + project dir. So the whole team draws down ONE shared 5h/weekly budget. +- Rendering the same numbers on each roster row would mislead, not inform. True + per-member budgets need per-SESSION account login, which even T-476 (per-REPO + login) does NOT provide — within one workspace/team the account is shared. + Treat per-member-different-account as out of scope unless per-session + multi-account auth ever exists. + +## RECOMMENDED SCOPE (shippable, honest) — resolves Q-34 +Surface the existing account-wide budget in the TEAM tab as a single "Account" +section (NOT per-member): the team sidebar then answers "how much budget is left +for the account the whole team shares." Satisfies the ticket title without the +false split. + +Implementation: +1. meta_sidebar/team_tab.dart (TeamTabView.build): add a `MetaSection` + (header i18n key `team.section.usage`) rendering the same 3 `MetaRow`s the + Activity tab uses (session / week-all / week-sonnet), shown only when usage + != null, with a one-line caption that it is account-wide / shared across the + team (so it isn''t read as per-member). +2. claude_meta_sidebar.dart: pass the existing `_usage` (ClaudeUsage?) into + TeamTabView (it already passes `members`). No new fetch — the primary''s + `/usage` already IS the account budget. +3. Optional: a refresh control in the team section mirroring the Activity tab + (publish `/usage` on `builtin.claude/command`), or just rely on the Activity + tab''s refresh keeping `_usage` current. + +Files to touch: meta_sidebar/team_tab.dart, claude_meta_sidebar.dart (thread +usage), meta_sidebar/models.dart (reuse MetaSection/MetaRow), +assets/i18n/en_us + nl_nl/builtin.claude.json (new `team.section.usage` [+ +caption] key — keep en/nl parity for the a11y i18n gate). + +## ALTERNATIVE PATHS (pick one before building) +A. Shared-account section in the team tab (recommended above). +B. Close T-158 as substantially DONE by T-415 — the budget is already visible in + the Activity tab; a team-tab duplicate may be redundant. Q-34 answered either + way. +C. Keep the true per-member split as a FUTURE item, explicitly blocked on a + (currently non-existent) per-session multi-account login; park T-158 behind + it. + +## ACCEPTANCE (for option A) +- Team tab shows ONE account-budget section (session / week / week-sonnet %) + when a `/usage` result is available, clearly labelled account-wide / shared. +- No per-member duplication of the same numbers. +- No new network/headless call beyond the existing user-initiated `/usage` + refresh (D-64 / POLICY: explicit user action only). +- en/nl parity for any new i18n keys. + +## REFERENCES +T-415 (usage parse + Activity render); Q-34 (this resolves it); T-476 (per-REPO, +not per-session, account login — does NOT enable per-member budgets); +`parseUsageText` / `ClaudeUsage` (claude_status.dart); activity_tab.dart USAGE +block; team_tab.dart roster; claude_meta_sidebar.dart usage binding. + +───────────────────────────────────────────── +DECISION 2026-06-26: go with option A. + +Build the single shared-account budget section in the TEAM tab (account-wide, +clearly labelled — NOT per-member). Options B (close as done-by-T-415) and C +(per-member, deferred behind per-session multi-account) are declined: per-member +is not meaningful under one shared account, and the team tab should carry its +own budget view rather than send the user to the Activity tab. + +Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection + +thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, +en/nl). This resolves Q-34. Ready to pick up; not yet started.', 'done', 'low', NULL, NULL, NULL, '2026-05-23 20:48:38', '2026-06-26 09:31:41.267', NULL, 'a96387776f48125b0def220885e1f27b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb529f8..215b1174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. fleshed out to full token + syntax coverage and ships alongside a contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance. (T-478) +- **Account budget in the Team tab.** The Claude meta sidebar's Team tab now + shows the shared account `/usage` budget as a single labelled card (usage is + per-account, not per-member). (T-158) ### Changed +- **Claude meta sidebar facelift.** The Activity, Team, and Config tabs render + their sections as elevated cards with small-caps headers, matching the + settings overlay's card design. (T-158) - **Bundled themes and Tier-0 i18n namespaces resolve from one canonical list each.** The app, testmode harness, and contrast/i18n gates iterate the shared lists instead of drifting copies; a new theme or catalog is validated diff --git a/assets/i18n/en_us/builtin.claude.json b/assets/i18n/en_us/builtin.claude.json index a71e4458..c9e8b4e2 100644 --- a/assets/i18n/en_us/builtin.claude.json +++ b/assets/i18n/en_us/builtin.claude.json @@ -194,6 +194,8 @@ "taskRow.reassign": { "translation": "Reassign task" }, "team.empty": { "translation": "No team active." }, "team.section.tasks": { "translation": "TASKS" }, + "team.section.usage": { "translation": "Account" }, + "team.usage.shared": { "translation": "Shared across the team" }, "tabStrip.activity": { "translation": "Activity" }, "tabStrip.team": { "translation": "Team" }, "tabStrip.team.count": { "translation": "Team · {count}" }, diff --git a/assets/i18n/nl_nl/builtin.claude.json b/assets/i18n/nl_nl/builtin.claude.json index 861b7c73..0f01e4e1 100644 --- a/assets/i18n/nl_nl/builtin.claude.json +++ b/assets/i18n/nl_nl/builtin.claude.json @@ -194,6 +194,8 @@ "taskRow.reassign": { "translation": "Taak opnieuw toewijzen" }, "team.empty": { "translation": "Geen team actief." }, "team.section.tasks": { "translation": "TAKEN" }, + "team.section.usage": { "translation": "Account" }, + "team.usage.shared": { "translation": "Gedeeld met het hele team" }, "tabStrip.activity": { "translation": "Activiteit" }, "tabStrip.team": { "translation": "Team" }, "tabStrip.team.count": { "translation": "Team · {count}" }, diff --git a/lib/builtin/claude/src/claude_meta_sidebar.dart b/lib/builtin/claude/src/claude_meta_sidebar.dart index ffb098f4..f9a1ab3e 100644 --- a/lib/builtin/claude/src/claude_meta_sidebar.dart +++ b/lib/builtin/claude/src/claude_meta_sidebar.dart @@ -19,9 +19,10 @@ /// `meta_sidebar/` (T-395 split). Activity and Config render on the same /// table geometry (`buildMetaTable`) so switching tabs doesn't visually jump. /// -/// The account/team token budget is intentionally absent: it isn't -/// programmatically exposed under subscription auth (see project memory / -/// GitHub anthropics/claude-code#44328). +/// The account budget now surfaces from a forwarded `/usage` (T-415): the +/// Activity tab renders it, and the Team tab repeats it as a single shared +/// ACCOUNT card (T-158) — usage is per-account (one `~/.claude` login), so it's +/// shown once, not split per member. library; import 'dart:async'; @@ -312,6 +313,7 @@ class _ClaudeMetaSidebarState extends State { members: _members, memberStatus: _memberStatus, orchestrator: _orchestrator, + usage: _usage, tasks: _tasks, injectingAgentId: _injectingAgentId, injectController: _injectCtl, diff --git a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart index 9b30af0c..4102693b 100644 --- a/lib/builtin/claude/src/meta_sidebar/activity_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/activity_tab.dart @@ -87,59 +87,61 @@ class ActivityTabView extends StatelessWidget { children: [ // SESSION control strip (T-415): drives the primary session through // the builtin.claude/command bus — identical to typing the command. - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClideText( - ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION'), - fontSize: clideFontSmall, - color: tokens.sidebarSectionHeader, + // Carded to match the settings overlay (T-158 facelift). + metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION')), + metaCard(tokens, [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + _control( + context, + tokens, + ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'), + 'trash', + '/clear', + ), + _control( + context, + tokens, + ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'), + 'arrows-in-simple', + '/compact', + ), + _control( + context, + tokens, + ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'), + 'git-branch', + '/fork', + ), + _control( + context, + tokens, + ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'), + 'clock-counter-clockwise', + '/resume', + ), + const Spacer(), + _control( + context, + tokens, + ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'), + 'arrow-clockwise', + '/usage', + ), + ], + ), ), - ), - Row( - children: [ - _control( - context, - tokens, - ClideSettings.i18n.string(context, 'activity.control.clear', namespace: 'builtin.claude', placeholder: 'clear'), - 'trash', - '/clear', - ), - _control( - context, - tokens, - ClideSettings.i18n.string(context, 'activity.control.compact', namespace: 'builtin.claude', placeholder: 'compact'), - 'arrows-in-simple', - '/compact', - ), - _control( - context, - tokens, - ClideSettings.i18n.string(context, 'activity.control.fork', namespace: 'builtin.claude', placeholder: 'fork'), - 'git-branch', - '/fork', - ), - _control( - context, - tokens, - ClideSettings.i18n.string(context, 'activity.control.resume', namespace: 'builtin.claude', placeholder: 'resume'), - 'clock-counter-clockwise', - '/resume', - ), - const Spacer(), - _control( - context, - tokens, - ClideSettings.i18n.string(context, 'activity.control.refreshUsage', namespace: 'builtin.claude', placeholder: 'refresh usage'), - 'arrow-clockwise', - '/usage', - ), - ], - ), - const SizedBox(height: 6), + ]), + if (sections.isNotEmpty) const SizedBox(height: 16), if (sections.isEmpty) - metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.')) + Padding( + padding: const EdgeInsets.only(top: 12), + child: metaPlaceholder(ClideSettings.i18n.string(context, 'activity.empty', namespace: 'builtin.claude', placeholder: 'No activity recorded yet.')), + ) else - ...metaTableChildren(tokens, sections), + ...metaTableChildren(context, tokens, sections), ], ); } diff --git a/lib/builtin/claude/src/meta_sidebar/config_tab.dart b/lib/builtin/claude/src/meta_sidebar/config_tab.dart index da586f33..92396db2 100644 --- a/lib/builtin/claude/src/meta_sidebar/config_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/config_tab.dart @@ -53,43 +53,40 @@ class ConfigTabView extends StatelessWidget { final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default'; final children = [ - // Pinned SETTINGS control panel — not collapsible. - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClideText( - ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS'), - fontSize: clideFontSmall, - color: tokens.sidebarSectionHeader, + // Pinned SETTINGS control panel — not collapsible. Carded to match the + // settings overlay (T-158 facelift). + metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS')), + metaCard(tokens, [ + SettingControlRow( + label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'), + value: model, + valueColor: tokens.globalFocus, + options: (models == null || models!.isEmpty) ? kFallbackModels : models!, + isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()), + command: 'model', ), - ), - SettingControlRow( - label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'), - value: model, - valueColor: tokens.globalFocus, - options: (models == null || models!.isEmpty) ? kFallbackModels : models!, - isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()), - command: 'model', - ), - SettingControlRow( - label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'), - value: effort, - options: kEffortLevels, - isActive: (o) => o.value == effort, - command: 'effort', - ), - SettingControlRow( - label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'), - value: permissionModeLabel(mode), - options: kPermissionModes, - isActive: (o) => o.value == mode, - command: 'permissions', - ), - _configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle), - _configRow( - tokens, - ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'), - ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'), - ), + SettingControlRow( + label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'), + value: effort, + options: kEffortLevels, + isActive: (o) => o.value == effort, + command: 'effort', + ), + SettingControlRow( + label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'), + value: permissionModeLabel(mode), + options: kPermissionModes, + isActive: (o) => o.value == mode, + command: 'permissions', + ), + _configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle), + _configRow( + tokens, + ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'), + ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'), + ), + ]), + const SizedBox(height: 12), // ---- Accordion sections ---- for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section), @@ -116,7 +113,7 @@ class ConfigTabView extends StatelessWidget { /// One read-only key→value row in the pinned SETTINGS table. Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) { return Padding( - padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -324,7 +321,7 @@ class _SettingControlRowState extends State { Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; return Padding( - padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ diff --git a/lib/builtin/claude/src/meta_sidebar/models.dart b/lib/builtin/claude/src/meta_sidebar/models.dart index f931985e..e2311b59 100644 --- a/lib/builtin/claude/src/meta_sidebar/models.dart +++ b/lib/builtin/claude/src/meta_sidebar/models.dart @@ -43,41 +43,64 @@ Widget metaPlaceholder(String text) => Padding( child: ClideText(text, muted: true, fontSize: kMetaFont), ); -/// Key→value sections on the shared table geometry (Activity + Config). -Widget buildMetaTable(SurfaceTokens tokens, List sections) => - ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections)); +/// Small-caps section header (mono, uppercase) — the same treatment the settings +/// overlay uses (settings_category_view `_SectionCard`), so the Claude meta +/// sidebar and the settings modal read as one card system (T-158 facelift). +Widget metaSectionHeader(BuildContext context, SurfaceTokens tokens, String label) => Padding( + padding: const EdgeInsets.only(left: 2, bottom: 6), + child: ClideText(label.toUpperCase(), fontSize: clideFontCaption, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)), +); -/// The table rows without the enclosing ListView, for tabs that compose extra -/// widgets around the sections (the Activity tab's control strip, T-415). -List metaTableChildren(SurfaceTokens tokens, List sections) { +/// An elevated card (the settings card surface) wrapping divider-separated +/// [rows]: `panelHeader` fill, `dividerColor` hairline border, 6px radius. +Widget metaCard(SurfaceTokens tokens, List rows) => ClideSurface( + color: tokens.panelHeader, + border: tokens.dividerColor, + borderRadius: BorderRadius.circular(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < rows.length; i++) ...[if (i > 0) const ClideDivider(), rows[i]], + ], + ), +); + +/// One label→value row sized for the card interior — the shared label column +/// then the value, uniform with the settings field rows. +Widget metaCardRow(SurfaceTokens tokens, MetaRow r) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: kMetaLabelColumnWidth, + child: ClideText(r.label, muted: true, fontSize: kMetaFont), + ), + Expanded( + child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground), + ), + ], + ), +); + +/// Key→value sections rendered as carded blocks (Activity + Config). +Widget buildMetaTable(BuildContext context, SurfaceTokens tokens, List sections) => + ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(context, tokens, sections)); + +/// The carded sections without the enclosing ListView, for tabs that compose +/// extra widgets around them (the Activity tab's control strip, T-415): a +/// small-caps header above an elevated card of label→value rows. +List metaTableChildren(BuildContext context, SurfaceTokens tokens, List sections) { final children = []; for (var i = 0; i < sections.length; i++) { final s = sections[i]; children.add( Padding( - padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8), - child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader), + padding: EdgeInsets.only(top: i == 0 ? 0 : 16), + child: metaSectionHeader(context, tokens, s.header), ), ); - for (final r in s.rows) { - children.add( - Padding( - padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: kMetaLabelColumnWidth, - child: ClideText(r.label, muted: true, fontSize: kMetaFont), - ), - Expanded( - child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground), - ), - ], - ), - ), - ); - } + children.add(metaCard(tokens, [for (final r in s.rows) metaCardRow(tokens, r)])); } return children; } diff --git a/lib/builtin/claude/src/meta_sidebar/team_tab.dart b/lib/builtin/claude/src/meta_sidebar/team_tab.dart index 48a2617d..963473b9 100644 --- a/lib/builtin/claude/src/meta_sidebar/team_tab.dart +++ b/lib/builtin/claude/src/meta_sidebar/team_tab.dart @@ -3,8 +3,13 @@ /// Stateless and props-driven; the parent owns the member list, inject /// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart /// (T-395). +/// +/// Also carries the shared account budget (T-158): the `/usage` figures are +/// per-ACCOUNT — every team session shares one `~/.claude` login, so this is a +/// single shared budget shown once and labelled as such, NOT a per-member split. library; +import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage; import 'package:clide/builtin/claude/src/meta_sidebar/models.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart'; @@ -31,6 +36,7 @@ class TeamTabView extends StatelessWidget { required this.onSetPermissionMode, required this.onFork, required this.onOpenChatPane, + this.usage, }); final List members; @@ -46,27 +52,32 @@ class TeamTabView extends StatelessWidget { final void Function(String memberName) onFork; final VoidCallback onOpenChatPane; + /// Parsed `/usage` budget for the account this team shares (T-158). Null until + /// the first `/usage` refresh (driven from the Activity tab control). + final ClaudeUsage? usage; + @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; - if (members.isEmpty) { - return metaPlaceholder(ClideSettings.i18n.string(context, 'team.empty', namespace: 'builtin.claude', placeholder: 'No team active.')); - } final children = [ - for (final m in members) - AgentRosterRow( - key: ValueKey(m.agentId), - member: m, - status: memberStatus[m.agentId], - orchestrator: orchestrator, - injectingAgentId: injectingAgentId, - injectController: injectController, - onToggleInject: onToggleInject, - onInjectSubmit: onInjectSubmit, - onClose: onClose, - onSetPermissionMode: onSetPermissionMode, - onFork: onFork, - ), + ..._accountSection(context, tokens), + if (members.isEmpty) + metaPlaceholder(ClideSettings.i18n.string(context, 'team.empty', namespace: 'builtin.claude', placeholder: 'No team active.')) + else + for (final m in members) + AgentRosterRow( + key: ValueKey(m.agentId), + member: m, + status: memberStatus[m.agentId], + orchestrator: orchestrator, + injectingAgentId: injectingAgentId, + injectController: injectController, + onToggleInject: onToggleInject, + onInjectSubmit: onInjectSubmit, + onClose: onClose, + onSetPermissionMode: onSetPermissionMode, + onFork: onFork, + ), ]; if (tasks.isNotEmpty) { @@ -85,16 +96,48 @@ class TeamTabView extends StatelessWidget { return ListView(padding: const EdgeInsets.all(12), children: children); } + /// The shared account-budget card (T-158): one ACCOUNT section with the three + /// `/usage` figures and a caption that it is account-wide, not per-member. + /// Empty when no `/usage` result has arrived yet. + List _accountSection(BuildContext context, SurfaceTokens tokens) { + final u = usage; + if (u == null || (u.session == null && u.week == null && u.weekSonnet == null)) return const []; + return [ + metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'team.section.usage', namespace: 'builtin.claude', placeholder: 'ACCOUNT')), + metaCard(tokens, [ + if (u.session != null) + metaCardRow( + tokens, + MetaRow(ClideSettings.i18n.string(context, 'activity.row.session', namespace: 'builtin.claude', placeholder: 'session'), u.session!), + ), + if (u.week != null) + metaCardRow( + tokens, + MetaRow(ClideSettings.i18n.string(context, 'activity.row.weekAll', namespace: 'builtin.claude', placeholder: 'week (all)'), u.week!), + ), + if (u.weekSonnet != null) + metaCardRow( + tokens, + MetaRow(ClideSettings.i18n.string(context, 'activity.row.weekSonnet', namespace: 'builtin.claude', placeholder: 'week (sonnet)'), u.weekSonnet!), + ), + ]), + Padding( + padding: const EdgeInsets.only(left: 2, top: 4), + child: ClideText( + ClideSettings.i18n.string(context, 'team.usage.shared', namespace: 'builtin.claude', placeholder: 'Shared across the team'), + muted: true, + fontSize: clideFontCaption, + ), + ), + const SizedBox(height: 16), + ]; + } + Widget _taskSection(BuildContext context, SurfaceTokens tokens) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClideText( - ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS'), - fontSize: clideFontSmall, - color: tokens.globalTextMuted, - ), - const SizedBox(height: 4), + metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS')), for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker), ], ); diff --git a/test/builtin/claude/team_tab_test.dart b/test/builtin/claude/team_tab_test.dart new file mode 100644 index 00000000..986b56a5 --- /dev/null +++ b/test/builtin/claude/team_tab_test.dart @@ -0,0 +1,75 @@ +/// T-158: the Team tab carries the shared account budget — a single ACCOUNT +/// card from the forwarded `/usage`, NOT a per-member split (usage is +/// per-account: every team session shares one ~/.claude login). +library; + +import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage; +import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + late TextEditingController inject; + + setUp(() async { + f = await KernelFixture.create(); + inject = TextEditingController(); + }); + tearDown(() { + inject.dispose(); + f.dispose(); + }); + + // No team members + no catalog seeded, so labels fall back to their English + // placeholders ('ACCOUNT' is the uppercased section header). + TeamTabView view({ClaudeUsage? usage}) => TeamTabView( + members: const [], + memberStatus: const {}, + orchestrator: null, + tasks: const [], + injectingAgentId: null, + injectController: inject, + onToggleInject: (_) {}, + onInjectSubmit: (_, _) {}, + onClose: (_) {}, + onSetPermissionMode: (_, _) {}, + onFork: (_) {}, + onOpenChatPane: () {}, + usage: usage, + ); + + testWidgets('renders the shared ACCOUNT budget card when usage is present', (tester) async { + await tester.pumpWidget(harness(f, view(usage: const ClaudeUsage(session: '15% used', week: '53% used', weekSonnet: '0% used')))); + await tester.pump(); + + expect(find.text('ACCOUNT'), findsOneWidget); + expect(find.text('15% used'), findsOneWidget); + expect(find.text('53% used'), findsOneWidget); + expect(find.text('0% used'), findsOneWidget); + // Labelled shared, not per-member. + expect(find.text('Shared across the team'), findsOneWidget); + // Solo (no members) still shows the budget above the empty-team notice. + expect(find.text('No team active.'), findsOneWidget); + }); + + testWidgets('omits the ACCOUNT card when no usage has been fetched', (tester) async { + await tester.pumpWidget(harness(f, view())); + await tester.pump(); + + expect(find.text('ACCOUNT'), findsNothing); + expect(find.text('Shared across the team'), findsNothing); + expect(find.text('No team active.'), findsOneWidget); + }); + + testWidgets('omits the ACCOUNT card when the usage result is empty', (tester) async { + await tester.pumpWidget(harness(f, view(usage: const ClaudeUsage()))); + await tester.pump(); + + expect(find.text('ACCOUNT'), findsNothing); + }); +}