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 +
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;
+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 +
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);
+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
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
+2
View File
@@ -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}" },
+2
View File
@@ -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}" },
@@ -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<ClaudeMetaSidebar> {
members: _members,
memberStatus: _memberStatus,
orchestrator: _orchestrator,
usage: _usage,
tasks: _tasks,
injectingAgentId: _injectingAgentId,
injectController: _injectCtl,
@@ -87,15 +87,12 @@ 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.
// 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.only(bottom: 8),
child: ClideText(
ClideSettings.i18n.string(context, 'activity.section.session', namespace: 'builtin.claude', placeholder: 'SESSION'),
fontSize: clideFontSmall,
color: tokens.sidebarSectionHeader,
),
),
Row(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Row(
children: [
_control(
context,
@@ -135,11 +132,16 @@ class ActivityTabView extends StatelessWidget {
),
],
),
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),
],
);
}
@@ -53,15 +53,10 @@ class ConfigTabView extends StatelessWidget {
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
final children = <Widget>[
// 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,
@@ -90,6 +85,8 @@ class ConfigTabView extends StatelessWidget {
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<SettingControlRow> {
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: [
+41 -18
View File
@@ -43,26 +43,32 @@ 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<MetaSection> 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<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
final children = <Widget>[];
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),
/// An elevated card (the settings card surface) wrapping divider-separated
/// [rows]: `panelHeader` fill, `dividerColor` hairline border, 6px radius.
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]],
],
),
);
for (final r in s.rows) {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
/// 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: [
@@ -75,9 +81,26 @@ List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections)
),
],
),
);
/// 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>[];
for (var i = 0; i < sections.length; i++) {
final s = sections[i];
children.add(
Padding(
padding: EdgeInsets.only(top: i == 0 ? 0 : 16),
child: metaSectionHeader(context, tokens, s.header),
),
);
}
children.add(metaCard(tokens, [for (final r in s.rows) metaCardRow(tokens, r)]));
}
return children;
}
@@ -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<TeamMemberJoined> members;
@@ -46,13 +52,18 @@ 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 = <Widget>[
..._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),
@@ -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<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) {
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),
],
);
+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);
});
}