feat(team): checkpoint dashboard and runtime UX updates
This commit is contained in:
parent
75c7455ed9
commit
409f84110e
99 changed files with 8213 additions and 901 deletions
|
|
@ -0,0 +1 @@
|
|||
{"taskId":"351e2899-3aba-4992-9250-bf85dccb4399","teamName":"ember-collective","provider":"codex","source":"codex-native-trace","updatedAt":"2026-05-09T07:59:53.638Z"}
|
||||
1
.board-task-log-freshness/351e2899.json
Normal file
1
.board-task-log-freshness/351e2899.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"taskId":"351e2899","teamName":"ember-collective","provider":"codex","source":"codex-native-trace","updatedAt":"2026-05-09T08:00:39.185Z"}
|
||||
|
|
@ -443,7 +443,9 @@ function requestReview(context, taskId, flags = {}) {
|
|||
text:
|
||||
`**Please review** task #${task.displayId || task.id}\n\n` +
|
||||
wrapAgentBlock(
|
||||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`This request is for the CURRENT review cycle. If you reviewed this task earlier, do not treat this message as a duplicate while the task is still in review; prior approvals become stale after later work.\n\n` +
|
||||
`Before declaring it duplicate, call task_get and check the current reviewState/status. If it is still in review for you, continue with the review tools below.\n\n` +
|
||||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>", note?: "<optional note>", notifyOwner: true }\n\n` +
|
||||
|
|
|
|||
|
|
@ -774,6 +774,9 @@ describe('agent-teams-controller API', () => {
|
|||
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].text).toContain('<info_for_agent>');
|
||||
expect(inbox[0].text).toContain('CURRENT review cycle');
|
||||
expect(inbox[0].text).toContain('Before declaring it duplicate, call task_get');
|
||||
expect(inbox[0].text).toContain('reviewState/status');
|
||||
expect(inbox[0].text).toContain('review_approve');
|
||||
expect(inbox[0].text).not.toContain('<agent-block>');
|
||||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -2,55 +2,84 @@
|
|||
|
||||
Agent Teams makes agent work visible as task state, messages, logs, and reviewable code changes.
|
||||
|
||||
## Lifecycle
|
||||
## Modes
|
||||
|
||||
| Stage | What happens |
|
||||
|-------|--------------|
|
||||
| Provisioning | The app starts the team and confirms runtime readiness |
|
||||
| Planning | The lead creates tasks and may assign teammates |
|
||||
| In progress | Agents work in parallel and update task state |
|
||||
| Review | Changes are reviewed by agents or by you |
|
||||
| Done | Accepted work stays linked to its task history |
|
||||
| Mode | Description |
|
||||
| --- | --- |
|
||||
| Solo | One teammate with self-managed tasks |
|
||||
| Team | Many teammates working in parallel, reviewing each other |
|
||||
|
||||
Both modes share the same kanban, task logs, and code review surfaces.
|
||||
|
||||
## Task lifecycle
|
||||
|
||||
| Stage | What happens | Owner |
|
||||
| --- | --- | --- |
|
||||
| Provisioning | The app starts the runtime, confirms the process is alive, and waits for bootstrap confirmation | App |
|
||||
| Planning | The lead creates tasks, optionally assigns teammates, and sets dependencies | Lead or user |
|
||||
| In progress | Agents work in parallel and update task state via board MCP tools | Teammates |
|
||||
| Review | Changes are reviewed by agents or by you before final acceptance | Team lead or user |
|
||||
| Done | Accepted work stays linked to its task history and can still be inspected later | User |
|
||||
|
||||
### Planning → In progress
|
||||
|
||||
When a teammate starts a task, the board status becomes `in_progress`. The agent creates a task comment with its plan and continues working. All native tool actions (read, bash, edit, write) are streamed into a task log.
|
||||
|
||||
### In progress → Review
|
||||
|
||||
When the teammate finishes work, it posts a result comment and marks the task `completed`. The lead can then decide whether to accept it immediately or move it into review.
|
||||
|
||||
### Review → Done
|
||||
|
||||
If the review surface shows acceptable changes, approve the review. The task is finalized and linked to its diff.
|
||||
|
||||
::: warning Fix-first review
|
||||
If a teammate is asked for changes during review, it should post a follow-up comment with the fixes, then the lead can approve.
|
||||
:::
|
||||
|
||||
## Kanban board
|
||||
|
||||
The board is the primary operating surface. It lets you scan work, spot blocked tasks, open task detail, inspect logs, and review changes without reading raw session files.
|
||||
The board is the primary operating surface. It lets you:
|
||||
|
||||
- Scan open, blocked, and in-review work
|
||||
- Open task detail and inspect runtime logs
|
||||
- Review changes without reading raw session files
|
||||
- Assign or reassign owners
|
||||
|
||||
::: tip
|
||||
Use quick action buttons on cards to start, complete, or request review without opening the detail panel.
|
||||
:::
|
||||
|
||||
## Messages and comments
|
||||
|
||||
Use **direct messages** when you need to redirect an agent or ask a quick question. Use **task comments** when the note belongs to a specific piece of work. Comments preserve context for later review.
|
||||
| Channel | When to use |
|
||||
| --- | --- |
|
||||
| Direct message | Redirect an agent, ask a quick question |
|
||||
| Task comment | Notes that belong to a specific task |
|
||||
|
||||
::: tip
|
||||
Task comments are the durable delivery channel. Agents should post findings, decisions, and blockers in comments so the whole team can see them on the board.
|
||||
:::
|
||||
Comments preserve context for later review and appear in the task timeline.
|
||||
|
||||
## Work-sync protocol
|
||||
|
||||
Agents follow a strict status cycle:
|
||||
|
||||
1. **Start** — mark the task `in_progress` when beginning real work.
|
||||
2. **Comment** — post a short note before doing follow-up fixes.
|
||||
3. **Reopen** — move the task back to `in_progress` for additional work.
|
||||
4. **Result comment** — post a summary of changes.
|
||||
5. **Complete** — mark the task `completed`.
|
||||
|
||||
::: warning
|
||||
Never skip the comment-and-status cycle. The board depends on accurate state to show what is actually happening.
|
||||
::: tip Prefer task comments
|
||||
If the remark is about a specific task, add it as a comment on that task rather than sending a direct message. It keeps the history linked to the work.
|
||||
:::
|
||||
|
||||
## Task logs
|
||||
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them when you need to answer:
|
||||
Task-specific logs isolate runtime output, actions, and messages for one assignment. Use them to answer:
|
||||
|
||||
- What did this agent run?
|
||||
- Why did it change this file?
|
||||
- Did it ask another teammate for help?
|
||||
- Which task produced this diff?
|
||||
|
||||
## Parallel work patterns
|
||||
|
||||
Teammates can work on independent tasks at the same time. You can also create dependency links (`blocked-by`) so that one task waits until another is complete. Watch the board for blocked lanes and reassign owners if one teammate is idle while another is overloaded.
|
||||
|
||||
## Live processes
|
||||
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results.
|
||||
The live process section shows URLs and running processes when agents start local servers or tools. Open URLs directly from the app to inspect results. Processes remain registered until they are explicitly stopped or the runtime exits.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Teams can send messages to each other. Use this to share findings, request reviews, or coordinate work across team boundaries without leaving the board.
|
||||
Agents can send messages to other teams when teams are linked. Use this for handoffs, shared libraries, or status checks between squads.
|
||||
|
|
|
|||
|
|
@ -4,45 +4,60 @@ Code review in Agent Teams is task-centered. You inspect what changed for a spec
|
|||
|
||||
## Review surface
|
||||
|
||||
Use the review UI to:
|
||||
For each completed task that touched files, the review UI lets you:
|
||||
|
||||
- Inspect changed files
|
||||
- Inspect changed files with before/after context
|
||||
- Accept or reject individual hunks
|
||||
- Leave comments
|
||||
- Connect the diff back to the task and agent logs
|
||||
|
||||
## Review lifecycle
|
||||
|
||||
When a task is ready for review:
|
||||
|
||||
1. The author marks it `completed`.
|
||||
2. A reviewer calls `review_start` to move the task into the **REVIEW** column.
|
||||
3. The reviewer inspects hunks and logs.
|
||||
4. If accepted, the reviewer calls `review_approve` to move the task to **APPROVED**.
|
||||
5. If changes are needed, the reviewer calls `review_request_changes` with a comment describing what to fix.
|
||||
|
||||
::: tip
|
||||
Approve the **work task** itself (e.g. `#1234`), not a separate "review task". The task ends in APPROVED, not DONE.
|
||||
:::
|
||||
- Leave inline comments
|
||||
- Connect the diff back to the task description and agent logs
|
||||
|
||||
## Hunk-level decisions
|
||||
|
||||
Accept small correct changes and reject isolated mistakes without throwing away the whole task. This is useful when an agent mostly solved the task but overreached in one file.
|
||||
|
||||
::: tip Accept incrementally
|
||||
If a diff is mostly correct, accept the good hunks first and request changes only for the parts that need fixing. This keeps the board moving.
|
||||
:::
|
||||
|
||||
## Initiating review
|
||||
|
||||
1. Open a completed task
|
||||
2. Look at the **Changes** tab
|
||||
3. If the diff looks reasonable, click **Request Review** to move the task into the review column
|
||||
|
||||
During review the task is not yet considered done, so other teammates or the lead can still comment on it.
|
||||
|
||||
## Review states
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `none` | Task is new, in progress, or completed but not yet in review |
|
||||
| `review` | The task is actively under review |
|
||||
| `needsFix` | Changes were requested; the owner must update before re-approval |
|
||||
| `approved` | The review was accepted and the task is finalized |
|
||||
|
||||
## Agent review workflow
|
||||
|
||||
Teams can review each other's work before you make the final call. This catches obvious regressions and keeps the board honest, but you should still review risky areas yourself.
|
||||
|
||||
## Review participants
|
||||
|
||||
The team lead is the default reviewer. You can configure additional reviewers in the Kanban settings if you want peers to review each other's work.
|
||||
|
||||
## What to check manually
|
||||
|
||||
Prioritize:
|
||||
Prioritize these areas when reviewing:
|
||||
|
||||
- Provider auth and runtime detection
|
||||
- IPC, preload, and filesystem boundaries
|
||||
- Git and worktree behavior
|
||||
- Parsing and task lifecycle logic
|
||||
- Persistence and code review flows
|
||||
- **Provider auth and runtime detection** — did the agent change runtime setup in a way that would break other paths?
|
||||
- **IPC, preload, and filesystem boundaries** — keep Electron responsibilities separated
|
||||
- **Git and worktree behavior** — verify branch naming, commits, and pushes
|
||||
- **Parsing and task lifecycle logic** — changes to task references, chunking, or filtering can break message delivery
|
||||
- **Persistence and code review flows** — changes to task storage or review state must stay consistent across IPC layers
|
||||
|
||||
## Verification
|
||||
|
||||
Prefer focused verification commands. Broad formatting or lint-fix commands should not be used unless the task explicitly intends broad formatting churn.
|
||||
|
||||
::: warning Do not auto-format across the whole project
|
||||
Unless the task is specifically about formatting, avoid running `pnpm lint:fix` on unrelated files. It creates noise in the review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -6,14 +6,28 @@ A team is a named group of agents with roles, a lead, a target project, and a co
|
|||
|
||||
Start with a small team:
|
||||
|
||||
| Role | Purpose |
|
||||
| --- | --- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Role | Purpose |
|
||||
| -------- | --------------------------------------------------- |
|
||||
| Lead | Splits work, creates tasks, coordinates teammates |
|
||||
| Builder | Implements scoped tasks |
|
||||
| Reviewer | Reviews output, catches regressions, asks for fixes |
|
||||
|
||||
This shape gives you enough coordination to see the product value without making the first launch noisy.
|
||||
|
||||
::: tip
|
||||
You can add more members later. Start small, validate the workflow, then scale up.
|
||||
:::
|
||||
|
||||
## Assign providers and models
|
||||
|
||||
Each team member runs on a provider backend. In the team editor, pick a provider (Claude, Codex, or OpenCode) and a model for every member. The app shows only providers you have already authenticated.
|
||||
|
||||
Mixing providers in one team is supported — for example, a Claude lead with OpenCode builders.
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the provider list when available.
|
||||
:::
|
||||
|
||||
## Write a good team brief
|
||||
|
||||
The team brief should include:
|
||||
|
|
@ -30,10 +44,40 @@ Example:
|
|||
Build a focused improvement to the download flow. Keep changes inside the landing app unless a shared helper is clearly needed. Create tasks before implementation, review each task diff, and run landing lint/build checks.
|
||||
```
|
||||
|
||||
## Worktree isolation
|
||||
|
||||
OpenCode members can use **worktree isolation** to work in a separate Git worktree instead of the main working directory. This prevents file conflicts when multiple agents edit the same project.
|
||||
|
||||
::: warning
|
||||
Worktree isolation requires a Git-tracked project and is currently limited to OpenCode members.
|
||||
:::
|
||||
|
||||
To enable it, toggle the **Worktree isolation** option when adding or editing an OpenCode team member.
|
||||
|
||||
## Choose autonomy
|
||||
|
||||
Agent Teams supports different levels of control. Use more autonomy for routine changes and tighter review for risky areas like provider auth, IPC, persistence, Git workflows, and release tooling.
|
||||
|
||||
### Effort level
|
||||
|
||||
Each team member has an **effort** setting that controls how much reasoning the provider invests before responding. Higher effort produces more thorough output at the cost of time and tokens.
|
||||
|
||||
| Level | When to use |
|
||||
| ------ | ---------------------------------------------------------- |
|
||||
| Low | Quick lookups, small formatting changes, routine edits |
|
||||
| Medium | Default for most implementation tasks |
|
||||
| High | Complex refactors, cross-cutting changes, risky code paths |
|
||||
|
||||
The app offers additional levels (minimal, xhigh, max) for providers that support them. If a model does not support configurable effort, the selector is disabled and the provider default is used.
|
||||
|
||||
### Fast mode
|
||||
|
||||
Toggle **Fast mode** per member to prioritize speed over depth. This maps to the provider's native fast/speed mode when available. Set it to **On** for routine tasks, **Off** for careful work, or **Inherit** to follow the team-level default.
|
||||
|
||||
### Limit context
|
||||
|
||||
Enable **Limit context** to reduce the context window for a member. This is useful for Claude models that support extended context (e.g. 1M tokens) — limiting context avoids unnecessary token usage and can improve latency for tasks that do not need large context.
|
||||
|
||||
## Add context
|
||||
|
||||
Attach files, screenshots, or specific notes when they materially change the task. Agents can use task descriptions, comments, and attachments as durable context.
|
||||
|
|
@ -49,3 +93,8 @@ Good teams create tasks that are:
|
|||
|
||||
If the lead creates vague tasks, send a direct message asking for smaller, testable tasks.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Runtime setup](/guide/runtime-setup) — configure provider auth and models
|
||||
- [Code review](/guide/code-review) — accept, reject, or comment on agent changes
|
||||
- [Troubleshooting](/guide/troubleshooting) — common issues and fixes
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Agent Teams is distributed as a desktop app for macOS, Windows, and Linux.
|
|||
|
||||
## Download builds
|
||||
|
||||
Use the latest GitHub release when you want the packaged app:
|
||||
Use the <a href="/download/" target="_self">download page</a> or the latest [GitHub release](https://github.com/777genius/agent-teams-ai/releases) when you want the packaged app:
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
|
|
@ -17,14 +17,27 @@ Unsigned or newly published open-source apps can trigger SmartScreen. If you tru
|
|||
|
||||
## Requirements
|
||||
|
||||
The packaged app is designed for zero-setup onboarding. It can guide runtime detection and provider authentication from the UI.
|
||||
The packaged app is designed for zero-setup onboarding. It guides you through runtime detection and provider authentication from the UI — no manual CLI configuration needed.
|
||||
|
||||
For source development, use:
|
||||
To use agent runtimes, you need access to at least one provider:
|
||||
|
||||
| Tool | Version |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
| Provider | Access method |
|
||||
| ------------------ | ------------------------------------------------- |
|
||||
| Claude (Anthropic) | Claude Code CLI login or API key |
|
||||
| Codex (OpenAI) | Codex CLI login or API key |
|
||||
| Gemini (Google) | _In development_ |
|
||||
| OpenCode | API key for a supported backend (e.g. OpenRouter) |
|
||||
|
||||
::: info
|
||||
Gemini provider support is in development. You can prepare access now, but it will not appear in the team editor until it is ready.
|
||||
:::
|
||||
|
||||
For source development, you also need:
|
||||
|
||||
| Tool | Version |
|
||||
| ------- | ------- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Run from source
|
||||
|
||||
|
|
@ -37,9 +50,27 @@ pnpm install
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
If you want the freshest local version, use the repository branch that currently carries active development.
|
||||
The `main` branch carries the latest stable development. Switch to feature branches only if you need a specific unreleased change.
|
||||
|
||||
## Updating
|
||||
## Auto-updates
|
||||
|
||||
Use the latest release for packaged builds. If you run from source, pull the branch you use and rerun install when dependencies change.
|
||||
The packaged app checks for updates automatically on launch and periodically while running. When an update is available, the app prompts you to download and install it. You can also check manually from the app menu.
|
||||
|
||||
::: tip
|
||||
Auto-updates are not available when running from source. Pull the latest changes and rerun `pnpm install` when dependencies change.
|
||||
:::
|
||||
|
||||
## Updating from source
|
||||
|
||||
If you run from source, pull the `main` branch and rerun install when dependencies change:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Quickstart](/guide/quickstart) — from install to first running team
|
||||
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection per runtime
|
||||
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
|
||||
|
|
|
|||
|
|
@ -1,33 +1,45 @@
|
|||
# Quickstart
|
||||
|
||||
This guide gets you from a fresh install to a running team.
|
||||
This guide gets you from a fresh install to a running team in a few minutes.
|
||||
|
||||
## 1. Install Agent Teams
|
||||
|
||||
Download the latest release for your platform from the landing page or GitHub releases.
|
||||
Download the latest release for your platform from the <a href="/download/" target="_self">download page</a> or [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
|
||||
|
||||
::: tip
|
||||
The app is free and open source. The agent runtime you choose may still require provider access, such as Claude, Codex, OpenCode, or API-key based providers.
|
||||
The app is free and open source. The agent runtime you choose may still require provider access — see [Installation](/guide/installation) for details.
|
||||
:::
|
||||
|
||||
## 2. Open or create a project
|
||||
|
||||
Launch the app and select the project directory you want agents to work in. Agent Teams reads local project files and runtime/session state so the UI can show tasks, logs, diffs, and teammate activity.
|
||||
|
||||
::: tip
|
||||
Pick a Git-tracked project for the best experience. Worktree isolation and diff-based review both rely on Git.
|
||||
:::
|
||||
|
||||
## 3. Choose a runtime path
|
||||
|
||||
Use the setup flow to detect available runtimes. A common first setup is:
|
||||
The setup flow auto-detects installed runtimes on your machine. A common first setup is:
|
||||
|
||||
| Runtime | Good for |
|
||||
| --- | --- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multimodel teams and many provider backends |
|
||||
| Runtime | Good for |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| Claude | Claude Code users and existing Anthropic access |
|
||||
| Codex | Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Multi-model teams and many provider backends |
|
||||
|
||||
::: info
|
||||
Gemini support is in development and will appear in the runtime list when available.
|
||||
:::
|
||||
|
||||
See [Runtime setup](/guide/runtime-setup) for detailed configuration per provider.
|
||||
|
||||
## 4. Create your first team
|
||||
|
||||
Create a team with a lead and one or more specialists. Keep the first team small: one lead, one implementation agent, and one review-oriented agent is enough to validate the workflow.
|
||||
|
||||
See [Create a team](/guide/create-team) for the recommended structure and tips.
|
||||
|
||||
## 5. Give the lead a concrete goal
|
||||
|
||||
Write the goal like you would brief an engineering lead:
|
||||
|
|
@ -36,15 +48,16 @@ Write the goal like you would brief an engineering lead:
|
|||
Improve the onboarding flow. Split the work into tasks, keep changes small, and ask for review before broad refactors.
|
||||
```
|
||||
|
||||
The lead should create tasks, assign work, and coordinate teammates. You can watch progress on the kanban board and intervene with comments or direct messages.
|
||||
The lead creates tasks, assigns work, and coordinates teammates. You can watch progress on the kanban board and intervene with comments or direct messages at any time.
|
||||
|
||||
## 6. Review results
|
||||
|
||||
Open completed or review-ready tasks, inspect the diff, and accept, reject, or comment on individual changes. Use task logs when you need to understand why an agent made a choice.
|
||||
|
||||
See [Code review](/guide/code-review) for the full review workflow.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create a team](/guide/create-team)
|
||||
- [Runtime setup](/guide/runtime-setup)
|
||||
- [Code review](/guide/code-review)
|
||||
|
||||
- [Create a team](/guide/create-team) — recommended team shapes and brief writing
|
||||
- [Runtime setup](/guide/runtime-setup) — provider auth and model selection
|
||||
- [Code review](/guide/code-review) — review, approve, or request changes
|
||||
|
|
|
|||
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
Agent Teams is a coordination layer. The actual model work runs through supported local runtimes and providers.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before launching a team, make sure:
|
||||
|
||||
- The runtime binary is installed and on your `PATH`.
|
||||
- Your provider account has active access to the model you intend to use.
|
||||
- The project path exists and is readable.
|
||||
|
||||
::: tip
|
||||
Start with a single teammate and one provider. Confirm one launch works before adding multimodel lanes.
|
||||
:::
|
||||
|
||||
## Supported paths
|
||||
|
||||
| Path | Use when |
|
||||
|------|----------|
|
||||
| Claude | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | You want Codex-native runtime integration |
|
||||
| OpenCode | You want multimodel routing and broad provider coverage |
|
||||
| Path | Default CLI | Typical providers | Use when |
|
||||
| --- | --- | --- | --- |
|
||||
| Claude | `claude` | Anthropic | You already use Claude Code or Anthropic-backed workflows |
|
||||
| Codex | `codex` | OpenAI | You want Codex-native runtime integration |
|
||||
| OpenCode | `opencode` | OpenRouter and many backends | You want multimodel routing and broad provider coverage |
|
||||
|
||||
The app detects supported runtimes and guides setup from the UI when possible.
|
||||
|
||||
|
|
@ -16,48 +28,71 @@ The app detects supported runtimes and guides setup from the UI when possible.
|
|||
|
||||
Agent Teams has no paid tier of its own. You bring the provider access you already have: subscriptions, local runtime auth, or API keys depending on the path you choose.
|
||||
|
||||
::: tip
|
||||
If you are new to Claude Code, the app includes a built-in installer and authentication helper. Look for the "Install Claude Code" button in the runtime settings.
|
||||
:::
|
||||
- **Claude** and **Codex** paths rely on their respective CLI auth tools.
|
||||
- **OpenCode** needs provider-specific API keys in a config file (e.g., `openrouter`, `openai`, `anthropic`).
|
||||
|
||||
## Auth configuration
|
||||
|
||||
### Claude Code
|
||||
|
||||
Run the standard auth flow in a terminal:
|
||||
|
||||
```bash
|
||||
claude login
|
||||
```
|
||||
|
||||
Then verify the CLI is reachable:
|
||||
|
||||
```bash
|
||||
claude --version
|
||||
```
|
||||
|
||||
### Codex
|
||||
|
||||
Install and authenticate via OpenAI's CLI flow:
|
||||
|
||||
```bash
|
||||
codex login
|
||||
```
|
||||
|
||||
### OpenCode
|
||||
|
||||
Create or edit `~/.opencode/config.json` (or the equivalent path on your platform) with the provider key you want:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the exact provider name that OpenCode expects. If you set a custom provider name, double-check it against the provider ID you use in the model string (for example `openrouter/moonshotai/kimi-k2.6` would use the `openrouter` block).
|
||||
|
||||
## Multimodel mode
|
||||
|
||||
Multimodel mode can route work through many provider backends via OpenCode-compatible configuration. Use it when you need provider flexibility or want teammates to use different model lanes.
|
||||
|
||||
Example `~/.opencode/config.json`:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"anthropic": { "apiKey": "<your-key>" },
|
||||
"openai": { "apiKey": "<your-key>" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-flight checklist
|
||||
|
||||
Before creating your first team:
|
||||
|
||||
- [ ] The chosen runtime is installed and available in your shell `PATH`.
|
||||
- [ ] You have authenticated with the provider (Claude Code `claude login`, OpenCode `opencode auth`, etc.).
|
||||
- [ ] The provider has access to the model you plan to assign.
|
||||
- [ ] The project path exists and is readable.
|
||||
|
||||
::: warning
|
||||
Do not add many providers or multimodel lanes until you have confirmed that a single teammate can launch successfully. Keep the first setup minimal.
|
||||
::: info Model lanes
|
||||
Each teammate can use a different `providerId` + `model` pair. In the team edit UI, expand member options to override the global defaults.
|
||||
:::
|
||||
|
||||
## Operational advice
|
||||
## Prelaunch checklist
|
||||
|
||||
- Keep the first runtime setup simple.
|
||||
- Confirm one team can launch before adding many providers.
|
||||
- Treat auth, provider model names, and runtime PATH issues as setup problems, not team-prompt problems.
|
||||
- If launch hangs, check the [Troubleshooting](./troubleshooting.md) page before changing team prompts.
|
||||
Before launching a team:
|
||||
|
||||
1. The selected runtime is installed
|
||||
2. The runtime binary is in the environment `PATH`
|
||||
3. Provider auth is configured for the chosen backend
|
||||
4. The provider has access to the exact model string you specify
|
||||
5. The project path exists and is readable
|
||||
|
||||
## When to switch runtime paths
|
||||
|
||||
Switch when the current path is blocked by model availability, rate limits, provider capabilities, or team role needs. Keep the same project and team workflow, but validate one small task after switching.
|
||||
|
||||
::: tip
|
||||
You can mix paths in the same team: for example, assign the lead to Claude while secondary teammates run in OpenCode lanes for multimodel flexibility.
|
||||
::: warning Treat setup errors as setup problems
|
||||
If auth fails, a model name is rejected, or the runtime binary cannot be found, fix the setup first. Do not change team prompts or project code to work around a runtime configuration issue.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,45 +1,55 @@
|
|||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, or provider limits.
|
||||
Most team issues fall into one of five buckets: runtime setup, launch confirmation, task parsing, provider limits, and review state gaps.
|
||||
|
||||
## Team does not launch
|
||||
|
||||
Check:
|
||||
Check each item in order:
|
||||
|
||||
- The selected runtime is installed or authenticated
|
||||
- The runtime is available in the environment `PATH`
|
||||
- The provider has access to the requested model
|
||||
- The project path exists and is readable
|
||||
1. **Runtime available** — the selected CLI (`claude`, `codex`, `opencode`) is installed
|
||||
2. **PATH reachable** — the binary is available in the environment `PATH`
|
||||
3. **Model access** — the provider has access to the requested model string (especially for OpenCode, exact provider/model names matter)
|
||||
4. **Project path** — the project directory exists and is readable
|
||||
5. **Network / VPN** — some providers drop traffic when a VPN is active
|
||||
|
||||
::: tip
|
||||
Run the runtime binary directly in a terminal to verify it is on PATH and authenticated. For example: `claude --version` or `opencode --version`.
|
||||
:::
|
||||
### OpenCode: registered but bootstrap unconfirmed
|
||||
|
||||
### OpenCode bootstrap unconfirmed
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed:
|
||||
Look at the newest launch failure artifact:
|
||||
|
||||
1. Inspect the launch logs in the UI.
|
||||
2. Check `~/.claude/teams/<team>/launch-state.json` for the member state.
|
||||
3. Look at `~/.claude/teams/<team>/.opencode-runtime/lanes/<lane-id>/manifest.json` for evidence.
|
||||
4. Do not change team prompts until you confirm whether the lane started but failed to commit evidence.
|
||||
```bash
|
||||
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||
```
|
||||
|
||||
::: warning
|
||||
A missing OpenCode inbox during primary launch is normal. Secondary lanes start after primary filesystem readiness. Do not treat primary hang as an OpenCode bug unless the UI explicitly shows `Y` members waiting with `Y` incorrectly including OpenCode lanes.
|
||||
The manifest inside includes:
|
||||
|
||||
- `classification` — why the launch was considered a failure
|
||||
- `bootstrapTransportBreadcrumb` — delivery path used
|
||||
- Member spawn statuses
|
||||
- Redacted logs and traces
|
||||
|
||||
Also check the lane manifest:
|
||||
|
||||
```bash
|
||||
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||
```
|
||||
|
||||
::: tip Do not guess from the UI
|
||||
Always correlate UI diagnostics with persisted files (`launch-state.json`, `bootstrap-journal.jsonl`) and runtime-specific evidence.
|
||||
:::
|
||||
|
||||
## Agent replies are missing
|
||||
|
||||
Open task logs and teammate messages. Missing replies often come from:
|
||||
|
||||
- Runtime delivery gaps
|
||||
- Parsing or task filtering issues
|
||||
- The agent is still processing (large tasks may take minutes)
|
||||
- **Runtime delivery retry** — the agent may have answered, but the message was not delivered to the app. Check the delivery ledger.
|
||||
- **Parsing or filtering** — the agent output did not include expected markers or task references.
|
||||
- **Task attribution** — the work happened during the session but was not linked to the task because the correct task id was missing from the output.
|
||||
|
||||
::: warning Do not assume silence means ignoring
|
||||
Do not assume the model ignored the message until logs confirm it.
|
||||
|
||||
::: tip
|
||||
For OpenCode teammates, check that `agent-teams_message_send` was called with the correct `from`, `to`, and `taskRefs`. OpenCode replies must be sent via MCP tools, not plain text.
|
||||
:::
|
||||
|
||||
## Tasks are not linked to changes
|
||||
|
|
@ -50,64 +60,50 @@ Use task-specific logs and code review links. If a diff appears detached:
|
|||
- Verify the agent called `task_add_comment` before making edits.
|
||||
- Ensure the agent called `task_start` so the board knows work began.
|
||||
|
||||
For OpenCode teammates, the authoritative proof that a session belongs to a task is in `opencode-sessions.json` and the lane manifest entry, not only the UI message stream.
|
||||
|
||||
## Rate limits
|
||||
|
||||
If a provider reports a known reset time, Agent Teams can nudge the lead to continue after cooldown. If reset time is unknown, wait or switch provider/runtime path.
|
||||
|
||||
## Common member states
|
||||
| Provider behavior | Suggested action |
|
||||
| --- | --- |
|
||||
| Known reset time displayed | Wait for cooldown and continue |
|
||||
| No reset time shown | Switch provider or runtime path |
|
||||
| Repeated 429s | Lower concurrency or use a different model lane |
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `confirmed_alive` + `bootstrapConfirmed` | Healthy and usable |
|
||||
| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof is not committed yet |
|
||||
| `failed_to_start` + `runtime_process` | A process exists but the launch gate failed. Inspect diagnostics |
|
||||
| `failed_to_start` + `stale_metadata` | Persisted pid/session is old or dead |
|
||||
## CLI auth issues
|
||||
|
||||
::: warning
|
||||
`member_briefing` alone is NOT runtime evidence. For OpenCode, the authoritative proof is committed runtime evidence such as `opencode-sessions.json` and its manifest entry.
|
||||
:::
|
||||
### `claude login` not persist
|
||||
|
||||
## Teammate runtime debug mode
|
||||
If the CLI is authenticated in one terminal but the app says it is not, verify the auth is saved to the expected config path and that the app process sees the same `$HOME`.
|
||||
|
||||
For local debugging, you can force pane-backed teammates through `tmux`:
|
||||
### OpenCode provider key rejected
|
||||
|
||||
```bash
|
||||
# Terminal launch
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
- Double-check the provider name in `config.json` matches the provider prefix in the model string
|
||||
- Ensure the key is not expired or revoked in the provider dashboard
|
||||
|
||||
# Or add to custom CLI args
|
||||
--teammate-mode tmux
|
||||
```
|
||||
## Lane bootstrap stuck
|
||||
|
||||
Use this to inspect interactive CLI behavior. Do not treat it as equivalent to the process backend for recovery semantics.
|
||||
For OpenCode secondary lanes:
|
||||
|
||||
## CLI auth diagnostic
|
||||
- A missing `inboxes/<member>.json` is not automatically a bug. OpenCode lanes do not have to be primary-inbox-created before they start.
|
||||
- If the UI shows the team still launching while primary members are already usable, "all teammates joined" is waiting for secondary lanes.
|
||||
- If `Prepared communication channels for X/Y members` hangs, verify whether `Y` incorrectly includes secondary OpenCode members.
|
||||
|
||||
Each run of `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` inside the Electron logs folder (typically `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next append.
|
||||
### Lane manifest empty entries
|
||||
|
||||
Check this file if you see "Not logged in" or authentication errors in the packaged app.
|
||||
|
||||
## Safe cleanup
|
||||
|
||||
When cleaning up stale processes:
|
||||
|
||||
1. Identify the pid and confirm it belongs to the current team/lane.
|
||||
2. Stop only processes explicitly owned by the smoke test or the launch you are debugging.
|
||||
3. Do **not** kill all OpenCode processes or shared hosts as a shortcut.
|
||||
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
||||
|
||||
## When to collect evidence
|
||||
|
||||
Collect:
|
||||
Before asking for help, collect:
|
||||
|
||||
- Task id
|
||||
- Task id (short or full)
|
||||
- Team name
|
||||
- Runtime path
|
||||
- Launch log excerpt
|
||||
- Provider/model
|
||||
- Exact time window
|
||||
- Runtime path (`claude`, `codex`, or `opencode`)
|
||||
- Launch log excerpt (from `latest.json` or `bootstrap-journal.jsonl`)
|
||||
- Provider / model string
|
||||
- Exact time window when the issue occurred
|
||||
|
||||
This is enough to debug most launch and task lifecycle issues.
|
||||
|
||||
::: tip
|
||||
If the problem persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with live process state before changing code.
|
||||
:::
|
||||
This data is usually enough to debug launch and task lifecycle issues.
|
||||
|
|
|
|||
|
|
@ -1,32 +1,75 @@
|
|||
# Concepts
|
||||
|
||||
This page defines the core terms used across Agent Teams.
|
||||
This page defines the core terms used across Agent Teams. Use it as the shared vocabulary for the app, task board, messages, and review flow.
|
||||
|
||||
## Team
|
||||
|
||||
A team is a group of agents configured for a project. A team usually has a lead and one or more teammates with specialized roles.
|
||||
A team is a named group of agents attached to one project path. It has a lead, optional teammates, runtime/provider settings, prompts, inboxes, tasks, and local launch state.
|
||||
|
||||
## Lead
|
||||
|
||||
The lead coordinates work. It should break goals into tasks, assign teammates, track blockers, and ask for review when needed.
|
||||
The lead is the coordinator for the team. It turns a user goal into tasks, assigns or redirects teammates, tracks blockers, asks for review, and keeps work moving through the board.
|
||||
|
||||
Lead messages use a different delivery path from teammate messages: the app relays lead inbox entries into the lead runtime, while teammates read their own inbox files between turns.
|
||||
|
||||
## Teammate
|
||||
|
||||
A teammate is a non-lead agent in the team. Teammates usually own focused roles such as builder, reviewer, researcher, or tester. A teammate can receive direct messages, task assignments, task comments, and review requests.
|
||||
|
||||
## Task
|
||||
|
||||
A task is the durable unit of work. It has status, description, comments, logs, attachments, and reviewable changes.
|
||||
A task is the durable unit of work. It has an id, status, owner, description, comments, logs, attachments, task references, and reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
Common task states are `todo`, `in_progress`, `done`, `review`, and `approved`. Internally the task file stores the work state, while review and approval placement can also use kanban overlay state.
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower token usage, and validating a prompt before expanding to a full team.
|
||||
## Kanban
|
||||
|
||||
## Cross-team communication
|
||||
Kanban is the board view for team work. It lets you scan tasks by state, open task details, inspect logs, review diffs, approve finished work, or request changes.
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate.
|
||||
## Inbox
|
||||
|
||||
## Autonomy level
|
||||
An inbox is a local message file for a team participant. Agent Teams uses inboxes for user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages, and some system notifications.
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths.
|
||||
Messages are durable local records. Delivery still depends on the selected runtime being alive and able to process its next turn.
|
||||
|
||||
## Agent Block
|
||||
|
||||
An agent block is hidden, agent-only instruction text wrapped with `<info_for_agent>...</info_for_agent>`. The UI strips these blocks from normal human-facing display, but agents and runtime delivery can use them for coordination details.
|
||||
|
||||
The current canonical marker is `info_for_agent`; older documents may still contain legacy agent block formats.
|
||||
|
||||
## Context Phase
|
||||
|
||||
A context phase is one segment of a session context timeline. Compaction starts a new phase, so token and context usage can be analyzed before and after the reset.
|
||||
|
||||
Context tracking separates categories such as project instructions, mentioned files, tool output, thinking text, team coordination, and user messages. These numbers are diagnostics, not provider billing statements.
|
||||
|
||||
## Runtime
|
||||
|
||||
A runtime is the local execution path that connects Agent Teams to a model/provider workflow, such as Claude, Codex, or OpenCode.
|
||||
A runtime is the local execution path that runs an agent turn. Supported runtime paths include Claude Code, Codex, and OpenCode.
|
||||
|
||||
The runtime owns model execution behavior, auth details, tool execution semantics, rate limits, model availability, and some transcript/log formats.
|
||||
|
||||
## Provider
|
||||
|
||||
A provider is the model access path behind a runtime. Current provider ids include Anthropic, Codex, Gemini, and OpenCode. OpenCode can route to many model providers through its own configuration.
|
||||
|
||||
Agent Teams orchestrates tasks and messages, but it does not replace provider authentication or provider policy.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode runs a one-member team. It is useful for quick work, lower coordination overhead, and validating a prompt before expanding to a full team.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Agents can message within and across teams. Use this when separate teams own related work and need to coordinate without collapsing everything into one large team.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy controls how much agents can do before asking. Higher autonomy is faster; lower autonomy is safer for sensitive code paths, persistence, provider auth, Git operations, and releases.
|
||||
|
||||
## Review
|
||||
|
||||
Review is the task-scoped acceptance flow. A task can move to review, receive comments or requested changes, and then move to approved when the result is accepted.
|
||||
|
||||
Review is tied to local diffs and task history, so it works best when tasks stay narrow and agents mention the task they are working on.
|
||||
|
|
|
|||
|
|
@ -4,26 +4,62 @@
|
|||
|
||||
Yes. The app is free and open source. Provider or runtime access may still cost money depending on what you use.
|
||||
|
||||
## Do I need to install Claude or Codex first?
|
||||
## Does Agent Teams include model access?
|
||||
|
||||
No. Agent Teams is the local orchestration and UI layer. Model access comes from the selected runtime/provider path, such as Claude Code, Codex, or OpenCode.
|
||||
|
||||
## Which runtimes are supported?
|
||||
|
||||
The supported runtime paths are Claude Code, Codex, and OpenCode. The app also tracks provider ids such as Anthropic, Codex, Gemini, and OpenCode when the runtime exposes them.
|
||||
|
||||
## Do I need to install Claude Code or Codex first?
|
||||
|
||||
Not always. The app guides runtime detection and setup from the UI. Some paths still require external runtime auth.
|
||||
|
||||
OpenCode setup is separate from Claude Code and Codex setup. If a launch fails, check runtime status and provider auth before changing the team prompt.
|
||||
|
||||
## Does it upload my code to Agent Teams servers?
|
||||
|
||||
No. Agent Teams is not a cloud code-sync service. Provider-backed model calls may receive prompt context depending on your selected runtime.
|
||||
|
||||
## Where are team files stored?
|
||||
|
||||
Team coordination data is stored locally under `~/.claude/teams/<team>/`, task files under `~/.claude/tasks/<team>/`, and project session data under `~/.claude/projects/<encoded-project>/` when available.
|
||||
|
||||
## What can leave my machine?
|
||||
|
||||
Prompt context, selected file contents, tool results, command output, task text, comments, and attachments can leave your machine through the runtime/provider path when an agent uses a provider-backed model. The exact behavior depends on the runtime and provider.
|
||||
|
||||
## Can agents talk to each other?
|
||||
|
||||
Yes. Agents can message teammates, comment on tasks, and coordinate across teams.
|
||||
Yes. Agents can message teammates, comment on tasks, coordinate across teams, and use task references to keep conversations attached to work.
|
||||
|
||||
## Can I review code before accepting it?
|
||||
|
||||
Yes. The review flow is built around task-scoped diffs and hunk-level decisions.
|
||||
|
||||
## What is an Agent Block?
|
||||
|
||||
An Agent Block is hidden agent-only text wrapped in markers such as `<info_for_agent>...</info_for_agent>`. The app strips it from normal user-facing display but keeps it available for agent coordination.
|
||||
|
||||
## What is solo mode?
|
||||
|
||||
Solo mode is a one-agent team. It is useful for smaller tasks and lower coordination overhead.
|
||||
|
||||
## Can different teammates use different providers?
|
||||
|
||||
Yes, provider/model settings can be carried per team member when the selected runtime path supports them. OpenCode is the main path for broad multi-provider routing.
|
||||
|
||||
## Why does a task show review or approved separately from done?
|
||||
|
||||
The work state and review state are related but not identical. A task can be done from the agent's perspective, then move through review and approval in the kanban UI.
|
||||
|
||||
## What should I do when a launch hangs?
|
||||
|
||||
Open troubleshooting, collect runtime logs, and verify provider auth before changing prompts.
|
||||
Open troubleshooting, collect launch diagnostics, check `~/.claude/teams/<team>/`, and verify runtime/provider auth before changing prompts.
|
||||
|
||||
For OpenCode, check lane/session evidence before assuming a teammate is online but ignoring messages.
|
||||
|
||||
## Why are logs different across runtimes?
|
||||
|
||||
Claude Code, Codex, and OpenCode expose different transcript formats and runtime evidence. Agent Teams normalizes what it can, but log completeness and attribution can differ by runtime.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,56 @@
|
|||
# Privacy and Local Data
|
||||
|
||||
Agent Teams is local-first, but the selected provider path still matters.
|
||||
Agent Teams is local-first, but the selected runtime/provider path still matters. This page describes what the desktop app stores locally and what may leave your machine when agents call provider-backed models.
|
||||
|
||||
## What stays local
|
||||
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI:
|
||||
The desktop app runs on your machine and reads local project/runtime data to power the UI. Typical local data includes:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- team configuration and member metadata
|
||||
- task metadata, task comments, and task references
|
||||
- inbox messages
|
||||
- runtime/session logs
|
||||
- launch state and bootstrap diagnostics
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
Important local locations include:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state, and review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files for the team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files used for session history, context analysis, and transcript-backed UI. |
|
||||
|
||||
Exact files can vary by runtime and app version. For launch debugging, the newest evidence is usually under the relevant `~/.claude/teams/<team>/` folder.
|
||||
|
||||
## What can leave your machine
|
||||
|
||||
When an agent asks a provider-backed model to work, prompt context and tool results may be sent through that provider/runtime path. This depends on the runtime and provider you choose.
|
||||
Agent Teams itself is not a cloud code-sync service for your repository. It does not need to upload your whole project to an Agent Teams server to show the board, inbox, logs, or review UI.
|
||||
|
||||
However, when an agent asks a provider-backed model to work, prompt context, selected file contents, task text, comments, tool results, command output, and other runtime-provided context may be sent through the selected runtime/provider path. What is sent depends on the runtime, model, tool calls, prompt, and provider configuration.
|
||||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing, and billing are governed by the provider/runtime you choose. Review those policies for sensitive projects.
|
||||
|
||||
## What the app does not guarantee
|
||||
|
||||
- It cannot guarantee that provider-backed model calls never receive private code.
|
||||
- It cannot override provider retention or billing policies.
|
||||
- It cannot make a remote provider behave like a fully local model.
|
||||
- It cannot protect secrets that an agent is instructed to paste into prompts, task comments, files, or commands.
|
||||
- It cannot make every runtime expose the same transcript or audit detail.
|
||||
|
||||
## Practical guidance
|
||||
|
||||
- Do not attach secrets to tasks.
|
||||
- Do not attach secrets to tasks, comments, or direct messages.
|
||||
- Review provider policies for sensitive projects.
|
||||
- Use lower autonomy for risky repositories.
|
||||
- Keep task scope narrow when working with private code.
|
||||
- Prefer local evidence and logs when debugging.
|
||||
- Check generated prompts, task descriptions, and attached files before asking agents to work on confidential material.
|
||||
- Use provider/model paths that match your privacy requirements.
|
||||
|
||||
## Open source model
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, and review flows work in the repository.
|
||||
|
||||
The app itself is open source and free. You can inspect how local orchestration, task tracking, inboxes, runtime diagnostics, and review flows work in the repository.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Providers and Runtimes
|
||||
|
||||
Agent Teams separates orchestration from model access.
|
||||
Agent Teams separates orchestration from model access. The app manages teams, tasks, messages, launch state, and review UI; the selected runtime/provider path performs the actual model work.
|
||||
|
||||
## What the app provides
|
||||
|
||||
|
|
@ -12,6 +12,8 @@ Agent Teams provides:
|
|||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
- runtime detection and capability checks
|
||||
- local logs and diagnostics
|
||||
|
||||
## What the runtime provides
|
||||
|
||||
|
|
@ -21,20 +23,52 @@ The runtime provides:
|
|||
- provider authentication
|
||||
- tool execution behavior
|
||||
- model-specific rate limits and capabilities
|
||||
- runtime-specific transcripts and delivery evidence
|
||||
|
||||
## Common choices
|
||||
## Supported runtime paths
|
||||
|
||||
| Runtime | Notes |
|
||||
| Runtime path | Provider/model path | Best fit | Notes |
|
||||
| --- | --- |
|
||||
| Claude | Good for Claude Code users and Anthropic access |
|
||||
| Codex | Good for Codex-native workflows and OpenAI access |
|
||||
| OpenCode | Good for multimodel routing and broad provider coverage |
|
||||
| Claude Code | Anthropic / Claude models | Claude Code users and Anthropic-backed workflows | Default local-first path for Claude teams. Requires the runtime and account access to be available locally. |
|
||||
| Codex | Codex / OpenAI-backed models | Codex-native workflows | Uses Codex runtime integration and Codex auth/account state where available. Some diagnostics are different from Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Multi-provider teams and broad model coverage | OpenCode can route through many model providers. Agent Teams treats OpenCode lanes as runtime-specific evidence and avoids guessing when lane identity is ambiguous. |
|
||||
|
||||
## Provider ids
|
||||
|
||||
The app currently recognizes these provider ids in team/runtime configuration:
|
||||
|
||||
| Provider id | Display intent |
|
||||
| --- | --- |
|
||||
| `anthropic` | Anthropic / Claude Code path |
|
||||
| `codex` | Codex path |
|
||||
| `gemini` | Gemini provider path when exposed by the runtime |
|
||||
| `opencode` | OpenCode path, including OpenCode-managed provider routing |
|
||||
|
||||
Do not read this table as a guarantee that every provider is authenticated, installed, or available for every model on every machine. The runtime status and capability checks are the source of truth for a given launch.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams keeps orchestration provider-aware but not provider-owned:
|
||||
|
||||
- teams, tasks, inboxes, comments, review state, and launch diagnostics stay in local Agent Teams storage
|
||||
- each member can carry provider/model settings through team launch metadata
|
||||
- model availability, auth, rate limits, and tool behavior remain runtime/provider responsibilities
|
||||
- OpenCode is the broadest routing path when you want one team to use multiple provider/model lanes
|
||||
|
||||
## Provider costs
|
||||
|
||||
Agent Teams is free. Provider usage is governed by the runtime/provider you select.
|
||||
Agent Teams is free and open source. Provider usage is governed by the runtime/provider you select: subscription limits, API keys, account auth, rate limits, and provider policies all remain external to the app.
|
||||
|
||||
## Capability checks
|
||||
|
||||
During setup, the app may perform access and capability checks. This helps detect missing runtime auth before a team launch fails halfway through provisioning.
|
||||
|
||||
Capability checks can report that a provider exists but is not authenticated, that a model list is unavailable, that a runtime path is missing, or that a specific extension capability is unsupported. Treat those results as setup diagnostics, not task failures.
|
||||
|
||||
## Limits to expect
|
||||
|
||||
- Runtime support does not mean equal feature parity across Claude Code, Codex, and OpenCode.
|
||||
- Log and transcript coverage differs by runtime.
|
||||
- OpenCode lanes need stable lane/session evidence before the app can attribute runtime logs safely.
|
||||
- Provider model names and availability can change outside the app.
|
||||
- A team prompt cannot fix missing auth, missing PATH entries, provider outages, or exhausted rate limits.
|
||||
|
|
|
|||
|
|
@ -5,65 +5,65 @@ Agent Teams делает работу агентов видимой через t
|
|||
## Режимы
|
||||
|
||||
| Режим | Описание |
|
||||
| --- | --- |
|
||||
| Solo | Один teammate с самоуправляемыми задачами |
|
||||
|-------|----------|
|
||||
| Solo | Один teammate с самостоятельным управлением задачами |
|
||||
| Team | Несколько teammates, работающих параллельно и ревьюящих друг друга |
|
||||
|
||||
В обоих режимах используются одни и те же канбан, task logs и surface для код-ревью.
|
||||
Оба режима используют одну и ту же канбан-доску, логи задач и поверхность код-ревью.
|
||||
|
||||
## Жизненный цикл задачи
|
||||
|
||||
| Этап | Что происходит | Владелец |
|
||||
| --- | --- | --- |
|
||||
| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | App |
|
||||
| Planning | Lead создаёт задачи, опционально назначает teammates и ставит зависимости | Lead или пользователь |
|
||||
| Этап | Что происходит | Ответственный |
|
||||
|------|---------------|---------------|
|
||||
| Provisioning | Приложение запускает runtime, проверяет, что процесс жив, и ждёт подтверждения bootstrap | Приложение |
|
||||
| Planning | Lead создаёт задачи, назначает teammates и задаёт зависимости | Lead или пользователь |
|
||||
| In progress | Агенты работают параллельно и обновляют статус задач через board MCP tools | Teammates |
|
||||
| Review | Изменения проверяют агенты или вы перед финальным принятием | Lead или пользователь |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи и доступна для просмотра | Пользователь |
|
||||
| Review | Изменения проверяют агенты или вы перед финальным принятием | Team lead или пользователь |
|
||||
| Done | Принятая работа остаётся связанной с историей задачи и доступна для инспекции | Пользователь |
|
||||
|
||||
### Planning → In progress
|
||||
|
||||
Когда teammate начинает задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом и продолжает работу. Все native tool actions (read, bash, edit, write) попадают в task log.
|
||||
Когда teammate берёт задачу, статус на доске меняется на `in_progress`. Агент создаёт task comment с планом работы и продолжает. Все нативные инструменты (read, bash, edit, write) попадают в task log.
|
||||
|
||||
### In progress → Review
|
||||
|
||||
Когда агент завершает работу, он публикует result comment и помечает задачу как `completed`. Lead может принять задачу сразу или перевести её в review.
|
||||
Когда teammate завершает работу, он публикует result comment и помечает задачу `completed`. Lead затем решает — принять сразу или отправить на ревью.
|
||||
|
||||
### Review → Done
|
||||
|
||||
Если изменения в review выглядят корректно, одобрите review. Задача финализируется и связывается с diff.
|
||||
Если изменения в review surface выглядят приемлемо, approve the review. Задача финализируется и связывается со своим diff.
|
||||
|
||||
::: warning Review с правками
|
||||
Если во время review агенту запрошены изменения, он должен оставить follow-up comment с исправлениями, после чего lead может одобрить задачу.
|
||||
::: warning Ревью с правками
|
||||
Если teammate попросили внести правки во время ревью, он должен добавить follow-up comment с исправлениями, после чего lead может approve.
|
||||
:::
|
||||
|
||||
## Канбан-доска
|
||||
|
||||
Доска - основной рабочий экран. Через неё удобно:
|
||||
Доска — основной рабочий экран. Через неё удобно:
|
||||
|
||||
- Смотреть открытые, заблокированные и на ревью задачи
|
||||
- Открывать task detail и читать runtime logs
|
||||
- Ревьюить changes без ручного чтения session files
|
||||
- Открывать task detail и инспектировать runtime logs
|
||||
- Ревьюить изменения без чтения raw session files
|
||||
- Назначать или переназначать владельцев
|
||||
|
||||
::: tip
|
||||
Используйте quick action buttons на карточках для старта, завершения или запроса ревью без открытия detail panel.
|
||||
Используйте quick action buttons на карточках для старта, завершения или запроса ревью, не открывая detail panel.
|
||||
:::
|
||||
|
||||
## Сообщения и комментарии
|
||||
|
||||
| Канал | Когда использовать |
|
||||
| --- | --- |
|
||||
|-------|-------------------|
|
||||
| Direct message | Перенаправить агента, задать быстрый вопрос |
|
||||
| Task comment | Заметки, относящиеся к конкретной задаче |
|
||||
|
||||
Комментарии сохраняют контекст для review и появляются в таймлайне задачи.
|
||||
Комментарии сохраняют контекст для последующего ревью и появляются в timeline задачи.
|
||||
|
||||
::: tip Предпочитайте task comments
|
||||
Если замечание относится к конкретной задаче, добавьте его как комментарий к задаче, а не direct message. Это сохраняет историю, привязанную к работе.
|
||||
Если заметка касается конкретной задачи, добавьте её как комментарий к задаче, а не как direct message. Это сохраняет историю, привязанную к работе.
|
||||
:::
|
||||
|
||||
## Task logs
|
||||
## Логи задач
|
||||
|
||||
Task-specific logs изолируют runtime output, actions и messages по одному assignment. Они помогают понять:
|
||||
|
||||
|
|
@ -72,14 +72,14 @@ Task-specific logs изолируют runtime output, actions и messages по
|
|||
- Просил ли он помощи у teammate?
|
||||
- Какая задача породила diff?
|
||||
|
||||
## Параллельная работа
|
||||
## Параллельные паттерны работы
|
||||
|
||||
Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать зависимости (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за заблокированными колонками на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен.
|
||||
Teammates могут работать над независимыми задачами одновременно. Вы также можете создавать dependency links (`blocked-by`), чтобы одна задача ждала завершения другой. Следите за blocked lanes на доске и переназначайте владельцев, если один teammate простаивает, а другой перегружен.
|
||||
|
||||
## Live processes
|
||||
## Процессы в реальном времени
|
||||
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными до явной остановки или выхода runtime.
|
||||
Live process section показывает URLs и running processes, когда агенты поднимают локальные servers или tools. Открывайте URL прямо из приложения. Процессы остаются зарегистрированными, пока не будут явно остановлены или runtime не завершится.
|
||||
|
||||
## Межкомандное взаимодействие
|
||||
|
||||
Агенты могут отправлять сообщения в другие команды, если команды связаны. Используйте это для handoffs, shared libraries или status checks между командами.
|
||||
Агенты могут отправлять сообщения другим командам, когда команды связаны. Используйте это для handoffs, shared libraries или проверки статуса между squad.
|
||||
|
|
|
|||
|
|
@ -1,63 +1,63 @@
|
|||
# Код-ревью
|
||||
|
||||
Code review в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
Код-ревью в Agent Teams строится вокруг задачи. Вы смотрите изменения конкретной задачи, а не огромный неструктурированный diff.
|
||||
|
||||
## Review surface
|
||||
## Поверхность ревью
|
||||
|
||||
Для каждой завершённой задачи, затронувшей файлы, review UI позволяет:
|
||||
|
||||
- Смотреть changed files с контекстом до/после
|
||||
- Принимать или отклонять отдельные hunks
|
||||
- Оставлять inline comments
|
||||
- Связывать diff с описанием задачи и agent logs
|
||||
- Связывать diff с описанием задачи и логами агента
|
||||
|
||||
## Hunk-level decisions
|
||||
## Решения на уровне hunk
|
||||
|
||||
Принимайте маленькие правильные изменения и отклоняйте отдельные ошибки без удаления всей работы. Это полезно, когда агент в целом решил задачу, но переборщил в одном файле.
|
||||
|
||||
::: tip Принимайте по частям
|
||||
Если diff в основном корректен, сначала примите хорошие hunks и запросите изменения только для тех частей, которые нуждаются в правке. Это не даёт доске застопориться.
|
||||
Если diff в основном верен, сначала примите хорошие hunks и запросите правки только для проблемных частей. Это не даёт доске застопориться.
|
||||
:::
|
||||
|
||||
## Запуск review
|
||||
## Инициирование ревью
|
||||
|
||||
1. Откройте завершённую задачу
|
||||
2. Перейдите на вкладку **Changes**
|
||||
3. Если diff выглядит разумно, нажмите **Request Review**, чтобы переместить задачу в колонку review
|
||||
|
||||
Во время review задача ещё не считается завершённой, поэтому другие teammates или lead могут оставлять к ней комментарии.
|
||||
Во время ревью задача ещё не считается завершённой, поэтому другие teammates или lead могут всё ещё комментировать её.
|
||||
|
||||
## Состояния review
|
||||
## Состояния ревью
|
||||
|
||||
| Состояние | Значение |
|
||||
| --- | --- |
|
||||
| `none` | Задача новая, в работе или завершена, но ещё не на review |
|
||||
| `review` | Задача активно на review |
|
||||
| `needsFix` | Запрошены изменения; владелец должен обновить до повторного одобрения |
|
||||
| `approved` | Review принят, задача финализирована |
|
||||
|-----------|---------|
|
||||
| `none` | Задача новая, в работе или завершена, но ещё не на ревью |
|
||||
| `review` | Задача активно на ревью |
|
||||
| `needsFix` | Запрошены правки; владелец должен обновить до повторного approve |
|
||||
| `approved` | Ревью принято, задача финализирована |
|
||||
|
||||
## Agent review workflow
|
||||
## Рабочий процесс ревью агентами
|
||||
|
||||
Команды могут ревьюить работу друг друга до вашего финального решения. Это ловит очевидные регрессии, но risky areas всё равно стоит проверять вручную.
|
||||
|
||||
## Участники review
|
||||
## Участники ревью
|
||||
|
||||
Team lead - reviewer по умолчанию. Вы можете настроить дополнительных reviewers в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
|
||||
Team lead — ревьюер по умолчанию. Вы можете настроить дополнительных ревьюеров в настройках Kanban, если хотите, чтобы peers ревьюили работу друг друга.
|
||||
|
||||
## Что проверять вручную
|
||||
|
||||
Приоритет при review:
|
||||
Приоритетные области при ревью:
|
||||
|
||||
- **Provider auth и runtime detection** — изменил ли агент setup так, что сломались другие пути?
|
||||
- **IPC, preload и filesystem boundaries** — сохраняется ли разделение ответственности в Electron
|
||||
- **Git и worktree behavior** — проверьте naming веток, коммиты и пуши
|
||||
- **Parsing и task lifecycle logic** — изменения task references, chunking или filtering могут сломать доставку сообщений
|
||||
- **Persistence и code review flows** — изменения хранилища задач или review state должны оставаться консистентными через IPC layers
|
||||
- **Provider auth и runtime detection** — не сломает ли агент настройку runtime для других путей?
|
||||
- **IPC, preload и filesystem boundaries** — сохраняйте разделение ответственности Electron
|
||||
- **Git и worktree behavior** — проверяйте имена веток, коммиты и push
|
||||
- **Parsing и task lifecycle logic** — изменения в task references, chunking или filtering могут сломать доставку сообщений
|
||||
- **Persistence и code review flows** — изменения в хранении задач или review state должны оставаться консистентными через IPC layers
|
||||
|
||||
## Verification
|
||||
## Верификация
|
||||
|
||||
Лучше запускать focused verification commands. Broad formatting или lint-fix команды не стоит использовать, если задача явно не про форматирование.
|
||||
|
||||
::: warning Не запускайте автоформатирование по всему проекту
|
||||
Если задача не про форматирование, избегайте `pnpm lint:fix` на нерелевантных файлах. Это создаёт шум в review surface.
|
||||
Если задача не специфически про форматирование, избегайте `pnpm lint:fix` на несвязанных файлах. Это создаёт шум в review surface.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,26 +1,40 @@
|
|||
# Создание команды
|
||||
|
||||
Команда - это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
Команда — это группа агентов с ролями, lead-агентом, целевым проектом и coordination prompt.
|
||||
|
||||
## Первая команда
|
||||
|
||||
Начните с малого:
|
||||
|
||||
| Роль | Задача |
|
||||
| --- | --- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
| Роль | Задача |
|
||||
| -------- | ---------------------------------------------------- |
|
||||
| Lead | Делит работу, создаёт задачи, координирует teammates |
|
||||
| Builder | Реализует scoped tasks |
|
||||
| Reviewer | Проверяет результат, ловит регрессии, просит fixes |
|
||||
|
||||
Такая форма даёт достаточно координации, но не создаёт лишний шум на первом запуске.
|
||||
|
||||
::: tip
|
||||
Команду можно расширить позже. Начните с малого, проверьте workflow, затем масштабируйте.
|
||||
:::
|
||||
|
||||
## Назначение провайдеров и моделей
|
||||
|
||||
Каждый участник команды работает через провайдер-бэкенд. В редакторе команды выберите провайдер (Claude, Codex или OpenCode) и модель для каждого участника. Приложение показывает только провайдеров, которые вы уже авторизовали.
|
||||
|
||||
Микс провайдеров в одной команде поддерживается — например, Claude lead с OpenCode builder-ами.
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке провайдеров, когда будет готова.
|
||||
:::
|
||||
|
||||
## Хороший team brief
|
||||
|
||||
В brief стоит указать:
|
||||
|
||||
- нужный outcome
|
||||
- важные files или feature areas
|
||||
- границы риска, например "не refactor unrelated modules"
|
||||
- границы риска, например «не refactor unrelated modules»
|
||||
- ожидания по review
|
||||
- verification commands, если они известны
|
||||
|
||||
|
|
@ -30,9 +44,39 @@
|
|||
Улучши download flow. Держи изменения внутри landing app, если shared helper явно не нужен. Создай задачи до реализации, проверь diff каждой задачи и запусти landing lint/build checks.
|
||||
```
|
||||
|
||||
## Изоляция через worktree
|
||||
|
||||
Участники на OpenCode могут использовать **изоляцию через worktree** — работать в отдельном Git worktree вместо основного рабочего каталога. Это предотвращает конфликты файлов, когда несколько агентов редактируют один проект.
|
||||
|
||||
::: warning
|
||||
Изоляция через worktree требует Git-репозиторий и пока доступна только для участников на OpenCode.
|
||||
:::
|
||||
|
||||
Чтобы включить, переключите опцию **Worktree isolation** при добавлении или редактировании участника на OpenCode.
|
||||
|
||||
## Уровень автономности
|
||||
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше - для provider auth, IPC, persistence, Git workflows и release tooling.
|
||||
Agent Teams поддерживает разные уровни контроля. Больше автономности подходит для рутинных изменений, меньше — для рискованных областей: provider auth, IPC, персистентность, Git-операции и release tooling.
|
||||
|
||||
### Уровень усилия (effort)
|
||||
|
||||
У каждого участника есть настройка **effort** — она определяет, сколько reasoning провайдер вкладывает перед ответом. Выше effort — тщательнее результат, но больше времени и токенов.
|
||||
|
||||
| Уровень | Когда использовать |
|
||||
| ------- | ------------------------------------------------------------------------- |
|
||||
| Low | Быстрые запросы, мелкие правки форматирования, рутинные изменения |
|
||||
| Medium | По умолчанию для большинства задач по реализации |
|
||||
| High | Сложные рефакторинги, кросс-модульные изменения, рискованные участки кода |
|
||||
|
||||
Приложение предлагает дополнительные уровни (minimal, xhigh, max) для провайдеров, которые их поддерживают. Если модель не поддерживает настройку effort, селектор отключён и используется значение по умолчанию провайдера.
|
||||
|
||||
### Быстрый режим (Fast mode)
|
||||
|
||||
Переключите **Fast mode** для отдельного участника, чтобы приоритизировать скорость над глубиной. Это использует нативный быстрый режим провайдера, когда он доступен. Установите **On** для рутинных задач, **Off** для аккуратной работы или **Inherit**, чтобы следовать командному значению по умолчанию.
|
||||
|
||||
### Ограничение контекста (Limit context)
|
||||
|
||||
Включите **Limit context**, чтобы уменьшить контекстное окно для участника. Это полезно для моделей Claude с расширенным контекстом (например, 1M токенов) — ограничение контекста избегает лишних токенов и улучшает задержку для задач, не требующих большого контекста.
|
||||
|
||||
## Контекст
|
||||
|
||||
|
|
@ -49,3 +93,8 @@ Agent Teams поддерживает разные уровни контроля.
|
|||
|
||||
Если lead создаёт размытые задачи, напишите ему direct message и попросите сделать задачи меньше и проверяемее.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Код-ревью](/ru/guide/code-review) — принять, отклонить или прокомментировать изменения агентов
|
||||
- [Диагностика](/ru/guide/troubleshooting) — частые проблемы и решения
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Agent Teams распространяется как desktop-приложение
|
|||
|
||||
## Готовые сборки
|
||||
|
||||
Берите последний GitHub release:
|
||||
Скачайте приложение на <a href="/ru/download/" target="_self">странице загрузок</a> или из последнего [GitHub release](https://github.com/777genius/agent-teams-ai/releases):
|
||||
|
||||
- macOS Apple Silicon: `.dmg`
|
||||
- macOS Intel: `.dmg`
|
||||
|
|
@ -17,14 +17,27 @@ Agent Teams распространяется как desktop-приложение
|
|||
|
||||
## Требования
|
||||
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication.
|
||||
Пакетная сборка рассчитана на zero-setup onboarding. Приложение само помогает с runtime detection и provider authentication — ручная настройка CLI не нужна.
|
||||
|
||||
Для запуска из исходников:
|
||||
Для работы агентных рантаймов нужен доступ хотя бы к одному провайдеру:
|
||||
|
||||
| Провайдер | Способ доступа |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Claude (Anthropic) | Claude Code CLI login или API key |
|
||||
| Codex (OpenAI) | Codex CLI login или API key |
|
||||
| Gemini (Google) | _В разработке_ |
|
||||
| OpenCode | API key для поддерживаемого бэкенда (например, OpenRouter) |
|
||||
|
||||
::: info
|
||||
Поддержка провайдера Gemini в разработке. Вы можете подготовить доступ сейчас, но он не появится в редакторе команды, пока не будет готов.
|
||||
:::
|
||||
|
||||
Для запуска из исходников также нужны:
|
||||
|
||||
| Инструмент | Версия |
|
||||
| --- | --- |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
| ---------- | ------ |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 10+ |
|
||||
|
||||
## Запуск из исходников
|
||||
|
||||
|
|
@ -37,9 +50,27 @@ pnpm install
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
Если нужна самая свежая локальная версия, используйте ветку репозитория, где сейчас идёт активная разработка.
|
||||
Ветка `main` содержит актуальную стабильную разработку. Переключайтесь на feature-ветки, только если нужна конкретная неопубликованная правка.
|
||||
|
||||
## Обновления
|
||||
## Автообновления
|
||||
|
||||
Для packaged builds берите последний release. Для запуска из исходников подтяните нужную ветку и повторите install, если поменялись зависимости.
|
||||
Пакетная сборка автоматически проверяет обновления при запуске и периодически во время работы. Когда обновление доступно, приложение предложит скачать и установить его. Проверить вручную можно через меню приложения.
|
||||
|
||||
::: tip
|
||||
При запуске из исходников автообновления недоступны. Подтягивайте свежие изменения и запускайте `pnpm install`, если зависимости изменились.
|
||||
:::
|
||||
|
||||
## Обновление из исходников
|
||||
|
||||
Подтяните ветку `main` и повторите install, если поменялись зависимости:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Быстрый старт](/ru/guide/quickstart) — от установки до первой запущенной команды
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
|
||||
|
|
|
|||
|
|
@ -1,50 +1,63 @@
|
|||
# Быстрый старт
|
||||
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды.
|
||||
Этот гайд проводит от свежей установки до первой запущенной команды за несколько минут.
|
||||
|
||||
## 1. Установите Agent Teams
|
||||
|
||||
Скачайте последний релиз под вашу платформу на лендинге или в GitHub releases.
|
||||
Скачайте последний релиз под вашу платформу на <a href="/ru/download/" target="_self">странице загрузок</a> или в [GitHub releases](https://github.com/777genius/agent-teams-ai/releases).
|
||||
|
||||
::: tip
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру, например Claude, Codex, OpenCode или API-key based providers.
|
||||
Приложение бесплатное и с открытым кодом. Выбранный runtime может требовать доступ к провайдеру — подробности в разделе [Установка](/ru/guide/installation).
|
||||
:::
|
||||
|
||||
## 2. Откройте проект
|
||||
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные project files и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
Запустите приложение и выберите директорию проекта, где агенты будут работать. Agent Teams читает локальные файлы проекта и runtime/session state, чтобы показывать задачи, логи, diffs и активность команды.
|
||||
|
||||
## 3. Выберите runtime path
|
||||
::: tip
|
||||
Выберите проект под Git — так вы получите лучший опыт. Изоляция через worktree и ревью по diff зависят от Git.
|
||||
:::
|
||||
|
||||
Стандартные варианты:
|
||||
## 3. Выберите runtime
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| --- | --- |
|
||||
| Claude | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multimodel teams и большого числа provider backends |
|
||||
Мастер настройки автоматически определит установленные рантаймы на вашей машине. Стандартные варианты:
|
||||
|
||||
| Runtime | Когда подходит |
|
||||
| -------- | ------------------------------------------------------------------- |
|
||||
| Claude | Если вы уже используете Claude Code или у вас есть Anthropic access |
|
||||
| Codex | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Для multi-model команд и большого числа provider backends |
|
||||
|
||||
::: info
|
||||
Поддержка Gemini в разработке и появится в списке рантаймов, когда будет готова.
|
||||
:::
|
||||
|
||||
Подробная настройка каждого провайдера — в разделе [Настройка рантайма](/ru/guide/runtime-setup).
|
||||
|
||||
## 4. Создайте первую команду
|
||||
|
||||
Начните с маленькой команды: lead, implementation agent и review-oriented agent. Этого достаточно, чтобы проверить workflow без лишнего шума.
|
||||
|
||||
Рекомендованная структура и советы — в разделе [Создание команды](/ru/guide/create-team).
|
||||
|
||||
## 5. Дайте lead-агенту конкретную цель
|
||||
|
||||
Пишите задачу как инженерному лиду:
|
||||
|
||||
```text
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими refactor.
|
||||
Улучши onboarding flow. Разбей работу на задачи, держи изменения маленькими и проси review перед широкими рефакторингами.
|
||||
```
|
||||
|
||||
Lead должен создать задачи, назначить работу и координировать teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages.
|
||||
Lead создаёт задачи, назначает работу и координирует teammates. Вы следите за прогрессом на канбан-доске и вмешиваетесь через комментарии или direct messages в любой момент.
|
||||
|
||||
## 6. Проверьте результат
|
||||
|
||||
Откройте задачи в review/done, посмотрите diff, примите или отклоните изменения. Если нужно понять мотивацию агента, откройте task logs.
|
||||
|
||||
Полный процесс ревью — в разделе [Код-ревью](/ru/guide/code-review).
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Создание команды](/ru/guide/create-team)
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup)
|
||||
- [Код-ревью](/ru/guide/code-review)
|
||||
|
||||
- [Создание команды](/ru/guide/create-team) — рекомендованные структуры и написание brief
|
||||
- [Настройка рантайма](/ru/guide/runtime-setup) — авторизация провайдеров и выбор моделей
|
||||
- [Код-ревью](/ru/guide/code-review) — ревью, одобрение и запрос правок
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Настройка рантайма
|
||||
|
||||
Agent Teams - coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
Agent Teams — coordination layer. Model work выполняется через локальные runtimes и providers.
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
|
|
@ -17,16 +17,16 @@ Agent Teams - coordination layer. Model work выполняется через
|
|||
## Поддерживаемые пути
|
||||
|
||||
| Путь | CLI по умолчанию | Типичные провайдеры | Когда использовать |
|
||||
| --- | --- | --- | --- |
|
||||
|------|-------------------|---------------------|-------------------|
|
||||
| Claude | `claude` | Anthropic | Если вы уже используете Claude Code или Anthropic access |
|
||||
| Codex | `codex` | OpenAI | Для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | `opencode` | OpenRouter и многие другие | Для multimodel routing и широкой provider coverage |
|
||||
|
||||
Приложение по возможности определяет доступные runtimes и ведёт настройку через UI.
|
||||
|
||||
## Provider access
|
||||
## Доступ к провайдеру
|
||||
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: subscription, local runtime auth или API keys в зависимости от выбранного пути.
|
||||
У Agent Teams нет своего платного тарифа. Вы используете доступ к провайдеру, который у вас уже есть: подписка, локальная авторизация рантайма или API-ключи в зависимости от выбранного пути.
|
||||
|
||||
- Для **Claude** и **Codex** используется auth соответствующего CLI.
|
||||
- Для **OpenCode** требуются provider-specific API keys в файле конфигурации (например, `openrouter`, `openai`, `anthropic`).
|
||||
|
|
@ -71,9 +71,9 @@ codex login
|
|||
|
||||
Используйте точное имя провайдера, которое ожидает OpenCode. Если вы используете кастомное имя, убедитесь, что оно совпадает с provider ID в строке модели (например, `openrouter/moonshotai/kimi-k2.6` использует блок `openrouter`).
|
||||
|
||||
## Multimodel mode
|
||||
## Multimodel-режим
|
||||
|
||||
Multimodel mode может направлять работу через разные provider backends в OpenCode-compatible конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
Multimodel-режим может направлять работу через разные provider backends в OpenCode-совместимой конфигурации. Используйте его, когда нужна гибкость провайдеров или разные model lanes для teammates.
|
||||
|
||||
::: info Model lanes
|
||||
Каждый teammate может использовать свою пару `providerId` + `model`. В UI редактирования команды разверните опции member, чтобы переопределить глобальные значения.
|
||||
|
|
|
|||
|
|
@ -1,105 +1,113 @@
|
|||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в пять групп: runtime setup, launch confirmation, task parsing, provider limits и review state gaps.
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
||||
|
||||
## Команда не запускается
|
||||
|
||||
Проверьте по порядку:
|
||||
Проверьте:
|
||||
|
||||
1. **Runtime доступен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
|
||||
2. **PATH reachable** — binary доступен в environment `PATH`
|
||||
3. **Доступ к модели** — у провайдера есть доступ к запрошенной строке модели (особенно для OpenCode точные имена провайдера/модели важны)
|
||||
4. **Путь к проекту** — директория проекта существует и доступна для чтения
|
||||
5. **Network / VPN** — некоторые провайдеры режут трафик при активном VPN
|
||||
- Выбранный runtime установлен или авторизован
|
||||
- Runtime доступен в environment `PATH`
|
||||
- У провайдера есть доступ к нужной модели
|
||||
- Project path существует и читается
|
||||
|
||||
### OpenCode: registered, но bootstrap не подтверждён
|
||||
::: tip
|
||||
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
|
||||
:::
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала смотрите артефакты, а не меняйте team prompts.
|
||||
### OpenCode: bootstrap не подтверждён
|
||||
|
||||
Посмотрите на свежий artifact неудачного запуска:
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён:
|
||||
|
||||
```bash
|
||||
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||
```
|
||||
1. Откройте launch logs в UI.
|
||||
2. Проверьте `~/.claude/teams/<team>/launch-state.json` — состояние member.
|
||||
3. Посмотрите `~/.claude/teams/<team>/.opencode-runtime/lanes/<lane-id>/manifest.json` на наличие evidence.
|
||||
4. Не меняйте team prompts, пока не убедитесь, что lane стартовал, но не смог закоммитить evidence.
|
||||
|
||||
Манифест внутри включает:
|
||||
|
||||
- `classification` — почему запуск считался неудачным
|
||||
- `bootstrapTransportBreadcrumb` — использованный путь доставки
|
||||
- Статусы spawn members
|
||||
- Редактированные логи и traces
|
||||
|
||||
Также проверьте lane manifest:
|
||||
|
||||
```bash
|
||||
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||
```
|
||||
|
||||
::: tip Не гадайте по UI
|
||||
Всегда коррелируйте UI-диагностику с persisted файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-specific evidence.
|
||||
::: warning
|
||||
Отсутствие OpenCode inbox во время primary launch — норма. Secondary lanes стартуют после готовности primary filesystem. Не считайте primary hang багом OpenCode, пока UI явно не показывает, что `Y` членов ждёт и `Y` некорректно включает OpenCode lanes.
|
||||
:::
|
||||
|
||||
## Не видны ответы агента
|
||||
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
|
||||
|
||||
- **Runtime delivery retry** — агент мог ответить, но сообщение не было доставлено в приложение. Проверьте delivery ledger.
|
||||
- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
|
||||
- **Task attribution** — работа выполнялась в сессии, но не была привязана к задаче, так как в выводе отсутствовал корректный task id.
|
||||
- Runtime delivery gaps
|
||||
- Parsing или task filtering issues
|
||||
- Агент всё ещё обрабатывает (большие задачи могут занимать минуты)
|
||||
|
||||
::: warning Не считайте молчание игнорированием
|
||||
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
|
||||
::: tip
|
||||
Для OpenCode teammates проверьте, что вызван `agent-teams_message_send` с правильными `from`, `to` и `taskRefs`. Ответы OpenCode должны отправляться через MCP tools, а не обычным текстом.
|
||||
:::
|
||||
|
||||
## Changes не связаны с tasks
|
||||
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached, проверьте, был ли task id или task reference в output агента.
|
||||
Используйте task-specific logs и code review links. Если diff выглядит detached:
|
||||
|
||||
Для OpenCode teammates авторитетным доказательством принадлежности сессии задаче является `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
|
||||
- Проверьте, был ли task id или task reference в output агента.
|
||||
- Убедитесь, что агент вызвал `task_add_comment` перед правками.
|
||||
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
|
||||
| Поведение провайдера | Рекомендуемое действие |
|
||||
| --- | --- |
|
||||
| Показан known reset time | Дождитесь cooldown и продолжите |
|
||||
| Reset time неизвестен | Смените провайдера или runtime path |
|
||||
| Повторяющиеся 429 | Снизьте concurrency или используйте другой model lane |
|
||||
## Распространённые состояния member
|
||||
|
||||
## Проблемы CLI auth
|
||||
| Состояние | Значение |
|
||||
|-----------|---------|
|
||||
| `confirmed_alive` + `bootstrapConfirmed` | Здоров и готов к работе |
|
||||
| `registered` / `runtime_pending_bootstrap` | Процесс или lane существует, но bootstrap proof ещё не закоммичен |
|
||||
| `failed_to_start` + `runtime_process` | Процесс есть, но launch gate не прошёл. Смотрите diagnostics |
|
||||
| `failed_to_start` + `stale_metadata` | Сохранённый pid/session устарел или мёртв |
|
||||
|
||||
### `claude login` не сохраняется
|
||||
::: warning
|
||||
`member_briefing` сам по себе НЕ является runtime evidence. Для OpenCode авторитетным доказательством служит committed runtime evidence, такая как `opencode-sessions.json` и запись в manifest.
|
||||
:::
|
||||
|
||||
Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён в ожидаемый config path и что процесс приложения видит тот же `$HOME`.
|
||||
## Режим отладки рантайма
|
||||
|
||||
### OpenCode provider key отклонён
|
||||
Для локальной отладки можно принудительно запускать teammates в tmux-панелях:
|
||||
|
||||
- Дважды проверьте, что имя провайдера в `config.json` совпадает с префиксом в строке модели
|
||||
- Убедитесь, что ключ не истёк и не отозван в dashboard провайдера
|
||||
```bash
|
||||
# Запуск из терминала
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
|
||||
## Lane bootstrap завис
|
||||
# Или добавьте в custom CLI args
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Для OpenCode secondary lanes:
|
||||
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
|
||||
|
||||
- Отсутствие `inboxes/<member>.json` — не автоматически баг. OpenCode lanes не обязаны быть primary-inbox-created перед стартом.
|
||||
- Если UI показывает, что команда всё ещё запускается, а primary members уже usable, "all teammates joined" ждёт secondary lanes.
|
||||
- Если `Prepared communication channels for X/Y members` зависло, проверьте, что `Y` некорректно включает secondary OpenCode members.
|
||||
## CLI auth diagnostic
|
||||
|
||||
### Пустые entries в lane manifest
|
||||
Каждый запуск `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
||||
|
||||
Если bridge говорит, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Member не должен считаться deliverable до тех пор, пока не существуют `opencode-sessions.json` и его запись в manifest.
|
||||
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
||||
|
||||
## Безопасная очистка
|
||||
|
||||
При очистке stale processes:
|
||||
|
||||
1. Определите pid и убедитесь, что он принадлежит текущей команде/lane.
|
||||
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
|
||||
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
|
||||
|
||||
## Какие данные собрать
|
||||
|
||||
Прежде чем обращаться за помощью, соберите:
|
||||
Соберите:
|
||||
|
||||
- Task id (короткий или полный)
|
||||
- Team name
|
||||
- Runtime path (`claude`, `codex` или `opencode`)
|
||||
- Excerpt launch logs (из `latest.json` или `bootstrap-journal.jsonl`)
|
||||
- Provider / model string
|
||||
- Точный time window, когда произошла проблема
|
||||
- task id
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- точный time window
|
||||
|
||||
Этих данных обычно достаточно для диагностики launch и task lifecycle issues.
|
||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||
|
||||
::: tip
|
||||
Если проблема не устраняется, откройте persisted files команды под `~/.claude/teams/<teamName>/` и сопоставьте UI diagnostics с live process state, прежде чем менять код.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ features:
|
|||
link: /ru/guide/code-review
|
||||
linkText: Ревью изменений
|
||||
- icon: "04"
|
||||
title: Runtime-aware setup
|
||||
title: Настройка рантайма
|
||||
details: Используйте Claude, Codex, OpenCode или multimodel-провайдеры через доступ, который у вас уже есть.
|
||||
link: /ru/guide/runtime-setup
|
||||
linkText: Настроить рантаймы
|
||||
|
|
|
|||
|
|
@ -1,32 +1,75 @@
|
|||
# Концепции
|
||||
|
||||
Основные термины Agent Teams.
|
||||
Основные термины Agent Teams. Эта страница задаёт общий словарь для приложения, доски задач, сообщений и review flow.
|
||||
|
||||
## Team
|
||||
|
||||
Team - группа агентов, настроенная для проекта. Обычно есть lead и один или несколько teammates со специализированными ролями.
|
||||
Team - именованная группа агентов, привязанная к одному project path. У команды есть lead, опциональные teammates, настройки runtime/provider, prompts, inboxes, tasks и локальное состояние запуска.
|
||||
|
||||
## Lead
|
||||
|
||||
Lead координирует работу: разбивает цель на tasks, назначает teammates, отслеживает blockers и просит review.
|
||||
Lead - координатор команды. Он превращает цель пользователя в tasks, назначает или перенаправляет teammates, отслеживает blockers, запрашивает review и двигает работу по board.
|
||||
|
||||
Сообщения lead доставляются иначе, чем сообщения teammate: приложение ретранслирует записи inbox в lead runtime, а teammates читают свои inbox-файлы между turns.
|
||||
|
||||
## Teammate
|
||||
|
||||
Teammate - не-lead агент в команде. Обычно teammate отвечает за сфокусированную роль: builder, reviewer, researcher или tester. Teammate может получать direct messages, task assignments, task comments и review requests.
|
||||
|
||||
## Task
|
||||
|
||||
Task - устойчивая единица работы. У неё есть status, description, comments, logs, attachments и reviewable changes.
|
||||
Task - долговечная единица работы. У неё есть id, status, owner, description, comments, logs, attachments, task references и reviewable changes.
|
||||
|
||||
## Solo mode
|
||||
Типичные состояния task: `todo`, `in_progress`, `done`, `review`, `approved`. Файл task хранит рабочее состояние, а review/approval позиция может дополнительно храниться в kanban overlay state.
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для маленьких задач, меньшего token usage и проверки prompt перед расширением до команды.
|
||||
## Kanban
|
||||
|
||||
## Cross-team communication
|
||||
Kanban - board view для командной работы. Он помогает смотреть tasks по состояниям, открывать детали, читать logs, ревьюить diffs, approve finished work или request changes.
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы.
|
||||
## Inbox
|
||||
|
||||
## Autonomy level
|
||||
Inbox - локальный message-файл участника команды. Agent Teams использует inboxes для user messages, lead messages, teammate messages, runtime delivery metadata, cross-team messages и части system notifications.
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше автономности быстрее, меньше - безопаснее для sensitive code paths.
|
||||
Messages - долговечные локальные записи. Но доставка всё равно зависит от того, жив ли выбранный runtime и сможет ли он обработать следующий turn.
|
||||
|
||||
## Agent Block
|
||||
|
||||
Agent Block - скрытый agent-only instruction text, обёрнутый в `<info_for_agent>...</info_for_agent>`. UI убирает такие блоки из обычного human-facing display, но agents и runtime delivery могут использовать их для coordination details.
|
||||
|
||||
Текущий canonical marker - `info_for_agent`; в старых документах могут встречаться legacy agent block formats.
|
||||
|
||||
## Context Phase
|
||||
|
||||
Context Phase - сегмент session context timeline. Compaction начинает новую phase, поэтому token/context usage можно анализировать до и после reset.
|
||||
|
||||
Context tracking разделяет категории: project instructions, mentioned files, tool output, thinking text, team coordination и user messages. Эти числа нужны для диагностики, а не как provider billing statement.
|
||||
|
||||
## Runtime
|
||||
|
||||
Runtime - локальный execution path, который соединяет Agent Teams с model/provider workflow, например Claude, Codex или OpenCode.
|
||||
Runtime - локальный execution path, который выполняет agent turn. Поддерживаемые runtime paths: Claude Code, Codex и OpenCode.
|
||||
|
||||
Runtime отвечает за model execution behavior, auth details, tool execution semantics, rate limits, model availability и часть transcript/log formats.
|
||||
|
||||
## Provider
|
||||
|
||||
Provider - путь доступа к модели за runtime. Текущие provider ids: Anthropic, Codex, Gemini и OpenCode. OpenCode может маршрутизировать к множеству model providers через собственную конфигурацию.
|
||||
|
||||
Agent Teams orchestrates tasks and messages, но не заменяет provider authentication или provider policy.
|
||||
|
||||
## Solo mode
|
||||
|
||||
Solo mode запускает команду из одного агента. Полезно для небольших задач, меньшего coordination overhead и проверки prompt перед расширением до команды.
|
||||
|
||||
## Cross-team communication
|
||||
|
||||
Агенты могут писать внутри и между командами. Это нужно, когда разные teams владеют связанными частями работы, но их не хочется объединять в одну большую команду.
|
||||
|
||||
## Autonomy level
|
||||
|
||||
Autonomy определяет, сколько агент может делать до запроса подтверждения. Больше autonomy быстрее, меньше - безопаснее для sensitive code paths, persistence, provider auth, Git operations и releases.
|
||||
|
||||
## Review
|
||||
|
||||
Review - task-scoped acceptance flow. Task может перейти в review, получить comments или requested changes, а затем перейти в approved, когда результат принят.
|
||||
|
||||
Review привязан к local diffs и task history, поэтому лучше работает с узкими tasks и явным упоминанием task, над которой агент работает.
|
||||
|
|
|
|||
|
|
@ -4,26 +4,62 @@
|
|||
|
||||
Да. Приложение бесплатное и open source. Provider или runtime access может стоить денег в зависимости от выбранного пути.
|
||||
|
||||
## Нужно ли заранее ставить Claude или Codex?
|
||||
## Agent Teams включает доступ к моделям?
|
||||
|
||||
Нет. Agent Teams - локальный orchestration и UI layer. Model access приходит через выбранный runtime/provider path, например Claude Code, Codex или OpenCode.
|
||||
|
||||
## Какие runtimes поддерживаются?
|
||||
|
||||
Поддерживаемые runtime paths: Claude Code, Codex и OpenCode. App также отслеживает provider ids вроде Anthropic, Codex, Gemini и OpenCode, когда runtime их отдаёт.
|
||||
|
||||
## Нужно ли заранее ставить Claude Code или Codex?
|
||||
|
||||
Не всегда. Приложение ведёт runtime detection и setup через UI. Некоторые пути всё равно требуют внешнюю авторизацию runtime.
|
||||
|
||||
OpenCode setup отделён от Claude Code и Codex setup. Если launch fails, сначала проверьте runtime status и provider auth, а не меняйте team prompt.
|
||||
|
||||
## Приложение загружает мой код на серверы Agent Teams?
|
||||
|
||||
Нет. Agent Teams не является cloud code-sync сервисом. Но provider-backed model calls могут получать prompt context в зависимости от выбранного runtime.
|
||||
|
||||
## Где хранятся team files?
|
||||
|
||||
Team coordination data хранится локально в `~/.claude/teams/<team>/`, task files - в `~/.claude/tasks/<team>/`, а project session data - в `~/.claude/projects/<encoded-project>/`, когда она доступна.
|
||||
|
||||
## Что может выйти с моей машины?
|
||||
|
||||
Prompt context, selected file contents, tool results, command output, task text, comments и attachments могут уйти через runtime/provider path, когда агент использует provider-backed model. Точное поведение зависит от runtime и provider.
|
||||
|
||||
## Агенты могут общаться друг с другом?
|
||||
|
||||
Да. Агенты могут писать teammates, комментировать tasks и координироваться между teams.
|
||||
Да. Агенты могут писать teammates, комментировать tasks, координироваться между teams и использовать task references, чтобы разговор оставался привязанным к работе.
|
||||
|
||||
## Можно ревьюить код перед принятием?
|
||||
|
||||
Да. Review flow построен вокруг task-scoped diffs и hunk-level decisions.
|
||||
|
||||
## Что такое Agent Block?
|
||||
|
||||
Agent Block - скрытый agent-only text в маркерах вроде `<info_for_agent>...</info_for_agent>`. App убирает его из обычного user-facing display, но сохраняет для agent coordination.
|
||||
|
||||
## Что такое solo mode?
|
||||
|
||||
Solo mode - команда из одного агента. Подходит для небольших задач и меньшего coordination overhead.
|
||||
|
||||
## Могут ли разные teammates использовать разных providers?
|
||||
|
||||
Да, provider/model settings могут задаваться per team member, если выбранный runtime path это поддерживает. OpenCode - основной путь для широкой multi-provider routing.
|
||||
|
||||
## Почему task может быть review или approved отдельно от done?
|
||||
|
||||
Work state и review state связаны, но не идентичны. Task может быть done с точки зрения агента, а затем пройти review и approval в kanban UI.
|
||||
|
||||
## Что делать, если launch завис?
|
||||
|
||||
Откройте диагностику, соберите runtime logs и проверьте provider auth до изменения prompts.
|
||||
Откройте troubleshooting, соберите launch diagnostics, проверьте `~/.claude/teams/<team>/` и runtime/provider auth до изменения prompts.
|
||||
|
||||
Для OpenCode проверьте lane/session evidence, прежде чем считать, что teammate online, но игнорирует messages.
|
||||
|
||||
## Почему logs отличаются между runtimes?
|
||||
|
||||
Claude Code, Codex и OpenCode отдают разные transcript formats и runtime evidence. Agent Teams нормализует то, что может, но log completeness и attribution могут отличаться по runtime.
|
||||
|
|
|
|||
|
|
@ -1,30 +1,56 @@
|
|||
# Приватность и локальные данные
|
||||
|
||||
Agent Teams local-first, но выбранный provider path всё равно важен.
|
||||
Agent Teams local-first, но выбранный runtime/provider path всё равно важен. Эта страница описывает, что desktop app хранит локально и что может покинуть машину, когда agents вызывают provider-backed models.
|
||||
|
||||
## Что остаётся локально
|
||||
|
||||
Desktop app работает на вашей машине и читает локальные project/runtime data для UI:
|
||||
Desktop app работает на вашей машине и читает local project/runtime data для UI. Обычно локально есть:
|
||||
|
||||
- project files
|
||||
- task metadata
|
||||
- team configuration и member metadata
|
||||
- task metadata, task comments и task references
|
||||
- inbox messages
|
||||
- runtime/session logs
|
||||
- launch state и bootstrap diagnostics
|
||||
- review state
|
||||
- local app settings
|
||||
|
||||
Важные local locations:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `~/.claude/teams/<team>/` | Team config, member metadata, inboxes, launch state, bootstrap evidence, runtime diagnostics, sent-message records, kanban state и review-related team files. |
|
||||
| `~/.claude/tasks/<team>/` | Durable task JSON files для team board. |
|
||||
| `~/.claude/projects/<encoded-project>/` | Claude/Codex-style project session files для session history, context analysis и transcript-backed UI. |
|
||||
|
||||
Точные файлы зависят от runtime и версии app. Для launch debugging самые свежие evidence обычно лежат в соответствующей папке `~/.claude/teams/<team>/`.
|
||||
|
||||
## Что может выйти с машины
|
||||
|
||||
Когда агент обращается к provider-backed model, prompt context и tool results могут отправляться через выбранный provider/runtime path. Это зависит от runtime и provider.
|
||||
Agent Teams сам по себе не является cloud code-sync сервисом для репозитория. Ему не нужно загружать весь project на Agent Teams server, чтобы показывать board, inbox, logs или review UI.
|
||||
|
||||
Но когда агент обращается к provider-backed model, prompt context, selected file contents, task text, comments, tool results, command output и другой runtime-provided context могут отправляться через выбранный runtime/provider path. Что именно отправится, зависит от runtime, model, tool calls, prompt и provider configuration.
|
||||
|
||||
Provider authentication, provider-side retention, training, logging, regional processing и billing регулируются выбранным provider/runtime. Для sensitive projects проверяйте их policies.
|
||||
|
||||
## Чего app не гарантирует
|
||||
|
||||
- App не может гарантировать, что provider-backed model calls никогда не получат private code.
|
||||
- App не может переопределить provider retention или billing policies.
|
||||
- App не может сделать remote provider полностью local model.
|
||||
- App не защитит secrets, если агенту поручили вставить их в prompts, task comments, files или commands.
|
||||
- App не может заставить все runtimes отдавать одинаковый transcript или audit detail.
|
||||
|
||||
## Практические правила
|
||||
|
||||
- Не прикладывайте secrets к tasks.
|
||||
- Не прикладывайте secrets к tasks, comments или direct messages.
|
||||
- Проверяйте provider policies для sensitive projects.
|
||||
- Используйте меньшую autonomy для risky repositories.
|
||||
- Держите task scope узким при работе с private code.
|
||||
- Для диагностики опирайтесь на local evidence и logs.
|
||||
- Проверяйте generated prompts, task descriptions и attached files перед работой с confidential material.
|
||||
- Выбирайте provider/model paths, которые соответствуют вашим privacy requirements.
|
||||
|
||||
## Open source
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking и review flows.
|
||||
|
||||
Само приложение open source и бесплатное. В репозитории можно посмотреть, как устроены local orchestration, task tracking, inboxes, runtime diagnostics и review flows.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Провайдеры и рантаймы
|
||||
|
||||
Agent Teams отделяет orchestration от model access.
|
||||
Agent Teams отделяет orchestration от model access. Приложение управляет teams, tasks, messages, launch state и review UI; выбранный runtime/provider path выполняет реальную model work.
|
||||
|
||||
## Что даёт приложение
|
||||
|
||||
|
|
@ -12,6 +12,8 @@ Agent Teams даёт:
|
|||
- task logs
|
||||
- review UI
|
||||
- local project integration
|
||||
- runtime detection и capability checks
|
||||
- local logs и diagnostics
|
||||
|
||||
## Что даёт runtime
|
||||
|
||||
|
|
@ -21,20 +23,52 @@ Runtime отвечает за:
|
|||
- provider authentication
|
||||
- tool execution behavior
|
||||
- rate limits и capabilities конкретной модели
|
||||
- runtime-specific transcripts и delivery evidence
|
||||
|
||||
## Частые варианты
|
||||
## Поддерживаемые runtime paths
|
||||
|
||||
| Runtime | Заметки |
|
||||
| Runtime path | Provider/model path | Когда подходит | Заметки |
|
||||
| --- | --- |
|
||||
| Claude | Хорошо для Claude Code users и Anthropic access |
|
||||
| Codex | Хорошо для Codex-native workflows и OpenAI access |
|
||||
| OpenCode | Хорошо для multimodel routing и широкой provider coverage |
|
||||
| Claude Code | Anthropic / Claude models | Для Claude Code users и Anthropic-backed workflows | Базовый local-first путь для Claude teams. Нужен локально доступный runtime и account access. |
|
||||
| Codex | Codex / OpenAI-backed models | Для Codex-native workflows | Использует Codex runtime integration и Codex auth/account state, когда они доступны. Часть diagnostics отличается от Claude transcripts. |
|
||||
| OpenCode | OpenCode-managed model routing | Для multi-provider teams и широкой model coverage | OpenCode может маршрутизировать через множество model providers. Agent Teams считает OpenCode lanes runtime-specific evidence и не угадывает attribution при ambiguous lane identity. |
|
||||
|
||||
## Provider ids
|
||||
|
||||
В team/runtime configuration приложение сейчас распознаёт такие provider ids:
|
||||
|
||||
| Provider id | Смысл |
|
||||
| --- | --- |
|
||||
| `anthropic` | Anthropic / Claude Code path |
|
||||
| `codex` | Codex path |
|
||||
| `gemini` | Gemini provider path, когда его отдаёт runtime |
|
||||
| `opencode` | OpenCode path, включая OpenCode-managed provider routing |
|
||||
|
||||
Эта таблица не гарантирует, что каждый provider authenticated, installed или доступен для каждой модели на каждой машине. Runtime status и capability checks - source of truth для конкретного launch.
|
||||
|
||||
## Multi-provider strategy
|
||||
|
||||
Agent Teams остаётся provider-aware, но не provider-owned:
|
||||
|
||||
- teams, tasks, inboxes, comments, review state и launch diagnostics хранятся в local Agent Teams storage
|
||||
- каждый member может нести provider/model settings через team launch metadata
|
||||
- model availability, auth, rate limits и tool behavior остаются ответственностью runtime/provider
|
||||
- OpenCode - основной путь, когда одной team нужны разные provider/model lanes
|
||||
|
||||
## Стоимость providers
|
||||
|
||||
Agent Teams бесплатен. Стоимость provider usage зависит от выбранного runtime/provider.
|
||||
Agent Teams бесплатен и open source. Provider usage зависит от выбранного runtime/provider: subscription limits, API keys, account auth, rate limits и provider policies остаются внешними для приложения.
|
||||
|
||||
## Capability checks
|
||||
|
||||
Во время setup приложение может выполнять access и capability checks. Это помогает найти отсутствующую авторизацию до того, как team launch застрянет в provisioning.
|
||||
|
||||
Capability checks могут показать, что provider существует, но не authenticated; model list недоступен; runtime path отсутствует; или конкретная extension capability unsupported. Считайте это setup diagnostics, а не task failures.
|
||||
|
||||
## Ожидаемые ограничения
|
||||
|
||||
- Runtime support не означает одинаковый feature parity для Claude Code, Codex и OpenCode.
|
||||
- Log и transcript coverage отличаются по runtime.
|
||||
- Для OpenCode lanes нужна стабильная lane/session evidence, прежде чем app сможет безопасно attribute runtime logs.
|
||||
- Provider model names и availability могут меняться вне приложения.
|
||||
- Team prompt не исправит missing auth, missing PATH entries, provider outages или exhausted rate limits.
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ function asRateLimits(
|
|||
};
|
||||
}
|
||||
|
||||
function hasVisibleRateLimitData(snapshot: CodexRateLimitSnapshotDto | null): boolean {
|
||||
return Boolean(snapshot?.primary || snapshot?.secondary || snapshot?.credits);
|
||||
}
|
||||
|
||||
function createRuntimeContext(
|
||||
binaryPath: string | null | undefined,
|
||||
codexHome: string | null | undefined
|
||||
|
|
@ -507,6 +511,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
const shouldRequestRateLimits =
|
||||
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||
let rateLimitsReadFailure: unknown | null = null;
|
||||
let rateLimitsReadReturnedEmpty = false;
|
||||
|
||||
try {
|
||||
const accountResult = await this.appServerClient.readAccountSnapshot({
|
||||
|
|
@ -542,11 +547,18 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
};
|
||||
}
|
||||
if (accountResult.rateLimits?.ok) {
|
||||
this.lastKnownRateLimits = {
|
||||
payload: accountResult.rateLimits.payload,
|
||||
observedAt: now,
|
||||
accountSignature: getCodexAccountSignature(accountResult.account.account),
|
||||
};
|
||||
const nextRateLimits = asRateLimits(accountResult.rateLimits.payload.rateLimits);
|
||||
if (hasVisibleRateLimitData(nextRateLimits)) {
|
||||
this.lastKnownRateLimits = {
|
||||
payload: accountResult.rateLimits.payload,
|
||||
observedAt: now,
|
||||
accountSignature:
|
||||
getCodexAccountSignature(accountResult.account.account) ??
|
||||
getCodexAccountSignature(accountPayload?.account ?? null),
|
||||
};
|
||||
} else {
|
||||
rateLimitsReadReturnedEmpty = true;
|
||||
}
|
||||
} else if (accountResult.rateLimits) {
|
||||
rateLimitsReadFailure = accountResult.rateLimits.error;
|
||||
}
|
||||
|
|
@ -587,13 +599,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
if (shouldLoadRateLimits) {
|
||||
if (this.hasFreshRateLimits(now) && reusableLastKnownRateLimits) {
|
||||
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
|
||||
} else if (rateLimitsReadFailure) {
|
||||
this.logger.warn('codex account rate limits refresh failed', {
|
||||
error:
|
||||
rateLimitsReadFailure instanceof Error
|
||||
? rateLimitsReadFailure.message
|
||||
: String(rateLimitsReadFailure),
|
||||
});
|
||||
} else if (rateLimitsReadFailure || rateLimitsReadReturnedEmpty) {
|
||||
if (rateLimitsReadFailure) {
|
||||
this.logger.warn('codex account rate limits refresh failed', {
|
||||
error:
|
||||
rateLimitsReadFailure instanceof Error
|
||||
? rateLimitsReadFailure.message
|
||||
: String(rateLimitsReadFailure),
|
||||
});
|
||||
}
|
||||
if (reusableLastKnownRateLimits) {
|
||||
rateLimits = asRateLimits(reusableLastKnownRateLimits.payload.rateLimits);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
}): {
|
||||
snapshot: CodexAccountSnapshotDto | null;
|
||||
loading: boolean;
|
||||
rateLimitsLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: (options?: {
|
||||
includeRateLimits?: boolean;
|
||||
|
|
@ -57,6 +58,7 @@ export function useCodexAccountSnapshot(options: {
|
|||
const electronMode = isElectronMode();
|
||||
const [snapshot, setSnapshot] = useState<CodexAccountSnapshotDto | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rateLimitsLoading, setRateLimitsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(() => isDocumentVisible());
|
||||
const lastUpdatedAtRef = useRef<number | null>(null);
|
||||
|
|
@ -78,13 +80,17 @@ export function useCodexAccountSnapshot(options: {
|
|||
}
|
||||
|
||||
const silent = refreshOptions?.silent === true;
|
||||
const includeRateLimits = refreshOptions?.includeRateLimits ?? options.includeRateLimits;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
if (includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
try {
|
||||
const nextSnapshot = await api.refreshCodexAccountSnapshot({
|
||||
includeRateLimits: refreshOptions?.includeRateLimits ?? options.includeRateLimits,
|
||||
includeRateLimits,
|
||||
forceRefreshToken: refreshOptions?.forceRefreshToken,
|
||||
});
|
||||
applySnapshot(nextSnapshot);
|
||||
|
|
@ -98,6 +104,9 @@ export function useCodexAccountSnapshot(options: {
|
|||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
if (includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applySnapshot, electronMode, options.enabled, options.includeRateLimits]
|
||||
|
|
@ -109,6 +118,9 @@ export function useCodexAccountSnapshot(options: {
|
|||
}
|
||||
|
||||
setLoading(true);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const initialSnapshotRequest = options.includeRateLimits
|
||||
|
|
@ -126,6 +138,9 @@ export function useCodexAccountSnapshot(options: {
|
|||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
if (options.includeRateLimits) {
|
||||
setRateLimitsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribe = api.onCodexAccountSnapshotChanged((_event, nextSnapshot) => {
|
||||
|
|
@ -224,12 +239,13 @@ export function useCodexAccountSnapshot(options: {
|
|||
() => ({
|
||||
snapshot,
|
||||
loading,
|
||||
rateLimitsLoading,
|
||||
error,
|
||||
refresh,
|
||||
startChatgptLogin: (mode) => runAction(() => api.startCodexChatgptLogin({ mode })),
|
||||
cancelChatgptLogin: () => runAction(() => api.cancelCodexChatgptLogin()),
|
||||
logout: () => runAction(() => api.logoutCodexAccount()),
|
||||
}),
|
||||
[error, loading, refresh, runAction, snapshot]
|
||||
[error, loading, rateLimitsLoading, refresh, runAction, snapshot]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { FolderGit2, FolderOpen, GitBranch, Terminal } from 'lucide-react';
|
||||
|
|
@ -33,10 +34,7 @@ export const RecentProjectCard = ({
|
|||
}}
|
||||
>
|
||||
{card.activeTeams && card.activeTeams.length > 0 && (
|
||||
<span className="absolute right-3 top-3 inline-flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<ActivePulseIndicator className="absolute right-3 top-3" />
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRunningTeamsDashboard,
|
||||
type RunningTeamCandidate,
|
||||
} from '../policies/buildRunningTeamsDashboard';
|
||||
|
||||
function candidate(overrides: Partial<RunningTeamCandidate>): RunningTeamCandidate {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
displayName: 'Team A',
|
||||
projectPath: '/workspace/a',
|
||||
lastActivity: null,
|
||||
status: 'offline',
|
||||
taskCounts: { pending: 0, inProgress: 0, completed: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildRunningTeamsDashboard', () => {
|
||||
it('keeps only active, running, and provisioning teams', () => {
|
||||
const result = buildRunningTeamsDashboard({
|
||||
teams: [
|
||||
candidate({ teamName: 'active', displayName: 'Active', status: 'active' }),
|
||||
candidate({ teamName: 'idle', displayName: 'Idle', status: 'idle' }),
|
||||
candidate({ teamName: 'launching', displayName: 'Launching', status: 'provisioning' }),
|
||||
candidate({ teamName: 'offline', displayName: 'Offline', status: 'offline' }),
|
||||
candidate({ teamName: 'failed', displayName: 'Failed', status: 'partial_failure' }),
|
||||
candidate({ teamName: 'pending', displayName: 'Pending', status: 'partial_pending' }),
|
||||
candidate({ teamName: 'skipped', displayName: 'Skipped', status: 'partial_skipped' }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.map((team) => team.teamName)).toEqual(['active', 'launching', 'idle']);
|
||||
});
|
||||
|
||||
it('merges synthetic provisioning teams and sorts by status, work, activity, then name', () => {
|
||||
const result = buildRunningTeamsDashboard({
|
||||
teams: [
|
||||
candidate({
|
||||
teamName: 'active-low',
|
||||
displayName: 'Active Low',
|
||||
status: 'active',
|
||||
lastActivity: '2026-05-01T00:00:00.000Z',
|
||||
taskCounts: { pending: 0, inProgress: 1, completed: 0 },
|
||||
}),
|
||||
candidate({
|
||||
teamName: 'active-high',
|
||||
displayName: 'Active High',
|
||||
status: 'active',
|
||||
lastActivity: '2026-04-01T00:00:00.000Z',
|
||||
taskCounts: { pending: 0, inProgress: 3, completed: 0 },
|
||||
}),
|
||||
candidate({
|
||||
teamName: 'idle-new',
|
||||
displayName: 'Idle New',
|
||||
status: 'idle',
|
||||
lastActivity: '2026-05-03T00:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
provisioningTeams: [
|
||||
candidate({
|
||||
teamName: 'launching',
|
||||
displayName: 'Launching',
|
||||
status: 'provisioning',
|
||||
lastActivity: '2026-05-04T00:00:00.000Z',
|
||||
taskCounts: { pending: 0, inProgress: 9, completed: 0 },
|
||||
}),
|
||||
candidate({
|
||||
teamName: 'active-low',
|
||||
displayName: 'Duplicate Active Low',
|
||||
status: 'provisioning',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.map((team) => team.teamName)).toEqual([
|
||||
'active-high',
|
||||
'active-low',
|
||||
'launching',
|
||||
'idle-new',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
export type RunningTeamsCandidateStatus =
|
||||
| 'active'
|
||||
| 'idle'
|
||||
| 'provisioning'
|
||||
| 'offline'
|
||||
| 'partial_failure'
|
||||
| 'partial_skipped'
|
||||
| 'partial_pending';
|
||||
|
||||
export type RunningTeamDashboardStatus = 'active' | 'idle' | 'provisioning';
|
||||
|
||||
export interface RunningTeamTaskCounts {
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface RunningTeamCandidate {
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
color?: string;
|
||||
projectPath?: string;
|
||||
lastActivity: string | null;
|
||||
status: RunningTeamsCandidateStatus;
|
||||
taskCounts?: RunningTeamTaskCounts;
|
||||
}
|
||||
|
||||
export interface BuildRunningTeamsDashboardInput {
|
||||
teams: RunningTeamCandidate[];
|
||||
provisioningTeams?: RunningTeamCandidate[];
|
||||
}
|
||||
|
||||
export interface RunningTeamDashboardEntry extends RunningTeamCandidate {
|
||||
status: RunningTeamDashboardStatus;
|
||||
}
|
||||
|
||||
const RUNNING_STATUS_PRIORITY: Record<RunningTeamDashboardStatus, number> = {
|
||||
active: 0,
|
||||
provisioning: 1,
|
||||
idle: 2,
|
||||
};
|
||||
|
||||
function isRunningDashboardStatus(
|
||||
status: RunningTeamsCandidateStatus
|
||||
): status is RunningTeamDashboardStatus {
|
||||
return status === 'active' || status === 'idle' || status === 'provisioning';
|
||||
}
|
||||
|
||||
function getInProgressTaskCount(team: RunningTeamCandidate): number {
|
||||
return team.taskCounts?.inProgress ?? 0;
|
||||
}
|
||||
|
||||
function getLastActivityMs(team: RunningTeamCandidate): number {
|
||||
if (!team.lastActivity) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(team.lastActivity);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function mergeTeams(
|
||||
teams: RunningTeamCandidate[],
|
||||
provisioningTeams: RunningTeamCandidate[]
|
||||
): RunningTeamCandidate[] {
|
||||
if (provisioningTeams.length === 0) {
|
||||
return teams;
|
||||
}
|
||||
|
||||
const existing = new Set(teams.map((team) => team.teamName));
|
||||
return [...teams, ...provisioningTeams.filter((team) => !existing.has(team.teamName))];
|
||||
}
|
||||
|
||||
export function buildRunningTeamsDashboard({
|
||||
teams,
|
||||
provisioningTeams = [],
|
||||
}: BuildRunningTeamsDashboardInput): RunningTeamDashboardEntry[] {
|
||||
return mergeTeams(teams, provisioningTeams)
|
||||
.filter((team): team is RunningTeamDashboardEntry => isRunningDashboardStatus(team.status))
|
||||
.sort((left, right) => {
|
||||
const statusDelta =
|
||||
RUNNING_STATUS_PRIORITY[left.status] - RUNNING_STATUS_PRIORITY[right.status];
|
||||
if (statusDelta !== 0) {
|
||||
return statusDelta;
|
||||
}
|
||||
|
||||
const inProgressDelta = getInProgressTaskCount(right) - getInProgressTaskCount(left);
|
||||
if (inProgressDelta !== 0) {
|
||||
return inProgressDelta;
|
||||
}
|
||||
|
||||
const activityDelta = getLastActivityMs(right) - getLastActivityMs(left);
|
||||
if (activityDelta !== 0) {
|
||||
return activityDelta;
|
||||
}
|
||||
|
||||
return left.displayName.localeCompare(right.displayName);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
|
||||
import type { RunningTeamDashboardEntry } from '../../core/domain/policies/buildRunningTeamsDashboard';
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
|
||||
export interface RunningTeamRowModel {
|
||||
id: string;
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
projectPath?: string;
|
||||
projectLabel: string;
|
||||
status: RunningTeamDashboardEntry['status'];
|
||||
statusLabel: string;
|
||||
accentColor: string;
|
||||
taskCounts?: TaskStatusCounts;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: RunningTeamDashboardEntry['status']): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'provisioning':
|
||||
return 'Launching';
|
||||
case 'idle':
|
||||
return 'Running';
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectLabel(projectPath?: string): string {
|
||||
if (!projectPath) {
|
||||
return 'No project';
|
||||
}
|
||||
|
||||
return getBaseName(projectPath) || projectPath;
|
||||
}
|
||||
|
||||
export function adaptRunningTeamsSection(
|
||||
teams: RunningTeamDashboardEntry[]
|
||||
): RunningTeamRowModel[] {
|
||||
return teams.map((team) => ({
|
||||
id: team.teamName,
|
||||
teamName: team.teamName,
|
||||
displayName: team.displayName,
|
||||
projectPath: team.projectPath,
|
||||
projectLabel: getProjectLabel(team.projectPath),
|
||||
status: team.status,
|
||||
statusLabel: getStatusLabel(team.status),
|
||||
accentColor: team.color
|
||||
? getTeamColorSet(team.color).border
|
||||
: nameColorSet(team.displayName).border,
|
||||
taskCounts: team.taskCounts,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTaskCountsByTeam } from '@renderer/utils/pathNormalize';
|
||||
import { resolveTeamStatus } from '@renderer/utils/teamListStatus';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { buildRunningTeamsDashboard } from '../../core/domain/policies/buildRunningTeamsDashboard';
|
||||
import { adaptRunningTeamsSection } from '../adapters/RunningTeamsSectionAdapter';
|
||||
|
||||
import type {
|
||||
RunningTeamCandidate,
|
||||
RunningTeamsCandidateStatus,
|
||||
} from '../../core/domain/policies/buildRunningTeamsDashboard';
|
||||
import type { RunningTeamRowModel } from '../adapters/RunningTeamsSectionAdapter';
|
||||
import type { LeadActivityState, TeamProvisioningProgress, TeamSummary } from '@shared/types';
|
||||
|
||||
interface RunningTeamsSectionState {
|
||||
rows: RunningTeamRowModel[];
|
||||
hidden: boolean;
|
||||
openRunningTeam: (row: RunningTeamRowModel) => void;
|
||||
}
|
||||
|
||||
function toCandidate(input: {
|
||||
team: TeamSummary;
|
||||
aliveTeams: string[];
|
||||
provisioningState: {
|
||||
currentProvisioningRunIdByTeam: Record<string, string | null>;
|
||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||
};
|
||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
taskCountsByTeam: ReturnType<typeof buildTaskCountsByTeam>;
|
||||
nowMs: number;
|
||||
}): RunningTeamCandidate {
|
||||
const status = resolveTeamStatus(
|
||||
input.team,
|
||||
input.team.teamName,
|
||||
input.aliveTeams,
|
||||
getCurrentProvisioningProgressForTeam(input.provisioningState, input.team.teamName),
|
||||
input.leadActivityByTeam,
|
||||
input.nowMs
|
||||
) as RunningTeamsCandidateStatus;
|
||||
|
||||
return {
|
||||
teamName: input.team.teamName,
|
||||
displayName: input.team.displayName,
|
||||
color: input.team.color,
|
||||
projectPath: input.team.projectPath,
|
||||
lastActivity: input.team.lastActivity,
|
||||
status,
|
||||
taskCounts: input.taskCountsByTeam.get(input.team.teamName),
|
||||
};
|
||||
}
|
||||
|
||||
export function useRunningTeamsSection(searchQuery: string): RunningTeamsSectionState {
|
||||
const {
|
||||
teams,
|
||||
globalTasks,
|
||||
globalTasksInitialized,
|
||||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
openTeamTab,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam,
|
||||
leadActivityByTeam,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
teams: state.teams,
|
||||
globalTasks: state.globalTasks,
|
||||
globalTasksInitialized: state.globalTasksInitialized,
|
||||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
openTeamTab: state.openTeamTab,
|
||||
provisioningRuns: state.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||
leadActivityByTeam: state.leadActivityByTeam,
|
||||
}))
|
||||
);
|
||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||
const searchActive = searchQuery.trim().length > 0;
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
);
|
||||
const provisioningTeamNames = useMemo(
|
||||
() =>
|
||||
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
||||
isTeamProvisioningActive(provisioningState, teamName)
|
||||
),
|
||||
[currentProvisioningRunIdByTeam, provisioningState]
|
||||
);
|
||||
const provisioningTeamNamesKey = useMemo(
|
||||
() =>
|
||||
[...provisioningTeamNames].sort((left, right) => left.localeCompare(right)).join('\u0000'),
|
||||
[provisioningTeamNames]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void api.teams
|
||||
.aliveList()
|
||||
.then((teamNames) => {
|
||||
if (!cancelled) {
|
||||
setAliveTeams(teamNames);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAliveTeams([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [provisioningTeamNamesKey, searchActive, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
searchActive ||
|
||||
globalTasksInitialized ||
|
||||
globalTasksLoading ||
|
||||
(teams.length === 0 && provisioningTeamNames.length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchAllTasks();
|
||||
}, [
|
||||
fetchAllTasks,
|
||||
globalTasksInitialized,
|
||||
globalTasksLoading,
|
||||
provisioningTeamNames.length,
|
||||
searchActive,
|
||||
teams.length,
|
||||
]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (searchActive) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const taskCountsByTeam = buildTaskCountsByTeam(globalTasks);
|
||||
const existingTeamNames = new Set(teams.map((team) => team.teamName));
|
||||
const syntheticProvisioningTeams = provisioningTeamNames
|
||||
.filter((teamName) => !existingTeamNames.has(teamName))
|
||||
.map((teamName) => provisioningSnapshotByTeam[teamName])
|
||||
.filter((team): team is TeamSummary => Boolean(team));
|
||||
const nowMs = Date.now();
|
||||
const candidateInput = {
|
||||
aliveTeams,
|
||||
provisioningState,
|
||||
leadActivityByTeam,
|
||||
taskCountsByTeam,
|
||||
nowMs,
|
||||
};
|
||||
const runningTeams = buildRunningTeamsDashboard({
|
||||
teams: teams.map((team) => toCandidate({ ...candidateInput, team })),
|
||||
provisioningTeams: syntheticProvisioningTeams.map((team) =>
|
||||
toCandidate({ ...candidateInput, team })
|
||||
),
|
||||
});
|
||||
|
||||
return adaptRunningTeamsSection(runningTeams);
|
||||
}, [
|
||||
aliveTeams,
|
||||
globalTasks,
|
||||
leadActivityByTeam,
|
||||
provisioningSnapshotByTeam,
|
||||
provisioningState,
|
||||
provisioningTeamNames,
|
||||
searchActive,
|
||||
teams,
|
||||
]);
|
||||
|
||||
const openRunningTeam = useCallback(
|
||||
(row: RunningTeamRowModel): void => {
|
||||
openTeamTab(row.teamName, row.projectPath);
|
||||
},
|
||||
[openTeamTab]
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
hidden: searchActive || rows.length === 0,
|
||||
openRunningTeam,
|
||||
};
|
||||
}
|
||||
1
src/features/running-teams/renderer/index.ts
Normal file
1
src/features/running-teams/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { RunningTeamsSection } from './ui/RunningTeamsSection';
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary';
|
||||
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
|
||||
import { useRunningTeamsSection } from '../hooks/useRunningTeamsSection';
|
||||
|
||||
import type { RunningTeamRowModel } from '../adapters/RunningTeamsSectionAdapter';
|
||||
import type React from 'react';
|
||||
|
||||
interface RunningTeamsSectionProps {
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
function getRowTitle(row: RunningTeamRowModel): string {
|
||||
return row.projectPath ? `${row.displayName} - ${row.projectPath}` : row.displayName;
|
||||
}
|
||||
|
||||
export function RunningTeamsSection({
|
||||
searchQuery,
|
||||
}: Readonly<RunningTeamsSectionProps>): React.JSX.Element | null {
|
||||
const { rows, hidden, openRunningTeam } = useRunningTeamsSection(searchQuery);
|
||||
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-12">
|
||||
<div className="mb-3 flex items-center">
|
||||
<h2 className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
Running Teams
|
||||
<span className="rounded-full border border-border bg-surface-overlay px-1.5 py-0.5 text-[10px] font-medium leading-none text-text-secondary">
|
||||
{rows.length}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 xl:grid-cols-4">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
onClick={() => openRunningTeam(row)}
|
||||
className="bg-surface/50 group relative flex min-w-0 items-start overflow-hidden rounded-lg border border-border px-3 py-2.5 pr-8 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised"
|
||||
style={{ borderLeftColor: row.accentColor }}
|
||||
title={getRowTitle(row)}
|
||||
>
|
||||
<ActivePulseIndicator className="absolute right-3 top-3" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-text">{row.displayName}</span>
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-1 text-[10px] text-text-muted">
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">{row.projectLabel}</span>
|
||||
</span>
|
||||
<TeamTaskStatusSummary
|
||||
counts={row.taskCounts}
|
||||
showProgress={false}
|
||||
iconSize={11}
|
||||
className="mt-1.5"
|
||||
countersClassName="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-text-muted"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
REVIEW_GET_FILE_CONTENT,
|
||||
REVIEW_GET_GIT_FILE_LOG,
|
||||
REVIEW_GET_TASK_CHANGES,
|
||||
REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES,
|
||||
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
|
||||
REVIEW_LOAD_DECISIONS,
|
||||
REVIEW_PREVIEW_REJECT,
|
||||
|
|
@ -49,7 +50,10 @@ import type {
|
|||
HunkDecision,
|
||||
RejectResult,
|
||||
SnippetDiff,
|
||||
TaskChangeRequestOptions,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
} from '@shared/types/review';
|
||||
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
|
|
@ -102,6 +106,7 @@ export function registerReviewHandlers(ipcMain: IpcMain): void {
|
|||
// Phase 1
|
||||
ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges);
|
||||
ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges);
|
||||
ipcMain.handle(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES, handleGetTeamTaskChangeSummaries);
|
||||
ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries);
|
||||
ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats);
|
||||
// Phase 2
|
||||
|
|
@ -127,6 +132,7 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
|
|||
// Phase 1
|
||||
ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES);
|
||||
ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES);
|
||||
ipcMain.removeHandler(REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES);
|
||||
ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES);
|
||||
ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS);
|
||||
// Phase 2
|
||||
|
|
@ -166,58 +172,79 @@ async function handleGetAgentChanges(
|
|||
);
|
||||
}
|
||||
|
||||
function sanitizeTaskChangeOptions(options?: unknown): TaskChangeRequestOptions | undefined {
|
||||
if (!options || typeof options !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const raw = options as Record<string, unknown>;
|
||||
return {
|
||||
owner: typeof raw.owner === 'string' ? raw.owner : undefined,
|
||||
status: typeof raw.status === 'string' ? raw.status : undefined,
|
||||
since: typeof raw.since === 'string' ? raw.since : undefined,
|
||||
intervals: Array.isArray(raw.intervals)
|
||||
? (raw.intervals.filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
) as { startedAt: string; completedAt?: string }[])
|
||||
: undefined,
|
||||
stateBucket:
|
||||
raw.stateBucket === 'approved' ||
|
||||
raw.stateBucket === 'review' ||
|
||||
raw.stateBucket === 'completed' ||
|
||||
raw.stateBucket === 'active'
|
||||
? raw.stateBucket
|
||||
: undefined,
|
||||
summaryOnly: raw.summaryOnly === true,
|
||||
forceFresh: raw.forceFresh === true,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetTaskChanges(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: unknown
|
||||
): Promise<IpcResult<TaskChangeSetV2>> {
|
||||
const opts =
|
||||
options && typeof options === 'object'
|
||||
? {
|
||||
owner:
|
||||
typeof (options as Record<string, unknown>).owner === 'string'
|
||||
? ((options as Record<string, unknown>).owner as string)
|
||||
: undefined,
|
||||
status:
|
||||
typeof (options as Record<string, unknown>).status === 'string'
|
||||
? ((options as Record<string, unknown>).status as string)
|
||||
: undefined,
|
||||
since:
|
||||
typeof (options as Record<string, unknown>).since === 'string'
|
||||
? ((options as Record<string, unknown>).since as string)
|
||||
: undefined,
|
||||
intervals: Array.isArray((options as Record<string, unknown>).intervals)
|
||||
? (((options as Record<string, unknown>).intervals as unknown[]).filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
) as { startedAt: string; completedAt?: string }[])
|
||||
: undefined,
|
||||
stateBucket:
|
||||
(options as Record<string, unknown>).stateBucket === 'approved' ||
|
||||
(options as Record<string, unknown>).stateBucket === 'review' ||
|
||||
(options as Record<string, unknown>).stateBucket === 'completed' ||
|
||||
(options as Record<string, unknown>).stateBucket === 'active'
|
||||
? ((options as Record<string, unknown>).stateBucket as
|
||||
| 'approved'
|
||||
| 'review'
|
||||
| 'completed'
|
||||
| 'active')
|
||||
: undefined,
|
||||
summaryOnly: (options as Record<string, unknown>).summaryOnly === true,
|
||||
forceFresh: (options as Record<string, unknown>).forceFresh === true,
|
||||
}
|
||||
: undefined;
|
||||
const opts = sanitizeTaskChangeOptions(options);
|
||||
|
||||
return wrapReviewHandler('getTaskChanges', () =>
|
||||
getChangeExtractor().getTaskChanges(teamName, taskId, opts)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetTeamTaskChangeSummaries(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
requests: unknown
|
||||
): Promise<IpcResult<TeamTaskChangeSummariesResponse>> {
|
||||
const sanitizedRequests: TeamTaskChangeSummaryRequest[] = Array.isArray(requests)
|
||||
? requests
|
||||
.map((request): TeamTaskChangeSummaryRequest | null => {
|
||||
if (!request || typeof request !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const raw = request as Record<string, unknown>;
|
||||
if (typeof raw.taskId !== 'string' || raw.taskId.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
taskId: raw.taskId.trim(),
|
||||
options: sanitizeTaskChangeOptions(raw.options),
|
||||
};
|
||||
})
|
||||
.filter((request): request is TeamTaskChangeSummaryRequest => request !== null)
|
||||
: [];
|
||||
|
||||
return wrapReviewHandler('getTeamTaskChangeSummaries', () =>
|
||||
getChangeExtractor().getTeamTaskChangeSummaries(teamName, sanitizedRequests)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleInvalidateTaskChangeSummaries(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ import type { BrowserWindow } from 'electron';
|
|||
|
||||
const logger = createLogger('UpdaterService');
|
||||
|
||||
function shouldSkipDevUpdateCheck(): boolean {
|
||||
return (
|
||||
!app.isPackaged &&
|
||||
(autoUpdater as { forceDevUpdateConfig?: boolean }).forceDevUpdateConfig !== true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a remote URL exists using a HEAD request.
|
||||
* Follows redirects (GitHub releases use 302 → S3).
|
||||
|
|
@ -93,6 +100,10 @@ export class UpdaterService {
|
|||
* Check for available updates.
|
||||
*/
|
||||
async checkForUpdates(): Promise<void> {
|
||||
if (shouldSkipDevUpdateCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
|
|
|
|||
128
src/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
Normal file
128
src/main/services/runtime/ClaudeMultimodelBridgeService.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from './ClaudeMultimodelBridgeService';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
type RuntimeStatusMapper = {
|
||||
mapRuntimeProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: unknown
|
||||
) => CliProviderStatus;
|
||||
};
|
||||
|
||||
function mapRuntimeProviderStatus(
|
||||
providerId: CliProviderId,
|
||||
runtimeStatus: unknown
|
||||
): CliProviderStatus {
|
||||
const service = new ClaudeMultimodelBridgeService() as unknown as RuntimeStatusMapper;
|
||||
return service.mapRuntimeProviderStatus(providerId, runtimeStatus);
|
||||
}
|
||||
|
||||
describe('ClaudeMultimodelBridgeService runtime status mapping', () => {
|
||||
test('maps Anthropic subscription rate limits from orchestrator runtime status', () => {
|
||||
const provider = mapRuntimeProviderStatus('anthropic', {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'claude.ai',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
models: ['haiku'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 42.5,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_777_777_000,
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 150,
|
||||
windowDurationMins: Number.NaN,
|
||||
resetsAt: Number.NaN,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.subscriptionRateLimits).toEqual({
|
||||
primary: {
|
||||
usedPercent: 42.5,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_777_777_000,
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 100,
|
||||
windowDurationMins: null,
|
||||
resetsAt: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('drops malformed Anthropic subscription rate limit windows', () => {
|
||||
const provider = mapRuntimeProviderStatus('anthropic', {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'claude.ai',
|
||||
verificationState: 'verified',
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: Number.NaN,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_777_777_000,
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 60,
|
||||
windowDurationMins: 10_080,
|
||||
resetsAt: 1_777_999_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.subscriptionRateLimits).toEqual({
|
||||
primary: null,
|
||||
secondary: {
|
||||
usedPercent: 60,
|
||||
windowDurationMins: 10_080,
|
||||
resetsAt: 1_777_999_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores subscription rate limits for non-Anthropic providers', () => {
|
||||
const provider = mapRuntimeProviderStatus('codex', {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 25,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_777_777_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.subscriptionRateLimits).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores Anthropic subscription rate limits for API key auth', () => {
|
||||
const provider = mapRuntimeProviderStatus('anthropic', {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 25,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: 1_777_777_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.subscriptionRateLimits).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,12 @@ import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
|||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
|
||||
import type { CliProviderId, CliProviderReasoningEffort, CliProviderStatus } from '@shared/types';
|
||||
import type {
|
||||
CliProviderId,
|
||||
CliProviderReasoningEffort,
|
||||
CliProviderStatus,
|
||||
CliProviderSubscriptionRateLimitSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||
|
||||
|
|
@ -51,6 +56,17 @@ interface RuntimeProviderCapabilitiesResponse {
|
|||
};
|
||||
}
|
||||
|
||||
interface RuntimeSubscriptionRateLimitWindowResponse {
|
||||
usedPercent?: number;
|
||||
windowDurationMins?: number | null;
|
||||
resetsAt?: number | null;
|
||||
}
|
||||
|
||||
interface RuntimeSubscriptionRateLimitSnapshotResponse {
|
||||
primary?: RuntimeSubscriptionRateLimitWindowResponse | null;
|
||||
secondary?: RuntimeSubscriptionRateLimitWindowResponse | null;
|
||||
}
|
||||
|
||||
interface RuntimeProviderModelCatalogItemResponse {
|
||||
id?: string;
|
||||
launchModel?: string;
|
||||
|
|
@ -111,6 +127,7 @@ interface ProviderStatusCommandResponse {
|
|||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
|
@ -179,6 +196,7 @@ interface UnifiedRuntimeStatusResponse {
|
|||
authMethodDetail?: string | null;
|
||||
} | null;
|
||||
runtimeCapabilities?: RuntimeProviderCapabilitiesResponse;
|
||||
subscriptionRateLimits?: RuntimeSubscriptionRateLimitSnapshotResponse | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
|
@ -350,6 +368,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
connection: null,
|
||||
modelCatalog: null,
|
||||
runtimeCapabilities: null,
|
||||
subscriptionRateLimits: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -544,6 +563,44 @@ function mapRuntimeProviderModelCatalog(
|
|||
};
|
||||
}
|
||||
|
||||
function mapRuntimeSubscriptionRateLimitWindow(
|
||||
window: RuntimeSubscriptionRateLimitWindowResponse | null | undefined
|
||||
): NonNullable<CliProviderSubscriptionRateLimitSnapshot['primary']> | null {
|
||||
if (!window || typeof window.usedPercent !== 'number' || !Number.isFinite(window.usedPercent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
usedPercent: Math.max(0, Math.min(100, window.usedPercent)),
|
||||
windowDurationMins:
|
||||
typeof window.windowDurationMins === 'number' && Number.isFinite(window.windowDurationMins)
|
||||
? window.windowDurationMins
|
||||
: null,
|
||||
resetsAt:
|
||||
typeof window.resetsAt === 'number' && Number.isFinite(window.resetsAt)
|
||||
? window.resetsAt
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeSubscriptionRateLimits(
|
||||
providerId: CliProviderId,
|
||||
authMethod: string | null | undefined,
|
||||
rateLimits: RuntimeSubscriptionRateLimitSnapshotResponse | null | undefined
|
||||
): CliProviderSubscriptionRateLimitSnapshot | null {
|
||||
if (
|
||||
providerId !== 'anthropic' ||
|
||||
(authMethod !== 'claude.ai' && authMethod !== 'oauth_token') ||
|
||||
!rateLimits
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primary = mapRuntimeSubscriptionRateLimitWindow(rateLimits.primary);
|
||||
const secondary = mapRuntimeSubscriptionRateLimitWindow(rateLimits.secondary);
|
||||
return primary || secondary ? { primary, secondary } : null;
|
||||
}
|
||||
|
||||
export class ClaudeMultimodelBridgeService {
|
||||
private async buildCliEnv(
|
||||
binaryPath: string
|
||||
|
|
@ -621,6 +678,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
})) ?? [],
|
||||
models: extractModelIds(runtimeStatus.models),
|
||||
modelCatalog: mapRuntimeProviderModelCatalog(providerId, runtimeStatus.modelCatalog),
|
||||
subscriptionRateLimits: mapRuntimeSubscriptionRateLimits(
|
||||
providerId,
|
||||
runtimeStatus.authMethod,
|
||||
runtimeStatus.subscriptionRateLimits
|
||||
),
|
||||
backend: runtimeStatus.backend?.kind
|
||||
? {
|
||||
kind: runtimeStatus.backend.kind,
|
||||
|
|
|
|||
|
|
@ -631,6 +631,7 @@ export class ProviderConnectionService {
|
|||
...provider,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
subscriptionRateLimits: null,
|
||||
verificationState:
|
||||
provider.verificationState === 'error' ? provider.verificationState : 'verified',
|
||||
statusMessage: 'Connected via API key',
|
||||
|
|
@ -641,6 +642,7 @@ export class ProviderConnectionService {
|
|||
...provider,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
subscriptionRateLimits: null,
|
||||
verificationState: provider.verificationState === 'error' ? 'error' : 'unknown',
|
||||
statusMessage: 'API key mode is selected, but no Anthropic API credential is available yet.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,12 +43,21 @@ import type { TaskBoundaryParser } from './TaskBoundaryParser';
|
|||
import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
|
||||
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
|
||||
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
|
||||
import type {
|
||||
AgentChangeSet,
|
||||
ChangeStats,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryItem,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:ChangeExtractorService');
|
||||
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
|
||||
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
|
||||
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
|
||||
|
||||
/** Кеш-запись: данные + mtime файла + время протухания */
|
||||
interface CacheEntry {
|
||||
|
|
@ -322,6 +331,57 @@ export class ChangeExtractorService {
|
|||
return promise;
|
||||
}
|
||||
|
||||
async getTeamTaskChangeSummaries(
|
||||
teamName: string,
|
||||
requests: TeamTaskChangeSummaryRequest[]
|
||||
): Promise<TeamTaskChangeSummariesResponse> {
|
||||
const cappedRequests = requests
|
||||
.filter((request) => typeof request.taskId === 'string' && request.taskId.trim().length > 0)
|
||||
.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
|
||||
const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({
|
||||
taskId: request.taskId.trim(),
|
||||
changeSet: null,
|
||||
}));
|
||||
let cursor = 0;
|
||||
|
||||
const runNext = async (): Promise<void> => {
|
||||
while (cursor < cappedRequests.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
const request = cappedRequests[index];
|
||||
const taskId = request.taskId.trim();
|
||||
try {
|
||||
const changeSet = await this.getTaskChanges(teamName, taskId, {
|
||||
...request.options,
|
||||
summaryOnly: true,
|
||||
});
|
||||
items[index] = { taskId, changeSet };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
items[index] = {
|
||||
taskId,
|
||||
changeSet: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY, cappedRequests.length) },
|
||||
() => runNext()
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
items,
|
||||
computedAt: new Date().toISOString(),
|
||||
truncated: requests.length > cappedRequests.length || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async invalidateTaskChangeSummaries(
|
||||
teamName: string,
|
||||
taskIds: string[],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import type { FileLineStats, MemberFullStats } from '@shared/types';
|
|||
const logger = createLogger('Service:MemberStatsComputer');
|
||||
|
||||
const TRAILING_PUNCT_CHARS = new Set([';', '.', ',']);
|
||||
const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']);
|
||||
const INVALID_NAMES = new Set(['null', 'undefined', 'none', 'false', 'true', '']);
|
||||
const WINDOWS_NULL_DEVICE_RE = /^[a-z]:\/nul$/;
|
||||
|
||||
function stripTrailingPunct(s: string): string {
|
||||
let end = s.length;
|
||||
|
|
@ -18,9 +19,26 @@ function stripTrailingPunct(s: string): string {
|
|||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
function isNullDevicePath(value: string): boolean {
|
||||
const normalized = value.replace(/\\/g, '/').toLowerCase();
|
||||
return (
|
||||
normalized === '/dev/null' ||
|
||||
normalized === '//./nul' ||
|
||||
normalized === '//?/nul' ||
|
||||
WINDOWS_NULL_DEVICE_RE.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidFilePath(value: string): boolean {
|
||||
const cleaned = stripTrailingPunct(value.trim());
|
||||
return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/');
|
||||
const normalizedName = cleaned.toLowerCase();
|
||||
const hasPathSeparator = cleaned.includes('/') || cleaned.includes('\\');
|
||||
return (
|
||||
cleaned.length > 1 &&
|
||||
!INVALID_NAMES.has(normalizedName) &&
|
||||
hasPathSeparator &&
|
||||
!isNullDevicePath(cleaned)
|
||||
);
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
|
|
|||
|
|
@ -1732,6 +1732,9 @@ interface ProvisioningRun {
|
|||
leadName: string;
|
||||
startedAt: string;
|
||||
textParts: string[];
|
||||
replyVisibility?: 'user' | 'internal_activity';
|
||||
hasVisibleSendMessage?: boolean;
|
||||
hasUserVisibleSendMessage?: boolean;
|
||||
settled: boolean;
|
||||
idleHandle: NodeJS.Timeout | null;
|
||||
idleMs: number;
|
||||
|
|
@ -4542,7 +4545,7 @@ ${AGENT_BLOCK_CLOSE}
|
|||
- instructions to run commands in terminal
|
||||
- task references without a leading # (for example write #abcd1234, not abcd1234)
|
||||
Instead, describe the action in human-friendly language (e.g. "Task #6 is complete." instead of showing a command to mark it complete). If you need to update task status, do it YOURSELF — never ask the user to run a command.
|
||||
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
|
||||
- CRITICAL: When processing relayed inbox messages, follow the relay prompt's reply visibility. Some relay turns record plain text only as internal lead activity. User-visible replies must be explicit when the relay prompt says the batch is internal. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include concise visible text only when the relay prompt allows or requests it.`;
|
||||
}
|
||||
|
||||
function getSystemLocale(): string {
|
||||
|
|
@ -10279,6 +10282,29 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private clearLeadInboxFollowUpRelayTimer(teamName: string): void {
|
||||
const key = `lead-inbox-follow-up:${teamName}`;
|
||||
const timer = this.pendingTimeouts.get(key);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pendingTimeouts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLeadInboxFollowUpRelay(teamName: string): void {
|
||||
const key = `lead-inbox-follow-up:${teamName}`;
|
||||
if (this.pendingTimeouts.has(key)) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimeouts.delete(key);
|
||||
void this.relayLeadInboxMessages(teamName).catch((error: unknown) =>
|
||||
logger.warn(`[${teamName}] lead inbox follow-up relay failed: ${String(error)}`)
|
||||
);
|
||||
}, 50);
|
||||
timer.unref?.();
|
||||
this.pendingTimeouts.set(key, timer);
|
||||
}
|
||||
|
||||
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
|
||||
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
|
||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||
|
|
@ -10290,6 +10316,7 @@ export class TeamProvisioningService {
|
|||
this.recentCrossTeamLeadDeliveryMessageIds.delete(teamName);
|
||||
this.recentSameTeamNativeFingerprints.delete(teamName);
|
||||
this.clearSameTeamRetryTimers(teamName);
|
||||
this.clearLeadInboxFollowUpRelayTimer(teamName);
|
||||
|
||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||
if (key.startsWith(`${teamName}:`)) {
|
||||
|
|
@ -10677,6 +10704,40 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private hasCapturedUserVisibleSendMessage(
|
||||
content: Record<string, unknown>[],
|
||||
teamName: string
|
||||
): boolean {
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== 'object') return false;
|
||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') return false;
|
||||
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
const inp = input as Record<string, unknown>;
|
||||
|
||||
if (part.name === 'SendMessage') {
|
||||
const target = (typeof inp.recipient === 'string' ? inp.recipient : '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const text = (typeof inp.content === 'string' ? inp.content : '').trim();
|
||||
return target === 'user' && text.length > 0;
|
||||
}
|
||||
|
||||
const isTeamMessageSendTool = isAgentTeamsToolUse({
|
||||
rawName: part.name,
|
||||
canonicalName: 'message_send',
|
||||
toolInput: inp,
|
||||
currentTeamName: teamName,
|
||||
});
|
||||
if (!isTeamMessageSendTool) return false;
|
||||
|
||||
const target = typeof inp.to === 'string' ? inp.to.trim().toLowerCase() : '';
|
||||
const text = typeof inp.text === 'string' ? inp.text.trim() : '';
|
||||
return target === 'user' && text.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async matchCrossTeamLeadInboxMessages(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
|
|
@ -21344,6 +21405,11 @@ export class TeamProvisioningService {
|
|||
return typeof message.messageId === 'string' && message.messageId.trim().length > 0;
|
||||
}
|
||||
|
||||
private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
|
||||
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
|
||||
return from === 'user' || message.source === 'user_sent';
|
||||
}
|
||||
|
||||
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
||||
const existing = this.leadInboxRelayInFlight.get(teamName);
|
||||
if (existing) {
|
||||
|
|
@ -21612,7 +21678,17 @@ export class TeamProvisioningService {
|
|||
if (actionableUnread.length === 0) return 0;
|
||||
|
||||
const MAX_RELAY = 10;
|
||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||
const userOriginatedUnread = actionableUnread.filter((message) =>
|
||||
this.isUserOriginatedLeadRelayMessage(message)
|
||||
);
|
||||
const replyVisibility: 'user' | 'internal_activity' =
|
||||
userOriginatedUnread.length > 0 ? 'user' : 'internal_activity';
|
||||
const batchSource = userOriginatedUnread.length > 0 ? userOriginatedUnread : actionableUnread;
|
||||
const batch = batchSource.slice(0, MAX_RELAY);
|
||||
const batchIds = new Set(batch.map((message) => message.messageId));
|
||||
const hasPendingFollowUpRelay = unread.some(
|
||||
(message) => !batchIds.has(message.messageId) && !readOnlyIgnoredIds.has(message.messageId)
|
||||
);
|
||||
const teammateRoster = (config.members ?? [])
|
||||
.filter((member) => {
|
||||
const name = member.name?.trim();
|
||||
|
|
@ -21634,13 +21710,25 @@ export class TeamProvisioningService {
|
|||
if (!sourceTeam || !conversationId) return [];
|
||||
return [{ toTeam: sourceTeam, conversationId }];
|
||||
});
|
||||
const replyVisibilityInstruction =
|
||||
replyVisibility === 'user'
|
||||
? [
|
||||
`Plain text reply visibility for this batch: user-visible.`,
|
||||
`These inbox rows originated from the human user, so a concise plain text reply is allowed and will be shown to the user.`,
|
||||
`If a visible reply is needed for a teammate or another team, use the appropriate messaging tool; plain text is only for the human response.`,
|
||||
]
|
||||
: [
|
||||
`Plain text reply visibility for this batch: internal lead activity only.`,
|
||||
`Do NOT write a user-facing summary for teammate/system/cross-team relay traffic. If the human user must be notified, explicitly call SendMessage with recipient "user".`,
|
||||
`If you take action and no visible message/tool result already records it, you may write one terse internal status line for the team activity log.`,
|
||||
`If a visible reply is needed for a teammate, another team, or the human user, use the appropriate messaging tool instead of relying on plain text.`,
|
||||
];
|
||||
|
||||
const message = [
|
||||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||
`Process them in order (oldest first).`,
|
||||
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
|
||||
`IMPORTANT: Your text response here is shown to the user.`,
|
||||
`If you actually take action, include a brief human-readable summary (e.g. "Delegated to carol.").`,
|
||||
...replyVisibilityInstruction,
|
||||
`If there is no action to take, produce ZERO text output. Do NOT write "No action needed.", status echoes, or any other no-op summary.`,
|
||||
`For pure system notifications, comment notifications, or routine teammate availability updates that require no reply/comment/action, say nothing.`,
|
||||
`Do NOT respond with only an agent-only block.`,
|
||||
|
|
@ -21712,6 +21800,9 @@ export class TeamProvisioningService {
|
|||
leadName,
|
||||
startedAt: nowIso(),
|
||||
textParts: [] as string[],
|
||||
replyVisibility,
|
||||
hasVisibleSendMessage: false,
|
||||
hasUserVisibleSendMessage: false,
|
||||
settled: false,
|
||||
idleHandle: null as NodeJS.Timeout | null,
|
||||
idleMs: captureIdleMs,
|
||||
|
|
@ -21768,6 +21859,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
let replyText: string | null = null;
|
||||
let capturedVisibleSendMessage = false;
|
||||
let capturedUserVisibleSendMessage = false;
|
||||
try {
|
||||
replyText = (await capturePromise).trim() || null;
|
||||
} catch {
|
||||
|
|
@ -21776,6 +21869,8 @@ export class TeamProvisioningService {
|
|||
replyText = partial && partial.length > 0 ? partial : null;
|
||||
} finally {
|
||||
if (run.leadRelayCapture) {
|
||||
capturedVisibleSendMessage = run.leadRelayCapture.hasVisibleSendMessage === true;
|
||||
capturedUserVisibleSendMessage = run.leadRelayCapture.hasUserVisibleSendMessage === true;
|
||||
if (run.leadRelayCapture.idleHandle) {
|
||||
clearTimeout(run.leadRelayCapture.idleHandle);
|
||||
run.leadRelayCapture.idleHandle = null;
|
||||
|
|
@ -21796,6 +21891,18 @@ export class TeamProvisioningService {
|
|||
if (cleanReply) {
|
||||
if (isTeamInternalControlMessageText(cleanReply)) {
|
||||
logger.debug(`[${teamName}] Suppressed internal lead relay echo`);
|
||||
} else if (
|
||||
(replyVisibility === 'internal_activity' && capturedVisibleSendMessage) ||
|
||||
(replyVisibility === 'user' && capturedUserVisibleSendMessage)
|
||||
) {
|
||||
logger.debug(`[${teamName}] Suppressed lead relay text duplicated by visible message`);
|
||||
} else if (replyVisibility === 'internal_activity') {
|
||||
this.pushLiveLeadTextMessage(
|
||||
run,
|
||||
cleanReply,
|
||||
`lead-relay-${runId}-${Date.now()}`,
|
||||
nowIso()
|
||||
);
|
||||
} else {
|
||||
const relayMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
|
|
@ -21817,6 +21924,9 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
}
|
||||
if (hasPendingFollowUpRelay) {
|
||||
this.scheduleLeadInboxFollowUpRelay(teamName);
|
||||
}
|
||||
|
||||
return batch.length;
|
||||
})();
|
||||
|
|
@ -27158,14 +27268,16 @@ export class TeamProvisioningService {
|
|||
continue;
|
||||
}
|
||||
|
||||
const recipient = isNativeSendMessage
|
||||
const rawRecipient = isNativeSendMessage
|
||||
? typeof inp.recipient === 'string'
|
||||
? inp.recipient
|
||||
: ''
|
||||
: typeof inp.to === 'string'
|
||||
? inp.to
|
||||
: '';
|
||||
if (!recipient.trim()) continue;
|
||||
const trimmedRecipient = rawRecipient.trim();
|
||||
if (!trimmedRecipient) continue;
|
||||
const recipient = trimmedRecipient.toLowerCase() === 'user' ? 'user' : trimmedRecipient;
|
||||
|
||||
const msgContent = isNativeSendMessage
|
||||
? typeof inp.content === 'string'
|
||||
|
|
@ -28283,6 +28395,14 @@ export class TeamProvisioningService {
|
|||
content,
|
||||
run.teamName
|
||||
);
|
||||
if (run.leadRelayCapture) {
|
||||
if (hasCapturedVisibleSendMessage) {
|
||||
run.leadRelayCapture.hasVisibleSendMessage = true;
|
||||
}
|
||||
if (this.hasCapturedUserVisibleSendMessage(content, run.teamName)) {
|
||||
run.leadRelayCapture.hasUserVisibleSendMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
const textParts = content
|
||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
|
|
@ -30843,6 +30963,7 @@ export class TeamProvisioningService {
|
|||
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||
this.recentSameTeamNativeFingerprints.delete(run.teamName);
|
||||
this.clearSameTeamRetryTimers(run.teamName);
|
||||
this.clearLeadInboxFollowUpRelayTimer(run.teamName);
|
||||
}
|
||||
for (const memberName of run.memberSpawnStatuses.keys()) {
|
||||
const key = this.getMemberLaunchGraceKey(run, memberName);
|
||||
|
|
|
|||
|
|
@ -529,6 +529,9 @@ export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges';
|
|||
/** Получить изменения задачи */
|
||||
export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges';
|
||||
|
||||
/** Получить summary изменений по нескольким задачам команды */
|
||||
export const REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES = 'review:getTeamTaskChangeSummaries';
|
||||
|
||||
/** Инвалидировать persisted/in-memory summary cache для задач */
|
||||
export const REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES = 'review:invalidateTaskChangeSummaries';
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ import {
|
|||
REVIEW_GET_FILE_CONTENT,
|
||||
REVIEW_GET_GIT_FILE_LOG,
|
||||
REVIEW_GET_TASK_CHANGES,
|
||||
REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES,
|
||||
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
|
||||
REVIEW_LOAD_DECISIONS,
|
||||
REVIEW_PREVIEW_REJECT,
|
||||
|
|
@ -305,6 +306,7 @@ import type {
|
|||
SshLastConnection,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangePresenceState,
|
||||
TaskChangeRequestOptions,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
|
|
@ -325,6 +327,8 @@ import type {
|
|||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
|
|
@ -1359,15 +1363,7 @@ const electronAPI: ElectronAPI = {
|
|||
getTaskChanges: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
|
||||
summaryOnly?: boolean;
|
||||
forceFresh?: boolean;
|
||||
}
|
||||
options?: TaskChangeRequestOptions
|
||||
) => {
|
||||
return invokeIpcWithResult<TaskChangeSetV2>(
|
||||
REVIEW_GET_TASK_CHANGES,
|
||||
|
|
@ -1376,6 +1372,16 @@ const electronAPI: ElectronAPI = {
|
|||
options
|
||||
);
|
||||
},
|
||||
getTeamTaskChangeSummaries: async (
|
||||
teamName: string,
|
||||
requests: TeamTaskChangeSummaryRequest[]
|
||||
) => {
|
||||
return invokeIpcWithResult<TeamTaskChangeSummariesResponse>(
|
||||
REVIEW_GET_TEAM_TASK_CHANGE_SUMMARIES,
|
||||
teamName,
|
||||
requests
|
||||
);
|
||||
},
|
||||
invalidateTaskChangeSummaries: async (teamName: string, taskIds: string[]) => {
|
||||
return invokeIpcWithResult<void>(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, teamName, taskIds);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import type {
|
|||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
SubagentDetail,
|
||||
TaskChangeRequestOptions,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
|
|
@ -82,6 +83,8 @@ import type {
|
|||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TeamWorktreeGitStatus,
|
||||
|
|
@ -1138,18 +1141,16 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getTaskChanges: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
_options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
|
||||
summaryOnly?: boolean;
|
||||
forceFresh?: boolean;
|
||||
}
|
||||
_options?: TaskChangeRequestOptions
|
||||
): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
getTeamTaskChangeSummaries: async (
|
||||
_teamName: string,
|
||||
_requests: TeamTaskChangeSummaryRequest[]
|
||||
): Promise<TeamTaskChangeSummariesResponse> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
invalidateTaskChangeSummaries: async (): Promise<never> => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatCodexRemainingPercent,
|
||||
formatCodexWindowDuration,
|
||||
mergeCodexProviderStatusWithSnapshot,
|
||||
normalizeCodexResetTimestamp,
|
||||
useCodexAccountSnapshot,
|
||||
} from '@features/codex-account/renderer';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
|
|
@ -68,7 +65,16 @@ import {
|
|||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
import type { CliProviderAuthMode, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
import {
|
||||
getAnthropicDashboardRateLimits,
|
||||
getCodexDashboardRateLimits,
|
||||
isDashboardRateLimitSubscriptionMode,
|
||||
shouldShowDashboardRateLimitSkeleton,
|
||||
} from './providerDashboardRateLimits';
|
||||
|
||||
import type { DashboardRateLimitItem } from './providerDashboardRateLimits';
|
||||
|
||||
// =============================================================================
|
||||
// Border color by state
|
||||
|
|
@ -88,12 +94,81 @@ const OPENCODE_DOWNLOAD_URL = 'https://opencode.ai/download';
|
|||
|
||||
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
interface CodexDashboardRateLimitItem {
|
||||
label: string;
|
||||
remaining: string;
|
||||
resetsAt: string;
|
||||
}
|
||||
const DashboardRateLimitChips = ({
|
||||
providerId,
|
||||
items,
|
||||
}: {
|
||||
providerId: CliProviderId;
|
||||
items: DashboardRateLimitItem[];
|
||||
}): React.JSX.Element => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${providerId}-${item.label}`}
|
||||
className="w-fit max-w-full rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.2)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
|
||||
{item.remaining}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[10px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={item.resetsAt}
|
||||
>
|
||||
• resets {item.resetsAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const RATE_LIMIT_SKELETON_LABELS = ['5h left', 'Weekly left'] as const;
|
||||
|
||||
const DashboardRateLimitSkeletonChips = (): React.JSX.Element => (
|
||||
<div className="flex flex-wrap items-center gap-2" aria-label="Rate limits loading">
|
||||
{RATE_LIMIT_SKELETON_LABELS.map((label, index) => (
|
||||
<div
|
||||
key={label}
|
||||
className="w-fit max-w-full rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(148, 163, 184, 0.16)',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.04)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="skeleton-shimmer h-3 rounded-sm"
|
||||
style={{ width: index === 0 ? '2rem' : '2.25rem' }}
|
||||
/>
|
||||
<span
|
||||
className="skeleton-shimmer h-3 rounded-sm"
|
||||
style={{ width: index === 0 ? '5.75rem' : '6.5rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function getCodexDashboardHint(provider: CliProviderStatus): string | null {
|
||||
if (provider.providerId !== 'codex') {
|
||||
|
|
@ -272,6 +347,12 @@ interface InstalledBannerProps {
|
|||
codexSnapshotPending: boolean;
|
||||
cliStatusError: string | null;
|
||||
providersCollapsed: boolean;
|
||||
providerConnectionAuthModes: {
|
||||
anthropic: CliProviderAuthMode | null;
|
||||
codex: CliProviderAuthMode | null;
|
||||
};
|
||||
codexRateLimitsLoading: boolean;
|
||||
anthropicRateLimitsRefreshing: boolean;
|
||||
isBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
|
|
@ -438,71 +519,6 @@ function formatRuntimeLabel(
|
|||
: runtimeLabel;
|
||||
}
|
||||
|
||||
function isCodexSubscriptionActive(
|
||||
connection: CliProviderStatus['connection'] | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
connection?.codex?.effectiveAuthMode === 'chatgpt' &&
|
||||
(connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCodexRateLimitLabel(
|
||||
fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left',
|
||||
windowDurationMins: number | null | undefined
|
||||
): string {
|
||||
const duration = formatCodexWindowDuration(windowDurationMins);
|
||||
return duration ? `${duration} left` : fallbackTitle;
|
||||
}
|
||||
|
||||
function formatCodexDashboardResetTime(timestampSeconds: number | null | undefined): string {
|
||||
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
|
||||
if (!normalized) {
|
||||
return 'reset unknown';
|
||||
}
|
||||
|
||||
return new Date(normalized).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getCodexDashboardRateLimits(
|
||||
provider: CliProviderStatus
|
||||
): CodexDashboardRateLimitItem[] | null {
|
||||
if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rateLimits = provider.connection?.codex?.rateLimits;
|
||||
if (!rateLimits?.primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: CodexDashboardRateLimitItem[] = [];
|
||||
const primaryRemaining = formatCodexRemainingPercent(rateLimits.primary.usedPercent) ?? 'Unknown';
|
||||
items.push({
|
||||
label: buildCodexRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins),
|
||||
remaining: primaryRemaining,
|
||||
resetsAt: formatCodexDashboardResetTime(rateLimits.primary.resetsAt),
|
||||
});
|
||||
|
||||
if (rateLimits.secondary) {
|
||||
items.push({
|
||||
label: buildCodexRateLimitLabel(
|
||||
rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left',
|
||||
rateLimits.secondary.windowDurationMins
|
||||
),
|
||||
remaining: formatCodexRemainingPercent(rateLimits.secondary.usedPercent) ?? 'Unknown',
|
||||
resetsAt: formatCodexDashboardResetTime(rateLimits.secondary.resetsAt),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function formatRuntimeAuthSummary(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>,
|
||||
visibleProviders: readonly CliProviderStatus[]
|
||||
|
|
@ -576,6 +592,9 @@ const InstalledBanner = ({
|
|||
codexSnapshotPending,
|
||||
cliStatusError,
|
||||
providersCollapsed,
|
||||
providerConnectionAuthModes,
|
||||
codexRateLimitsLoading,
|
||||
anthropicRateLimitsRefreshing,
|
||||
isBusy,
|
||||
onInstall,
|
||||
onRefresh,
|
||||
|
|
@ -717,6 +736,14 @@ const InstalledBanner = ({
|
|||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const codexDashboardRateLimits = getCodexDashboardRateLimits(provider);
|
||||
const anthropicDashboardRateLimits = getAnthropicDashboardRateLimits(provider);
|
||||
const dashboardRateLimits = codexDashboardRateLimits ?? anthropicDashboardRateLimits;
|
||||
const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length);
|
||||
const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({
|
||||
provider,
|
||||
sourceProvider: sourceProviderMap.get(provider.providerId) ?? null,
|
||||
configuredAuthModes: providerConnectionAuthModes,
|
||||
});
|
||||
const codexDashboardHint = getCodexDashboardHint(provider);
|
||||
const codexNeedsReconnect =
|
||||
provider.providerId === 'codex' &&
|
||||
|
|
@ -738,11 +765,17 @@ const InstalledBanner = ({
|
|||
isProviderCardLoading(provider, providerLoading) ||
|
||||
isCodexSnapshotPending(provider, codexSnapshotPending) ||
|
||||
maskNegativeBootstrapState;
|
||||
const showInlineCodexAccessoryRow =
|
||||
!showSkeleton &&
|
||||
provider.providerId === 'codex' &&
|
||||
provider.models.length > 0 &&
|
||||
Boolean(codexDashboardRateLimits?.length);
|
||||
const showRateLimitSkeleton =
|
||||
(showSkeleton &&
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
provider,
|
||||
sourceProvider,
|
||||
configuredAuthModes: providerConnectionAuthModes,
|
||||
})) ||
|
||||
(isSubscriptionRateLimitMode &&
|
||||
!hasDashboardRateLimits &&
|
||||
((provider.providerId === 'codex' && codexRateLimitsLoading) ||
|
||||
(provider.providerId === 'anthropic' && anthropicRateLimitsRefreshing)));
|
||||
const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
|
||||
const hasDetailContent = Boolean(
|
||||
(provider.backend?.label && !runtimeSummary) ||
|
||||
|
|
@ -808,80 +841,7 @@ const InstalledBanner = ({
|
|||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{showInlineCodexAccessoryRow ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
modelAvailability={provider.modelAvailability}
|
||||
providerStatus={provider}
|
||||
collapseAfter={15}
|
||||
/>
|
||||
{codexDashboardRateLimits!.map((item) => (
|
||||
<div
|
||||
key={`${provider.providerId}-${item.label}`}
|
||||
className="rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.2)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
|
||||
{item.remaining}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[10px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={item.resetsAt}
|
||||
>
|
||||
• resets {item.resetsAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showSkeleton &&
|
||||
codexDashboardRateLimits &&
|
||||
codexDashboardRateLimits.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{codexDashboardRateLimits.map((item) => (
|
||||
<div
|
||||
key={`${provider.providerId}-${item.label}`}
|
||||
className="rounded-md border px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: 'rgba(74, 222, 128, 0.2)',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5 whitespace-nowrap">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-[0.06em]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs font-medium" style={{ color: '#86efac' }}>
|
||||
{item.remaining}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate text-[10px]"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={item.resetsAt}
|
||||
>
|
||||
• resets {item.resetsAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !showSkeleton && codexDashboardHint ? (
|
||||
{!showSkeleton && codexDashboardHint ? (
|
||||
<div
|
||||
className="mt-2 rounded-md border px-2.5 py-2 text-[11px]"
|
||||
style={{
|
||||
|
|
@ -1012,7 +972,7 @@ const InstalledBanner = ({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && provider.models.length > 0 && !showInlineCodexAccessoryRow && (
|
||||
{!showSkeleton && provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
@ -1023,6 +983,19 @@ const InstalledBanner = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{!showSkeleton && dashboardRateLimits && dashboardRateLimits.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<DashboardRateLimitChips
|
||||
providerId={provider.providerId}
|
||||
items={dashboardRateLimits}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showRateLimitSkeleton && (
|
||||
<div className="col-span-2">
|
||||
<DashboardRateLimitSkeletonChips />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1076,6 +1049,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||
loadDashboardCliStatusBannerCollapsed()
|
||||
);
|
||||
const [anthropicRateLimitsRefreshing, setAnthropicRateLimitsRefreshing] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const selectedProjectPath = useMemo(
|
||||
() => resolveProjectPathById(selectedProjectId, projects, repositoryGroups)?.path ?? null,
|
||||
|
|
@ -1088,6 +1062,16 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
: cliStatus,
|
||||
[cliStatus, cliStatusLoading, multimodelEnabled]
|
||||
);
|
||||
const providerConnectionAuthModes = useMemo(
|
||||
() => ({
|
||||
anthropic: appConfig?.providerConnections?.anthropic.authMode ?? null,
|
||||
codex: appConfig?.providerConnections?.codex.preferredAuthMode ?? null,
|
||||
}),
|
||||
[
|
||||
appConfig?.providerConnections?.anthropic.authMode,
|
||||
appConfig?.providerConnections?.codex.preferredAuthMode,
|
||||
]
|
||||
);
|
||||
const codexAccount = useCodexAccountSnapshot({
|
||||
enabled:
|
||||
isElectron &&
|
||||
|
|
@ -1130,6 +1114,27 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
[loadingCliStatus, visibleCliProviders]
|
||||
);
|
||||
const renderCliStatus = effectiveCliStatus;
|
||||
const shouldPollAnthropicSubscriptionLimits = useMemo(() => {
|
||||
if (
|
||||
!renderCliStatus?.installed ||
|
||||
renderCliStatus.flavor !== 'agent_teams_orchestrator' ||
|
||||
!multimodelEnabled
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const provider =
|
||||
renderCliStatus.providers.find((candidate) => candidate.providerId === 'anthropic') ?? null;
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isDashboardRateLimitSubscriptionMode({
|
||||
provider,
|
||||
sourceProvider: loadingCliProviderMap.get('anthropic') ?? null,
|
||||
configuredAuthModes: providerConnectionAuthModes,
|
||||
});
|
||||
}, [loadingCliProviderMap, multimodelEnabled, providerConnectionAuthModes, renderCliStatus]);
|
||||
const runtimeDisplayName = getHumanRuntimeDisplayName(renderCliStatus, multimodelEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1156,6 +1161,38 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
return () => clearInterval(interval);
|
||||
}, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron || !shouldPollAnthropicSubscriptionLimits) {
|
||||
setAnthropicRateLimitsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const refreshAnthropicLimits = async (): Promise<void> => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAnthropicRateLimitsRefreshing(true);
|
||||
try {
|
||||
await fetchCliProviderStatus('anthropic', { silent: true });
|
||||
} finally {
|
||||
if (active) {
|
||||
setAnthropicRateLimitsRefreshing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
void refreshAnthropicLimits();
|
||||
}, ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchCliProviderStatus, isElectron, shouldPollAnthropicSubscriptionLimits]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
installCli();
|
||||
}, [installCli]);
|
||||
|
|
@ -1426,6 +1463,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
|
|
@ -1652,6 +1692,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
|
|
@ -1712,6 +1755,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
|
|
@ -1932,6 +1978,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
codexSnapshotPending={codexSnapshotPending}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
providerConnectionAuthModes={providerConnectionAuthModes}
|
||||
codexRateLimitsLoading={codexAccount.rateLimitsLoading}
|
||||
anthropicRateLimitsRefreshing={anthropicRateLimitsRefreshing}
|
||||
isBusy={isBusy}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { RecentProjectsSection } from '@features/recent-projects/renderer';
|
||||
import { RunningTeamsSection } from '@features/running-teams/renderer';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
import { Command, Search, Users } from 'lucide-react';
|
||||
|
|
@ -131,6 +132,8 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<RunningTeamsSection searchQuery={searchQuery} />
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
getAnthropicDashboardRateLimits,
|
||||
getCodexDashboardRateLimits,
|
||||
shouldShowDashboardRateLimitSkeleton,
|
||||
} from './providerDashboardRateLimits';
|
||||
|
||||
import type { CliProviderConnectionInfo, CliProviderStatus } from '@shared/types';
|
||||
|
||||
function createProvider(overrides: Partial<CliProviderStatus>): CliProviderStatus {
|
||||
return {
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'claude.ai',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['haiku'],
|
||||
modelAvailability: [],
|
||||
runtimeCapabilities: null,
|
||||
subscriptionRateLimits: null,
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
||||
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||
},
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'oauth',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
codex: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCodexConnection(): CliProviderConnectionInfo {
|
||||
return {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'chatgpt',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
codex: {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: 'chatgpt',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: {
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
planType: 'pro',
|
||||
},
|
||||
requiresOpenaiAuth: false,
|
||||
localAccountArtifactsPresent: true,
|
||||
localActiveChatgptAccountPresent: true,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: {
|
||||
limitId: null,
|
||||
limitName: null,
|
||||
primary: {
|
||||
usedPercent: 20,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: null,
|
||||
},
|
||||
secondary: null,
|
||||
credits: null,
|
||||
planType: 'pro',
|
||||
},
|
||||
launchAllowed: true,
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState: 'ready_chatgpt',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('providerDashboardRateLimits', () => {
|
||||
test('shows Anthropic subscription limits for subscription auth', () => {
|
||||
const items = getAnthropicDashboardRateLimits(
|
||||
createProvider({
|
||||
authMethod: 'claude.ai',
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 25,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: null,
|
||||
},
|
||||
secondary: {
|
||||
usedPercent: 50,
|
||||
windowDurationMins: 10_080,
|
||||
resetsAt: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
label: '5h left',
|
||||
remaining: '75%',
|
||||
resetsAt: 'reset unknown',
|
||||
},
|
||||
{
|
||||
label: 'Weekly left',
|
||||
remaining: '50%',
|
||||
resetsAt: 'reset unknown',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('hides Anthropic subscription limits in API key mode', () => {
|
||||
const provider = createProvider({
|
||||
authMethod: 'claude.ai',
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
codex: null,
|
||||
},
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 25,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: null,
|
||||
},
|
||||
secondary: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAnthropicDashboardRateLimits(provider)).toBeNull();
|
||||
});
|
||||
|
||||
test('hides Anthropic limits when auth method is API key even if a snapshot exists', () => {
|
||||
expect(
|
||||
getAnthropicDashboardRateLimits(
|
||||
createProvider({
|
||||
authMethod: 'api_key',
|
||||
subscriptionRateLimits: {
|
||||
primary: {
|
||||
usedPercent: 25,
|
||||
windowDurationMins: 300,
|
||||
resetsAt: null,
|
||||
},
|
||||
secondary: null,
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps existing Codex subscription limit rendering', () => {
|
||||
const items = getCodexDashboardRateLimits(
|
||||
createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authMethod: 'oauth_token',
|
||||
connection: createCodexConnection(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
label: '5h left',
|
||||
remaining: '80%',
|
||||
resetsAt: 'reset unknown',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('shows Anthropic rate limit skeletons when subscription mode is selected in config', () => {
|
||||
expect(
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
provider: createProvider({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Checking...',
|
||||
connection: null,
|
||||
}),
|
||||
configuredAuthModes: {
|
||||
anthropic: 'oauth',
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('hides Anthropic rate limit skeletons when API key mode is selected', () => {
|
||||
expect(
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
provider: createProvider({
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Checking...',
|
||||
connection: null,
|
||||
}),
|
||||
sourceProvider: createProvider({
|
||||
authenticated: true,
|
||||
authMethod: 'claude.ai',
|
||||
}),
|
||||
configuredAuthModes: {
|
||||
anthropic: 'api_key',
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('shows Codex rate limit skeletons when ChatGPT account mode is selected', () => {
|
||||
expect(
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
provider: createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Checking...',
|
||||
connection: null,
|
||||
}),
|
||||
configuredAuthModes: {
|
||||
codex: 'chatgpt',
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('hides Codex rate limit skeletons when API key mode is selected', () => {
|
||||
expect(
|
||||
shouldShowDashboardRateLimitSkeleton({
|
||||
provider: createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
statusMessage: 'Checking...',
|
||||
connection: null,
|
||||
}),
|
||||
sourceProvider: createProvider({
|
||||
providerId: 'codex',
|
||||
displayName: 'Codex',
|
||||
authMethod: 'chatgpt',
|
||||
connection: createCodexConnection(),
|
||||
}),
|
||||
configuredAuthModes: {
|
||||
codex: 'api_key',
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
268
src/renderer/components/dashboard/providerDashboardRateLimits.ts
Normal file
268
src/renderer/components/dashboard/providerDashboardRateLimits.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import {
|
||||
formatCodexRemainingPercent,
|
||||
formatCodexWindowDuration,
|
||||
normalizeCodexResetTimestamp,
|
||||
} from '@features/codex-account/renderer';
|
||||
|
||||
import type {
|
||||
CodexAccountAuthMode,
|
||||
CodexAccountEffectiveAuthMode,
|
||||
} from '@features/codex-account/contracts';
|
||||
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
|
||||
|
||||
export interface DashboardRateLimitItem {
|
||||
label: string;
|
||||
remaining: string;
|
||||
resetsAt: string;
|
||||
}
|
||||
|
||||
export interface DashboardRateLimitSkeletonModeInput {
|
||||
provider: CliProviderStatus;
|
||||
sourceProvider?: CliProviderStatus | null;
|
||||
configuredAuthModes?: {
|
||||
anthropic?: CliProviderAuthMode | null;
|
||||
codex?: CodexAccountAuthMode | CliProviderAuthMode | null;
|
||||
};
|
||||
}
|
||||
|
||||
function firstKnown<T>(...values: Array<T | null | undefined>): T | null {
|
||||
for (const value of values) {
|
||||
if (value !== null && typeof value !== 'undefined') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCodexSubscriptionActive(
|
||||
connection: CliProviderStatus['connection'] | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
connection?.codex?.effectiveAuthMode === 'chatgpt' &&
|
||||
(connection.codex.managedAccount?.type === 'chatgpt' || connection.codex.launchAllowed)
|
||||
);
|
||||
}
|
||||
|
||||
function isAnthropicSubscriptionActive(provider: CliProviderStatus): boolean {
|
||||
return (
|
||||
provider.providerId === 'anthropic' &&
|
||||
provider.authenticated &&
|
||||
provider.connection?.configuredAuthMode !== 'api_key' &&
|
||||
(provider.authMethod === 'claude.ai' || provider.authMethod === 'oauth_token')
|
||||
);
|
||||
}
|
||||
|
||||
function getProviderConfiguredAuthMode({
|
||||
provider,
|
||||
sourceProvider,
|
||||
configuredAuthModes,
|
||||
}: DashboardRateLimitSkeletonModeInput): CliProviderAuthMode | null {
|
||||
if (provider.providerId === 'anthropic') {
|
||||
return firstKnown(
|
||||
configuredAuthModes?.anthropic,
|
||||
provider.connection?.configuredAuthMode,
|
||||
sourceProvider?.connection?.configuredAuthMode
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return firstKnown(
|
||||
configuredAuthModes?.codex as CliProviderAuthMode | null | undefined,
|
||||
provider.connection?.codex?.preferredAuthMode,
|
||||
provider.connection?.configuredAuthMode,
|
||||
sourceProvider?.connection?.codex?.preferredAuthMode,
|
||||
sourceProvider?.connection?.configuredAuthMode
|
||||
);
|
||||
}
|
||||
|
||||
return firstKnown(
|
||||
provider.connection?.configuredAuthMode,
|
||||
sourceProvider?.connection?.configuredAuthMode
|
||||
);
|
||||
}
|
||||
|
||||
function getCodexEffectiveAuthMode(
|
||||
provider: CliProviderStatus,
|
||||
sourceProvider: CliProviderStatus | null | undefined
|
||||
): CodexAccountEffectiveAuthMode {
|
||||
return firstKnown(
|
||||
provider.connection?.codex?.effectiveAuthMode,
|
||||
sourceProvider?.connection?.codex?.effectiveAuthMode
|
||||
) as CodexAccountEffectiveAuthMode;
|
||||
}
|
||||
|
||||
export function isDashboardRateLimitSubscriptionMode({
|
||||
provider,
|
||||
sourceProvider = null,
|
||||
configuredAuthModes,
|
||||
}: DashboardRateLimitSkeletonModeInput): boolean {
|
||||
if (provider.providerId === 'anthropic') {
|
||||
const configuredAuthMode = getProviderConfiguredAuthMode({
|
||||
provider,
|
||||
sourceProvider,
|
||||
configuredAuthModes,
|
||||
});
|
||||
|
||||
if (configuredAuthMode === 'api_key') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configuredAuthMode === 'oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
provider.authMethod === 'claude.ai' ||
|
||||
provider.authMethod === 'oauth_token' ||
|
||||
sourceProvider?.authMethod === 'claude.ai' ||
|
||||
sourceProvider?.authMethod === 'oauth_token'
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
const configuredAuthMode = getProviderConfiguredAuthMode({
|
||||
provider,
|
||||
sourceProvider,
|
||||
configuredAuthModes,
|
||||
});
|
||||
|
||||
if (configuredAuthMode === 'api_key') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configuredAuthMode === 'chatgpt') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getCodexEffectiveAuthMode(provider, sourceProvider) === 'chatgpt';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowDashboardRateLimitSkeleton(
|
||||
input: DashboardRateLimitSkeletonModeInput
|
||||
): boolean {
|
||||
return isDashboardRateLimitSubscriptionMode(input);
|
||||
}
|
||||
|
||||
function buildRateLimitLabel(
|
||||
fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left',
|
||||
windowDurationMins: number | null | undefined
|
||||
): string {
|
||||
const duration = formatCodexWindowDuration(windowDurationMins);
|
||||
return duration ? `${duration} left` : fallbackTitle;
|
||||
}
|
||||
|
||||
function buildAnthropicRateLimitLabel(
|
||||
fallbackTitle: 'Primary left' | 'Secondary left' | 'Weekly left',
|
||||
windowDurationMins: number | null | undefined
|
||||
): string {
|
||||
if (windowDurationMins === 10_080) {
|
||||
return 'Weekly left';
|
||||
}
|
||||
|
||||
return buildRateLimitLabel(fallbackTitle, windowDurationMins);
|
||||
}
|
||||
|
||||
function formatDashboardResetTime(timestampSeconds: number | null | undefined): string {
|
||||
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
|
||||
if (!normalized) {
|
||||
return 'reset unknown';
|
||||
}
|
||||
|
||||
return new Date(normalized).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function buildRateLimitItem(
|
||||
label: string,
|
||||
usedPercent: number,
|
||||
resetsAt: number | null | undefined
|
||||
): DashboardRateLimitItem {
|
||||
return {
|
||||
label,
|
||||
remaining: formatCodexRemainingPercent(usedPercent) ?? 'Unknown',
|
||||
resetsAt: formatDashboardResetTime(resetsAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCodexDashboardRateLimits(
|
||||
provider: CliProviderStatus
|
||||
): DashboardRateLimitItem[] | null {
|
||||
if (provider.providerId !== 'codex' || !isCodexSubscriptionActive(provider.connection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rateLimits = provider.connection?.codex?.rateLimits;
|
||||
if (!rateLimits?.primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: DashboardRateLimitItem[] = [
|
||||
buildRateLimitItem(
|
||||
buildRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins),
|
||||
rateLimits.primary.usedPercent,
|
||||
rateLimits.primary.resetsAt
|
||||
),
|
||||
];
|
||||
|
||||
if (rateLimits.secondary) {
|
||||
items.push(
|
||||
buildRateLimitItem(
|
||||
buildRateLimitLabel(
|
||||
rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left',
|
||||
rateLimits.secondary.windowDurationMins
|
||||
),
|
||||
rateLimits.secondary.usedPercent,
|
||||
rateLimits.secondary.resetsAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getAnthropicDashboardRateLimits(
|
||||
provider: CliProviderStatus
|
||||
): DashboardRateLimitItem[] | null {
|
||||
if (!isAnthropicSubscriptionActive(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rateLimits = provider.subscriptionRateLimits;
|
||||
if (!rateLimits?.primary && !rateLimits?.secondary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items: DashboardRateLimitItem[] = [];
|
||||
if (rateLimits.primary) {
|
||||
items.push(
|
||||
buildRateLimitItem(
|
||||
buildAnthropicRateLimitLabel('Primary left', rateLimits.primary.windowDurationMins),
|
||||
rateLimits.primary.usedPercent,
|
||||
rateLimits.primary.resetsAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (rateLimits.secondary) {
|
||||
items.push(
|
||||
buildRateLimitItem(
|
||||
buildAnthropicRateLimitLabel(
|
||||
rateLimits.secondary.windowDurationMins === 10_080 ? 'Weekly left' : 'Secondary left',
|
||||
rateLimits.secondary.windowDurationMins
|
||||
),
|
||||
rateLimits.secondary.usedPercent,
|
||||
rateLimits.secondary.resetsAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
597
src/renderer/components/team/TeamChangesSection.tsx
Normal file
597
src/renderer/components/team/TeamChangesSection.tsx
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||
import {
|
||||
buildTaskChangeRequestOptions,
|
||||
canDisplayTaskChangesForOptions,
|
||||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { FileIcon } from './editor/FileIcon';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
|
||||
import type {
|
||||
FileChangeSummary,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
||||
const TEAM_CHANGES_MAX_REQUESTS = 120;
|
||||
const TEAM_CHANGES_UNKNOWN_SCAN_LIMIT = 32;
|
||||
const TEAM_CHANGES_MAX_RENDERED_FILE_ROWS = 300;
|
||||
|
||||
interface TeamChangesSectionProps {
|
||||
teamName: string;
|
||||
tasks: TeamTaskWithKanban[];
|
||||
onViewChanges: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
interface TeamChangeCandidate {
|
||||
task: TeamTaskWithKanban;
|
||||
options: TaskChangeRequestOptions;
|
||||
priority: number;
|
||||
isUnknownScan: boolean;
|
||||
}
|
||||
|
||||
interface TeamChangeRequestPlan {
|
||||
requests: TeamTaskChangeSummaryRequest[];
|
||||
requestOptionsByTaskId: Map<string, TaskChangeRequestOptions>;
|
||||
eligibleCount: number;
|
||||
requestedCount: number;
|
||||
deferredCount: number;
|
||||
nextUnknownScanCursor: number;
|
||||
}
|
||||
|
||||
interface TeamChangeSummaryState {
|
||||
taskId: string;
|
||||
changeSet: TaskChangeSetV2 | null;
|
||||
error?: string;
|
||||
options: TaskChangeRequestOptions;
|
||||
loadedAt: number;
|
||||
}
|
||||
|
||||
interface TeamChangeStats {
|
||||
eligibleCount: number;
|
||||
requestedCount: number;
|
||||
deferredCount: number;
|
||||
}
|
||||
|
||||
function getTaskTimeMs(task: TeamTaskWithKanban): number {
|
||||
const value = task.updatedAt ?? task.createdAt;
|
||||
if (!value) return 0;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
function compareCandidateRecency(a: TeamChangeCandidate, b: TeamChangeCandidate): number {
|
||||
const priorityDelta = a.priority - b.priority;
|
||||
if (priorityDelta !== 0) return priorityDelta;
|
||||
return getTaskTimeMs(b.task) - getTaskTimeMs(a.task);
|
||||
}
|
||||
|
||||
function rotateCandidates<T>(items: T[], cursor: number): T[] {
|
||||
if (items.length === 0) return items;
|
||||
const start = cursor % items.length;
|
||||
if (start === 0) return items;
|
||||
return [...items.slice(start), ...items.slice(0, start)];
|
||||
}
|
||||
|
||||
function buildTeamChangeRequestPlan(
|
||||
tasks: TeamTaskWithKanban[],
|
||||
unknownScanCursor: number,
|
||||
forceFresh: boolean
|
||||
): TeamChangeRequestPlan {
|
||||
const primary: TeamChangeCandidate[] = [];
|
||||
const active: TeamChangeCandidate[] = [];
|
||||
const unknown: TeamChangeCandidate[] = [];
|
||||
const seenTaskIds = new Set<string>();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.id || task.status === 'deleted' || seenTaskIds.has(task.id)) {
|
||||
continue;
|
||||
}
|
||||
seenTaskIds.add(task.id);
|
||||
|
||||
const options = buildTaskChangeRequestOptions(task, { summaryOnly: true });
|
||||
const presence = task.changePresence ?? 'unknown';
|
||||
const canDisplay = canDisplayTaskChangesForOptions(options);
|
||||
if (!canDisplay && presence !== 'has_changes' && presence !== 'needs_attention') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (presence === 'has_changes') {
|
||||
primary.push({ task, options, priority: 0, isUnknownScan: false });
|
||||
continue;
|
||||
}
|
||||
if (presence === 'needs_attention') {
|
||||
primary.push({ task, options, priority: 1, isUnknownScan: false });
|
||||
continue;
|
||||
}
|
||||
if (options.stateBucket === 'active' && options.status === 'in_progress') {
|
||||
active.push({ task, options, priority: 2, isUnknownScan: false });
|
||||
continue;
|
||||
}
|
||||
if (presence === 'unknown') {
|
||||
unknown.push({ task, options, priority: 3, isUnknownScan: true });
|
||||
}
|
||||
}
|
||||
|
||||
primary.sort(compareCandidateRecency);
|
||||
active.sort(compareCandidateRecency);
|
||||
unknown.sort(compareCandidateRecency);
|
||||
|
||||
const unknownWindow = rotateCandidates(unknown, unknownScanCursor).slice(
|
||||
0,
|
||||
TEAM_CHANGES_UNKNOWN_SCAN_LIMIT
|
||||
);
|
||||
const selected = [...primary, ...active, ...unknownWindow].slice(0, TEAM_CHANGES_MAX_REQUESTS);
|
||||
const requestOptionsByTaskId = new Map<string, TaskChangeRequestOptions>();
|
||||
const requests = selected.map((candidate) => {
|
||||
const options = {
|
||||
...candidate.options,
|
||||
summaryOnly: true,
|
||||
forceFresh: forceFresh ? true : candidate.options.forceFresh,
|
||||
};
|
||||
requestOptionsByTaskId.set(candidate.task.id, options);
|
||||
return {
|
||||
taskId: candidate.task.id,
|
||||
options,
|
||||
};
|
||||
});
|
||||
const eligibleCount = primary.length + active.length + unknown.length;
|
||||
const nextUnknownScanCursor =
|
||||
unknown.length > 0
|
||||
? (unknownScanCursor + Math.min(TEAM_CHANGES_UNKNOWN_SCAN_LIMIT, unknown.length)) %
|
||||
unknown.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
requests,
|
||||
requestOptionsByTaskId,
|
||||
eligibleCount,
|
||||
requestedCount: requests.length,
|
||||
deferredCount: Math.max(0, eligibleCount - requests.length),
|
||||
nextUnknownScanCursor,
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskChangeContributors(
|
||||
task: TeamTaskWithKanban,
|
||||
changeSet: TaskChangeSetV2 | null
|
||||
): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const contributor of changeSet?.scope.contributors ?? []) {
|
||||
if (contributor.memberName) names.add(contributor.memberName);
|
||||
}
|
||||
for (const name of changeSet?.scope.memberNames ?? []) {
|
||||
names.add(name);
|
||||
}
|
||||
if (changeSet?.scope.primaryMemberName) {
|
||||
names.add(changeSet.scope.primaryMemberName);
|
||||
}
|
||||
for (const file of changeSet?.files ?? []) {
|
||||
for (const name of file.ledgerSummary?.memberNames ?? []) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
if (names.size === 0 && task.owner) {
|
||||
names.add(task.owner);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function getVisibleFileName(file: FileChangeSummary): string {
|
||||
const value = file.relativePath || file.filePath;
|
||||
return value.split('/').pop() ?? value;
|
||||
}
|
||||
|
||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||
if (!changeSet) return undefined;
|
||||
if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
|
||||
if (changeSet.warnings.length > 0) return 'attention';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildTasksFingerprint(tasks: TeamTaskWithKanban[]): string {
|
||||
return tasks
|
||||
.map((task) =>
|
||||
[
|
||||
task.id,
|
||||
task.status,
|
||||
task.owner ?? '',
|
||||
task.updatedAt ?? '',
|
||||
task.changePresence ?? 'unknown',
|
||||
task.workIntervals?.length ?? 0,
|
||||
].join(':')
|
||||
)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||
teamName,
|
||||
tasks,
|
||||
onViewChanges,
|
||||
}: TeamChangesSectionProps): React.JSX.Element {
|
||||
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
|
||||
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
|
||||
const [sectionOpen, setSectionOpen] = useState(false);
|
||||
const [summariesByTaskId, setSummariesByTaskId] = useState<
|
||||
Record<string, TeamChangeSummaryState>
|
||||
>({});
|
||||
const [stats, setStats] = useState<TeamChangeStats>({
|
||||
eligibleCount: 0,
|
||||
requestedCount: 0,
|
||||
deferredCount: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
const requestSeqRef = useRef(0);
|
||||
const unknownScanCursorRef = useRef(0);
|
||||
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
|
||||
const tasksFingerprint = useMemo(() => buildTasksFingerprint(tasks), [tasks]);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
|
||||
const visibleSummaries = useMemo(() => {
|
||||
return Object.values(summariesByTaskId)
|
||||
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
|
||||
.filter(
|
||||
(entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } =>
|
||||
Boolean(entry.task) &&
|
||||
(Boolean(entry.summary.error) ||
|
||||
(entry.summary.changeSet?.files.length ?? 0) > 0 ||
|
||||
(entry.summary.changeSet?.warnings.length ?? 0) > 0)
|
||||
)
|
||||
.sort((a, b) => getTaskTimeMs(b.task) - getTaskTimeMs(a.task));
|
||||
}, [summariesByTaskId, taskMap]);
|
||||
|
||||
const totalFiles = visibleSummaries.reduce(
|
||||
(sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0),
|
||||
0
|
||||
);
|
||||
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
||||
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined;
|
||||
|
||||
const loadSummaries = useCallback(
|
||||
async ({
|
||||
forceFresh = false,
|
||||
showSpinner = false,
|
||||
preserveOnError = true,
|
||||
}: {
|
||||
forceFresh?: boolean;
|
||||
showSpinner?: boolean;
|
||||
preserveOnError?: boolean;
|
||||
} = {}): Promise<void> => {
|
||||
const plan = buildTeamChangeRequestPlan(tasks, unknownScanCursorRef.current, forceFresh);
|
||||
unknownScanCursorRef.current = plan.nextUnknownScanCursor;
|
||||
setStats({
|
||||
eligibleCount: plan.eligibleCount,
|
||||
requestedCount: plan.requestedCount,
|
||||
deferredCount: plan.deferredCount,
|
||||
});
|
||||
setError(null);
|
||||
|
||||
if (plan.requests.length === 0) {
|
||||
setSummariesByTaskId({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSeq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = requestSeq;
|
||||
if (showSpinner) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const item of response.items) {
|
||||
const changeSet = item.changeSet;
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!changeSet || !options) continue;
|
||||
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
|
||||
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown');
|
||||
}
|
||||
|
||||
setSummariesByTaskId((previous) => {
|
||||
const next: Record<string, TeamChangeSummaryState> = {};
|
||||
for (const [taskId, summary] of Object.entries(previous)) {
|
||||
if (currentTaskIds.has(taskId)) {
|
||||
next[taskId] = summary;
|
||||
}
|
||||
}
|
||||
for (const item of response.items) {
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!options) continue;
|
||||
next[item.taskId] = {
|
||||
taskId: item.taskId,
|
||||
changeSet: item.changeSet,
|
||||
error: item.error,
|
||||
options,
|
||||
loadedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
if (!preserveOnError) {
|
||||
setSummariesByTaskId({});
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Failed to load team changes');
|
||||
} finally {
|
||||
if (requestSeqRef.current === requestSeq) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[recordTaskChangePresence, setSelectedTeamTaskChangePresence, tasks, teamName]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hasLoadedRef.current = false;
|
||||
requestSeqRef.current += 1;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
setError(null);
|
||||
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || hasLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
hasLoadedRef.current = true;
|
||||
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
||||
void loadSummaries({ showSpinner: true, preserveOnError: false });
|
||||
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || !hasLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (lastRequestedTasksFingerprintRef.current === tasksFingerprint) {
|
||||
return;
|
||||
}
|
||||
lastRequestedTasksFingerprintRef.current = tasksFingerprint;
|
||||
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
||||
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void loadSummaries({ showSpinner: false, preserveOnError: true });
|
||||
}, TEAM_CHANGES_AUTO_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadSummaries, sectionOpen]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
|
||||
}, [loadSummaries]);
|
||||
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="changes"
|
||||
title="Changes"
|
||||
icon={<FileDiff size={14} />}
|
||||
badge={badge}
|
||||
defaultOpen={false}
|
||||
onOpenChange={setSectionOpen}
|
||||
headerExtra={
|
||||
loading && !sectionOpen ? (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="pointer-events-none animate-spin text-[var(--color-text-muted)]"
|
||||
/>
|
||||
) : sectionOpen ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-section-hover)] hover:text-[var(--color-text)] disabled:opacity-50"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRefresh();
|
||||
}}
|
||||
disabled={loading || refreshing}
|
||||
aria-label="Refresh team changes"
|
||||
>
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={loading || refreshing ? 'animate-spin' : undefined}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
>
|
||||
{loading && visibleSummaries.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading changes...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
) : visibleSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
||||
{visibleSummaries.map(({ summary, task }) => {
|
||||
const changeSet = summary.changeSet;
|
||||
const files = changeSet?.files ?? [];
|
||||
const fileBudget = Math.max(0, remainingFileRows);
|
||||
const visibleFiles = files.slice(0, fileBudget);
|
||||
remainingFileRows -= visibleFiles.length;
|
||||
const contributors = getTaskChangeContributors(task, changeSet);
|
||||
const contributorLabel =
|
||||
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
|
||||
const extraContributors = Math.max(0, contributors.length - 3);
|
||||
const badgeText = getTaskSummaryBadge(changeSet);
|
||||
|
||||
if (visibleFiles.length === 0 && !summary.error && !changeSet?.warnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.taskId}
|
||||
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-t-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => onViewChanges(task.id)}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
#{deriveTaskDisplayId(task.id)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</span>
|
||||
<span
|
||||
className="hidden max-w-[180px] shrink-0 truncate text-[10px] text-[var(--color-text-muted)] sm:inline"
|
||||
title={contributors.join(', ')}
|
||||
>
|
||||
{contributorLabel}
|
||||
{extraContributors > 0 ? ` +${extraContributors}` : ''}
|
||||
</span>
|
||||
{badgeText ? (
|
||||
<span className="shrink-0 rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{badgeText}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{summary.error ? (
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border)] px-2 py-1.5 text-xs text-red-400">
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{summary.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{changeSet?.warnings.length ? (
|
||||
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
|
||||
{changeSet.warnings.slice(0, 2).map((warning) => (
|
||||
<div
|
||||
key={warning}
|
||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
||||
>
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleFiles.length > 0 ? (
|
||||
<div className="border-t border-[var(--color-border)] py-0.5">
|
||||
{visibleFiles.map((file) => (
|
||||
<div
|
||||
key={`${summary.taskId}:${file.filePath}`}
|
||||
className="group flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
>
|
||||
<FileIcon fileName={getVisibleFileName(file)} className="size-3.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
title={file.relativePath || file.filePath}
|
||||
>
|
||||
{file.relativePath || file.filePath}
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
) : null}
|
||||
{file.linesRemoved > 0 ? (
|
||||
<span className="text-red-400">-{file.linesRemoved}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
aria-label="Review diff"
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Review diff</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{files.length > visibleFiles.length && fileBudget > 0 ? (
|
||||
<div className="border-t border-[var(--color-border)] px-2 py-1.5 text-xs text-[var(--color-text-muted)]">
|
||||
{files.length - visibleFiles.length} more files
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{refreshing ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Refreshing
|
||||
</span>
|
||||
) : null}
|
||||
{hiddenFileRows > 0 ? <span>{hiddenFileRows} file rows hidden</span> : null}
|
||||
{stats.deferredCount > 0 ? (
|
||||
<span>{stats.deferredCount} tasks deferred this pass</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 py-1">
|
||||
<p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p>
|
||||
{stats.eligibleCount > 0 ? (
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||
Scanned {stats.requestedCount} of {stats.eligibleCount} candidate tasks
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTeamSection>
|
||||
);
|
||||
});
|
||||
|
||||
TeamChangesSection.displayName = 'TeamChangesSection';
|
||||
|
|
@ -133,6 +133,7 @@ import { LeadSessionDetailGate } from './LeadSessionDetailGate';
|
|||
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
|
||||
import { ProcessesSection } from './ProcessesSection';
|
||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||||
import { TeamChangesSection } from './TeamChangesSection';
|
||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
|
|
@ -2714,6 +2715,12 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<TeamChangesSection
|
||||
teamName={teamName}
|
||||
tasks={data.tasks}
|
||||
onViewChanges={handleViewChangesForFile}
|
||||
/>
|
||||
|
||||
<CollapsibleTeamSection
|
||||
sectionId="schedules"
|
||||
title="Schedules"
|
||||
|
|
|
|||
|
|
@ -31,18 +31,7 @@ import { nameColorSet } from '@renderer/utils/projectColor';
|
|||
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
||||
import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Play,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Copy, FolderOpen, GitBranch, Play, RotateCcw, Search, Square, Trash2 } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
|
|
@ -52,6 +41,7 @@ import {
|
|||
resolveTeamProjectSelection,
|
||||
teamMatchesProjectSelection,
|
||||
} from './teamProjectSelection';
|
||||
import { TeamTaskStatusSummary } from './TeamTaskStatusSummary';
|
||||
|
||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||
|
|
@ -1044,58 +1034,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
)}
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
{(() => {
|
||||
const tc = taskCountsByTeam.get(team.teamName);
|
||||
const pending = tc?.pending ?? 0;
|
||||
const inProgress = tc?.inProgress ?? 0;
|
||||
const completed = tc?.completed ?? 0;
|
||||
const totalTasks = pending + inProgress + completed;
|
||||
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
|
||||
return (
|
||||
<div className="mt-2 w-full space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalTasks}
|
||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
||||
style={{ width: `${Math.round(completedRatio * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||
{completed}/{totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
{totalTasks > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{inProgress > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Play size={10} className="shrink-0 text-blue-400" />
|
||||
{inProgress} in_progress
|
||||
</span>
|
||||
)}
|
||||
{pending > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={10} className="shrink-0 text-amber-400" />
|
||||
{pending} pending
|
||||
</span>
|
||||
)}
|
||||
{completed > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
|
||||
{completed} completed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<TeamTaskStatusSummary counts={taskCountsByTeam.get(team.teamName)} />
|
||||
{renderTeamRecentPaths(team, status, matchesCurrentProject, isLight)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
88
src/renderer/components/team/TeamTaskStatusSummary.tsx
Normal file
88
src/renderer/components/team/TeamTaskStatusSummary.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { CheckCircle, Clock, Play } from 'lucide-react';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type React from 'react';
|
||||
|
||||
interface TeamTaskStatusSummaryProps {
|
||||
counts?: TaskStatusCounts | null;
|
||||
className?: string;
|
||||
showProgress?: boolean;
|
||||
iconSize?: number;
|
||||
countersClassName?: string;
|
||||
}
|
||||
|
||||
function normalizeCounts(counts?: TaskStatusCounts | null): TaskStatusCounts {
|
||||
return {
|
||||
pending: counts?.pending ?? 0,
|
||||
inProgress: counts?.inProgress ?? 0,
|
||||
completed: counts?.completed ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskStatusTotal(counts?: TaskStatusCounts | null): number {
|
||||
const normalized = normalizeCounts(counts);
|
||||
return normalized.pending + normalized.inProgress + normalized.completed;
|
||||
}
|
||||
|
||||
export const TeamTaskStatusSummary = ({
|
||||
counts,
|
||||
className = 'mt-2 w-full space-y-1.5',
|
||||
showProgress = true,
|
||||
iconSize = 10,
|
||||
countersClassName = 'flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]',
|
||||
}: Readonly<TeamTaskStatusSummaryProps>): React.JSX.Element | null => {
|
||||
const normalized = normalizeCounts(counts);
|
||||
const totalTasks = getTaskStatusTotal(normalized);
|
||||
const completedRatio = totalTasks > 0 ? normalized.completed / totalTasks : 0;
|
||||
|
||||
if (!showProgress && totalTasks === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{showProgress && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={normalized.completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalTasks}
|
||||
aria-label={`Tasks ${normalized.completed}/${totalTasks} completed`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
|
||||
style={{ width: `${Math.round(completedRatio * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
|
||||
{normalized.completed}/{totalTasks}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{totalTasks > 0 && (
|
||||
<div className={countersClassName}>
|
||||
{normalized.inProgress > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Play size={iconSize} className="shrink-0 text-blue-400" />
|
||||
{normalized.inProgress} in_progress
|
||||
</span>
|
||||
)}
|
||||
{normalized.pending > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={iconSize} className="shrink-0 text-amber-400" />
|
||||
{normalized.pending} pending
|
||||
</span>
|
||||
)}
|
||||
{normalized.completed > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CheckCircle size={iconSize} className="shrink-0 text-emerald-400" />
|
||||
{normalized.completed} completed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamTaskStatusSummary } from '../TeamTaskStatusSummary';
|
||||
|
||||
function renderSummary(element: React.ReactElement): {
|
||||
host: HTMLDivElement;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
} {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(element);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('TeamTaskStatusSummary', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('renders task status counters with team card labels', () => {
|
||||
const { host, root } = renderSummary(
|
||||
<TeamTaskStatusSummary
|
||||
showProgress={false}
|
||||
counts={{ inProgress: 2, pending: 2, completed: 1 }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain('2 in_progress');
|
||||
expect(host.textContent).toContain('2 pending');
|
||||
expect(host.textContent).toContain('1 completed');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides zero counters when progress is disabled', () => {
|
||||
const { host, root } = renderSummary(
|
||||
<TeamTaskStatusSummary
|
||||
showProgress={false}
|
||||
counts={{ inProgress: 0, pending: 0, completed: 0 }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(host.textContent).toBe('');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -347,7 +347,7 @@ const PassiveIdlePeerSummaryRow = ({
|
|||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5" style={{ opacity: 0.78 }}>
|
||||
<span className="bg-sky-500/12 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
|
||||
update
|
||||
note
|
||||
</span>
|
||||
<MemberBadge
|
||||
name={senderName}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING =
|
||||
'Sonnet 1M context can affect billing depending on your Anthropic plan and runtime. Claude Platform lists Sonnet 4.6 1M at standard API pricing, while Claude Code plans can require Extra Usage for Sonnet 1M; enable Limit context to 200K tokens to avoid long-context behavior.';
|
||||
export const ANTHROPIC_LONG_CONTEXT_PRICING_URL =
|
||||
'https://platform.claude.com/docs/en/about-claude/pricing';
|
||||
|
||||
export const AnthropicExtraUsageWarning = (): React.JSX.Element => (
|
||||
<p>
|
||||
{ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
|
||||
<a
|
||||
href={ANTHROPIC_LONG_CONTEXT_PRICING_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-amber-100 underline underline-offset-2 hover:text-white"
|
||||
>
|
||||
Read Anthropic pricing docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
|
|
@ -463,10 +463,6 @@ export const CreateTeamDialog = ({
|
|||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
setStoredCreateTeamProvider(normalizedValue);
|
||||
if (normalizedValue !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
setStoredCreateTeamLimitContext(false);
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
};
|
||||
|
||||
|
|
@ -589,6 +585,8 @@ export const CreateTeamDialog = ({
|
|||
])
|
||||
);
|
||||
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||
const hasSelectedAnthropicRuntime = selectedMemberProviders.includes('anthropic');
|
||||
const effectiveAnthropicRuntimeLimitContext = hasSelectedAnthropicRuntime ? limitContext : false;
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (
|
||||
|
|
@ -670,13 +668,13 @@ export const CreateTeamDialog = ({
|
|||
selectedProviderId,
|
||||
selectedModel,
|
||||
selectedMemberProviders,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
membersSignature: prepareMembersSignature,
|
||||
}),
|
||||
[
|
||||
effectiveCwd,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
prepareMembersSignature,
|
||||
prepareRuntimeStatusSignature,
|
||||
selectedMemberProviders,
|
||||
|
|
@ -777,7 +775,7 @@ export const CreateTeamDialog = ({
|
|||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||
const leadModel = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId
|
||||
);
|
||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||
|
|
@ -816,7 +814,7 @@ export const CreateTeamDialog = ({
|
|||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
const cachedModelResultsById = {
|
||||
|
|
@ -859,7 +857,7 @@ export const CreateTeamDialog = ({
|
|||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ status, details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
|
|
@ -940,7 +938,7 @@ export const CreateTeamDialog = ({
|
|||
launchTeam,
|
||||
effectiveCwd,
|
||||
effectiveMemberDrafts,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
prepareRequestSignature,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
|
|
@ -1169,11 +1167,16 @@ export const CreateTeamDialog = ({
|
|||
() =>
|
||||
computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
),
|
||||
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||
[
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
const teammateRuntimeCompatibility = useMemo(
|
||||
() =>
|
||||
|
|
@ -1209,10 +1212,15 @@ export const CreateTeamDialog = ({
|
|||
runtimeCapabilities: runtimeProviderStatusById.get('anthropic')?.runtimeCapabilities,
|
||||
},
|
||||
selectedModel,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
})
|
||||
: null,
|
||||
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||
[
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
const anthropicFastModeResolution = useMemo(
|
||||
() =>
|
||||
|
|
@ -1274,7 +1282,7 @@ export const CreateTeamDialog = ({
|
|||
runtimeCapabilities: null,
|
||||
},
|
||||
selectedModel,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
}),
|
||||
selectedEffort,
|
||||
selectedFastMode,
|
||||
|
|
@ -1320,7 +1328,7 @@ export const CreateTeamDialog = ({
|
|||
anthropicProviderFastModeDefault,
|
||||
anthropicRuntimeSelection,
|
||||
codexRuntimeSelection,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeProviderStatusById,
|
||||
selectedEffort,
|
||||
selectedFastMode,
|
||||
|
|
@ -1350,7 +1358,7 @@ export const CreateTeamDialog = ({
|
|||
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
|
||||
? selectedFastMode
|
||||
: undefined,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
skipPermissions,
|
||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||
extraCliArgs: customArgs.trim() || undefined,
|
||||
|
|
@ -1368,7 +1376,7 @@ export const CreateTeamDialog = ({
|
|||
effectiveModel,
|
||||
selectedEffort,
|
||||
selectedFastMode,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
skipPermissions,
|
||||
worktreeEnabled,
|
||||
worktreeName,
|
||||
|
|
@ -1512,12 +1520,16 @@ export const CreateTeamDialog = ({
|
|||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (effectiveAnthropicRuntimeLimitContext) {
|
||||
summary.push('Anthropic limited to 200K context');
|
||||
}
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||
return summary;
|
||||
}, [
|
||||
anthropicProviderFastModeDefault,
|
||||
customArgs,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
prompt,
|
||||
selectedFastMode,
|
||||
selectedProviderId,
|
||||
|
|
@ -1833,7 +1845,7 @@ export const CreateTeamDialog = ({
|
|||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
limitContext={limitContext}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
|
|
@ -1944,7 +1956,7 @@ export const CreateTeamDialog = ({
|
|||
onValueChange={setSelectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
model={selectedModel}
|
||||
limitContext={limitContext}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
id="create-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
|
|
|
|||
|
|
@ -516,6 +516,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
),
|
||||
[effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
|
||||
);
|
||||
const hasSelectedAnthropicRuntime = isLaunchMode && selectedMemberProviders.includes('anthropic');
|
||||
const effectiveAnthropicRuntimeLimitContext =
|
||||
hasSelectedAnthropicRuntime && !isSchedule ? limitContext : false;
|
||||
|
||||
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||
const entries: (readonly [TeamProviderId, string | null])[] = (
|
||||
|
|
@ -642,10 +645,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
: normalizeOneShotProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
||||
if (normalizedValue !== 'anthropic') {
|
||||
setLimitContextRaw(false);
|
||||
localStorage.setItem('team:lastLimitContext', 'false');
|
||||
}
|
||||
setSelectedModelRaw(getStoredTeamModel(normalizedValue));
|
||||
};
|
||||
|
||||
|
|
@ -897,17 +896,20 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return previousProviderId !== selectedProviderId;
|
||||
}, [isLaunchMode, previousProviderId, selectedProviderId]);
|
||||
|
||||
const effectiveAnthropicRuntimeLimitContext = isSchedule ? false : limitContext;
|
||||
|
||||
const effectiveLeadRuntimeModel = useMemo(
|
||||
() =>
|
||||
computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
) ?? '',
|
||||
[limitContext, runtimeProviderStatusById, selectedModel, selectedProviderId]
|
||||
[
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
]
|
||||
);
|
||||
const selectedProviderBackendId = useMemo(
|
||||
() =>
|
||||
|
|
@ -1401,13 +1403,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedProviderId,
|
||||
selectedModel,
|
||||
selectedMemberProviders,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
modelChecksSignature: selectedModelChecksByProviderSignature,
|
||||
}),
|
||||
[
|
||||
effectiveCwd,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
prepareRuntimeStatusSignature,
|
||||
selectedMemberProviders,
|
||||
selectedModel,
|
||||
|
|
@ -1477,7 +1479,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
runtimeStatusSignature: prepareRuntimeStatusSignature,
|
||||
});
|
||||
const cachedModelResultsById = {
|
||||
|
|
@ -1520,7 +1522,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId: plan.providerId,
|
||||
selectedModelIds: plan.selectedModelChecks,
|
||||
prepareProvisioning: api.teams.prepareProvisioning,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
cachedModelResultsById: plan.cachedModelResultsById,
|
||||
onModelProgress: ({ status, details }) => {
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
|
|
@ -1599,6 +1601,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
open,
|
||||
isLaunchMode,
|
||||
effectiveCwd,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
prepareRequestSignature,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
|
|
@ -1742,7 +1745,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
if (skipPermissions) args.push('--dangerously-skip-permissions');
|
||||
const model = computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
);
|
||||
|
|
@ -1769,7 +1772,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
isLaunchMode,
|
||||
skipPermissions,
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedEffort,
|
||||
selectedProviderId,
|
||||
clearContext,
|
||||
|
|
@ -1799,7 +1802,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||
if (effectiveAnthropicRuntimeLimitContext) {
|
||||
summary.push('Anthropic limited to 200K context');
|
||||
}
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (clearContext) summary.push('Fresh session');
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
|
|
@ -1814,7 +1819,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedEffort,
|
||||
selectedFastMode,
|
||||
anthropicProviderFastModeDefault,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
skipPermissions,
|
||||
clearContext,
|
||||
worktreeEnabled,
|
||||
|
|
@ -2054,7 +2059,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
undefined,
|
||||
model: computeEffectiveTeamModel(
|
||||
selectedModel,
|
||||
limitContext,
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
selectedProviderId,
|
||||
runtimeProviderStatusById.get(selectedProviderId)
|
||||
),
|
||||
|
|
@ -2063,7 +2068,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
selectedProviderId === 'anthropic' || selectedProviderId === 'codex'
|
||||
? selectedFastMode
|
||||
: undefined,
|
||||
limitContext,
|
||||
limitContext: effectiveAnthropicRuntimeLimitContext,
|
||||
clearContext: clearContext || undefined,
|
||||
skipPermissions,
|
||||
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||
|
|
@ -2542,7 +2547,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
limitContext={limitContext}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface LimitContextCheckboxProps {
|
|||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
scopeLabel?: string;
|
||||
}
|
||||
|
||||
export const LimitContextCheckbox: React.FC<LimitContextCheckboxProps> = ({
|
||||
|
|
@ -22,21 +23,25 @@ export const LimitContextCheckbox: React.FC<LimitContextCheckboxProps> = ({
|
|||
checked,
|
||||
onCheckedChange,
|
||||
disabled = false,
|
||||
scopeLabel,
|
||||
}) => (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked && !disabled}
|
||||
checked={disabled ? true : checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={`flex cursor-pointer items-center gap-1.5 text-xs font-normal ${
|
||||
className={`flex flex-wrap items-center gap-1.5 text-xs font-normal leading-snug ${
|
||||
disabled ? 'cursor-not-allowed text-text-muted opacity-50' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
Limit context to 200K tokens
|
||||
{scopeLabel ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">({scopeLabel})</span>
|
||||
) : null}
|
||||
{disabled && <span className="text-[10px] italic">(always 200K for this model)</span>}
|
||||
</Label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
|
|
@ -48,8 +53,8 @@ export const LimitContextCheckbox: React.FC<LimitContextCheckboxProps> = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px]">
|
||||
<p>
|
||||
Agents will use 200K context window instead of the default 1M. Useful if you want to
|
||||
save tokens and reduce costs.
|
||||
Enable this to cap Anthropic runtimes at 200K tokens. Leave it off only when you want
|
||||
the selected Anthropic model or runtime to use a longer context window when available.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,8 @@ export function formatTeamModelSummary(
|
|||
* Computes the effective model string for team provisioning.
|
||||
* By default adds [1m] suffix for Opus 1M context.
|
||||
* When limitContext=true, returns base model without [1m] (200K context).
|
||||
* Sonnet and Haiku default to standard context to avoid extra-usage-only variants.
|
||||
* Standard Sonnet and Haiku selections stay standard context. Explicit Sonnet 1M selections keep
|
||||
* their [1m] suffix unless the 200K limit is enabled.
|
||||
*/
|
||||
export function computeEffectiveTeamModel(
|
||||
selectedModel: string,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
||||
LimitContextCheckbox: () => React.createElement('div', null, 'limit-context'),
|
||||
LimitContextCheckbox: ({ disabled, scopeLabel }: { disabled?: boolean; scopeLabel?: string }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
null,
|
||||
['limit-context', scopeLabel, disabled ? 'disabled' : 'enabled'].filter(Boolean).join(' ')
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||
|
|
@ -55,8 +60,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({
|
|||
|
||||
vi.mock('@renderer/utils/teamModelCatalog', () => ({
|
||||
isAnthropicHaikuTeamModel: () => false,
|
||||
isAnthropicSonnetTeamModel: (model: string | undefined) =>
|
||||
model === 'sonnet' || model === 'claude-sonnet-4-6' || model === 'sonnet[1m]',
|
||||
isAnthropicSonnetOneMillionContextTeamModel: (model: string | undefined) =>
|
||||
model === 'sonnet[1m]' || model === 'claude-sonnet-4-6' || model === 'claude-sonnet-4-6[1m]',
|
||||
}));
|
||||
|
||||
vi.mock('../../ui/button', () => ({
|
||||
|
|
@ -135,15 +140,16 @@ describe('LeadModelRow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('warns that unchecked 200K limit can put Sonnet on Anthropic Extra Usage', () => {
|
||||
it('warns that unchecked 200K limit can affect Sonnet 1M billing by plan/runtime', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
model: 'sonnet[1m]',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Sonnet with 1M context can use Anthropic Extra Usage');
|
||||
expect(host.textContent).toContain('Requests over 200K input tokens');
|
||||
expect(host.textContent).toContain('Sonnet 1M context can affect billing');
|
||||
expect(host.textContent).toContain('standard API pricing');
|
||||
expect(host.textContent).toContain('Extra Usage for Sonnet 1M');
|
||||
const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`);
|
||||
|
||||
expect(docsLink?.textContent).toContain('Anthropic pricing docs');
|
||||
|
|
@ -158,7 +164,7 @@ describe('LeadModelRow', () => {
|
|||
it('does not show the Sonnet Extra Usage warning when 200K limit is enabled', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
model: 'sonnet[1m]',
|
||||
limitContext: true,
|
||||
});
|
||||
|
||||
|
|
@ -168,4 +174,77 @@ describe('LeadModelRow', () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the Sonnet Extra Usage warning for standard-context Sonnet', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Anthropic Extra Usage');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('warns for native 1M Sonnet launch ids without an explicit suffix', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Sonnet 1M context can affect billing');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the team-wide Anthropic context control when only teammates use Anthropic', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
showAnthropicContextLimit: true,
|
||||
});
|
||||
|
||||
const modelButton = host.querySelector(
|
||||
'button[aria-label="codex provider, gpt-5.4"]'
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
modelButton.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('limit-context Anthropic team-wide');
|
||||
expect(host.textContent).toContain(
|
||||
'The 200K context limit is team-wide for Anthropic runtimes'
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('honors the explicit disabled state for the Anthropic context control', () => {
|
||||
const { host, root } = renderLeadModelRow({
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
disableAnthropicContextLimit: true,
|
||||
});
|
||||
|
||||
const modelButton = host.querySelector(
|
||||
'button[aria-label="anthropic provider, haiku"]'
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
modelButton.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('limit-context disabled');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import {
|
||||
AnthropicExtraUsageWarning,
|
||||
ANTHROPIC_LONG_CONTEXT_PRICING_URL,
|
||||
ANTHROPIC_SONNET_EXTRA_USAGE_WARNING,
|
||||
} from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
import {
|
||||
|
|
@ -16,7 +21,7 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
isAnthropicHaikuTeamModel,
|
||||
isAnthropicSonnetTeamModel,
|
||||
isAnthropicSonnetOneMillionContextTeamModel,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
|
@ -25,10 +30,7 @@ import { Button } from '../../ui/button';
|
|||
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING =
|
||||
'Sonnet with 1M context can use Anthropic Extra Usage. Requests over 200K input tokens are billed at premium long-context rates; enable Limit context to 200K tokens to avoid that billing path.';
|
||||
export const ANTHROPIC_LONG_CONTEXT_PRICING_URL =
|
||||
'https://platform.claude.com/docs/en/about-claude/pricing';
|
||||
export { ANTHROPIC_LONG_CONTEXT_PRICING_URL, ANTHROPIC_SONNET_EXTRA_USAGE_WARNING };
|
||||
|
||||
interface LeadModelRowProps {
|
||||
providerId: TeamProviderId;
|
||||
|
|
@ -44,6 +46,8 @@ interface LeadModelRowProps {
|
|||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
showAnthropicContextLimit?: boolean;
|
||||
disableAnthropicContextLimit?: boolean;
|
||||
}
|
||||
|
||||
export const LeadModelRow = ({
|
||||
|
|
@ -60,6 +64,8 @@ export const LeadModelRow = ({
|
|||
warningText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
showAnthropicContextLimit = providerId === 'anthropic',
|
||||
disableAnthropicContextLimit,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
|
@ -70,11 +76,16 @@ export const LeadModelRow = ({
|
|||
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
|
||||
const hasModelIssue = Boolean(modelIssueText);
|
||||
const showSonnetExtraUsageWarning =
|
||||
providerId === 'anthropic' && !limitContext && isAnthropicSonnetTeamModel(model);
|
||||
providerId === 'anthropic' &&
|
||||
!limitContext &&
|
||||
isAnthropicSonnetOneMillionContextTeamModel(model);
|
||||
const warningMessages = [warningText?.trim() || null].filter((message): message is string =>
|
||||
Boolean(message)
|
||||
);
|
||||
const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning;
|
||||
const contextLimitDisabled =
|
||||
disableAnthropicContextLimit ??
|
||||
(providerId === 'anthropic' && isAnthropicHaikuTeamModel(model));
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -154,20 +165,7 @@ export const LeadModelRow = ({
|
|||
{warningMessages.map((message) => (
|
||||
<p key={message}>{message}</p>
|
||||
))}
|
||||
{showSonnetExtraUsageWarning ? (
|
||||
<p>
|
||||
{ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
|
||||
<a
|
||||
href={ANTHROPIC_LONG_CONTEXT_PRICING_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium text-amber-100 underline underline-offset-2 hover:text-white"
|
||||
>
|
||||
Read Anthropic pricing docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{showSonnetExtraUsageWarning ? <AnthropicExtraUsageWarning /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -191,19 +189,22 @@ export const LeadModelRow = ({
|
|||
model={model}
|
||||
limitContext={limitContext}
|
||||
/>
|
||||
{providerId === 'anthropic' ? (
|
||||
{showAnthropicContextLimit ? (
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
checked={limitContext}
|
||||
onCheckedChange={onLimitContextChange}
|
||||
disabled={isAnthropicHaikuTeamModel(model)}
|
||||
disabled={contextLimitDisabled}
|
||||
scopeLabel={providerId === 'anthropic' ? undefined : 'Anthropic team-wide'}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
These settings control the team lead and act as the default runtime for teammates that
|
||||
do not have their own override.
|
||||
Lead runtime applies to teammates unless they set their own provider or model.
|
||||
{showAnthropicContextLimit
|
||||
? ' The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.'
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
<CurrentTaskIndicator
|
||||
task={reviewTask}
|
||||
borderColor={colors.border}
|
||||
activityLabel="reviewing"
|
||||
activityLabel={reviewTaskTimer ? 'reviewing' : 'review requested'}
|
||||
activityTimer={reviewTaskTimer}
|
||||
isTimerRunning={reviewTaskTimerRunning}
|
||||
onOpenTask={onOpenReviewTask}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client';
|
|||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ANTHROPIC_LONG_CONTEXT_PRICING_URL } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
|
||||
|
||||
vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
|
||||
ProviderBrandLogo: () => React.createElement('span', { 'data-testid': 'provider-logo' }),
|
||||
}));
|
||||
|
|
@ -182,4 +184,89 @@ describe('MemberDraftRow', () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('explains that Anthropic context limit is team-wide for teammate overrides', () => {
|
||||
const { host, root } = renderMemberDraftRow({
|
||||
limitContext: true,
|
||||
});
|
||||
|
||||
const modelButton = host.querySelector(
|
||||
'button[aria-label="anthropic provider, opus"]'
|
||||
) as HTMLButtonElement;
|
||||
act(() => {
|
||||
modelButton.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Anthropic context is team-wide for this launch');
|
||||
expect(host.textContent).toContain('200K limit enabled');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('warns custom Anthropic Sonnet teammates about plan/runtime billing when 200K limit is off', () => {
|
||||
const { host, root } = renderMemberDraftRow({
|
||||
member: {
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet[1m]',
|
||||
},
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Sonnet 1M context can affect billing');
|
||||
expect(host.textContent).toContain('Extra Usage for Sonnet 1M');
|
||||
const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`);
|
||||
|
||||
expect(docsLink?.textContent).toContain('Anthropic pricing docs');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not warn standard-context Anthropic Sonnet teammates about Extra Usage', () => {
|
||||
const { host, root } = renderMemberDraftRow({
|
||||
member: createMemberDraft({
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
}),
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Anthropic Extra Usage');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate the Sonnet Extra Usage warning for effort-only inherited teammates', () => {
|
||||
const { host, root } = renderMemberDraftRow({
|
||||
member: createMemberDraft({
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
providerId: undefined,
|
||||
model: '',
|
||||
effort: 'max',
|
||||
}),
|
||||
inheritedProviderId: 'anthropic',
|
||||
inheritedModel: 'sonnet[1m]',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Anthropic Extra Usage');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import {
|
||||
formatTeamModelSummary,
|
||||
|
|
@ -21,6 +22,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { isAnthropicSonnetOneMillionContextTeamModel } from '@renderer/utils/teamModelCatalog';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -225,6 +227,20 @@ export const MemberDraftRow = ({
|
|||
const worktreeIsolationDisabled =
|
||||
isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree');
|
||||
const hasModelIssue = Boolean(modelIssueText);
|
||||
const hasCustomProviderOrModel =
|
||||
!forceInheritedModelSettings && Boolean(member.providerId || member.model?.trim());
|
||||
const showSonnetExtraUsageWarning =
|
||||
effectiveProviderId === 'anthropic' &&
|
||||
!limitContext &&
|
||||
hasCustomProviderOrModel &&
|
||||
isAnthropicSonnetOneMillionContextTeamModel(effectiveModel);
|
||||
const warningMessages = [warningText?.trim() || null].filter((message): message is string =>
|
||||
Boolean(message)
|
||||
);
|
||||
const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning;
|
||||
const anthropicContextModeLabel = limitContext
|
||||
? '200K limit enabled'
|
||||
: '1M-capable context allowed';
|
||||
const runtimeSummary = formatTeamModelSummary(
|
||||
effectiveProviderId,
|
||||
effectiveModel?.trim() ?? '',
|
||||
|
|
@ -413,11 +429,16 @@ export const MemberDraftRow = ({
|
|||
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isRemoved && warningText ? (
|
||||
{!isRemoved && hasWarnings ? (
|
||||
<div className="md:col-span-3">
|
||||
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{warningText}</p>
|
||||
<div className="space-y-1">
|
||||
{warningMessages.map((message) => (
|
||||
<p key={message}>{message}</p>
|
||||
))}
|
||||
{showSonnetExtraUsageWarning ? <AnthropicExtraUsageWarning /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -518,6 +539,15 @@ export const MemberDraftRow = ({
|
|||
model={effectiveModel}
|
||||
limitContext={limitContext}
|
||||
/>
|
||||
{effectiveProviderId === 'anthropic' ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Anthropic context is team-wide for this launch: {anthropicContextModeLabel}. Use
|
||||
the lead runtime panel's Limit context checkbox to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{lockProviderModel && (
|
||||
<p className="text-[11px] text-amber-300">
|
||||
{modelLockReason ??
|
||||
|
|
|
|||
|
|
@ -203,6 +203,8 @@ const ToolUsageBars = ({
|
|||
};
|
||||
|
||||
const TRAILING_PUNCT = ';.,';
|
||||
const INVALID_PATH_NAMES = new Set(['null', 'undefined', 'none']);
|
||||
const WINDOWS_NULL_DEVICE_RE = /^[a-z]:\/nul$/;
|
||||
|
||||
function isInvalidPath(path: string): boolean {
|
||||
let trimmed = path.trim();
|
||||
|
|
@ -211,7 +213,15 @@ function isInvalidPath(path: string): boolean {
|
|||
end--;
|
||||
}
|
||||
trimmed = trimmed.slice(0, end);
|
||||
return !trimmed || trimmed === 'null' || trimmed === 'undefined' || trimmed === 'None';
|
||||
const normalized = trimmed.replace(/\\/g, '/').toLowerCase();
|
||||
return (
|
||||
!trimmed ||
|
||||
INVALID_PATH_NAMES.has(normalized) ||
|
||||
normalized === '/dev/null' ||
|
||||
normalized === '//./nul' ||
|
||||
normalized === '//?/nul' ||
|
||||
WINDOWS_NULL_DEVICE_RE.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
const FilesTouchedSection = ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
|
||||
const leadRowMockState = vi.hoisted(() => ({
|
||||
lastLeadProps: null as {
|
||||
showAnthropicContextLimit?: boolean;
|
||||
disableAnthropicContextLimit?: boolean;
|
||||
} | null,
|
||||
}));
|
||||
|
||||
vi.mock('./LeadModelRow', () => ({
|
||||
LeadModelRow: (props: {
|
||||
showAnthropicContextLimit?: boolean;
|
||||
disableAnthropicContextLimit?: boolean;
|
||||
}) => {
|
||||
leadRowMockState.lastLeadProps = props;
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'lead-model-row' },
|
||||
String(props.showAnthropicContextLimit)
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./MembersEditorSection', () => ({
|
||||
MembersEditorSection: ({ headerExtra }: { headerExtra?: React.ReactNode }) =>
|
||||
React.createElement('div', null, headerExtra),
|
||||
}));
|
||||
|
||||
import { TeamRosterEditorSection } from './TeamRosterEditorSection';
|
||||
|
||||
function renderTeamRosterEditorSection(overrides: {
|
||||
providerId?: React.ComponentProps<typeof TeamRosterEditorSection>['providerId'];
|
||||
model?: string;
|
||||
members?: MemberDraft[];
|
||||
syncModelsWithTeammates?: boolean;
|
||||
forceInheritedModelSettings?: boolean;
|
||||
hideMembersContent?: boolean;
|
||||
}): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TeamRosterEditorSection, {
|
||||
members: overrides.members ?? [],
|
||||
onMembersChange: () => undefined,
|
||||
inheritedProviderId: overrides.providerId ?? 'codex',
|
||||
inheritedModel: '',
|
||||
providerId: overrides.providerId ?? 'codex',
|
||||
model: overrides.model ?? '',
|
||||
limitContext: false,
|
||||
onProviderChange: () => undefined,
|
||||
onModelChange: () => undefined,
|
||||
onEffortChange: () => undefined,
|
||||
onLimitContextChange: () => undefined,
|
||||
syncModelsWithTeammates: overrides.syncModelsWithTeammates ?? false,
|
||||
onSyncModelsWithTeammatesChange: () => undefined,
|
||||
forceInheritedModelSettings: overrides.forceInheritedModelSettings,
|
||||
hideMembersContent: overrides.hideMembersContent,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('TeamRosterEditorSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
leadRowMockState.lastLeadProps = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('shows the Anthropic context control for explicit Anthropic teammates under a non-Anthropic lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'codex',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the Anthropic context control when teammates are synced to a non-Anthropic lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'codex',
|
||||
syncModelsWithTeammates: true,
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale Anthropic teammate drafts when member content is hidden', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'codex',
|
||||
hideMembersContent: true,
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the team-wide context control enabled for Anthropic teammate overrides under a Haiku lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: 'opus',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true);
|
||||
expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the team-wide context control enabled for inherited Anthropic model overrides under a Haiku lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
model: 'opus',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true);
|
||||
expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the team-wide context control enabled for Anthropic provider defaults under a Haiku lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
providerId: 'anthropic',
|
||||
model: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true);
|
||||
expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the team-wide context control disabled when teammates only inherit a Haiku lead', () => {
|
||||
const { root } = renderTeamRosterEditorSection({
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
members: [
|
||||
{
|
||||
id: 'member-1',
|
||||
name: 'alice',
|
||||
roleSelection: 'developer',
|
||||
customRole: '',
|
||||
model: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(leadRowMockState.lastLeadProps?.showAnthropicContextLimit).toBe(true);
|
||||
expect(leadRowMockState.lastLeadProps?.disableAnthropicContextLimit).toBe(true);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
||||
|
||||
import { LeadModelRow } from './LeadModelRow';
|
||||
import { MembersEditorSection } from './MembersEditorSection';
|
||||
|
||||
|
|
@ -98,6 +100,33 @@ export const TeamRosterEditorSection = ({
|
|||
worktreeIsolationDisabledReason,
|
||||
onTeammateWorktreeDefaultChange,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
const canUseCustomMemberRuntimes =
|
||||
!hideMembersContent && !forceInheritedModelSettings && !syncModelsWithTeammates;
|
||||
const activeRuntimeMembers = canUseCustomMemberRuntimes
|
||||
? members.filter((member) => !member.removedAt)
|
||||
: [];
|
||||
const hasCustomAnthropicRuntime = activeRuntimeMembers.some(
|
||||
(member) => member.providerId === 'anthropic'
|
||||
);
|
||||
const hasMemberAnthropicRuntimeWithContextChoice = activeRuntimeMembers.some((member) => {
|
||||
if (member.providerId === 'anthropic') {
|
||||
const memberModel = member.model?.trim();
|
||||
return !memberModel || !isAnthropicHaikuTeamModel(memberModel);
|
||||
}
|
||||
|
||||
if (member.providerId == null && providerId === 'anthropic') {
|
||||
const memberModel = member.model?.trim();
|
||||
return Boolean(memberModel && !isAnthropicHaikuTeamModel(memberModel));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
const hasAnthropicRuntime = providerId === 'anthropic' || hasCustomAnthropicRuntime;
|
||||
const disableAnthropicContextLimit =
|
||||
providerId === 'anthropic' &&
|
||||
isAnthropicHaikuTeamModel(model) &&
|
||||
!hasMemberAnthropicRuntimeWithContextChoice;
|
||||
|
||||
return (
|
||||
<MembersEditorSection
|
||||
members={members}
|
||||
|
|
@ -145,6 +174,8 @@ export const TeamRosterEditorSection = ({
|
|||
warningText={leadWarningText}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={leadModelIssueText}
|
||||
showAnthropicContextLimit={hasAnthropicRuntime}
|
||||
disableAnthropicContextLimit={disableAnthropicContextLimit}
|
||||
/>
|
||||
{headerBottom}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -589,7 +589,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
const activityTimelineMessages = useMemo(() => {
|
||||
return filterTeamMessages(effectiveMessages, {
|
||||
includeAutomationEvents: true,
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||
leadNames,
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
|
|
|
|||
16
src/renderer/components/ui/ActivePulseIndicator.tsx
Normal file
16
src/renderer/components/ui/ActivePulseIndicator.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface ActivePulseIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ActivePulseIndicator = ({
|
||||
className,
|
||||
}: Readonly<ActivePulseIndicatorProps>): React.JSX.Element => (
|
||||
<span className={cn('relative inline-flex size-2.5', className)} aria-hidden="true">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
);
|
||||
|
|
@ -303,8 +303,7 @@
|
|||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#splash.splash-status-error #splash-timeline,
|
||||
#splash.splash-status-slow #splash-timeline {
|
||||
#splash.splash-status-error #splash-timeline {
|
||||
display: flex;
|
||||
}
|
||||
.splash-step {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ function renderStartupTimeline(status: AppStartupStatus): void {
|
|||
const timeline = document.getElementById('splash-timeline');
|
||||
if (!timeline) return;
|
||||
|
||||
if (!status.error) {
|
||||
timeline.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT);
|
||||
timeline.replaceChildren();
|
||||
|
||||
|
|
@ -109,7 +114,8 @@ function renderStartupTimeline(status: AppStartupStatus): void {
|
|||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'splash-step-time';
|
||||
time.textContent = formatDuration(getStepElapsedMs(step, status));
|
||||
const elapsed = formatDuration(getStepElapsedMs(step, status));
|
||||
time.textContent = step.finishedAt ? `took ${elapsed}` : `running ${elapsed}`;
|
||||
|
||||
row.append(dot, label, time);
|
||||
timeline.append(row);
|
||||
|
|
|
|||
|
|
@ -374,32 +374,39 @@ export function deriveReviewActivityTimerAnchor(
|
|||
};
|
||||
}
|
||||
|
||||
if (reviewIntervals.length > 0) return null;
|
||||
const anchorEvent = getCurrentReviewTimerAnchorEvent(task, memberKey);
|
||||
if (!anchorEvent) return null;
|
||||
|
||||
const startedAtMs = parseIsoMs(anchorEvent.timestamp);
|
||||
if (startedAtMs <= 0) return null;
|
||||
|
||||
return {
|
||||
startedAt: anchorEvent.timestamp,
|
||||
startedAtMs,
|
||||
baseElapsedMs: 0,
|
||||
timerId: createMemberActivityTimerId({
|
||||
teamName: params.teamName,
|
||||
memberName: params.memberName,
|
||||
phase: 'review',
|
||||
taskId: task.id,
|
||||
startedAt: anchorEvent.timestamp,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentReviewTimerAnchorEvent(
|
||||
task: TeamTaskWithKanban,
|
||||
memberKey: string
|
||||
): { timestamp: string } | null {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (event.type === 'review_started') {
|
||||
if (normalizeMemberName(event.actor) !== memberKey) {
|
||||
return null;
|
||||
}
|
||||
const startedAtMs = parseIsoMs(event.timestamp);
|
||||
if (startedAtMs <= 0) return null;
|
||||
return {
|
||||
startedAt: event.timestamp,
|
||||
startedAtMs,
|
||||
baseElapsedMs: 0,
|
||||
timerId: createMemberActivityTimerId({
|
||||
teamName: params.teamName,
|
||||
memberName: params.memberName,
|
||||
phase: 'review',
|
||||
taskId: task.id,
|
||||
startedAt: event.timestamp,
|
||||
}),
|
||||
};
|
||||
return normalizeMemberName(event.actor) === memberKey ? { timestamp: event.timestamp } : null;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'review_requested' ||
|
||||
event.type === 'review_approved' ||
|
||||
event.type === 'review_changes_requested' ||
|
||||
event.type === 'task_created' ||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { parseModelString } from '@shared/utils/modelParser';
|
||||
import { inferContextWindowTokens } from '@shared/utils/contextMetrics';
|
||||
import {
|
||||
getOpenCodeQualifiedModelSourceLabel,
|
||||
parseOpenCodeQualifiedModelRef,
|
||||
|
|
@ -249,6 +250,25 @@ export function isAnthropicSonnetTeamModel(model: string | undefined): boolean {
|
|||
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
|
||||
}
|
||||
|
||||
export function isAnthropicOneMillionContextTeamModel(model: string | undefined): boolean {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
inferContextWindowTokens({
|
||||
providerId: 'anthropic',
|
||||
modelName: trimmed,
|
||||
limitContext: false,
|
||||
}) === 1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
export function isAnthropicSonnetOneMillionContextTeamModel(model: string | undefined): boolean {
|
||||
return isAnthropicSonnetTeamModel(model) && isAnthropicOneMillionContextTeamModel(model);
|
||||
}
|
||||
|
||||
export function getTeamProviderLabel(
|
||||
providerId: SupportedProviderId | undefined
|
||||
): string | undefined {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ import type {
|
|||
HunkDecision,
|
||||
RejectResult,
|
||||
SnippetDiff,
|
||||
TaskChangeRequestOptions,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
TeamTaskChangeSummaryRequest,
|
||||
} from './review';
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
|
|
@ -709,21 +712,12 @@ export interface ReviewAPI {
|
|||
getTaskChanges: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
/** Persisted work intervals (preferred for reliable owner-log attribution). */
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Back-compat: single since timestamp (deprecated). */
|
||||
since?: string;
|
||||
/** Derived task lifecycle bucket used for safe summary caching. */
|
||||
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
|
||||
/** Lightweight response for summary UIs; skips snippets/timeline details. */
|
||||
summaryOnly?: boolean;
|
||||
/** Force a fresh recompute and overwrite any cache snapshot. */
|
||||
forceFresh?: boolean;
|
||||
}
|
||||
options?: TaskChangeRequestOptions
|
||||
) => Promise<TaskChangeSetV2>;
|
||||
getTeamTaskChangeSummaries: (
|
||||
teamName: string,
|
||||
requests: TeamTaskChangeSummaryRequest[]
|
||||
) => Promise<TeamTaskChangeSummariesResponse>;
|
||||
invalidateTaskChangeSummaries: (teamName: string, taskIds: string[]) => Promise<void>;
|
||||
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
|
||||
getFileContent: (
|
||||
|
|
|
|||
|
|
@ -185,6 +185,17 @@ export interface CliProviderRuntimeCapabilities {
|
|||
};
|
||||
}
|
||||
|
||||
export interface CliProviderSubscriptionRateLimitWindow {
|
||||
usedPercent: number;
|
||||
windowDurationMins: number | null;
|
||||
resetsAt: number | null;
|
||||
}
|
||||
|
||||
export interface CliProviderSubscriptionRateLimitSnapshot {
|
||||
primary: CliProviderSubscriptionRateLimitWindow | null;
|
||||
secondary: CliProviderSubscriptionRateLimitWindow | null;
|
||||
}
|
||||
|
||||
export interface CliProviderStatus {
|
||||
providerId: CliProviderId;
|
||||
displayName: string;
|
||||
|
|
@ -199,6 +210,7 @@ export interface CliProviderStatus {
|
|||
modelCatalog?: CliProviderModelCatalog | null;
|
||||
modelAvailability?: CliProviderModelAvailability[];
|
||||
runtimeCapabilities?: CliProviderRuntimeCapabilities | null;
|
||||
subscriptionRateLimits?: CliProviderSubscriptionRateLimitSnapshot | null;
|
||||
canLoginFromUi: boolean;
|
||||
capabilities: {
|
||||
teamLaunch: boolean;
|
||||
|
|
|
|||
|
|
@ -291,6 +291,39 @@ export interface TaskChangeSetV2 extends TaskChangeSet {
|
|||
provenance?: TaskChangeProvenance;
|
||||
}
|
||||
|
||||
export interface TaskChangeRequestOptions {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
/** Persisted work intervals (preferred for reliable owner-log attribution). */
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Back-compat: single since timestamp (deprecated). */
|
||||
since?: string;
|
||||
/** Derived task lifecycle bucket used for safe summary caching. */
|
||||
stateBucket?: 'approved' | 'review' | 'completed' | 'active';
|
||||
/** Lightweight response for summary UIs; skips snippets/timeline details. */
|
||||
summaryOnly?: boolean;
|
||||
/** Force a fresh recompute and overwrite any cache snapshot. */
|
||||
forceFresh?: boolean;
|
||||
}
|
||||
|
||||
export interface TeamTaskChangeSummaryRequest {
|
||||
taskId: string;
|
||||
options?: TaskChangeRequestOptions;
|
||||
}
|
||||
|
||||
export interface TeamTaskChangeSummaryItem {
|
||||
taskId: string;
|
||||
changeSet: TaskChangeSetV2 | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TeamTaskChangeSummariesResponse {
|
||||
teamName: string;
|
||||
items: TeamTaskChangeSummaryItem[];
|
||||
computedAt: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
// ── Phase 4: Enhanced Features types ──
|
||||
|
||||
/** Одно событие в timeline файла */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ function stripOneMillionSuffix(model: string): string {
|
|||
return model.replace(/(?:\[1m\])+$/i, '');
|
||||
}
|
||||
|
||||
function hasOneMillionSuffix(model: string): boolean {
|
||||
return /\[1m\]$/i.test(model);
|
||||
}
|
||||
|
||||
function isAnthropicHaikuModel(model: string): boolean {
|
||||
const baseModel = stripOneMillionSuffix(model);
|
||||
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
|
||||
|
|
@ -15,6 +19,20 @@ function isAnthropicSonnetModel(model: string): boolean {
|
|||
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
|
||||
}
|
||||
|
||||
function getStandardContextAlias(model: string): string | null {
|
||||
const baseModel = stripOneMillionSuffix(model);
|
||||
if (baseModel === 'opus' || baseModel.startsWith('claude-opus-')) {
|
||||
return 'opus';
|
||||
}
|
||||
if (baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-')) {
|
||||
return 'sonnet';
|
||||
}
|
||||
if (baseModel === 'haiku' || baseModel.startsWith('claude-haiku-')) {
|
||||
return 'haiku';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStandardOnlyAnthropicModel(model: string): string {
|
||||
const baseModel = stripOneMillionSuffix(model);
|
||||
return isAnthropicHaikuModel(baseModel) || isAnthropicSonnetModel(baseModel) ? baseModel : model;
|
||||
|
|
@ -64,7 +82,9 @@ export function resolveAnthropicLaunchModel(params: {
|
|||
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
|
||||
const rawPreferredDefault = runtimeDefault || staticDefault;
|
||||
const preferredDefault = params.limitContext
|
||||
? stripOneMillionSuffix(rawPreferredDefault) || staticDefault
|
||||
? (getStandardContextAlias(rawPreferredDefault) ??
|
||||
stripOneMillionSuffix(rawPreferredDefault)) ||
|
||||
staticDefault
|
||||
: normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault;
|
||||
if (availableModels.size === 0) {
|
||||
return preferredDefault;
|
||||
|
|
@ -80,16 +100,28 @@ export function resolveAnthropicLaunchModel(params: {
|
|||
);
|
||||
}
|
||||
|
||||
const selectedOneMillionContext = hasOneMillionSuffix(selectedModel);
|
||||
const baseModel = stripOneMillionSuffix(selectedModel);
|
||||
if (!baseModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
params.limitContext ||
|
||||
isAnthropicHaikuModel(baseModel) ||
|
||||
isAnthropicSonnetModel(baseModel)
|
||||
) {
|
||||
if (params.limitContext) {
|
||||
const standardAlias = getStandardContextAlias(baseModel);
|
||||
if (!standardAlias) {
|
||||
return baseModel;
|
||||
}
|
||||
if (availableModels.size === 0) {
|
||||
return standardAlias;
|
||||
}
|
||||
return chooseAvailableModel(availableModels, [standardAlias, baseModel]) ?? baseModel;
|
||||
}
|
||||
|
||||
if (isAnthropicHaikuModel(baseModel)) {
|
||||
return baseModel;
|
||||
}
|
||||
|
||||
if (isAnthropicSonnetModel(baseModel) && !selectedOneMillionContext) {
|
||||
return baseModel;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ export function stripTranscriptSpeakerPrefix(value: string): string {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
export function isTranscriptSpeakerPlaceholderText(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return /^(?:(?:Human|User):\s*)+$/i.test(trimmed);
|
||||
}
|
||||
|
||||
export function isNativeAppManagedBootstrapCheckText(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
|
|
@ -48,6 +56,7 @@ export function isTeammateProtocolControlText(value: unknown): boolean {
|
|||
|
||||
export function isTeamInternalControlMessageText(value: unknown): boolean {
|
||||
return (
|
||||
isTranscriptSpeakerPlaceholderText(value) ||
|
||||
isNativeAppManagedBootstrapCheckText(value) ||
|
||||
isLeadInboxRelayControlPromptText(value) ||
|
||||
isTeammateProtocolControlText(value)
|
||||
|
|
|
|||
|
|
@ -158,14 +158,16 @@ function createConfigManager(preferredAuthMode: 'auto' | 'chatgpt' | 'api_key' =
|
|||
};
|
||||
}
|
||||
|
||||
function createAccountResponse(overrides?: Partial<{
|
||||
requiresOpenaiAuth: boolean;
|
||||
account: { type: 'chatgpt'; email: string; planType: 'pro' | 'plus' } | null;
|
||||
}>) {
|
||||
function createAccountResponse(
|
||||
overrides?: Partial<{
|
||||
requiresOpenaiAuth: boolean;
|
||||
account: { type: 'chatgpt'; email: string; planType: 'pro' | 'plus' } | null;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
account:
|
||||
overrides && 'account' in overrides
|
||||
? overrides.account ?? null
|
||||
? (overrides.account ?? null)
|
||||
: {
|
||||
type: 'chatgpt' as const,
|
||||
email: 'user@example.com',
|
||||
|
|
@ -622,7 +624,9 @@ describe('createCodexAccountFeature', () => {
|
|||
});
|
||||
readRateLimitsMock
|
||||
.mockResolvedValueOnce(createRateLimitsResponse())
|
||||
.mockRejectedValueOnce(new Error('codex account authentication required to read rate limits'));
|
||||
.mockRejectedValueOnce(
|
||||
new Error('codex account authentication required to read rate limits')
|
||||
);
|
||||
const logger = createLoggerPort();
|
||||
const feature = createCodexAccountFeature({
|
||||
logger,
|
||||
|
|
@ -649,6 +653,86 @@ describe('createCodexAccountFeature', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps rate limits visible when account truth is temporarily reused from last known state', async () => {
|
||||
detectLocalAccountStateMock.mockResolvedValue({
|
||||
hasArtifacts: true,
|
||||
hasActiveChatgptAccount: true,
|
||||
});
|
||||
readAccountMock
|
||||
.mockResolvedValueOnce({
|
||||
account: createAccountResponse(),
|
||||
initialize: {
|
||||
codexHome: '/Users/test/.codex',
|
||||
platformFamily: 'unix',
|
||||
platformOs: 'macos',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
account: createAccountResponse({ account: null, requiresOpenaiAuth: true }),
|
||||
initialize: {
|
||||
codexHome: '/Users/test/.codex',
|
||||
platformFamily: 'unix',
|
||||
platformOs: 'macos',
|
||||
},
|
||||
});
|
||||
readRateLimitsMock.mockResolvedValue(createRateLimitsResponse());
|
||||
const feature = createCodexAccountFeature({
|
||||
logger: createLoggerPort(),
|
||||
configManager: createConfigManager('chatgpt'),
|
||||
});
|
||||
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||
dateNowSpy.mockReturnValue(1_776_000_060_000);
|
||||
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||
|
||||
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||
expect(secondSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||
expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||
expect(readRateLimitsMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps last known rate limits visible during a transient empty rate limit response', async () => {
|
||||
readAccountMock.mockResolvedValue({
|
||||
account: createAccountResponse(),
|
||||
initialize: {
|
||||
codexHome: '/Users/test/.codex',
|
||||
platformFamily: 'unix',
|
||||
platformOs: 'macos',
|
||||
},
|
||||
});
|
||||
readRateLimitsMock.mockResolvedValueOnce(createRateLimitsResponse()).mockResolvedValueOnce({
|
||||
rateLimits: null,
|
||||
rateLimitsByLimitId: null,
|
||||
});
|
||||
const feature = createCodexAccountFeature({
|
||||
logger: createLoggerPort(),
|
||||
configManager: createConfigManager('chatgpt'),
|
||||
});
|
||||
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||
const firstSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||
dateNowSpy.mockReturnValue(1_776_000_060_000);
|
||||
const secondSnapshot = await feature.refreshSnapshot({ includeRateLimits: true });
|
||||
|
||||
expect(firstSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||
expect(secondSnapshot.rateLimits?.primary?.usedPercent).toBe(77);
|
||||
expect(readRateLimitsMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
await feature.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reuse stale rate limits after the active ChatGPT account changes', async () => {
|
||||
readAccountMock
|
||||
.mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -352,6 +352,38 @@ describe('ChangeExtractorService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('loads team task change summaries with capped requests and per-task errors', async () => {
|
||||
const { service } = createService({ logPaths: [] });
|
||||
const getTaskChanges = vi
|
||||
.spyOn(service, 'getTaskChanges')
|
||||
.mockImplementation(async (_teamName, taskId, options) => {
|
||||
expect(options?.summaryOnly).toBe(true);
|
||||
if (taskId === 'task-2') {
|
||||
throw new Error('broken summary');
|
||||
}
|
||||
return makeTaskChangeResult(taskId, { taskId });
|
||||
});
|
||||
|
||||
const response = await service.getTeamTaskChangeSummaries(
|
||||
TEAM_NAME,
|
||||
Array.from({ length: 205 }, (_, index) => ({
|
||||
taskId: `task-${index}`,
|
||||
options: { ...SUMMARY_OPTIONS, summaryOnly: false },
|
||||
}))
|
||||
);
|
||||
|
||||
expect(response.teamName).toBe(TEAM_NAME);
|
||||
expect(response.truncated).toBe(true);
|
||||
expect(response.items).toHaveLength(200);
|
||||
expect(getTaskChanges).toHaveBeenCalledTimes(200);
|
||||
expect(response.items[0].changeSet?.taskId).toBe('task-0');
|
||||
expect(response.items[2]).toMatchObject({
|
||||
taskId: 'task-2',
|
||||
changeSet: null,
|
||||
error: 'broken summary',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reuse detailed task-change cache across different scope inputs', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ describe('isValidFilePath', () => {
|
|||
expect(isValidFilePath(' /tmp/file.txt ')).toBe(true);
|
||||
expect(isValidFilePath(' null ')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null-device paths', () => {
|
||||
expect(isValidFilePath('/dev/null')).toBe(false);
|
||||
expect(isValidFilePath('/dev/null;')).toBe(false);
|
||||
expect(isValidFilePath('C:/NUL')).toBe(false);
|
||||
expect(isValidFilePath('//./NUL')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateBashLinesChanged', () => {
|
||||
|
|
|
|||
|
|
@ -546,6 +546,22 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('suppresses bare transcript speaker placeholders from lead thought output', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||
service.setTeamChangeEmitter(emitter);
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Human: ' }],
|
||||
});
|
||||
|
||||
expect(service.getLiveLeadProcessMessages('my-team')).toHaveLength(0);
|
||||
expect(emitter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SendMessage(to:teammate) creates inbox row and emits inbox detail for recipient', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
|
|
@ -622,6 +638,34 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('canonicalizes capitalized SendMessage user recipients before persistence', () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||
|
||||
callHandleStreamJsonMessage(service, run, {
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'SendMessage',
|
||||
input: {
|
||||
type: 'message',
|
||||
recipient: 'User',
|
||||
content: 'Task completed!',
|
||||
summary: 'Done',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(hoisted.appendSentMessage).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.objectContaining({ to: 'user' })
|
||||
);
|
||||
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('upgrades qualified SendMessage recipients into cross-team sends', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
seedConfig('my-team');
|
||||
|
|
|
|||
|
|
@ -344,7 +344,37 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves visible summary text after stripping an echoed lead relay prompt', async () => {
|
||||
it('does not persist bare transcript speaker placeholders as lead replies', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'tom',
|
||||
text: '#f8d7235a done.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: '#f8d7235a done',
|
||||
messageId: 'm-1',
|
||||
},
|
||||
]);
|
||||
|
||||
attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Human: ' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(0);
|
||||
expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records non-user lead relay summary text as internal lead activity', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
|
|
@ -374,15 +404,256 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
expect(service.getLiveLeadProcessMessages(teamName).map((message) => message.text)).toEqual([
|
||||
'Delegated to bob.',
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live.map((message) => message.text)).toEqual(['Delegated to bob.']);
|
||||
expect(live[0]?.to).toBeUndefined();
|
||||
expect(hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps user-originated lead relay replies user-visible', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Create the docs task.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Docs task',
|
||||
messageId: 'user-msg-1',
|
||||
source: 'user_sent',
|
||||
},
|
||||
]);
|
||||
|
||||
attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Creating the task now.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live.map((message) => message.text)).toEqual(['Creating the task now.']);
|
||||
expect(live[0]?.to).toBe('user');
|
||||
const sentRows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]'
|
||||
) as Array<{
|
||||
text?: string;
|
||||
to?: string;
|
||||
}>;
|
||||
expect(sentRows.map((message) => message.text)).toEqual(['Delegated to bob.']);
|
||||
expect(sentRows).toMatchObject([{ text: 'Creating the task now.', to: 'user' }]);
|
||||
});
|
||||
|
||||
it('does not mix internal lead relay rows into a user-visible relay batch', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'Internal status for the lead.',
|
||||
timestamp: '2026-02-23T09:59:00.000Z',
|
||||
read: false,
|
||||
summary: 'Internal status',
|
||||
messageId: 'internal-msg-1',
|
||||
},
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Please create the release task.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Release task',
|
||||
messageId: 'user-msg-2',
|
||||
source: 'user_sent',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Creating the release task.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Please create the release task.');
|
||||
expect(payload).not.toContain('Internal status for the lead.');
|
||||
|
||||
await vi.waitFor(() => expect(writeSpy.mock.calls.length).toBe(2), { timeout: 1000 });
|
||||
const followUpRun = await waitForCapture(service);
|
||||
(service as any).handleStreamJsonMessage(followUpRun, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Noted internal status.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(followUpRun, { type: 'result', subtype: 'success' });
|
||||
});
|
||||
|
||||
it('relays deferred internal rows on the next pass after a user-visible batch', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'Internal status for the lead.',
|
||||
timestamp: '2026-02-23T09:59:00.000Z',
|
||||
read: false,
|
||||
summary: 'Internal status',
|
||||
messageId: 'internal-msg-next-pass',
|
||||
source: 'system_notification',
|
||||
},
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Please create the release task.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Release task',
|
||||
messageId: 'user-msg-next-pass',
|
||||
source: 'user_sent',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const firstPromise = service.relayLeadInboxMessages(teamName);
|
||||
let run = await waitForCapture(service);
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Creating the release task.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
await expect(firstPromise).resolves.toBe(1);
|
||||
|
||||
await vi.waitFor(() => expect(writeSpy.mock.calls.length).toBe(2), { timeout: 1000 });
|
||||
run = await waitForCapture(service);
|
||||
expect(run?.leadRelayCapture).toBeTruthy();
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Noted internal status.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
for (let i = 0; i < 20 && service.getLiveLeadProcessMessages(teamName).length < 2; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
const firstPayload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
const secondPayload = String(writeSpy.mock.calls[1]?.[0] ?? '');
|
||||
expect(firstPayload).toContain('Please create the release task.');
|
||||
expect(firstPayload).not.toContain('Internal status for the lead.');
|
||||
expect(secondPayload).toContain('Internal status for the lead.');
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live.map((message) => ({ to: message.to, text: message.text }))).toEqual([
|
||||
{ to: 'user', text: 'Creating the release task.' },
|
||||
{ to: undefined, text: 'Noted internal status.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate relay narration when the lead sends an explicit visible message', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'This needs the user to know.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Notify user',
|
||||
messageId: 'internal-msg-2',
|
||||
},
|
||||
]);
|
||||
|
||||
attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Sending the user update now.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'SendMessage',
|
||||
input: {
|
||||
recipient: 'user',
|
||||
content: 'Bob found an issue that needs your attention.',
|
||||
summary: 'Needs attention',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live.map((message) => message.text)).toEqual([
|
||||
'Bob found an issue that needs your attention.',
|
||||
]);
|
||||
const sentRows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]'
|
||||
) as Array<{ text?: string }>;
|
||||
expect(sentRows.map((message) => message.text)).toEqual([
|
||||
'Bob found an issue that needs your attention.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps user-originated plain reply when the lead also messages a teammate', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'user',
|
||||
text: 'Please ask Alice to review the release notes.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
summary: 'Review release notes',
|
||||
messageId: 'user-msg-3',
|
||||
source: 'user_sent',
|
||||
},
|
||||
]);
|
||||
|
||||
attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Asked Alice to review the release notes.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'SendMessage',
|
||||
input: {
|
||||
recipient: 'alice',
|
||||
content: 'Please review the release notes.',
|
||||
summary: 'Review release notes',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
|
||||
await expect(relayPromise).resolves.toBe(1);
|
||||
const live = service.getLiveLeadProcessMessages(teamName);
|
||||
expect(live.map((message) => ({ to: message.to, text: message.text }))).toEqual([
|
||||
{ to: 'alice', text: 'Please review the release notes.' },
|
||||
{ to: 'user', text: 'Asked Alice to review the release notes.' },
|
||||
]);
|
||||
const sentRows = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/sentMessages.json`) ?? '[]'
|
||||
) as Array<{ text?: string; to?: string }>;
|
||||
expect(sentRows).toMatchObject([
|
||||
{ to: 'user', text: 'Asked Alice to review the release notes.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats member work sync nudges as actionable in lead relay prompt', async () => {
|
||||
|
|
@ -616,7 +887,10 @@ Messages:
|
|||
expect(first).toBe(1);
|
||||
expect(second).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
||||
expect(service.getLiveLeadProcessMessages(teamName).map((message) => message.text)).toEqual([
|
||||
'Acknowledged.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark as relayed when stdin is not writable', async () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ interface StoreState {
|
|||
general: {
|
||||
multimodelEnabled: boolean;
|
||||
};
|
||||
providerConnections?: {
|
||||
anthropic: {
|
||||
authMode: 'auto' | 'oauth' | 'api_key';
|
||||
fastModeDefault: boolean;
|
||||
};
|
||||
codex: {
|
||||
preferredAuthMode: 'auto' | 'chatgpt' | 'api_key';
|
||||
};
|
||||
};
|
||||
runtime?: {
|
||||
providerBackends?: Record<string, string>;
|
||||
};
|
||||
|
|
@ -50,6 +59,7 @@ let providerRuntimeSettingsDialogProps: {
|
|||
const codexAccountHookState = {
|
||||
snapshot: null as CodexAccountSnapshotDto | null,
|
||||
loading: false,
|
||||
rateLimitsLoading: false,
|
||||
error: null as string | null,
|
||||
refresh: vi.fn(() => Promise.resolve(undefined)),
|
||||
startChatgptLogin: vi.fn(() => Promise.resolve(true)),
|
||||
|
|
@ -97,10 +107,9 @@ vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@renderer/components/runtime/ProviderRuntimeBackendSelector')>(
|
||||
'@renderer/components/runtime/ProviderRuntimeBackendSelector'
|
||||
);
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@renderer/components/runtime/ProviderRuntimeBackendSelector')
|
||||
>('@renderer/components/runtime/ProviderRuntimeBackendSelector');
|
||||
return {
|
||||
getProviderRuntimeBackendSummary: actual.getProviderRuntimeBackendSummary,
|
||||
};
|
||||
|
|
@ -196,8 +205,7 @@ function createApiKeyMisconfiguredProvider(
|
|||
connection: {
|
||||
supportsOAuth: providerId === 'anthropic',
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes:
|
||||
providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : [],
|
||||
configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : [],
|
||||
configuredAuthMode: providerId === 'anthropic' ? 'api_key' : null,
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
|
|
@ -244,7 +252,8 @@ function createCodexNativeRolloutProvider(
|
|||
overrides?.state === 'ready' || overrides?.available === true ? 'verified' : 'unknown',
|
||||
statusMessage: overrides?.statusMessage ?? 'Ready',
|
||||
detailMessage:
|
||||
overrides?.detailMessage ?? 'Codex native runtime is ready through the local codex exec seam.',
|
||||
overrides?.detailMessage ??
|
||||
'Codex native runtime is ready through the local codex exec seam.',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId:
|
||||
overrides?.state === 'ready' || overrides?.available === true ? 'codex-native' : null,
|
||||
|
|
@ -266,7 +275,8 @@ function createCodexNativeRolloutProvider(
|
|||
audience: overrides?.audience ?? 'general',
|
||||
statusMessage: overrides?.statusMessage ?? 'Ready',
|
||||
detailMessage:
|
||||
overrides?.detailMessage ?? 'Codex native runtime is ready through the local codex exec seam.',
|
||||
overrides?.detailMessage ??
|
||||
'Codex native runtime is ready through the local codex exec seam.',
|
||||
},
|
||||
],
|
||||
backend:
|
||||
|
|
@ -291,6 +301,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
providerRuntimeSettingsDialogProps = null;
|
||||
codexAccountHookState.snapshot = null;
|
||||
codexAccountHookState.loading = false;
|
||||
codexAccountHookState.rateLimitsLoading = false;
|
||||
codexAccountHookState.error = null;
|
||||
codexAccountHookState.refresh.mockClear();
|
||||
codexAccountHookState.startChatgptLogin.mockClear();
|
||||
|
|
@ -317,6 +328,15 @@ describe('CLI status visibility during completed install state', () => {
|
|||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
fastModeDefault: false,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {},
|
||||
},
|
||||
|
|
@ -569,6 +589,254 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows subscription limit placeholders while an Anthropic subscription provider is checking', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.appConfig.providerConnections = {
|
||||
anthropic: {
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: false,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
};
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
authLoggedIn: false,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('5h left');
|
||||
expect(host.textContent).toContain('Weekly left');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides subscription limit placeholders while an Anthropic API key provider is checking', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.appConfig.providerConnections = {
|
||||
anthropic: {
|
||||
authMode: 'api_key',
|
||||
fastModeDefault: false,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
};
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
authLoggedIn: false,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'unknown',
|
||||
statusMessage: 'Checking...',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
backend: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('5h left');
|
||||
expect(host.textContent).not.toContain('Weekly left');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('periodically refreshes Anthropic subscription limits while subscription mode is active', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.appConfig.providerConnections = {
|
||||
anthropic: {
|
||||
authMode: 'oauth',
|
||||
fastModeDefault: false,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
};
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'claude.ai',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected via Anthropic subscription',
|
||||
models: ['claude-sonnet-4-5'],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'oauth',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.fetchCliProviderStatus).toHaveBeenCalledWith('anthropic', {
|
||||
silent: true,
|
||||
});
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not periodically refresh Anthropic limits while API key mode is active', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.appConfig.providerConnections = {
|
||||
anthropic: {
|
||||
authMode: 'api_key',
|
||||
fastModeDefault: false,
|
||||
},
|
||||
codex: {
|
||||
preferredAuthMode: 'auto',
|
||||
},
|
||||
};
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
displayName: 'Anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Connected via API key',
|
||||
models: ['claude-sonnet-4-5'],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'oauth', 'api_key'],
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
apiKeySourceLabel: 'Stored Anthropic API key',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.fetchCliProviderStatus).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
@ -756,9 +1024,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
expect(host.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(host.textContent).not.toContain('Anthropic');
|
||||
expect(host.textContent).not.toContain('Manage');
|
||||
expect(
|
||||
host.querySelector('button[aria-label="Expand provider details"]')
|
||||
).not.toBeNull();
|
||||
expect(host.querySelector('button[aria-label="Expand provider details"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -866,9 +1132,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
|
||||
expect(secondHost.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(secondHost.textContent).not.toContain('ChatGPT account ready');
|
||||
expect(
|
||||
secondHost.querySelector('button[aria-label="Expand provider details"]')
|
||||
).not.toBeNull();
|
||||
expect(secondHost.querySelector('button[aria-label="Expand provider details"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
secondRoot.unmount();
|
||||
|
|
@ -1347,7 +1611,9 @@ describe('CLI status visibility during completed install state', () => {
|
|||
|
||||
expect(host.textContent).toContain('Providers: 1/1 connected');
|
||||
expect(host.textContent).toContain('ChatGPT account ready');
|
||||
expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
expect(host.textContent).toContain('5h left');
|
||||
expect(host.textContent).toContain('95%');
|
||||
expect(host.textContent).toContain('1w left');
|
||||
|
|
@ -1360,6 +1626,78 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows Codex limit placeholders while ChatGPT account limits are loading', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
codexAccountHookState.rateLimitsLoading = true;
|
||||
codexAccountHookState.snapshot = {
|
||||
preferredAuthMode: 'chatgpt',
|
||||
effectiveAuthMode: 'chatgpt',
|
||||
launchAllowed: true,
|
||||
launchIssueMessage: null,
|
||||
launchReadinessState: 'ready_chatgpt',
|
||||
appServerState: 'healthy',
|
||||
appServerStatusMessage: null,
|
||||
managedAccount: {
|
||||
type: 'chatgpt',
|
||||
email: 'user@example.com',
|
||||
planType: 'pro',
|
||||
},
|
||||
apiKey: {
|
||||
available: false,
|
||||
source: null,
|
||||
sourceLabel: null,
|
||||
},
|
||||
requiresOpenaiAuth: false,
|
||||
login: {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
startedAt: null,
|
||||
},
|
||||
rateLimits: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'agent_teams_orchestrator',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
createCodexNativeRolloutProvider({
|
||||
connection: {
|
||||
supportsOAuth: false,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: ['auto', 'chatgpt', 'api_key'],
|
||||
configuredAuthMode: 'chatgpt',
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
codex: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('5h left');
|
||||
expect(host.textContent).toContain('Weekly left');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the live Codex account snapshot in the settings runtime section too', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
@ -1449,7 +1787,9 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).toContain('ChatGPT account ready');
|
||||
expect(host.textContent).not.toContain('Connect a ChatGPT account to use your Codex subscription.');
|
||||
expect(host.textContent).not.toContain(
|
||||
'Connect a ChatGPT account to use your Codex subscription.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -1749,7 +2089,8 @@ describe('CLI status visibility during completed install state', () => {
|
|||
},
|
||||
rateLimits: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Reconnect ChatGPT to refresh the current Codex subscription session.',
|
||||
launchIssueMessage:
|
||||
'Reconnect ChatGPT to refresh the current Codex subscription session.',
|
||||
launchReadinessState: 'missing_auth',
|
||||
},
|
||||
},
|
||||
|
|
@ -1985,7 +2326,8 @@ describe('CLI status visibility during completed install state', () => {
|
|||
available: false,
|
||||
selectable: false,
|
||||
statusMessage: 'Codex CLI not found',
|
||||
detailMessage: 'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
detailMessage:
|
||||
'Codex native runtime requires the codex CLI binary to be installed and discoverable.',
|
||||
backend: null,
|
||||
resolvedBackendId: null,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -138,49 +138,44 @@ describe('computeEffectiveTeamModel', () => {
|
|||
|
||||
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
|
||||
expect(
|
||||
computeEffectiveTeamModel(
|
||||
'opus',
|
||||
false,
|
||||
'anthropic',
|
||||
{
|
||||
computeEffectiveTeamModel('opus', false, 'anthropic', {
|
||||
providerId: 'anthropic',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-models-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-04-21T00:00:00.000Z',
|
||||
staleAt: '2026-04-21T00:10:00.000Z',
|
||||
defaultModelId: 'opus',
|
||||
defaultLaunchModel: 'opus',
|
||||
models: [
|
||||
{
|
||||
id: 'opus',
|
||||
launchModel: 'opus',
|
||||
displayName: 'Opus 4.8',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
source: 'anthropic-models-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-04-21T00:00:00.000Z',
|
||||
staleAt: '2026-04-21T00:10:00.000Z',
|
||||
defaultModelId: 'opus',
|
||||
defaultLaunchModel: 'opus',
|
||||
models: [
|
||||
{
|
||||
id: 'opus',
|
||||
launchModel: 'opus',
|
||||
displayName: 'Opus 4.8',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
).toBe('opus');
|
||||
});
|
||||
|
||||
it('does not double-append [1m] when input already has it', () => {
|
||||
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet');
|
||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
|
||||
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
|
|
@ -243,9 +238,8 @@ describe('computeEffectiveTeamModel', () => {
|
|||
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('claude-opus-4-7[1m]', true, 'anthropic')).toBe(
|
||||
'claude-opus-4-7'
|
||||
);
|
||||
expect(computeEffectiveTeamModel('claude-opus-4-7[1m]', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('claude-sonnet-4-6', true, 'anthropic')).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('returns haiku as-is', () => {
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ describe('ActivityItem legacy system message fallback', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('update');
|
||||
expect(host.textContent).toContain('note');
|
||||
expect(host.textContent).toContain('alice');
|
||||
expect(host.textContent).toContain('bob');
|
||||
expect(host.textContent).toContain('aligned on rollout order');
|
||||
|
|
@ -470,7 +470,7 @@ describe('ActivityItem legacy system message fallback', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('renders user-directed peer-summary rows as passive updates instead of pseudo messages', async () => {
|
||||
it('renders user-directed peer-summary rows as passive notes instead of pseudo messages', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -495,7 +495,7 @@ describe('ActivityItem legacy system message fallback', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('update');
|
||||
expect(host.textContent).toContain('note');
|
||||
expect(host.textContent).toContain('alice');
|
||||
expect(host.textContent).toContain('user');
|
||||
expect(host.textContent).toContain('Я здесь.');
|
||||
|
|
|
|||
|
|
@ -671,6 +671,194 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not submit a stale Anthropic context limit after the last Anthropic runtime is removed', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.mocked(isTeamModelAvailableForUi).mockImplementation(() => true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
verificationState: 'verified',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
verificationState: 'verified',
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
limitContext: true,
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
const onLaunch = vi.fn<(request: { limitContext?: boolean }) => Promise<void>>(async () => {});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch,
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.limitContext).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
teamRosterEditorSectionMock.lastProps?.onMembersChange([
|
||||
{
|
||||
id: 'draft-0',
|
||||
name: 'alice',
|
||||
originalName: 'alice',
|
||||
roleSelection: '',
|
||||
customRole: 'Reviewer',
|
||||
workflow: '',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
]);
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.limitContext).toBe(false);
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
expect(submitButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(onLaunch).toHaveBeenCalledTimes(1);
|
||||
const launchRequest = onLaunch.mock.calls[0]?.[0] as { limitContext?: boolean } | undefined;
|
||||
expect(launchRequest?.limitContext).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the Anthropic context limit when the lead changes but Anthropic teammates remain', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.mocked(isTeamModelAvailableForUi).mockImplementation(() => true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
verificationState: 'verified',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
verificationState: 'verified',
|
||||
models: ['sonnet'],
|
||||
capabilities: { teamLaunch: true, oneShot: true },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
limitContext: true,
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch: vi.fn(async () => {}),
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.limitContext).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
teamRosterEditorSectionMock.lastProps?.onProviderChange('codex');
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(teamRosterEditorSectionMock.lastProps?.limitContext).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
|
||||
vi.mock('@renderer/components/ui/checkbox', () => ({
|
||||
Checkbox: ({
|
||||
checked,
|
||||
disabled,
|
||||
id,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}) =>
|
||||
React.createElement('input', {
|
||||
checked: Boolean(checked),
|
||||
disabled,
|
||||
id,
|
||||
type: 'checkbox',
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onCheckedChange?.(event.target.checked),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/label', () => ({
|
||||
Label: ({
|
||||
children,
|
||||
...props
|
||||
}: React.LabelHTMLAttributes<HTMLLabelElement> & { children: React.ReactNode }) =>
|
||||
React.createElement('label', props, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
function renderLimitContextCheckbox(
|
||||
overrides: Partial<React.ComponentProps<typeof LimitContextCheckbox>> = {}
|
||||
): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(LimitContextCheckbox, {
|
||||
id: 'limit-context',
|
||||
checked: false,
|
||||
onCheckedChange: () => undefined,
|
||||
...overrides,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe('LimitContextCheckbox', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('renders disabled always-200K state as checked and disabled', () => {
|
||||
const { host, root } = renderLimitContextCheckbox({ checked: false, disabled: true });
|
||||
|
||||
const checkbox = host.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.disabled).toBe(true);
|
||||
expect(host.textContent).toContain('always 200K for this model');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the real checked state when enabled', () => {
|
||||
const { host, root } = renderLimitContextCheckbox({ checked: false, disabled: false });
|
||||
|
||||
const checkbox = host.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.disabled).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberStatsTab } from '@renderer/components/team/members/MemberStatsTab';
|
||||
|
||||
import type { MemberFullStats } from '@shared/types';
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
teams: {
|
||||
getMemberStats: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function createStats(overrides: Partial<MemberFullStats> = {}): MemberFullStats {
|
||||
return {
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
filesTouched: [],
|
||||
fileStats: {},
|
||||
toolUsage: {},
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
costUsd: 0,
|
||||
tasksCompleted: 0,
|
||||
messageCount: 0,
|
||||
totalDurationMs: 0,
|
||||
sessionCount: 1,
|
||||
computedAt: '2026-05-09T12:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MemberStatsTab', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not render null-device paths as touched files', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberStatsTab, {
|
||||
teamName: 'northstar-core',
|
||||
memberName: 'alice',
|
||||
prefetchedStats: createStats({
|
||||
filesTouched: ['/dev/null', '/repo/src/app.ts'],
|
||||
fileStats: {
|
||||
'/dev/null': { added: 4, removed: 0 },
|
||||
'/repo/src/app.ts': { added: 2, removed: 1 },
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Files Touched (1)');
|
||||
expect(host.textContent).toContain('app.ts');
|
||||
expect(host.querySelector('[title="/dev/null"]')).toBeNull();
|
||||
expect(host.textContent).not.toContain('null');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -225,7 +225,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
sidebarUiState.bottomSheetSnapIndex = 2;
|
||||
});
|
||||
|
||||
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
|
||||
it('hides passive peer summaries by default while unread badge only counts filtered unread messages', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -278,7 +278,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('passive-idle');
|
||||
expect(host.textContent).not.toContain('passive-idle');
|
||||
expect(host.textContent).toContain('human-reply');
|
||||
expect(host.textContent).toContain('1 new');
|
||||
expect(host.textContent).not.toContain('2 new');
|
||||
|
|
|
|||
|
|
@ -195,6 +195,134 @@ describe('memberActivityTimer', () => {
|
|||
).toBe('2026-05-07T09:35:00.000Z');
|
||||
});
|
||||
|
||||
it('uses the current review_started event when older review intervals are already closed', () => {
|
||||
const task: TeamTaskWithKanban = {
|
||||
...baseTask,
|
||||
status: 'completed',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
reviewer: 'alice',
|
||||
reviewIntervals: [
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-07T09:30:00.000Z',
|
||||
completedAt: '2026-05-07T09:40:00.000Z',
|
||||
},
|
||||
],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T09:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
type: 'review_approved',
|
||||
from: 'review',
|
||||
to: 'approved',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T09:40:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
type: 'status_changed',
|
||||
from: 'completed',
|
||||
to: 'in_progress',
|
||||
timestamp: '2026-05-07T09:50:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-4',
|
||||
type: 'status_changed',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
timestamp: '2026-05-07T09:55:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-5',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
deriveReviewActivityTimerAnchor(task, {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
})?.startedAt
|
||||
).toBe('2026-05-07T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not start a review timer from a requested-only review cycle', () => {
|
||||
const task: TeamTaskWithKanban = {
|
||||
...baseTask,
|
||||
status: 'completed',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
reviewer: 'alice',
|
||||
reviewIntervals: [
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-07T09:30:00.000Z',
|
||||
completedAt: '2026-05-07T09:40:00.000Z',
|
||||
},
|
||||
],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T09:30:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
type: 'review_approved',
|
||||
from: 'review',
|
||||
to: 'approved',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T09:40:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
type: 'status_changed',
|
||||
from: 'completed',
|
||||
to: 'in_progress',
|
||||
timestamp: '2026-05-07T09:50:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-4',
|
||||
type: 'status_changed',
|
||||
from: 'in_progress',
|
||||
to: 'completed',
|
||||
timestamp: '2026-05-07T09:55:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-5',
|
||||
type: 'review_requested',
|
||||
from: 'none',
|
||||
to: 'review',
|
||||
reviewer: 'alice',
|
||||
timestamp: '2026-05-07T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
deriveReviewActivityTimerAnchor(task, {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('anchors review timers to persisted review intervals and adds paused review time', () => {
|
||||
const task: TeamTaskWithKanban = {
|
||||
...baseTask,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,30 @@ describe('filterTeamMessages', () => {
|
|||
expect(result[0].source).toBe('lead_process');
|
||||
});
|
||||
|
||||
it('hides bare transcript speaker placeholders from lead output', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
messageId: 'speaker-placeholder',
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'Human:',
|
||||
source: 'lead_process',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'visible-message',
|
||||
text: 'Visible message',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterTeamMessages(messages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result.map((message) => message.messageId)).toEqual(['visible-message']);
|
||||
});
|
||||
|
||||
it('hides native app-managed bootstrap private control messages', () => {
|
||||
const messages = [
|
||||
makeMessage({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import {
|
||||
getVisibleTeamProviderModels,
|
||||
isAnthropicOneMillionContextTeamModel,
|
||||
isAnthropicSonnetOneMillionContextTeamModel,
|
||||
isAnthropicSonnetTeamModel,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
|
||||
|
|
@ -18,13 +20,7 @@ describe('teamModelCatalog', () => {
|
|||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.1-codex-max',
|
||||
])
|
||||
).toEqual([
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1-codex-max',
|
||||
]);
|
||||
).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2', 'gpt-5.1-codex-max']);
|
||||
});
|
||||
|
||||
it('adds curated Anthropic Opus 4.7 badges when the runtime list only reports legacy Opus variants', () => {
|
||||
|
|
@ -55,4 +51,17 @@ describe('teamModelCatalog', () => {
|
|||
expect(isAnthropicSonnetTeamModel('opus')).toBe(false);
|
||||
expect(isAnthropicSonnetTeamModel('haiku')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects 1M Anthropic selections and native 1M launch ids', () => {
|
||||
expect(isAnthropicOneMillionContextTeamModel('sonnet')).toBe(false);
|
||||
expect(isAnthropicOneMillionContextTeamModel('sonnet[1m]')).toBe(true);
|
||||
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-7')).toBe(true);
|
||||
expect(isAnthropicOneMillionContextTeamModel('claude-opus-4-7[1m]')).toBe(true);
|
||||
expect(isAnthropicOneMillionContextTeamModel('claude-sonnet-4-6')).toBe(true);
|
||||
expect(isAnthropicSonnetOneMillionContextTeamModel('sonnet')).toBe(false);
|
||||
expect(isAnthropicSonnetOneMillionContextTeamModel('sonnet[1m]')).toBe(true);
|
||||
expect(isAnthropicSonnetOneMillionContextTeamModel('claude-sonnet-4-6')).toBe(true);
|
||||
expect(isAnthropicSonnetOneMillionContextTeamModel('claude-sonnet-4-6[1m]')).toBe(true);
|
||||
expect(isAnthropicSonnetOneMillionContextTeamModel('opus[1m]')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ describe('resolveAnthropicLaunchModel', () => {
|
|||
).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants', () => {
|
||||
it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants from standard selections', () => {
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'sonnet',
|
||||
|
|
@ -79,8 +79,56 @@ describe('resolveAnthropicLaunchModel', () => {
|
|||
availableLaunchModels: ['haiku'],
|
||||
})
|
||||
).toBe('haiku');
|
||||
expect(resolveAnthropicLaunchModel({ selectedModel: 'opus[1m][1m]', limitContext: false })).toBe(
|
||||
'opus[1m]'
|
||||
);
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({ selectedModel: 'opus[1m][1m]', limitContext: false })
|
||||
).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
it('honors explicit 1M Sonnet selections unless 200K context is requested', () => {
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'sonnet[1m]',
|
||||
limitContext: false,
|
||||
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||
})
|
||||
).toBe('sonnet[1m]');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'claude-sonnet-4-6[1m]',
|
||||
limitContext: false,
|
||||
availableLaunchModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'],
|
||||
})
|
||||
).toBe('claude-sonnet-4-6[1m]');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'sonnet[1m]',
|
||||
limitContext: true,
|
||||
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||
})
|
||||
).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('prefers standard aliases for native 1M raw ids when 200K context is requested', () => {
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'claude-sonnet-4-6',
|
||||
limitContext: true,
|
||||
availableLaunchModels: ['sonnet', 'claude-sonnet-4-6'],
|
||||
})
|
||||
).toBe('sonnet');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'claude-opus-4-7[1m]',
|
||||
limitContext: true,
|
||||
availableLaunchModels: ['opus', 'claude-opus-4-7'],
|
||||
})
|
||||
).toBe('opus');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'claude-sonnet-4-6',
|
||||
limitContext: true,
|
||||
availableLaunchModels: ['claude-sonnet-4-6'],
|
||||
})
|
||||
).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue