Commit 61be7e3
[multi-team] /team switcher so the agent CLI has a team scope (#15448)
## Description
Prerequisite for the client settings-getter migration onto per-window
team scope
(tech spec #15347, tracking issue REV-2205). **No settings getter is
migrated here and
no autonomy enforcement path is touched** — this makes the agent CLI's
window
team-scoped, and lets the user see and change which team that is, so the
follow-up
migration can read scope there safely.
> Rewritten from an earlier `--team` flag design after product feedback.
The flag,
> `WARP_TEAM`, and the fail-closed refusal are all gone; see *What
changed from the
> first revision* at the bottom.
### The gap this closes
`UserWorkspaces::register_window` has exactly one production caller:
`RootView::new`
(`app/src/root_view.rs:1889`). The headless TUI has no `RootView`, so
its window is absent
from `UserWorkspaces::window_team_uids` entirely. It *does* have a real
warpui window, so
`window_id_for_view` returns a valid `WindowId` and nothing errors,
nothing warns, and no
test fails — the scope simply resolves to "no team".
That matters because the TUI does not merely touch the AI-autonomy
enforcement path, it
instantiates the whole of it:
`crates/warp_tui/src/terminal_session_view.rs:1502-1595`
constructs `BlocklistAIActionModel`, `BlocklistAIController` and
`CLISubagentController`
with `terminal_surface_id = ctx.view_id()`, the same code the GUI runs.
If the autonomy
getters were migrated today, enterprise autonomy policy would stop being
enforced in the
agent CLI entirely: today it reads one arbitrary team's policy — wrong,
but restrictive —
and after, none at all.
### Why reassigning a window's team did not already exist
This is the first question worth answering, because the GUI *does* have
a team switcher and
its absence from this seam looks like an oversight.
`app/src/workspace/view.rs:6128-6256` has one: a title-bar pill gated on
`can_switch_teams()`
plus a dropdown listing the workspace's teams with a check on the active
one. But every item
dispatches `WorkspaceAction::OpenNewWindowForTeam`, which opens a
**new** window scoped to the
chosen team rather than re-scoping the current one. **The GUI never
needed reassignment because
it opens a new window instead**, which is why `register_window` and
`set_team_for_window` are
both insert-only and why neither has ever overwritten anything.
The TUI is one process with one window, so it cannot copy that. `/team`
genuinely mutates.
### What this adds
- **`/team`** — a TUI-only slash command opening a searchable switcher
listing the teams in
the current workspace, marking the active one, and re-scoping the window
in place. Modelled
on `/model` (`SlashCommandKind::Model` + `TuiModelMenuModel`), which is
the existing
list-and-change-the-active-thing pattern in the TUI.
- **`UserWorkspaces::switch_window_to_team`** — the reassignment `/team`
needs. Deliberately a
separate entry point rather than relaxing `set_team_for_window`: that
method's insert-only,
first-write-wins semantics are load-bearing for the GUI, and a single
greppable overwrite
path is worth more than symmetry for a field that decides which team's
admin policy applies.
It emits `WindowTeamChanged` so scoped consumers resync, and no-ops when
the team is unchanged.
- **`UserWorkspaces::is_window_registered`** — `team_uid_for_window`
returns `None` both for an
absent window and a registered teamless one, so it cannot answer "has
this window got a team
yet". This can, and it replaces what would otherwise be a resolution
state machine.
- **A remembered team.** The team a session ends on is stored locally
and preferred at next
startup, following the GUI's `WindowSnapshot::team_uid` precedent
(`app/src/persistence/sqlite.rs:987` and `:2683`), including its
deliberate tolerance: an
unreadable or unparseable stored value degrades to `None` rather than
failing. It is local
rather than cloud-synced because it records what this machine's TUI was
doing.
### Startup, and why the stored team is validated
Restored team if the user still belongs to it → otherwise the default a
new GUI window gets
(`inherited_or_default_team_uid`, i.e. `teams.first()`, or `None` for a
user on no teams) →
`/team` overrides and persists.
There is no state machine. `UserWorkspaces`'s window table is the single
source of truth:
registration happens on the first `TeamsChanged`, `is_window_registered`
makes it idempotent,
and a `/team` choice is never clobbered because the same check
short-circuits it.
The validation on the stored team deserves its own note, because the
obvious reading of it is
wrong. **It is not for safety.** A stale uid — the user left the team,
an admin removed them,
they signed in as someone else — cannot leak another team's settings,
because `team_from_uid`
searches only the current workspace and so resolves it to no team at
all. It is for
**promptness**, and it is needed because of an ordering inside
`update_workspaces`:
```rust
*self.workspaces = workspaces;
let reassigned_windows = self.reconcile_window_team_assignments(); // the sweep runs here
self.notify_and_emit_teams_changed(ctx); // TeamsChanged emitted here
```
Registration happens *on* `TeamsChanged`, so a team written from that
handler lands after the
sweep that would have corrected it, and waits for the next poll. Without
validation a returning
user who had left a team would sit on **no team** — degraded policy —
for up to a full poll
interval. A test pins this
(`a_stale_stored_team_is_reconciled_onto_the_default`); it failed
before the validation was added, which is how the ordering was found.
### Two consequences worth naming
1. **The "no team matching X" error is gone, along with
`TeamResolutionError` entirely.** There
is no flag to typo and `/team` picks from a list, so no path can name a
team that does not
exist. The only remaining unresolvable input is a stale stored uid,
which is a silent
fall-back to the default rather than an error, because the user did not
ask for it.
2. **There is now no non-interactive way to select a team.** Scripts and
CI get the default or
whatever the last interactive session left behind. That is deliberate
for now, but it is a
real limitation rather than an oversight.
### Notes for the reviewer
- **`oz agent run` is unaffected and deliberately untouched.** It
reaches `launch()` →
`ai::agent_sdk::run` → `TerminalDriver::create` →
`open_new_with_workspace_source`
(`app/src/ai/agent_sdk/driver/terminal.rs:201`), which builds a real
`RootView`, so it already
registers a window.
- **An offline start leaves the window unregistered, and the follow-up
must not read that as
teamless.** With no metadata response nothing registers. That is today's
behaviour and not a
regression. `team_context` already distinguishes the two states — `None`
for a window absent
entirely, `Some(TeamContext { team_uid: None })` for one registered with
no team — and **the
migration that builds on this is required to honour that difference and
fail closed on
absent**. Tests assert both states so the distinction cannot quietly
collapse.
- **Drift is now visible and correctable.**
`reconcile_window_team_assignments` can still move a
window onto `teams.first()` — a teamless user later added to a team, or
a logout/login as
another account. Previously that was an unobservable landing on an
arbitrary team; with
`/team` and the indicator stacked on top, the user can see it and change
it.
- **No workspace-settings fallback is retired by this PR** (per the
convention on REV-2205),
since it migrates no getter.
## Linked Issue
https://linear.app/warpdotdev/issue/REV-2205 — [multi team] Scope team
settings on the client to window context
- [x] The linked issue is labeled `ready-to-spec` or
`ready-to-implement`.
- [x] Where appropriate, screenshots or a short video of the
implementation are included below (especially for user-visible or UI
changes).
## Testing
Automated, all added or rewritten in this PR:
- `app/src/tui/team_scope_tests.rs` — registration only after a metadata
response; the default
when nothing is stored; teamless users registered rather than left
absent; a stored team
preferred over the default; a stale stored team falling back to the
default; a `/team` switch
moving the window, persisting, and surviving a later poll; an
unparseable stored value
degrading to the default. These assert through `team_context`, since
`team_uid_for_window`
cannot tell an absent window from a registered teamless one.
- `app/src/workspaces/user_workspaces_tests.rs` — `is_window_registered`
distinguishing absent
from teamless; `switch_window_to_team` overwriting and emitting
`WindowTeamChanged`; the same
switch to the current team emitting nothing; `can_switch_teams` gating
on more than one team.
- `crates/warp_tui/src/team_menu_tests.rs` — the active team marked, and
every team selectable
so re-picking the active one is a no-op rather than an error.
Commands run:
- `./script/format` — clean.
- `cargo clippy -p warp_tui -p warp --all-targets --tests -- -D
warnings` — clean.
- `cargo nextest run -p warp --features tui -E 'test(team_scope) or
test(user_workspaces) or test(tui::)'` — 89 passed.
- `cargo nextest run -p warp_tui --features test-util -E
'test(team_menu) or test(session::tests) or test(input_suggestions_mode)
or test(input::view)'` — 135 passed.
- [ ] I have manually tested my changes locally with `./script/run`
Not manually tested: exercising `/team` needs a logged-in account in a
workspace with more than
one team, which does not exist today — every workspace currently has
exactly one. The menu's
rendering is covered by unit tests, but I have not seen it on screen,
and I would value a
reviewer running it against a multi-team fixture if one exists.
### Screenshots / Videos
None — see above. The switcher is a standard inline menu built from the
same
`TuiInlineMenuSnapshot` vocabulary as `/model`.
## Agent Mode
- [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
### What changed from the first revision
The first revision added `--team <NAME_OR_ID>` plus `WARP_TEAM`, and
refused to start when a
user on more than one team gave no flag. Product direction replaced it:
a switcher plus a
remembered team is better, and it removes the need for the refusal,
since the argument for
failing closed was that the CLI could neither show nor correct an
arbitrary default — and now
it can do both. Removed with the flag: `WARP_TEAM`, the blank-value
handling, the one-shot flag
consumption, and `TeamResolutionError` with its whole `Display`
contract.
<!--
CHANGELOG-TUI: Added `/team` to switch which team's settings and admin
policy the session uses.
-->
<!-- warp:pr-description-artifacts start -->
<!-- warp:pr-description-artifacts end -->
---------
Co-authored-by: warp-agent-staging[bot] <240773466+warp-agent-staging[bot]@users.noreply.github.com>
Co-authored-by: Oz <oz-agent@warp.dev>1 parent 19548ae commit 61be7e3
22 files changed
Lines changed: 720 additions & 21 deletions
File tree
- app/src
- terminal/input/slash_commands
- workspaces
- crates/warp_tui/src
- handoff
- input
Lines changed: 14 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
560 | 560 | | |
561 | 561 | | |
562 | 562 | | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
563 | 576 | | |
564 | 577 | | |
565 | 578 | | |
| |||
994 | 1007 | | |
995 | 1008 | | |
996 | 1009 | | |
| 1010 | + | |
997 | 1011 | | |
998 | 1012 | | |
999 | 1013 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
| 97 | + | |
97 | 98 | | |
98 | 99 | | |
99 | 100 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1301 | 1301 | | |
1302 | 1302 | | |
1303 | 1303 | | |
| 1304 | + | |
1304 | 1305 | | |
1305 | 1306 | | |
1306 | 1307 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
183 | 183 | | |
184 | 184 | | |
185 | 185 | | |
186 | | - | |
| 186 | + | |
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
| |||
265 | 265 | | |
266 | 266 | | |
267 | 267 | | |
| 268 | + | |
268 | 269 | | |
269 | 270 | | |
270 | 271 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| 41 | + | |
41 | 42 | | |
42 | 43 | | |
43 | 44 | | |
| |||
288 | 289 | | |
289 | 290 | | |
290 | 291 | | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
291 | 308 | | |
292 | 309 | | |
293 | 310 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
413 | 413 | | |
414 | 414 | | |
415 | 415 | | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
416 | 430 | | |
417 | 431 | | |
418 | 432 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
1 | 3 | | |
2 | 4 | | |
3 | 5 | | |
| |||
1152 | 1154 | | |
1153 | 1155 | | |
1154 | 1156 | | |
| 1157 | + | |
| 1158 | + | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
| 1167 | + | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
| 1173 | + | |
| 1174 | + | |
| 1175 | + | |
| 1176 | + | |
| 1177 | + | |
| 1178 | + | |
| 1179 | + | |
| 1180 | + | |
| 1181 | + | |
| 1182 | + | |
| 1183 | + | |
| 1184 | + | |
| 1185 | + | |
| 1186 | + | |
| 1187 | + | |
| 1188 | + | |
| 1189 | + | |
| 1190 | + | |
| 1191 | + | |
| 1192 | + | |
| 1193 | + | |
| 1194 | + | |
| 1195 | + | |
| 1196 | + | |
| 1197 | + | |
| 1198 | + | |
| 1199 | + | |
| 1200 | + | |
| 1201 | + | |
| 1202 | + | |
| 1203 | + | |
| 1204 | + | |
| 1205 | + | |
| 1206 | + | |
| 1207 | + | |
| 1208 | + | |
| 1209 | + | |
| 1210 | + | |
| 1211 | + | |
| 1212 | + | |
| 1213 | + | |
| 1214 | + | |
| 1215 | + | |
| 1216 | + | |
| 1217 | + | |
| 1218 | + | |
| 1219 | + | |
| 1220 | + | |
| 1221 | + | |
| 1222 | + | |
| 1223 | + | |
| 1224 | + | |
| 1225 | + | |
| 1226 | + | |
| 1227 | + | |
| 1228 | + | |
| 1229 | + | |
| 1230 | + | |
| 1231 | + | |
| 1232 | + | |
| 1233 | + | |
| 1234 | + | |
| 1235 | + | |
| 1236 | + | |
| 1237 | + | |
| 1238 | + | |
| 1239 | + | |
| 1240 | + | |
| 1241 | + | |
| 1242 | + | |
| 1243 | + | |
| 1244 | + | |
| 1245 | + | |
| 1246 | + | |
| 1247 | + | |
| 1248 | + | |
| 1249 | + | |
| 1250 | + | |
| 1251 | + | |
| 1252 | + | |
| 1253 | + | |
| 1254 | + | |
| 1255 | + | |
| 1256 | + | |
| 1257 | + | |
| 1258 | + | |
| 1259 | + | |
| 1260 | + | |
| 1261 | + | |
| 1262 | + | |
| 1263 | + | |
1155 | 1264 | | |
1156 | 1265 | | |
1157 | 1266 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
30 | 31 | | |
31 | 32 | | |
32 | 33 | | |
| |||
403 | 404 | | |
404 | 405 | | |
405 | 406 | | |
| 407 | + | |
406 | 408 | | |
407 | 409 | | |
408 | 410 | | |
| |||
877 | 879 | | |
878 | 880 | | |
879 | 881 | | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
880 | 931 | | |
881 | 932 | | |
882 | 933 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
| 168 | + | |
168 | 169 | | |
169 | 170 | | |
170 | 171 | | |
| |||
1366 | 1367 | | |
1367 | 1368 | | |
1368 | 1369 | | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
1369 | 1373 | | |
1370 | 1374 | | |
1371 | 1375 | | |
| |||
0 commit comments