feat(claude): shared account budget in Team tab + meta-sidebar card facelift (T-158)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:31:50 +02:00
co-authored by Claude Opus 4.8
parent c4be179a87
commit 763d628fc9
11 changed files with 483 additions and 141 deletions
@@ -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 + Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection +
thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, 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; 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;
+188
View File
@@ -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 + Scope is now exactly the "RECOMMENDED SCOPE" above (team_tab.dart MetaSection +
thread _usage from claude_meta_sidebar.dart + team.section.usage i18n key, 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); 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);
+6
View File
@@ -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 fleshed out to full token + syntax coverage and ships alongside a
contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance. contrast-hardened `summer-night-hc`, both selectable in Settings → Appearance.
(T-478) (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 ### 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 - **Bundled themes and Tier-0 i18n namespaces resolve from one canonical list
each.** The app, testmode harness, and contrast/i18n gates iterate the shared each.** The app, testmode harness, and contrast/i18n gates iterate the shared
lists instead of drifting copies; a new theme or catalog is validated lists instead of drifting copies; a new theme or catalog is validated
+2
View File
@@ -194,6 +194,8 @@
"taskRow.reassign": { "translation": "Reassign task" }, "taskRow.reassign": { "translation": "Reassign task" },
"team.empty": { "translation": "No team active." }, "team.empty": { "translation": "No team active." },
"team.section.tasks": { "translation": "TASKS" }, "team.section.tasks": { "translation": "TASKS" },
"team.section.usage": { "translation": "Account" },
"team.usage.shared": { "translation": "Shared across the team" },
"tabStrip.activity": { "translation": "Activity" }, "tabStrip.activity": { "translation": "Activity" },
"tabStrip.team": { "translation": "Team" }, "tabStrip.team": { "translation": "Team" },
"tabStrip.team.count": { "translation": "Team · {count}" }, "tabStrip.team.count": { "translation": "Team · {count}" },
+2
View File
@@ -194,6 +194,8 @@
"taskRow.reassign": { "translation": "Taak opnieuw toewijzen" }, "taskRow.reassign": { "translation": "Taak opnieuw toewijzen" },
"team.empty": { "translation": "Geen team actief." }, "team.empty": { "translation": "Geen team actief." },
"team.section.tasks": { "translation": "TAKEN" }, "team.section.tasks": { "translation": "TAKEN" },
"team.section.usage": { "translation": "Account" },
"team.usage.shared": { "translation": "Gedeeld met het hele team" },
"tabStrip.activity": { "translation": "Activiteit" }, "tabStrip.activity": { "translation": "Activiteit" },
"tabStrip.team": { "translation": "Team" }, "tabStrip.team": { "translation": "Team" },
"tabStrip.team.count": { "translation": "Team · {count}" }, "tabStrip.team.count": { "translation": "Team · {count}" },
@@ -19,9 +19,10 @@
/// `meta_sidebar/` (T-395 split). Activity and Config render on the same /// `meta_sidebar/` (T-395 split). Activity and Config render on the same
/// table geometry (`buildMetaTable`) so switching tabs doesn't visually jump. /// table geometry (`buildMetaTable`) so switching tabs doesn't visually jump.
/// ///
/// The account/team token budget is intentionally absent: it isn't /// The account budget now surfaces from a forwarded `/usage` (T-415): the
/// programmatically exposed under subscription auth (see project memory / /// Activity tab renders it, and the Team tab repeats it as a single shared
/// GitHub anthropics/claude-code#44328). /// ACCOUNT card (T-158) — usage is per-account (one `~/.claude` login), so it's
/// shown once, not split per member.
library; library;
import 'dart:async'; import 'dart:async';
@@ -312,6 +313,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
members: _members, members: _members,
memberStatus: _memberStatus, memberStatus: _memberStatus,
orchestrator: _orchestrator, orchestrator: _orchestrator,
usage: _usage,
tasks: _tasks, tasks: _tasks,
injectingAgentId: _injectingAgentId, injectingAgentId: _injectingAgentId,
injectController: _injectCtl, injectController: _injectCtl,
@@ -87,59 +87,61 @@ class ActivityTabView extends StatelessWidget {
children: [ children: [
// SESSION control strip (T-415): drives the primary session through // SESSION control strip (T-415): drives the primary session through
// the builtin.claude/command bus — identical to typing the command. // the builtin.claude/command bus — identical to typing the command.
Padding( // Carded to match the settings overlay (T-158 facelift).
padding: const EdgeInsets.only(bottom: 8), metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION')),
child: ClideText( metaCard(tokens, [
ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION'), Padding(
fontSize: clideFontSmall, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
color: tokens.sidebarSectionHeader, 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( if (sections.isNotEmpty) const SizedBox(height: 16),
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.isEmpty) 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 else
...metaTableChildren(tokens, sections), ...metaTableChildren(context, tokens, sections),
], ],
); );
} }
@@ -53,43 +53,40 @@ class ConfigTabView extends StatelessWidget {
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default'; final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[ final children = <Widget>[
// Pinned SETTINGS control panel — not collapsible. // Pinned SETTINGS control panel — not collapsible. Carded to match the
Padding( // settings overlay (T-158 facelift).
padding: const EdgeInsets.only(bottom: 8), metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS')),
child: ClideText( metaCard(tokens, [
ClideSettings.i18n.string(context, 'config.section.settings', namespace: 'builtin.claude', placeholder: 'SETTINGS'), SettingControlRow(
fontSize: clideFontSmall, label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'),
color: tokens.sidebarSectionHeader, 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(
SettingControlRow( label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'),
label: ClideSettings.i18n.string(context, 'config.row.model', namespace: 'builtin.claude', placeholder: 'model'), value: effort,
value: model, options: kEffortLevels,
valueColor: tokens.globalFocus, isActive: (o) => o.value == effort,
options: (models == null || models!.isEmpty) ? kFallbackModels : models!, command: 'effort',
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()), ),
command: 'model', SettingControlRow(
), label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'),
SettingControlRow( value: permissionModeLabel(mode),
label: ClideSettings.i18n.string(context, 'config.row.effort', namespace: 'builtin.claude', placeholder: 'effort'), options: kPermissionModes,
value: effort, isActive: (o) => o.value == mode,
options: kEffortLevels, command: 'permissions',
isActive: (o) => o.value == effort, ),
command: 'effort', _configRow(tokens, ClideSettings.i18n.string(context, 'config.row.outputStyle', namespace: 'builtin.claude', placeholder: 'output style'), outputStyle),
), _configRow(
SettingControlRow( tokens,
label: ClideSettings.i18n.string(context, 'config.row.permissionMode', namespace: 'builtin.claude', placeholder: 'permission mode'), ClideSettings.i18n.string(context, 'config.row.source', namespace: 'builtin.claude', placeholder: 'source'),
value: permissionModeLabel(mode), ClideSettings.i18n.string(context, 'config.row.source.value', namespace: 'builtin.claude', placeholder: '~/.claude + .claude'),
options: kPermissionModes, ),
isActive: (o) => o.value == mode, ]),
command: 'permissions', const SizedBox(height: 12),
),
_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'),
),
// ---- Accordion sections ---- // ---- Accordion sections ----
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section), 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. /// One read-only key→value row in the pinned SETTINGS table.
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) { Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -324,7 +321,7 @@ class _SettingControlRowState extends State<SettingControlRow> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
+50 -27
View File
@@ -43,41 +43,64 @@ Widget metaPlaceholder(String text) => Padding(
child: ClideText(text, muted: true, fontSize: kMetaFont), child: ClideText(text, muted: true, fontSize: kMetaFont),
); );
/// Key→value sections on the shared table geometry (Activity + Config). /// Small-caps section header (mono, uppercase) — the same treatment the settings
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) => /// overlay uses (settings_category_view `_SectionCard`), so the Claude meta
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections)); /// 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 /// An elevated card (the settings card surface) wrapping divider-separated
/// widgets around the sections (the Activity tab's control strip, T-415). /// [rows]: `panelHeader` fill, `dividerColor` hairline border, 6px radius.
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) { Widget metaCard(SurfaceTokens tokens, List<Widget> 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<MetaSection> 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<Widget> metaTableChildren(BuildContext context, SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[]; final children = <Widget>[];
for (var i = 0; i < sections.length; i++) { for (var i = 0; i < sections.length; i++) {
final s = sections[i]; final s = sections[i];
children.add( children.add(
Padding( Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8), padding: EdgeInsets.only(top: i == 0 ? 0 : 16),
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader), child: metaSectionHeader(context, tokens, s.header),
), ),
); );
for (final r in s.rows) { children.add(metaCard(tokens, [for (final r in s.rows) metaCardRow(tokens, r)]));
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),
),
],
),
),
);
}
} }
return children; return children;
} }
@@ -3,8 +3,13 @@
/// Stateless and props-driven; the parent owns the member list, inject /// Stateless and props-driven; the parent owns the member list, inject
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart /// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
/// (T-395). /// (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; 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/models.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart'; import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
import 'package:clide/builtin/claude/src/meta_sidebar/task_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.onSetPermissionMode,
required this.onFork, required this.onFork,
required this.onOpenChatPane, required this.onOpenChatPane,
this.usage,
}); });
final List<TeamMemberJoined> members; final List<TeamMemberJoined> members;
@@ -46,27 +52,32 @@ class TeamTabView extends StatelessWidget {
final void Function(String memberName) onFork; final void Function(String memberName) onFork;
final VoidCallback onOpenChatPane; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; 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 = <Widget>[ final children = <Widget>[
for (final m in members) ..._accountSection(context, tokens),
AgentRosterRow( if (members.isEmpty)
key: ValueKey(m.agentId), metaPlaceholder(ClideSettings.i18n.string(context, 'team.empty', namespace: 'builtin.claude', placeholder: 'No team active.'))
member: m, else
status: memberStatus[m.agentId], for (final m in members)
orchestrator: orchestrator, AgentRosterRow(
injectingAgentId: injectingAgentId, key: ValueKey(m.agentId),
injectController: injectController, member: m,
onToggleInject: onToggleInject, status: memberStatus[m.agentId],
onInjectSubmit: onInjectSubmit, orchestrator: orchestrator,
onClose: onClose, injectingAgentId: injectingAgentId,
onSetPermissionMode: onSetPermissionMode, injectController: injectController,
onFork: onFork, onToggleInject: onToggleInject,
), onInjectSubmit: onInjectSubmit,
onClose: onClose,
onSetPermissionMode: onSetPermissionMode,
onFork: onFork,
),
]; ];
if (tasks.isNotEmpty) { if (tasks.isNotEmpty) {
@@ -85,16 +96,48 @@ class TeamTabView extends StatelessWidget {
return ListView(padding: const EdgeInsets.all(12), children: children); 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<Widget> _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) { Widget _taskSection(BuildContext context, SurfaceTokens tokens) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText( metaSectionHeader(context, tokens, ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS')),
ClideSettings.i18n.string(context, 'team.section.tasks', namespace: 'builtin.claude', placeholder: 'TASKS'),
fontSize: clideFontSmall,
color: tokens.globalTextMuted,
),
const SizedBox(height: 4),
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker), for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
], ],
); );
+75
View File
@@ -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);
});
}