mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
5 Commits
v0.1.51
...
storage-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 |
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
npm run db:query # Show DB table row counts
|
||||
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the SQLite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
217
docs/STORAGE_REVAMP_PLAN.md
Normal file
217
docs/STORAGE_REVAMP_PLAN.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Storage Revamp Plan
|
||||
|
||||
Status: active rollout, phases 1 and 2 complete
|
||||
|
||||
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
|
||||
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
|
||||
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
|
||||
|
||||
## Goals
|
||||
|
||||
- make structured records durable in Drizzle + SQLite
|
||||
- make projects and workspaces explicit first-class records
|
||||
- stop deriving project/workspace identity from agent `cwd`
|
||||
- keep agent snapshot persistence behind clear ownership
|
||||
- move committed timeline history to storage-owned rows
|
||||
- remove legacy JSON and in-memory authority once the DB path is proven
|
||||
|
||||
## Out of scope
|
||||
|
||||
- moving config, keypairs, push tokens, or server identity into the DB
|
||||
- persisting raw provider deltas or transport-only chunk streams
|
||||
- designing a hosted/remote database story beyond keeping the schema portable
|
||||
- durable reasoning history unless product explicitly asks for it later
|
||||
|
||||
## Current state
|
||||
|
||||
The storage revamp is no longer hypothetical.
|
||||
|
||||
Completed:
|
||||
|
||||
- Drizzle + SQLite database bootstrap is in place
|
||||
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
|
||||
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
|
||||
- `agent_snapshots.workspace_id` is `NOT NULL`
|
||||
- legacy JSON import feeds the DB-backed structured records
|
||||
- project/workspace records use explicit `directory` fields instead of path-as-identity
|
||||
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
|
||||
- `workspace-reconciliation-service.ts` is deleted
|
||||
- `workspace-registry-bootstrap.ts` is deleted
|
||||
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
|
||||
|
||||
Still pending:
|
||||
|
||||
- explicit `create_project` / `create_workspace` API cleanup
|
||||
- final archive cascade behavior for descendants and live agents
|
||||
- committed timeline storage cutover
|
||||
- removal of remaining legacy JSON and in-memory committed-history authority
|
||||
|
||||
## Converged decisions
|
||||
|
||||
### Structured record authority
|
||||
|
||||
Projects, workspaces, and agent snapshots are DB-backed structured records.
|
||||
The server should not recreate project/workspace identity from:
|
||||
|
||||
- git remotes
|
||||
- worktree main-repo roots
|
||||
- normalized cwd strings
|
||||
|
||||
Temporary exception:
|
||||
|
||||
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
|
||||
`workspaceId` explicitly
|
||||
|
||||
That fallback is transitional and should be deleted once the client always sends the workspace id.
|
||||
|
||||
### Storage seams
|
||||
|
||||
The useful seams remain concrete and domain-shaped:
|
||||
|
||||
- `ProjectRegistry`
|
||||
- `WorkspaceRegistry`
|
||||
- `AgentSnapshotStore`
|
||||
- `AgentTimelineStore`
|
||||
|
||||
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
|
||||
|
||||
### Timeline contract
|
||||
|
||||
The long-term timeline contract remains:
|
||||
|
||||
- committed rows are durable, canonical history
|
||||
- provisional live updates are transient subscription state
|
||||
- committed history is fetched by seq
|
||||
- provider history replay is not the durability mechanism
|
||||
|
||||
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
|
||||
stable DB-backed agent and workspace identity.
|
||||
|
||||
## Remaining phases
|
||||
|
||||
### Phase 3: Explicit creation and archive cleanup
|
||||
|
||||
Goal:
|
||||
Remove the last transitional write paths that still infer state from directories.
|
||||
|
||||
Required work:
|
||||
|
||||
- add explicit `create_project` handling
|
||||
- add explicit `create_workspace` handling
|
||||
- make agent creation require `workspaceId` once the UI is ready
|
||||
- finish archive semantics for workspaces/projects and any descendant agent state
|
||||
- remove the temporary find-or-create-by-directory fallback from agent creation
|
||||
|
||||
Exit gate:
|
||||
|
||||
- project/workspace creation is explicit end to end
|
||||
- no normal creation path infers identity from cwd or git metadata
|
||||
- archive flows behave consistently for structured records and live runtime state
|
||||
|
||||
### Phase 4: Timeline storage cutover
|
||||
|
||||
Goal:
|
||||
Make committed history durable and storage-owned.
|
||||
|
||||
Required work:
|
||||
|
||||
- make `AgentTimelineStore` authoritative for committed history
|
||||
- write one committed row per finalized logical item
|
||||
- support tail, before-seq, and after-seq queries from storage
|
||||
- stop treating provider history hydration as the normal refresh/load path
|
||||
- keep provisional live updates in memory only
|
||||
|
||||
Exit gate:
|
||||
|
||||
- committed history survives daemon restart
|
||||
- reconnect uses committed catch-up plus future live events without gaps or duplicates
|
||||
- unloaded agents can serve committed history from storage alone
|
||||
|
||||
### Phase 5: Legacy cleanup
|
||||
|
||||
Goal:
|
||||
Remove compatibility paths after the DB-backed model is fully authoritative.
|
||||
|
||||
Required work:
|
||||
|
||||
- remove legacy JSON authority for structured records
|
||||
- remove in-memory committed-history ownership
|
||||
- remove provider-history rehydrate compatibility paths
|
||||
- trim dead protocol and reducer logic from the pre-storage model
|
||||
- update architecture docs to match the final model
|
||||
|
||||
Exit gate:
|
||||
|
||||
- there is one durable storage path for structured records
|
||||
- there is one durable storage path for committed timeline history
|
||||
- the runtime no longer depends on the removed JSON/in-memory model
|
||||
|
||||
## Data model summary
|
||||
|
||||
### Projects
|
||||
|
||||
- integer primary key
|
||||
- `directory` is unique
|
||||
- `display_name`
|
||||
- `kind`: `git | directory`
|
||||
- optional `git_remote`
|
||||
- timestamps and archive state
|
||||
|
||||
### Workspaces
|
||||
|
||||
- integer primary key
|
||||
- belongs to a project by `project_id`
|
||||
- `directory` is unique
|
||||
- `display_name`
|
||||
- `kind`: `checkout | worktree`
|
||||
- timestamps and archive state
|
||||
|
||||
### Agent snapshots
|
||||
|
||||
- `agent_id` remains the primary key
|
||||
- belongs to a workspace by integer `workspace_id`
|
||||
- `workspace_id` is required
|
||||
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
|
||||
|
||||
### Timeline rows
|
||||
|
||||
Target shape once Phase 4 lands:
|
||||
|
||||
- `agent_id`
|
||||
- committed `seq`
|
||||
- committed timestamp
|
||||
- canonical finalized item payload
|
||||
|
||||
Not part of durable history:
|
||||
|
||||
- raw streaming chunks
|
||||
- provisional assistant text
|
||||
- provisional reasoning text
|
||||
|
||||
## Verification requirements
|
||||
|
||||
Every remaining phase should keep the same bar:
|
||||
|
||||
- `npm run typecheck`
|
||||
- targeted tests for the touched storage/session/runtime paths
|
||||
- migration/import coverage when storage authority changes
|
||||
- reconnect and catch-up scenario coverage when timeline behavior changes
|
||||
|
||||
At minimum, timeline cutover must explicitly prove:
|
||||
|
||||
- `fetch-after-seq`
|
||||
- `fetch-before-seq`
|
||||
- restart durability
|
||||
- no-gap/no-duplicate reconnect behavior
|
||||
|
||||
## Main risks
|
||||
|
||||
- timeline work reintroduces provider-history replay as hidden authority
|
||||
- archive behavior diverges between stored records and live in-memory agents
|
||||
- explicit creation work leaves the transitional cwd fallback in place too long
|
||||
- cleanup stalls after compatibility paths stop being exercised
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
|
||||
it is probably moving in the wrong direction.
|
||||
506
docs/TERMINAL-MODE.md
Normal file
506
docs/TERMINAL-MODE.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# Terminal Mode — Implementation Plan
|
||||
|
||||
## Concept
|
||||
|
||||
Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI.
|
||||
|
||||
**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView.
|
||||
|
||||
## What Changes
|
||||
|
||||
### Phase 1: Server — Data Model & Provider Interface
|
||||
|
||||
#### 1.1 Add `terminal` flag to `ManagedAgentBase`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||
|
||||
```typescript
|
||||
type ManagedAgentBase = {
|
||||
// ...existing fields...
|
||||
terminal: boolean; // NEW — if true, this agent renders as a terminal TUI
|
||||
};
|
||||
```
|
||||
|
||||
This flag is set at creation time and never changes. A terminal agent is always a terminal agent.
|
||||
|
||||
#### 1.2 Add `terminal` to `AgentSessionConfig`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type AgentSessionConfig = {
|
||||
// ...existing fields...
|
||||
terminal?: boolean; // NEW — create as terminal agent
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.3 Add `terminal` to the Zod schema
|
||||
|
||||
**File:** `packages/server/src/shared/messages.ts`
|
||||
|
||||
Add to `AgentSessionConfigSchema`:
|
||||
```typescript
|
||||
terminal: z.boolean().optional(),
|
||||
```
|
||||
|
||||
Add to the `AgentStateSchema` (the wire format sent to clients):
|
||||
```typescript
|
||||
terminal: z.boolean().optional(),
|
||||
```
|
||||
|
||||
#### 1.4 Add terminal command builders to `AgentClient`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type TerminalCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface AgentClient {
|
||||
// ...existing methods...
|
||||
|
||||
/**
|
||||
* Build the shell command to launch this agent's TUI for a new session.
|
||||
* Only available if capabilities.supportsTerminalMode is true.
|
||||
*/
|
||||
buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand;
|
||||
|
||||
/**
|
||||
* Build the shell command to resume an existing session in the agent's TUI.
|
||||
* Only available if capabilities.supportsTerminalMode is true.
|
||||
*/
|
||||
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 Add `supportsTerminalMode` capability
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
|
||||
```typescript
|
||||
export type AgentCapabilityFlags = {
|
||||
// ...existing flags...
|
||||
supportsTerminalMode: boolean; // NEW
|
||||
};
|
||||
```
|
||||
|
||||
Also add to the Zod schema in `messages.ts`:
|
||||
```typescript
|
||||
supportsTerminalMode: z.boolean(),
|
||||
```
|
||||
|
||||
#### 1.6 Implement terminal command builders in providers
|
||||
|
||||
**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
const args: string[] = [];
|
||||
if (config.modeId === "bypassPermissions") {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
}
|
||||
if (config.model) args.push("--model", config.model);
|
||||
// mode mapping: default → nothing, plan → --plan, etc.
|
||||
return { command: "claude", args, env: {} };
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
return {
|
||||
command: "claude",
|
||||
args: ["--resume", handle.sessionId],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
const args: string[] = [];
|
||||
if (config.model) args.push("--model", config.model);
|
||||
if (config.modeId) args.push("--approval-mode", config.modeId);
|
||||
return { command: "codex", args, env: {} };
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
return {
|
||||
command: "codex",
|
||||
args: ["--resume", handle.nativeHandle ?? handle.sessionId],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`):
|
||||
```typescript
|
||||
buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand {
|
||||
return { command: "opencode", args: [], env: {} };
|
||||
}
|
||||
// No resume support for OpenCode initially
|
||||
```
|
||||
|
||||
Capabilities for each provider:
|
||||
- Claude: `supportsTerminalMode: true`
|
||||
- Codex: `supportsTerminalMode: true`
|
||||
- OpenCode: `supportsTerminalMode: true`
|
||||
|
||||
#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()`
|
||||
|
||||
**File:** `packages/server/src/server/agent/agent-manager.ts`
|
||||
|
||||
When `config.terminal === true`:
|
||||
1. Do NOT call `client.createSession()` — there is no managed session
|
||||
2. Call `client.buildTerminalCreateCommand(config)` to get the command
|
||||
3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command
|
||||
4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null`
|
||||
5. Store the terminal ID in the agent's metadata or a new field
|
||||
6. The agent's persistence handle can be populated later (the CLI will create its own session file)
|
||||
|
||||
```typescript
|
||||
async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record<string, string> }): Promise<ManagedAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
|
||||
if (normalizedConfig.terminal) {
|
||||
// Terminal mode — no managed session, just build the command
|
||||
const buildCmd = client.buildTerminalCreateCommand;
|
||||
if (!buildCmd) {
|
||||
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||
}
|
||||
const cmd = buildCmd.call(client, normalizedConfig);
|
||||
return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, {
|
||||
labels: options?.labels,
|
||||
});
|
||||
}
|
||||
|
||||
// ...existing managed agent flow...
|
||||
}
|
||||
```
|
||||
|
||||
New method `registerTerminalAgent()`:
|
||||
- Creates a ManagedAgent with `terminal: true`
|
||||
- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect)
|
||||
- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state)
|
||||
- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents)
|
||||
- Broadcasts `agent_state` event so clients know about it
|
||||
|
||||
#### 1.8 New message: create terminal for agent
|
||||
|
||||
The client needs a way to request a terminal for a terminal agent. Options:
|
||||
|
||||
**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command.
|
||||
|
||||
**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step.
|
||||
|
||||
**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided:
|
||||
- Look up the agent (must be a terminal agent)
|
||||
- Use the agent's stored command to create the terminal
|
||||
- Associate the terminal with the agent
|
||||
|
||||
**File:** `packages/server/src/shared/messages.ts`
|
||||
|
||||
```typescript
|
||||
const CreateTerminalRequestMessageSchema = z.object({
|
||||
type: z.literal("create_terminal_request"),
|
||||
cwd: z.string(),
|
||||
name: z.string().optional(),
|
||||
agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent
|
||||
requestId: z.string(),
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.9 Terminal → Agent lifecycle binding
|
||||
|
||||
When a terminal associated with a terminal agent exits:
|
||||
- Set agent lifecycle to `"closed"`
|
||||
- Attempt to detect the agent's session file for persistence handle
|
||||
- Broadcast state update
|
||||
|
||||
When a terminal agent is opened from the sessions page:
|
||||
- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists
|
||||
- Otherwise calls `buildTerminalCreateCommand(config)`
|
||||
- Creates a new terminal with that command
|
||||
|
||||
#### 1.10 Extend `createTerminal()` to support command + args
|
||||
|
||||
**File:** `packages/server/src/terminal/terminal.ts`
|
||||
|
||||
```typescript
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
name?: string;
|
||||
command?: string; // NEW — if provided, run this instead of shell
|
||||
args?: string[]; // NEW — arguments for command
|
||||
}
|
||||
```
|
||||
|
||||
In `createTerminal()`:
|
||||
```typescript
|
||||
const spawnCommand = options.command ?? shell;
|
||||
const spawnArgs = options.command ? (options.args ?? []) : [];
|
||||
|
||||
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||
name: "xterm-256color",
|
||||
cols, rows, cwd,
|
||||
env: { ...process.env, ...env, TERM: "xterm-256color" },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: App — Draft UI & Terminal Toggle
|
||||
|
||||
#### 2.1 Add terminal toggle to draft tab
|
||||
|
||||
**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx`
|
||||
|
||||
Add a toggle switch in the draft UI: **"Chat" / "Terminal"**
|
||||
|
||||
State:
|
||||
```typescript
|
||||
const [isTerminalMode, setIsTerminalMode] = useState(false);
|
||||
```
|
||||
|
||||
The toggle should be persistent per draft (stored in the draft store or as a preference).
|
||||
|
||||
When terminal mode is selected:
|
||||
- The provider/model pickers still work (same UI)
|
||||
- The mode picker still works
|
||||
- The "send" button label changes to "Launch" or "Start"
|
||||
- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI)
|
||||
|
||||
#### 2.2 Modify agent creation to pass `terminal: true`
|
||||
|
||||
When the user submits a draft in terminal mode:
|
||||
|
||||
```typescript
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
model: selectedModel,
|
||||
modeId: selectedMode,
|
||||
terminal: true, // NEW
|
||||
};
|
||||
```
|
||||
|
||||
The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed.
|
||||
|
||||
#### 2.3 Terminal mode in `AgentStatusBar`
|
||||
|
||||
**File:** `packages/app/src/components/agent-status-bar.tsx`
|
||||
|
||||
When rendering a draft's status bar, filter the capability:
|
||||
- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected
|
||||
- The terminal toggle can live next to the provider selector or as a segmented control above the input area
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: App — Agent Panel Rendering
|
||||
|
||||
#### 3.1 Branch rendering in `AgentPanel`
|
||||
|
||||
**File:** `packages/app/src/panels/agent-panel.tsx`
|
||||
|
||||
```typescript
|
||||
function AgentPanelContent({ agentId, ... }) {
|
||||
const agent = useAgentState(agentId);
|
||||
|
||||
if (agent?.terminal) {
|
||||
return <TerminalAgentPanel agentId={agentId} agent={agent} />;
|
||||
}
|
||||
|
||||
return <AgentPanelBody agent={agent} ... />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 New component: `TerminalAgentPanel`
|
||||
|
||||
**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file)
|
||||
|
||||
This component:
|
||||
1. Gets the terminal ID associated with the agent (from agent metadata or a new field)
|
||||
2. Renders a `TerminalPane` connected to that terminal session
|
||||
3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })`
|
||||
4. Handles terminal exit → agent close lifecycle
|
||||
|
||||
Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal.
|
||||
|
||||
#### 3.3 Tab descriptor for terminal agents
|
||||
|
||||
**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor`
|
||||
|
||||
The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge.
|
||||
|
||||
Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Sessions Page
|
||||
|
||||
#### 4.1 Terminal agents appear in sessions list
|
||||
|
||||
No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`.
|
||||
|
||||
#### 4.2 Opening a terminal agent from sessions
|
||||
|
||||
**File:** `packages/app/src/screens/sessions/` (sessions screen)
|
||||
|
||||
When the user clicks a closed terminal agent:
|
||||
1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists
|
||||
2. Creates a new terminal with that command
|
||||
3. Opens agent tab in workspace
|
||||
|
||||
If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: CLI Gating
|
||||
|
||||
#### 5.1 `paseo send` — error for terminal agents
|
||||
|
||||
**File:** `packages/cli/src/commands/send.ts`
|
||||
|
||||
```typescript
|
||||
if (agent.terminal) {
|
||||
throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead.");
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.2 `paseo run` — could support `--terminal` flag (future)
|
||||
|
||||
Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only.
|
||||
|
||||
#### 5.3 `paseo ls` — show terminal flag
|
||||
|
||||
Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents.
|
||||
|
||||
---
|
||||
|
||||
## Wire Format Changes Summary
|
||||
|
||||
### AgentSessionConfig (create request)
|
||||
```diff
|
||||
{
|
||||
provider: string;
|
||||
cwd: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
+ terminal?: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### AgentState (server → client)
|
||||
```diff
|
||||
{
|
||||
id: string;
|
||||
provider: string;
|
||||
lifecycle: string;
|
||||
+ terminal?: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### AgentCapabilityFlags
|
||||
```diff
|
||||
{
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
+ supportsTerminalMode: boolean;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### CreateTerminalRequest
|
||||
```diff
|
||||
{
|
||||
type: "create_terminal_request";
|
||||
cwd: string;
|
||||
name?: string;
|
||||
+ agentId?: string;
|
||||
requestId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### TerminalCommand (new type)
|
||||
```typescript
|
||||
{
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases & Agent Assignments
|
||||
|
||||
### Phase 1: Server data model (1 agent)
|
||||
- Add `terminal` to types, schemas, and agent manager
|
||||
- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient`
|
||||
- Add `supportsTerminalMode` capability flag
|
||||
- Extend `createTerminal()` to support command+args
|
||||
- Implement terminal agent creation flow in `AgentManager`
|
||||
- Wire terminal exit → agent close lifecycle
|
||||
- Implement command builders in Claude, Codex, OpenCode providers
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 2: App draft UI + terminal toggle (1 agent)
|
||||
- Add terminal mode toggle to `workspace-draft-agent-tab.tsx`
|
||||
- Pass `terminal: true` in config when toggle is on
|
||||
- Filter toggle based on `supportsTerminalMode` capability
|
||||
- Persist toggle preference
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 3: App panel rendering (1 agent)
|
||||
- Branch `AgentPanelContent` on `agent.terminal`
|
||||
- Create `TerminalAgentPanel` component
|
||||
- Handle terminal creation for agent on open
|
||||
- Handle terminal exit lifecycle
|
||||
- Typecheck must pass
|
||||
|
||||
### Phase 4: Sessions page + CLI gating (1 agent)
|
||||
- Terminal agents show in sessions with badge
|
||||
- Opening from sessions resumes or creates terminal
|
||||
- `paseo send` errors for terminal agents
|
||||
- `paseo ls` shows terminal badge
|
||||
- Typecheck must pass
|
||||
|
||||
---
|
||||
|
||||
## Feature Interaction Guards
|
||||
|
||||
Terminal agents are explicitly excluded from automated dispatch paths:
|
||||
|
||||
- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false`
|
||||
- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false`
|
||||
- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send
|
||||
- **CLI `paseo send`**: Returns error for terminal agents
|
||||
- **MCP agent creation**: Programmatic paths don't pass `terminal: true`
|
||||
|
||||
All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents.
|
||||
|
||||
## What This Does NOT Change
|
||||
|
||||
- The existing managed agent flow is untouched
|
||||
- Terminal sessions (non-agent) still work as before
|
||||
- The `AgentSession` interface is unchanged
|
||||
- Mobile experience is unchanged (terminal mode is web/desktop only for now)
|
||||
- No new providers are added (existing providers gain terminal command builders)
|
||||
- No hooks, no env injection, no process tree detection (v1 keeps it simple)
|
||||
|
||||
## Future Work (Not In This Plan)
|
||||
|
||||
- Auto-detect agent type from PTY process tree (for standalone terminals)
|
||||
- "Convert to chat" / "Convert to terminal" actions
|
||||
- Terminal title/icon from OSC sequences
|
||||
- `paseo run --terminal` CLI support
|
||||
- Mobile terminal mode (if xterm.js works well enough on mobile web)
|
||||
- Gemini / Aider / Goose provider definitions (terminal-only providers)
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Cz3xidzBIWER4ktn3wWzT9PDm9PnipVA7XnsTQC440U=";
|
||||
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
1489
package-lock.json
generated
1489
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,7 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
|
||||
196
packages/app/e2e/helpers/launcher.ts
Normal file
196
packages/app/e2e/helpers/launcher.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||
|
||||
/** Wait for the workspace tab bar to be visible. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||
if (testId) ids.push(testId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||
const ids = await getTabTestIds(page);
|
||||
return ids.filter((id) => id.includes(kind)).length;
|
||||
}
|
||||
|
||||
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
// Fallback: the tab with focused styling
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-tab");
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Meta+t");
|
||||
}
|
||||
|
||||
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||
|
||||
/** Wait for the launcher panel to render with its primary tiles. */
|
||||
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "New Chat" tile. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "Terminal" tile. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||
|
||||
/** Click the "New Chat" tile on the launcher panel. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the "Terminal" tile on the launcher panel. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||
await tile.click();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
|
||||
/** Wait for any tab in the bar to display the given title text. */
|
||||
export async function waitForTabWithTitle(
|
||||
page: Page,
|
||||
title: string | RegExp,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||
.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab");
|
||||
// There might be multiple panes, each with a "+" button
|
||||
// But within a single pane there should only be one
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||
* Returns elapsed milliseconds.
|
||||
*/
|
||||
export async function measureTileTransition(
|
||||
page: Page,
|
||||
clickAction: () => Promise<void>,
|
||||
successLocator: ReturnType<Page["locator"]>,
|
||||
timeout = 5_000,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
await clickAction();
|
||||
await expect(successLocator).toBeVisible({ timeout });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||
* Returns all unique snapshots observed.
|
||||
*/
|
||||
export async function sampleTabsDuringTransition(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
durationMs = 2_000,
|
||||
intervalMs = 30,
|
||||
): Promise<string[][]> {
|
||||
const snapshots: string[][] = [];
|
||||
const startSampling = async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < durationMs) {
|
||||
snapshots.push(await getTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
const samplingPromise = startSampling();
|
||||
await action();
|
||||
await samplingPromise;
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||
return createTempGitRepo(prefix);
|
||||
}
|
||||
343
packages/app/e2e/launcher-tab.spec.ts
Normal file
343
packages/app/e2e/launcher-tab.spec.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
waitForLauncherPanel,
|
||||
assertProviderTilesVisible,
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
clickProviderTile,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
} from "./helpers/launcher";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
waitForTerminalContent,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Launcher Tab Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher tab", () => {
|
||||
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
await waitForLauncherPanel(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
await assertProviderTilesVisible(page);
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||
|
||||
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||
});
|
||||
|
||||
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Launcher tab should have been replaced (not added alongside)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
|
||||
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
// Total tab count should stay the same (replaced, not added)
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Tab count stays the same (in-place replacement)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
|
||||
// The launcher tab is gone, a terminal tab exists
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
// Click the first visible provider tile under "Terminal Agents"
|
||||
const providerTiles = page.locator('[role="button"]').filter({
|
||||
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||
});
|
||||
|
||||
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The provider grid follows the label. Click the first provider tile.
|
||||
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||
const firstProvider = providerGrid.getByRole("button").first();
|
||||
if (await firstProvider.isVisible().catch(() => false)) {
|
||||
await firstProvider.click();
|
||||
} else {
|
||||
// Fallback: look for any provider button after the section label
|
||||
const allButtons = page.getByRole("button");
|
||||
const count = await allButtons.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = allButtons.nth(i);
|
||||
const text = await btn.innerText().catch(() => "");
|
||||
// Skip known non-provider buttons
|
||||
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||
if (!text.trim()) continue;
|
||||
await btn.click();
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
if (!clicked) {
|
||||
test.skip(true, "No provider tiles available");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Should see an agent panel (terminal surface or agent stream)
|
||||
const agentOrTerminal = page.locator(
|
||||
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||
);
|
||||
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Tab count stays the same (replaced, not added)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await assertSingleNewTabButton(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open the terminal
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
}
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// No-Flash Transition Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher transitions (no flash)", () => {
|
||||
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
2_000,
|
||||
30,
|
||||
);
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||
const counts = snapshots.map((s) => s.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composer,
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft replacement is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ import { getIsDesktop } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -371,6 +372,7 @@ function AppContainer({
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -68,7 +69,9 @@ export default function HostIndexRoute() {
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
router.replace(
|
||||
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -130,6 +131,7 @@ function SessionRow({
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -145,12 +147,21 @@ function SessionRow({
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.terminal ? (
|
||||
<SessionBadge
|
||||
label="Terminal"
|
||||
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
) : null}
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge
|
||||
label="Archived"
|
||||
@@ -239,6 +250,7 @@ export function AgentList({
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
requestReopen: agent.terminal && agent.status === "closed",
|
||||
});
|
||||
router.navigate(route as any);
|
||||
},
|
||||
@@ -433,6 +445,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
||||
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -56,11 +56,12 @@ type QueuedMessage = {
|
||||
|
||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||
|
||||
interface AgentInputAreaProps {
|
||||
interface ComposerProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
@@ -89,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
|
||||
export function AgentInputArea({
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
@@ -109,8 +111,8 @@ export function AgentInputArea({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||
}: ComposerProps) {
|
||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -480,7 +482,7 @@ export function AgentInputArea({
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||
console.error("[Composer] Failed to start voice mode", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||
if (message && message.trim().length > 0) {
|
||||
@@ -677,6 +679,7 @@ export function AgentInputArea({
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||
images={selectedImages}
|
||||
16
packages/app/src/components/icons/aider-icon.tsx
Normal file
16
packages/app/src/components/icons/aider-icon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AiderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
29
packages/app/src/components/icons/amp-icon.tsx
Normal file
29
packages/app/src/components/icons/amp-icon.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
17
packages/app/src/components/icons/gemini-icon.tsx
Normal file
17
packages/app/src/components/icons/gemini-icon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface GeminiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
19
packages/app/src/components/icons/opencode-icon.tsx
Normal file
19
packages/app/src/components/icons/opencode-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenCodeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export interface MessageInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
allowEmptySubmit?: boolean;
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
images?: ImageAttachment[];
|
||||
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
@@ -851,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { AiderIcon } from "@/components/icons/aider-icon";
|
||||
import { AmpIcon } from "@/components/icons/amp-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
gemini: GeminiIcon as unknown as typeof Bot,
|
||||
amp: AmpIcon as unknown as typeof Bot,
|
||||
aider: AiderIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -45,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -83,8 +82,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
@@ -239,7 +238,7 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
workspaceKind === "checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
@@ -654,7 +653,6 @@ function ProjectHeaderRow({
|
||||
canCreateWorktree,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
@@ -666,50 +664,30 @@ function ProjectHeaderRow({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
|
||||
|
||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
onWorkspacePress?.();
|
||||
beginWorkspaceSetup({
|
||||
serverId,
|
||||
projectPath: project.iconWorkingDir,
|
||||
projectName: displayName,
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
|
||||
const createWorktreeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!serverId) {
|
||||
throw new Error("No server");
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: project.iconWorkingDir,
|
||||
worktreeSlug: createNameId(),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? "Failed to create worktree");
|
||||
}
|
||||
return payload.workspace;
|
||||
},
|
||||
onSuccess: (workspace) => {
|
||||
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
|
||||
onWorktreeCreated?.(workspace.id);
|
||||
onWorkspacePress?.();
|
||||
router.navigate(
|
||||
prepareWorkspaceTab({
|
||||
serverId: serverId!,
|
||||
workspaceId: workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-new-${project.projectKey}`,
|
||||
actions: ["worktree.new"],
|
||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
||||
enabled: isProjectActive && canCreateWorktree,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
createWorktreeMutation.mutate();
|
||||
handleBeginWorkspaceSetup();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -753,9 +731,9 @@ function ProjectHeaderRow({
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
onPress={handleBeginWorkspaceSetup}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
loading={false}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
@@ -840,7 +818,7 @@ function WorkspaceRowInner({
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
enabled: workspace.projectKind === "git",
|
||||
});
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1096,7 +1074,7 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1241,7 +1219,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1352,7 +1330,7 @@ function FlattenedProjectRow({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
|
||||
@@ -86,12 +86,7 @@ interface SplitContainerProps {
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: {
|
||||
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
paneId?: string;
|
||||
}) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
isPaneFocused: boolean;
|
||||
@@ -267,9 +262,7 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -536,9 +529,7 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -658,9 +649,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -693,9 +682,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -743,9 +730,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -792,9 +777,7 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -902,9 +885,7 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(
|
||||
pane.id,
|
||||
|
||||
715
packages/app/src/components/workspace-setup-dialog.tsx
Normal file
715
packages/app/src/components/workspace-setup-dialog.tsx
Normal file
@@ -0,0 +1,715 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { MessagePayload } from "./message-input";
|
||||
|
||||
type SetupStep = "choose" | "chat" | "terminal-agent";
|
||||
|
||||
export function WorkspaceSetupDialog() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [step, setStep] = useState<SetupStep>("choose");
|
||||
const [terminalPrompt, setTerminalPrompt] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
|
||||
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey: `workspace-setup:${serverId}:${projectPath}`,
|
||||
composer: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspace?.id ?? projectPath,
|
||||
},
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
if (!composerState && pendingWorkspaceSetup) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
const { providers: sortedProviders, recordUsage } = useProviderRecency(
|
||||
composerState?.providerDefinitions ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStep("choose");
|
||||
setTerminalPrompt("");
|
||||
setErrorMessage(null);
|
||||
setCreatedWorkspace(null);
|
||||
setPendingAction(null);
|
||||
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearWorkspaceSetup();
|
||||
}, [clearWorkspaceSetup]);
|
||||
|
||||
const navigateAfterCreation = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
|
||||
) => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
navigationMethod: pendingWorkspaceSetup.navigationMethod,
|
||||
});
|
||||
},
|
||||
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
||||
);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected]);
|
||||
|
||||
const ensureWorkspace = useCallback(async () => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
throw new Error("No workspace setup is pending");
|
||||
}
|
||||
|
||||
if (createdWorkspace) {
|
||||
return createdWorkspace;
|
||||
}
|
||||
|
||||
const connectedClient = withConnectedClient();
|
||||
const payload =
|
||||
pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? await connectedClient.createPaseoWorktree({
|
||||
cwd: pendingWorkspaceSetup.projectPath,
|
||||
worktreeSlug: createNameId(),
|
||||
})
|
||||
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(
|
||||
payload.error ??
|
||||
(pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? "Failed to create worktree"
|
||||
: "Failed to open project"),
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
|
||||
if (pendingWorkspaceSetup.creationMethod === "open_project") {
|
||||
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
|
||||
}
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
}, [
|
||||
createdWorkspace,
|
||||
mergeWorkspaces,
|
||||
pendingWorkspaceSetup,
|
||||
setHasHydratedWorkspaces,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const getIsStillActive = useCallback(() => {
|
||||
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
|
||||
return (
|
||||
current?.serverId === pendingWorkspaceSetup?.serverId &&
|
||||
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
|
||||
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
||||
);
|
||||
}, [
|
||||
pendingWorkspaceSetup?.creationMethod,
|
||||
pendingWorkspaceSetup?.projectPath,
|
||||
pendingWorkspaceSetup?.serverId,
|
||||
]);
|
||||
|
||||
const handleCreateChatAgent = useCallback(
|
||||
async ({ text, images }: MessagePayload) => {
|
||||
try {
|
||||
setPendingAction("chat");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const encodedImages = await encodeImages(images);
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(text.trim() ? { initialPrompt: text.trim() } : {}),
|
||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateTerminalAgent = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal-agent");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
terminal: true,
|
||||
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordUsage(composerState.selectedProvider);
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
recordUsage,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
terminalPrompt,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const handleCreateTerminal = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
|
||||
const payload = await connectedClient.createTerminal(workspace.id);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
|
||||
|
||||
const workspaceTitle =
|
||||
workspace?.name ||
|
||||
workspace?.projectDisplayName ||
|
||||
projectName ||
|
||||
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
|
||||
projectPath;
|
||||
const workspacePath = workspace?.projectRootPath || projectPath;
|
||||
|
||||
if (!pendingWorkspaceSetup || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Set up workspace"
|
||||
visible={true}
|
||||
onClose={handleClose}
|
||||
snapPoints={["82%", "94%"]}
|
||||
testID="workspace-setup-dialog"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
|
||||
<Text style={styles.workspacePath}>{workspacePath}</Text>
|
||||
</View>
|
||||
|
||||
{step === "choose" ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>What do you want to open?</Text>
|
||||
<View style={styles.choiceGrid}>
|
||||
<ChoiceCard
|
||||
title="Chat Agent"
|
||||
description="Open this workspace with a prompt-first chat agent."
|
||||
Icon={MessagesSquare}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("chat");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal Agent"
|
||||
description="Launch an agent-backed terminal in an agent tab."
|
||||
Icon={Bot}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("terminal-agent");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal"
|
||||
description="Create the workspace, then open a standalone terminal tab."
|
||||
Icon={SquareTerminal}
|
||||
disabled={pendingAction !== null}
|
||||
pending={pendingAction === "terminal"}
|
||||
onPress={() => {
|
||||
void handleCreateTerminal();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "chat" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Chat Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
|
||||
</Text>
|
||||
<View style={styles.composerCard}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${projectPath}`}
|
||||
serverId={serverId}
|
||||
isInputActive={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
images={chatDraft.images}
|
||||
onChangeImages={chatDraft.setImages}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={
|
||||
composerState
|
||||
? {
|
||||
...composerState.statusControls,
|
||||
disabled: pendingAction !== null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "terminal-agent" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Terminal Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
|
||||
</Text>
|
||||
|
||||
<View style={styles.providerGrid}>
|
||||
{sortedProviders.map((provider) => (
|
||||
<ProviderOption
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
selected={provider.id === composerState?.selectedProvider}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => composerState?.setProviderFromUser(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Initial prompt</Text>
|
||||
<AdaptiveTextInput
|
||||
value={terminalPrompt}
|
||||
onChangeText={setTerminalPrompt}
|
||||
placeholder="Optional"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
multiline
|
||||
autoCapitalize="sentences"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
void handleCreateTerminalAgent();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View style={styles.stepHeader}>
|
||||
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({
|
||||
title,
|
||||
description,
|
||||
Icon,
|
||||
disabled,
|
||||
pending = false,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
disabled: boolean;
|
||||
pending?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.choiceCard,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceIconWrap}>
|
||||
{pending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.choiceBody}>
|
||||
<Text style={styles.choiceTitle}>{title}</Text>
|
||||
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderOption({
|
||||
provider,
|
||||
selected,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
provider: { id: AgentProvider; label: string; description: string };
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.providerCard,
|
||||
selected ? styles.providerCardSelected : null,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.providerBody}>
|
||||
<Text style={styles.providerTitle}>{provider.label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspaceTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
workspacePath: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
helper: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
choiceGrid: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
choiceCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
choiceCardHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cardDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
choiceIconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
choiceBody: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
composerCard: {
|
||||
minHeight: 180,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
stepHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
providerCardSelected: {
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerBody: {
|
||||
flex: 1,
|
||||
},
|
||||
providerTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
textAlignVertical: "top",
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
}));
|
||||
@@ -521,9 +521,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
@@ -749,13 +748,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
@@ -858,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
@@ -963,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const { agentId, event, timestamp, seq } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
@@ -1005,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const result = processAgentStreamEvent({
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
@@ -1029,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (
|
||||
current &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string" &&
|
||||
current.epoch === epoch &&
|
||||
seq >= current.startSeq &&
|
||||
seq <= current.endSeq
|
||||
) {
|
||||
@@ -1039,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === nextCursor.epoch &&
|
||||
current.startSeq === nextCursor.startSeq &&
|
||||
current.endSeq === nextCursor.endSeq
|
||||
) {
|
||||
@@ -1090,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
|
||||
@@ -7,6 +7,7 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
terminal: false,
|
||||
status,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
@@ -19,6 +20,7 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -9,13 +9,9 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -33,22 +29,30 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
||||
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text },
|
||||
} as AgentStreamEventPayload;
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "shell",
|
||||
status,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "after",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
event: makeTimelineEvent("hello"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
timestamp: new Date(2000),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns error with no init resolution when no deferred exists", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
error: "timeout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBe("timeout");
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces tail and clears head when reset=true", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "old",
|
||||
text: "old message",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const existingHead: StreamItem[] = [
|
||||
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||
const provisionalHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-1",
|
||||
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: existingTail,
|
||||
currentHead: existingHead,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "first"),
|
||||
makeTimelineEntry(2, "second"),
|
||||
makeTimelineEntry(3, "third"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).not.toBe(existingTail);
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: provisionalHead,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap tail replaces
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
|
||||
// Should have catch-up side effect
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||
expect(catchUp).toEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("appends incrementally for contiguous seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
||||
it("prepends older committed history for before pagination", () => {
|
||||
const currentTail: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "tail-3",
|
||||
text: "newer",
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(10, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
// Gap should trigger catch-up
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale entries silently", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
||||
},
|
||||
});
|
||||
|
||||
// No new items appended (all dropped as stale)
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-2",
|
||||
entries: [makeTimelineEntry(6, "different epoch")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves init when deferred matches direction", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.initResolution).toBe("resolve");
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
currentTail,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
entries: [],
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
entries: [
|
||||
makeTimelineEntry(1, "hello", "user_message"),
|
||||
makeTimelineEntry(2, "older"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// "before" direction doesn't match "after" initRequestDirection,
|
||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
});
|
||||
expect(result.tail).toHaveLength(3);
|
||||
expect(result.tail[0]?.kind).toBe("user_message");
|
||||
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||
expect(result.tail[2]).toBe(currentTail[0]);
|
||||
});
|
||||
|
||||
it("clears initializing even without deferred", () => {
|
||||
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
expect(result.initResolution).toBe(null);
|
||||
});
|
||||
|
||||
it("always includes flush_pending_updates side effect on success", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes cursor when no existing cursor on first entries", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
||||
startSeq: 121,
|
||||
endSeq: 121,
|
||||
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "still streaming",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toBe(currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
});
|
||||
|
||||
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 125,
|
||||
endSeq: 125,
|
||||
entries: [makeTimelineEntry(125, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
it("treats seq-less timeline events as provisional head updates", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnEvent,
|
||||
event: makeTimelineEvent("partial"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "partial",
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts timeline event with cursor advance", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("new chunk"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
event: makeTimelineEvent("finalized reply"),
|
||||
seq: 121,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||
const provisional = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
});
|
||||
|
||||
const committed = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: 8,
|
||||
currentHead: provisional.head,
|
||||
currentTail: provisional.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
|
||||
expect(committed.head).toEqual([]);
|
||||
expect(committed.tail).toHaveLength(1);
|
||||
expect(committed.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requests catch-up when a committed live row skips ahead", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("far ahead"),
|
||||
seq: 10,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
seq: 125,
|
||||
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale timeline event", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("old"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears provisional head on terminal turn events without committing it to tail", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
},
|
||||
currentHead: [
|
||||
{
|
||||
kind: "thought",
|
||||
id: "reasoning-1",
|
||||
text: "thinking",
|
||||
timestamp: new Date(1000),
|
||||
status: "loading",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops timeline event with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("wrong epoch"),
|
||||
seq: 6,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("first"),
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: undefined,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent).not.toBe(null);
|
||||
expect(result.agent!.status).toBe("idle");
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "something broke",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnFailedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "idle",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when no agent is provided", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: null,
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(5000),
|
||||
lastActivityAt: new Date(5000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("does not produce agent patch for non-terminal events", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("just text"),
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -12,38 +12,25 @@ import {
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared cursor type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineCursor = {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect discriminated unions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect = {
|
||||
type: "catch_up";
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
cursor: { endSeq: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seqStart: number;
|
||||
seq: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
|
||||
payload: {
|
||||
agentId: string;
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
startCursor: { seq: number } | null;
|
||||
endCursor: { seq: number } | null;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
entries: TimelineResponseEntry[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error path: reject init and leave stream state unchanged
|
||||
// ------------------------------------------------------------------
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
// ------------------------------------------------------------------
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
reset: payload.reset,
|
||||
epoch: payload.epoch,
|
||||
endCursor: payload.endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (replace) {
|
||||
// ----------------------------------------------------------------
|
||||
// Replace path: full hydration from scratch
|
||||
// ----------------------------------------------------------------
|
||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
});
|
||||
nextHead = [];
|
||||
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
nextCursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (timelineUnits.length > 0) {
|
||||
// ----------------------------------------------------------------
|
||||
// Incremental append path
|
||||
// ----------------------------------------------------------------
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
||||
epoch: payload.epoch,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
if (decision === "init") {
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cursor) {
|
||||
continue;
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cursor &&
|
||||
(!currentCursor ||
|
||||
currentCursor.epoch !== cursor.epoch ||
|
||||
currentCursor.startSeq !== cursor.startSeq ||
|
||||
currentCursor.endSeq !== cursor.endSeq)
|
||||
) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flush pending agent updates side effect
|
||||
// ------------------------------------------------------------------
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Init resolution
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
function cursorsEqual(
|
||||
left: TimelineCursor | null | undefined,
|
||||
right: TimelineCursor | null | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||
}
|
||||
|
||||
function removeSupersededProvisionalItems(
|
||||
head: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
): StreamItem[] {
|
||||
if (head.length === 0 || event.type !== "timeline") {
|
||||
return head;
|
||||
}
|
||||
|
||||
let nextHead = head;
|
||||
if (event.item.type === "assistant_message") {
|
||||
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||
} else if (event.item.type === "tool_call") {
|
||||
const committedToolCall = event.item;
|
||||
nextHead = head.filter(
|
||||
(item) =>
|
||||
item.kind !== "tool_call" ||
|
||||
item.payload.source !== "agent" ||
|
||||
item.payload.data.callId !== committedToolCall.callId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextHead.length === head.length ? head : nextHead;
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
endSeq: payload.endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (bootstrapPolicy.replace) {
|
||||
nextTail = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextHead = [];
|
||||
nextCursor =
|
||||
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: payload.endSeq,
|
||||
}
|
||||
: null;
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (payload.direction === "before") {
|
||||
const prepended = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||
const derivedCursor =
|
||||
typeof payload.startSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||
}
|
||||
: currentCursor;
|
||||
nextCursor = derivedCursor;
|
||||
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||
} else if (timelineUnits.length > 0) {
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
cursor =
|
||||
decision === "init"
|
||||
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
if (event.type === "timeline" && typeof seq === "number") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (decision === "gap") {
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
cursor: { endSeq: currentCursor.endSeq },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
} else if (decision !== "drop_stale") {
|
||||
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||
source: "canonical",
|
||||
});
|
||||
changedTail = nextTail !== currentTail;
|
||||
|
||||
nextHead = removeSupersededProvisionalItems(currentHead, event);
|
||||
changedHead = nextHead !== currentHead;
|
||||
|
||||
nextTimelineCursor =
|
||||
decision === "init"
|
||||
? { startSeq: seq, endSeq: seq }
|
||||
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||
}
|
||||
} else if (event.type === "timeline") {
|
||||
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||
source: "live",
|
||||
});
|
||||
changedHead = nextHead !== currentHead;
|
||||
} else if (
|
||||
(event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed") &&
|
||||
currentHead.length > 0
|
||||
) {
|
||||
nextHead = [];
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
})
|
||||
: {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
|
||||
}
|
||||
|
||||
return {
|
||||
tail,
|
||||
head,
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
|
||||
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
deriveInitialTimelineRequest,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("uses tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const advancedCursor = { endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { epoch: string; seq: number };
|
||||
cursor?: { seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
cursor: { seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
endSeq: number | null;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
catchUpCursor: { endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
|
||||
onlineServerIds?: string[];
|
||||
};
|
||||
|
||||
type UseAgentFormStateResult = {
|
||||
export type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
|
||||
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
@@ -107,7 +107,6 @@ export function useAgentInitialization({
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
|
||||
270
packages/app/src/hooks/use-agent-input-draft.live.test.tsx
Normal file
270
packages/app/src/hooks/use-agent-input-draft.live.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
asyncStorage.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
asyncStorage.delete(key);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/service", () => ({
|
||||
garbageCollectAttachments: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./use-agent-form-state", () => ({
|
||||
useAgentFormState: () => ({
|
||||
selectedServerId: "host-1",
|
||||
setSelectedServerId: () => undefined,
|
||||
setSelectedServerIdFromUser: () => undefined,
|
||||
selectedProvider: "codex",
|
||||
setProviderFromUser: () => undefined,
|
||||
selectedMode: "auto",
|
||||
setModeFromUser: () => undefined,
|
||||
selectedModel: "",
|
||||
setModelFromUser: () => undefined,
|
||||
selectedThinkingOptionId: "",
|
||||
setThinkingOptionFromUser: () => undefined,
|
||||
workingDir: "/repo",
|
||||
setWorkingDir: () => undefined,
|
||||
setWorkingDirFromUser: () => undefined,
|
||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
||||
providerDefinitionMap: new Map(),
|
||||
agentDefinition: undefined,
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
availableModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
allProviderModels: new Map([
|
||||
[
|
||||
"codex",
|
||||
[
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
isAllModelsLoading: false,
|
||||
availableThinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
isModelLoading: false,
|
||||
modelError: null,
|
||||
refreshProviderModels: () => undefined,
|
||||
setProviderAndModelFromUser: () => undefined,
|
||||
workingDirIsEmpty: false,
|
||||
persistFormPreferences: async () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
});
|
||||
|
||||
it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
id: "attachment-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/1",
|
||||
createdAt: 1,
|
||||
fileName: "image.png",
|
||||
byteSize: 128,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe({ draftKey }: { draftKey: string }) {
|
||||
latest = useAgentInputDraft({
|
||||
draftKey,
|
||||
composer: {
|
||||
initialServerId: "host-1",
|
||||
initialValues: { workingDir: "/repo" },
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
lockedWorkingDir: "/repo",
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
let root: Root | null = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("hello world");
|
||||
getLatest().setImages([image]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.unmount();
|
||||
});
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("hello world");
|
||||
expect(getLatest().images).toEqual([image]);
|
||||
});
|
||||
|
||||
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const sentImage: AttachmentMetadata = {
|
||||
id: "attachment-sent",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/sent",
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setImages([sentImage]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("sent");
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("");
|
||||
expect(getLatest().images).toEqual([]);
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "sent",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("draft again");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("abandoned");
|
||||
});
|
||||
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "abandoned",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
149
packages/app/src/hooks/use-agent-input-draft.test.ts
Normal file
149
packages/app/src/hooks/use-agent-input-draft.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let __private__: typeof import("./use-agent-input-draft").__private__;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ __private__ } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft", () => {
|
||||
describe("__private__.resolveDraftKey", () => {
|
||||
it("returns an object draft key string unchanged", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: "draft:key",
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:key");
|
||||
});
|
||||
|
||||
it("resolves a computed draft key from the selected server", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:host-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "gpt-5.4-mini",
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected model when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "gpt-5.4-mini",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("falls back to the provider default model", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
type UseAgentFormStateResult,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
||||
|
||||
type AgentInputDraftComposerOptions = {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
};
|
||||
|
||||
type DraftKeyContext = {
|
||||
selectedServerId: string | null;
|
||||
};
|
||||
|
||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||
|
||||
type UseAgentInputDraftInput = {
|
||||
draftKey: DraftKeyInput;
|
||||
composer?: AgentInputDraftComposerOptions;
|
||||
};
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
statusControls: DraftAgentStatusBarProps;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
@@ -11,6 +46,7 @@ interface AgentInputDraft {
|
||||
setImages: (updater: ImageUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
||||
@@ -36,7 +72,108 @@ function areImagesEqual(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
function resolveDraftKey(input: {
|
||||
draftKey: DraftKeyInput;
|
||||
selectedServerId: string | null;
|
||||
}): string {
|
||||
if (typeof input.draftKey === "function") {
|
||||
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||
}
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
const selectedModel = input.selectedModel.trim();
|
||||
if (selectedModel) {
|
||||
return selectedModel;
|
||||
}
|
||||
|
||||
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
function buildDraftComposerCommandConfig(input: {
|
||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
}): DraftAgentStatusBarProps {
|
||||
const { formState } = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
onSelectProvider: formState.setProviderFromUser,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
onSelectMode: formState.setModeFromUser,
|
||||
models: formState.availableModels,
|
||||
selectedModel: formState.selectedModel,
|
||||
onSelectModel: formState.setModelFromUser,
|
||||
isModelLoading: formState.isModelLoading,
|
||||
allProviderModels: formState.allProviderModels,
|
||||
isAllModelsLoading: formState.isAllModelsLoading,
|
||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||
thinkingOptions: formState.availableThinkingOptions,
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||
const composerOptions = input.composer ?? null;
|
||||
const formState = useAgentFormState({
|
||||
initialServerId: composerOptions?.initialServerId ?? null,
|
||||
initialValues: composerOptions?.initialValues,
|
||||
isVisible: composerOptions?.isVisible ?? false,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
||||
});
|
||||
const draftKey = useMemo(
|
||||
() =>
|
||||
resolveDraftKey({
|
||||
draftKey: input.draftKey,
|
||||
selectedServerId: formState.selectedServerId,
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
});
|
||||
}, [draftKey, images, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
if (formState.workingDir.trim() === lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||
if (!composerOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
statusControls: buildDraftStatusControls({ formState }),
|
||||
commandDraftConfig,
|
||||
};
|
||||
}, [
|
||||
commandDraftConfig,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
formState,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
setImages,
|
||||
clear,
|
||||
isHydrated,
|
||||
composerState,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
|
||||
serverId: "server-1",
|
||||
id,
|
||||
provider: "claude",
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -5,6 +5,14 @@ export interface AgentScreenAgent {
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
lastError?: string | null;
|
||||
terminalExit?: {
|
||||
command: string;
|
||||
message: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
outputLines: string[];
|
||||
} | null;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
cwd?: string;
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
|
||||
serverId,
|
||||
serverLabel,
|
||||
title: agent.title ?? null,
|
||||
terminal: agent.terminal,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
serverId: "server-1",
|
||||
id: input?.id ?? "agent-1",
|
||||
provider: input?.provider ?? "codex",
|
||||
terminal: input?.terminal ?? false,
|
||||
status: input?.status ?? "idle",
|
||||
createdAt: input?.createdAt ?? timestamp,
|
||||
updatedAt: input?.updatedAt ?? timestamp,
|
||||
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: input?.currentModeId ?? null,
|
||||
availableModes: input?.availableModes ?? [],
|
||||
|
||||
@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
terminal: source.terminal,
|
||||
status: source.status,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
|
||||
@@ -72,6 +72,7 @@ type CreateRequestContext = {
|
||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
draftId: string;
|
||||
getPendingServerId: () => string | null;
|
||||
allowEmptyText?: boolean;
|
||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||
onCreateStart?: () => void;
|
||||
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
allowEmptyText = false,
|
||||
validateBeforeSubmit,
|
||||
onBeforeSubmit,
|
||||
onCreateStart,
|
||||
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "user_message",
|
||||
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt) {
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const error = new Error("Initial prompt is required");
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
setPendingCreateAttempt,
|
||||
updatePendingAgentId,
|
||||
validateBeforeSubmit,
|
||||
allowEmptyText,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
133
packages/app/src/hooks/use-open-project.test.ts
Normal file
133
packages/app/src/hooks/use-open-project.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { replaceRoute } = vi.hoisted(() => ({
|
||||
replaceRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
replace: replaceRoute,
|
||||
},
|
||||
}));
|
||||
|
||||
import { openProjectDirectly } from "@/hooks/use-open-project";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/project";
|
||||
|
||||
describe("openProjectDirectly", () => {
|
||||
beforeEach(() => {
|
||||
replaceRoute.mockReset();
|
||||
useSessionStore.setState({
|
||||
sessions: {},
|
||||
});
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as never);
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
requestId: "request-1",
|
||||
error: null,
|
||||
workspace: {
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
},
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
|
||||
expect.objectContaining({ id: "1", projectId: "1", projectRootPath: WORKSPACE_ID }),
|
||||
]);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "1",
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string];
|
||||
expect(layout.root.kind).toBe("pane");
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target).toEqual({
|
||||
kind: "launcher",
|
||||
launcherId: "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ");
|
||||
});
|
||||
|
||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
requestId: "request-2",
|
||||
error: "Failed to open project",
|
||||
workspace: null,
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({});
|
||||
expect(replaceRoute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,76 @@
|
||||
import { router } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { router } from "expo-router";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
isConnected: boolean;
|
||||
client: Pick<DaemonClient, "openProject"> | null;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
openLauncherTab: (workspaceKey: string) => string | null;
|
||||
replaceRoute: (route: string) => void;
|
||||
}
|
||||
|
||||
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
|
||||
const normalizedServerId = input.serverId.trim();
|
||||
const trimmedPath = input.projectPath.trim();
|
||||
if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await input.client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
input.mergeWorkspaces(normalizedServerId, [workspace]);
|
||||
input.setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.openLauncherTab(workspaceKey);
|
||||
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
async (path: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath || !client || !normalizedServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
|
||||
mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]);
|
||||
setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: payload.workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
}) as any,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open project");
|
||||
return false;
|
||||
}
|
||||
return openProjectDirectly({
|
||||
serverId: normalizedServerId,
|
||||
projectPath: path,
|
||||
isConnected,
|
||||
client,
|
||||
mergeWorkspaces,
|
||||
setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute: (route) => {
|
||||
router.replace(route as any);
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
|
||||
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function workspace(
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
activityAt: Date | null;
|
||||
@@ -130,6 +131,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
projectKind: workspace.projectKind,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
@@ -248,8 +250,8 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
id: number;
|
||||
projectId: number;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
|
||||
@@ -204,7 +204,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
@@ -216,7 +216,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,16 +4,14 @@ import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import {
|
||||
@@ -28,6 +26,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
|
||||
import {
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatus,
|
||||
@@ -96,7 +95,7 @@ function useAgentPanelDescriptor(
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
const icon = getProviderIcon(provider);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
@@ -155,6 +154,12 @@ function isNotFoundErrorMessage(message: string): boolean {
|
||||
return /agent not found|not found/i.test(message);
|
||||
}
|
||||
|
||||
type AgentLookupState =
|
||||
| { tag: "idle" }
|
||||
| { tag: "loading" }
|
||||
| { tag: "not_found"; message: string }
|
||||
| { tag: "error"; message: string };
|
||||
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -227,6 +232,219 @@ function AgentPanelBody({
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
terminal: agent?.terminal ?? false,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
lastError: agent?.lastError ?? null,
|
||||
terminalExit: agent?.terminalExit ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
lookupAttemptTokenRef.current += 1;
|
||||
setLookupState({ tag: "idle" });
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id) {
|
||||
if (lookupState.tag !== "idle") {
|
||||
setLookupState({ tag: "idle" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
if (lookupState.tag === "loading" || lookupState.tag === "not_found") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupState({ tag: "loading" });
|
||||
const attemptToken = ++lookupAttemptTokenRef.current;
|
||||
|
||||
client
|
||||
.fetchAgent(agentId)
|
||||
.then((result) => {
|
||||
if (attemptToken !== lookupAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
setLookupState({
|
||||
tag: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLookupState({ tag: "idle" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (attemptToken !== lookupAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
setLookupState({ tag: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (lookupState.tag === "error") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-load-error">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||
<Text style={styles.statusText}>{lookupState.message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent: AgentScreenAgent | null =
|
||||
agentState.serverId && agentState.id && agentState.status && agentState.cwd
|
||||
? {
|
||||
serverId: agentState.serverId,
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
lastError: agentState.lastError ?? null,
|
||||
terminalExit: agentState.terminalExit ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
||||
|
||||
if (agentState.terminal) {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<TerminalAgentPanel
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
agent={agent}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ActivityIndicator size="large" color={theme.colors.foreground} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatAgentContent
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={client}
|
||||
isConnected={isConnected}
|
||||
connectionStatus={connectionStatus}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatAgentContent({
|
||||
serverId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const panelToast = useToastHost();
|
||||
@@ -241,12 +459,12 @@ function AgentPanelBody({
|
||||
routeKey: string;
|
||||
reason: "initial-entry" | "resume";
|
||||
} | null>(null);
|
||||
const agentInputDraft = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: agentId ?? "__pending__",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
@@ -262,8 +480,11 @@ function AgentPanelBody({
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
terminal: agent?.terminal ?? false,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
lastError: agent?.lastError ?? null,
|
||||
terminalExit: agent?.terminalExit ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
@@ -494,6 +715,8 @@ function AgentPanelBody({
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
lastError: agentState.lastError ?? null,
|
||||
terminalExit: agentState.terminalExit ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
: null;
|
||||
@@ -615,98 +838,6 @@ function AgentPanelBody({
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id || shouldUseOptimisticStream) {
|
||||
if (missingAgentState.kind !== "idle") {
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMissingAgentState({ kind: "resolving" });
|
||||
const attemptToken = ++initAttemptTokenRef.current;
|
||||
|
||||
ensureAgentIsInitialized(agentId)
|
||||
.then(async () => {
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
|
||||
if (!currentAgent) {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
setMissingAgentState({
|
||||
kind: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
setMissingAgentState({ kind: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setMissingAgentState({ kind: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentState.id,
|
||||
agentId,
|
||||
client,
|
||||
ensureAgentIsInitialized,
|
||||
hasSession,
|
||||
isConnected,
|
||||
missingAgentState.kind,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
shouldUseOptimisticStream,
|
||||
]);
|
||||
|
||||
const isHistoryRefreshCatchingUp =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -781,7 +912,7 @@ function AgentPanelBody({
|
||||
</View>
|
||||
|
||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
|
||||
446
packages/app/src/panels/launcher-panel.tsx
Normal file
446
packages/app/src/panels/launcher-panel.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useCallback, useMemo, useState, type ComponentType } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
const MAX_VISIBLE_PROVIDER_TILES = 4;
|
||||
|
||||
function useLauncherPanelDescriptor() {
|
||||
return {
|
||||
label: "New Tab",
|
||||
subtitle: "New Tab",
|
||||
titleState: "ready" as const,
|
||||
icon: Plus,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function LauncherPanel() {
|
||||
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { providers, recordUsage } = useProviderRecency();
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
|
||||
|
||||
const visibleProviders = useMemo(
|
||||
() => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES),
|
||||
[providers],
|
||||
);
|
||||
const overflowProviders = useMemo(
|
||||
() => providers.slice(MAX_VISIBLE_PROVIDER_TILES),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const launchTerminalAgent = useCallback(
|
||||
async (providerId: AgentProvider) => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction(providerId);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
provider: providerId,
|
||||
cwd: workspaceId,
|
||||
terminal: true,
|
||||
});
|
||||
recordUsage(providerId);
|
||||
// Retarget first so the launcher converts in place before session reconciliation
|
||||
// can materialize the new agent as a separate tab.
|
||||
retargetCurrentTab({ kind: "agent", agentId: agent.id });
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(toErrorMessage(error));
|
||||
} finally {
|
||||
setPendingAction((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
|
||||
);
|
||||
|
||||
const openDraftTab = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
setPendingAction("draft");
|
||||
retargetCurrentTab({
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
});
|
||||
setPendingAction(null);
|
||||
}, [retargetCurrentTab]);
|
||||
|
||||
const openTerminalTab = useCallback(async () => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("terminal");
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const payload = await client.createTerminal(workspaceId);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
retargetCurrentTab({
|
||||
kind: "terminal",
|
||||
terminalId: payload.terminal.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(toErrorMessage(error));
|
||||
} finally {
|
||||
setPendingAction((current) => (current === "terminal" ? null : current));
|
||||
}
|
||||
}, [client, isConnected, retargetCurrentTab, workspaceId]);
|
||||
|
||||
const actionsDisabled = pendingAction !== null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
!isPaneFocused ? styles.contentUnfocused : null,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<View style={styles.primaryRow}>
|
||||
<LauncherTile
|
||||
title="New Chat"
|
||||
Icon={SquarePen}
|
||||
accent
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === "draft"}
|
||||
onPress={openDraftTab}
|
||||
/>
|
||||
<LauncherTile
|
||||
title="Terminal"
|
||||
Icon={SquareTerminal}
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === "terminal"}
|
||||
onPress={() => {
|
||||
void openTerminalTab();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionLabel}>Terminal Agents</Text>
|
||||
|
||||
<View style={styles.providerGrid}>
|
||||
{visibleProviders.map((provider) => (
|
||||
<ProviderTile
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === provider.id}
|
||||
onPress={() => {
|
||||
void launchTerminalAgent(provider.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overflowProviders.length > 0 ? (
|
||||
<ViewAllProvidersTile
|
||||
providers={overflowProviders}
|
||||
disabled={actionsDisabled}
|
||||
pendingProviderId={pendingAction}
|
||||
onSelectProvider={(providerId) => {
|
||||
void launchTerminalAgent(providerId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LauncherTile({
|
||||
title,
|
||||
Icon,
|
||||
accent = false,
|
||||
disabled,
|
||||
pending,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
accent?: boolean;
|
||||
disabled: boolean;
|
||||
pending: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
|
||||
const titleColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.primaryTile,
|
||||
accent ? styles.primaryTileAccent : null,
|
||||
(hovered || pressed) && !disabled
|
||||
? accent
|
||||
? styles.primaryTileAccentInteractive
|
||||
: styles.tileInteractive
|
||||
: null,
|
||||
disabled ? styles.tileDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.primaryIconWrap, accent ? styles.primaryIconWrapAccent : null]}>
|
||||
{pending ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={accent ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
) : (
|
||||
<Icon size={16} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.primaryTileTitle, { color: titleColor }]}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderTile({
|
||||
provider,
|
||||
disabled,
|
||||
pending,
|
||||
onPress,
|
||||
}: {
|
||||
provider: { id: string; label: string; description: string };
|
||||
disabled: boolean;
|
||||
pending: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.providerTile,
|
||||
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
|
||||
disabled ? styles.tileDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
{pending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.providerLabel}>{provider.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAllProvidersTile({
|
||||
providers,
|
||||
disabled,
|
||||
pendingProviderId,
|
||||
onSelectProvider,
|
||||
}: {
|
||||
providers: Array<{ id: string; label: string; description: string }>;
|
||||
disabled: boolean;
|
||||
pendingProviderId: string | null;
|
||||
onSelectProvider: (providerId: AgentProvider) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<Bot size={16} color={theme.colors.foreground} />
|
||||
</View>
|
||||
<Text style={styles.providerLabel}>More</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
{open ? <View style={styles.dropdownOutline} /> : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" width={260}>
|
||||
{providers.map((provider) => {
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
description={provider.description}
|
||||
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
|
||||
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
|
||||
status={pendingProviderId === provider.id ? "pending" : "idle"}
|
||||
pendingLabel={`Launching ${provider.label}...`}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
|
||||
kind: "launcher",
|
||||
component: LauncherPanel,
|
||||
useDescriptor: useLauncherPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
contentUnfocused: {
|
||||
opacity: 0.96,
|
||||
},
|
||||
inner: {
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
primaryRow: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tileInteractive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tileDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
primaryTile: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
primaryTileAccent: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
primaryTileAccentInteractive: {
|
||||
backgroundColor: theme.colors.accentBright,
|
||||
borderColor: theme.colors.accentBright,
|
||||
},
|
||||
primaryIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
primaryIconWrapAccent: {
|
||||
backgroundColor: "rgba(255,255,255,0.14)",
|
||||
},
|
||||
primaryTileTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
providerGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerTile: {
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
dropdownOutline: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { launcherPanelRegistration } from "@/panels/launcher-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
|
||||
@@ -14,5 +15,6 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(launcherPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
316
packages/app/src/panels/terminal-agent-panel.tsx
Normal file
316
packages/app/src/panels/terminal-agent-panel.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import {
|
||||
buildTerminalAgentReopenKey,
|
||||
useTerminalAgentReopenStore,
|
||||
} from "@/stores/terminal-agent-reopen-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
type TerminalAgentPanelProps = {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
agent: AgentScreenAgent;
|
||||
isPaneFocused: boolean;
|
||||
};
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getTerminalExitTitle(agent: AgentScreenAgent): string {
|
||||
const exitCode = agent.terminalExit?.exitCode;
|
||||
const signal = agent.terminalExit?.signal;
|
||||
if (
|
||||
agent.status === "error" ||
|
||||
(exitCode != null && exitCode !== 0) ||
|
||||
signal != null
|
||||
) {
|
||||
return "Terminal session failed";
|
||||
}
|
||||
return "Terminal session ended";
|
||||
}
|
||||
|
||||
function getTerminalExitMessage(agent: AgentScreenAgent): string {
|
||||
const summary = agent.terminalExit?.message?.trim();
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
const lastError = agent.lastError?.trim();
|
||||
if (lastError) {
|
||||
return lastError;
|
||||
}
|
||||
return "Reopen the agent from the sessions list to start it again.";
|
||||
}
|
||||
|
||||
function isCleanTerminalExit(agent: AgentScreenAgent): boolean {
|
||||
return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null;
|
||||
}
|
||||
|
||||
export function TerminalAgentPanel({
|
||||
serverId,
|
||||
client,
|
||||
agent,
|
||||
isPaneFocused,
|
||||
}: TerminalAgentPanelProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
const { theme } = useUnistyles();
|
||||
const { tabId, workspaceId } = usePaneContext();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const [terminalId, setTerminalId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [didExitInPanel, setDidExitInPanel] = useState(false);
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id });
|
||||
const reopenIntentVersion = useTerminalAgentReopenStore((state) =>
|
||||
reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0,
|
||||
);
|
||||
|
||||
// Refs for effect guards — these values gate whether the creation effect
|
||||
// should run, but changes to them should NOT re-trigger the effect.
|
||||
const isCreatingRef = useRef(false);
|
||||
const didExitRef = useRef(false);
|
||||
const lastHandledReopenIntentRef = useRef(reopenIntentVersion);
|
||||
const isAutoClosingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalId(null);
|
||||
setIsCreating(false);
|
||||
setCreateError(null);
|
||||
setDidExitInPanel(false);
|
||||
isCreatingRef.current = false;
|
||||
didExitRef.current = false;
|
||||
}, [agent.id, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reopenIntentVersion <= lastHandledReopenIntentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHandledReopenIntentRef.current = reopenIntentVersion;
|
||||
if (!didExitRef.current && !didExitInPanel && !createError) {
|
||||
return;
|
||||
}
|
||||
|
||||
didExitRef.current = false;
|
||||
setDidExitInPanel(false);
|
||||
setCreateError(null);
|
||||
}, [createError, didExitInPanel, reopenIntentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
return client.on("terminal_stream_exit", (message) => {
|
||||
if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) {
|
||||
return;
|
||||
}
|
||||
setTerminalId((current) => (current === message.payload.terminalId ? null : current));
|
||||
setDidExitInPanel(true);
|
||||
didExitRef.current = true;
|
||||
});
|
||||
}, [client, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAutoClosingRef.current = true;
|
||||
void archiveAgent({ serverId, agentId: agent.id })
|
||||
.then(() => {
|
||||
unpinWorkspaceAgent(workspaceKey, agent.id);
|
||||
closeWorkspaceTab(workspaceKey, tabId);
|
||||
})
|
||||
.finally(() => {
|
||||
isAutoClosingRef.current = false;
|
||||
});
|
||||
}, [
|
||||
agent,
|
||||
archiveAgent,
|
||||
closeWorkspaceTab,
|
||||
serverId,
|
||||
tabId,
|
||||
unpinWorkspaceAgent,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
// Create the terminal when the panel becomes visible and no terminal exists yet.
|
||||
// Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect
|
||||
// when their values change — we only want this to fire on genuine state transitions
|
||||
// (focus change, terminal cleared, agent change).
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isScreenFocused ||
|
||||
!isPaneFocused ||
|
||||
terminalId ||
|
||||
isCreatingRef.current ||
|
||||
didExitRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
isCreatingRef.current = true;
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
void client
|
||||
.createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id })
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (payload.error || !payload.terminal) {
|
||||
setCreateError(payload.error ?? "Failed to open terminal");
|
||||
return;
|
||||
}
|
||||
setTerminalId(payload.terminal.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCreateError(toErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
isCreatingRef.current = false;
|
||||
setIsCreating(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]);
|
||||
|
||||
if (!isScreenFocused) {
|
||||
return <View style={styles.container} />;
|
||||
}
|
||||
|
||||
if (terminalId) {
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={agent.cwd}
|
||||
terminalId={terminalId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-loading">
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.message}>Opening terminal…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (createError) {
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-error">
|
||||
<Text style={styles.title}>Failed to open terminal</Text>
|
||||
<Text style={styles.message}>{createError}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (didExitInPanel || agent.status === "closed" || agent.status === "error") {
|
||||
const terminalExit = agent.terminalExit ?? null;
|
||||
const exitMeta =
|
||||
terminalExit?.exitCode != null
|
||||
? `Exit code ${terminalExit.exitCode}`
|
||||
: terminalExit?.signal != null
|
||||
? `Signal ${terminalExit.signal}`
|
||||
: null;
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-closed">
|
||||
<Text style={styles.title}>{getTerminalExitTitle(agent)}</Text>
|
||||
<Text style={styles.message}>{getTerminalExitMessage(agent)}</Text>
|
||||
{terminalExit ? (
|
||||
<View style={styles.detailsCard}>
|
||||
{exitMeta ? <Text style={styles.detailsLabel}>{exitMeta}</Text> : null}
|
||||
{terminalExit.outputLines.length > 0 ? (
|
||||
<Text style={styles.output}>{terminalExit.outputLines.join("\n")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.message}>Reopen the agent from the sessions list to start it again.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-idle">
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
state: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
detailsCard: {
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
padding: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
detailsLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
output: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
lineHeight: 20,
|
||||
},
|
||||
}));
|
||||
@@ -39,7 +39,7 @@ function useTerminalPanelDescriptor(
|
||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||
|
||||
return {
|
||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
||||
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
titleState: "ready",
|
||||
icon: Terminal,
|
||||
|
||||
@@ -133,6 +133,7 @@ function makeFetchAgentsEntry(input: {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -11,15 +11,14 @@ import Animated from "react-native-reanimated";
|
||||
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
@@ -65,6 +64,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
@@ -202,37 +202,45 @@ function DraftAgentScreenContent({
|
||||
return values;
|
||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
|
||||
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
const draftInput = useAgentInputDraft(
|
||||
{
|
||||
draftKey: ({ selectedServerId }) =>
|
||||
buildDraftStoreKey({
|
||||
serverId: selectedServerId ?? "",
|
||||
agentId: draftAgentIdRef.current,
|
||||
draftId: draftIdRef.current,
|
||||
}),
|
||||
composer: {
|
||||
initialServerId: resolvedServerId ?? null,
|
||||
initialValues,
|
||||
isVisible,
|
||||
onlineServerIds,
|
||||
},
|
||||
},
|
||||
);
|
||||
const composerState = draftInput.composerState;
|
||||
if (!composerState) {
|
||||
throw new Error("Draft agent composer state is required");
|
||||
}
|
||||
|
||||
const {
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
providerDefinitions,
|
||||
workingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: resolvedServerId ?? null,
|
||||
initialValues,
|
||||
isVisible,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
commandDraftConfig,
|
||||
statusControls,
|
||||
} = composerState;
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
@@ -245,15 +253,6 @@ function DraftAgentScreenContent({
|
||||
);
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
const draftInput = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
serverId: selectedServerId ?? "",
|
||||
agentId: draftAgentIdRef.current,
|
||||
draftId: draftIdRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
||||
initialWorktreeMode,
|
||||
@@ -744,47 +743,6 @@ function DraftAgentScreenContent({
|
||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||
|
||||
const createAgentClient = sessionClient;
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||
const cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
).trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: selectedProvider,
|
||||
cwd,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedProvider,
|
||||
selectedWorktreePath,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -818,7 +776,7 @@ function DraftAgentScreenContent({
|
||||
if (isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
if (!effectiveModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (isAttachWorktree && !selectedWorktreePath) {
|
||||
@@ -851,15 +809,19 @@ function DraftAgentScreenContent({
|
||||
const cwd =
|
||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||
".";
|
||||
const provider = selectedProvider;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
const provider = composerState.selectedProvider;
|
||||
const model = effectiveModelId || null;
|
||||
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
id: draftAgentIdRef.current,
|
||||
provider,
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -888,14 +850,17 @@ function DraftAgentScreenContent({
|
||||
const resolvedWorkingDir =
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: undefined;
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||
...(effectiveThinkingOptionId
|
||||
? { thinkingOptionId: effectiveThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -1216,7 +1181,7 @@ function DraftAgentScreenContent({
|
||||
)}
|
||||
</Animated.View>
|
||||
<View style={styles.inputAreaWrapper}>
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isInputActive={isFocused}
|
||||
@@ -1230,24 +1195,9 @@ function DraftAgentScreenContent({
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={!isSubmitting}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
commandDraftConfig={commandDraftConfig}
|
||||
statusControls={{
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider: setProviderFromUser,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode: setModeFromUser,
|
||||
models: availableModels,
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
...statusControls,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeAgent(input: {
|
||||
serverId: "srv",
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
terminal: false,
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
@@ -31,6 +32,7 @@ function makeAgent(input: {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
|
||||
import { Columns2, Plus, Rows2, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -36,11 +36,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
type NewTabSelection = {
|
||||
optionId: NewTabOptionId;
|
||||
paneId?: string;
|
||||
};
|
||||
|
||||
export interface WorkspaceDesktopTabRowItem {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -64,10 +59,8 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
onSplitRight: () => void;
|
||||
onSplitDown: () => void;
|
||||
externalDndContext?: boolean;
|
||||
@@ -76,6 +69,9 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
};
|
||||
|
||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -298,10 +294,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
onReorderTabs,
|
||||
onNewTerminalTab,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
externalDndContext = false,
|
||||
@@ -309,8 +303,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabDropPreviewIndex = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
|
||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
@@ -437,48 +430,22 @@ export function WorkspaceDesktopTabsRow({
|
||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-agent-tab"
|
||||
onPress={() =>
|
||||
onSelectNewTabOption({
|
||||
optionId: newTabAgentOptionId,
|
||||
paneId,
|
||||
})
|
||||
}
|
||||
testID="workspace-new-tab"
|
||||
onPress={() => onCreateLauncherTab({ paneId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New agent tab"
|
||||
accessibilityLabel="New tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
{newAgentTabKeys ? (
|
||||
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={() => onNewTerminalTab({ paneId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
{newTerminalTabKeys ? (
|
||||
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
<Text style={styles.newTabTooltipText}>New tab</Text>
|
||||
{newTabKeys ? (
|
||||
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceDraftAgentConfig } from "./workspace-draft-agent-config";
|
||||
|
||||
describe("workspace-draft-agent-config", () => {
|
||||
it("builds chat-only config for workspace draft agents", () => {
|
||||
expect(
|
||||
buildWorkspaceDraftAgentConfig({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export function buildWorkspaceDraftAgentConfig(input: {
|
||||
provider: AgentSessionConfig["provider"];
|
||||
cwd: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
}): AgentSessionConfig {
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
...(input.modeId ? { modeId: input.modeId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -27,6 +24,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
type WorkspaceDraftAgentTabProps = {
|
||||
@@ -51,65 +49,31 @@ export function WorkspaceDraftAgentTab({
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftInput = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: tabId,
|
||||
draftId,
|
||||
}),
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: tabId,
|
||||
draftId,
|
||||
}),
|
||||
[draftId, serverId, tabId],
|
||||
);
|
||||
|
||||
const {
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
workingDir,
|
||||
setWorkingDir,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: serverId,
|
||||
initialValues: { workingDir: workspaceId },
|
||||
isVisible: true,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
});
|
||||
|
||||
// Lock working directory to workspace.
|
||||
useEffect(() => {
|
||||
if (workingDir.trim() === workspaceId.trim()) {
|
||||
return;
|
||||
}
|
||||
setWorkingDir(workspaceId);
|
||||
}, [setWorkingDir, workingDir, workspaceId]);
|
||||
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const draftInput = useAgentInputDraft(
|
||||
{
|
||||
draftKey: draftStoreKey,
|
||||
composer: {
|
||||
initialServerId: serverId,
|
||||
initialValues: { workingDir: workspaceId },
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const composerState = draftInput.composerState;
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace draft composer state is required");
|
||||
}
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -124,13 +88,13 @@ export function WorkspaceDraftAgentTab({
|
||||
if (!text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (providerDefinitions.length === 0) {
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (isModelLoading) {
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
if (!composerState.effectiveModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!client) {
|
||||
@@ -139,7 +103,7 @@ export function WorkspaceDraftAgentTab({
|
||||
return null;
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
void composerState.persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
@@ -147,13 +111,17 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
buildDraftAgent: (attempt) => {
|
||||
const now = attempt.timestamp;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
const model = composerState.effectiveModelId || null;
|
||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
return {
|
||||
serverId,
|
||||
id: tabId,
|
||||
provider: selectedProvider,
|
||||
provider: composerState.selectedProvider,
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -164,7 +132,7 @@ export function WorkspaceDraftAgentTab({
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||
title: "Agent",
|
||||
cwd: workspaceId,
|
||||
model,
|
||||
@@ -177,21 +145,20 @@ export function WorkspaceDraftAgentTab({
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
};
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
});
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
const result = await client.createAgent({
|
||||
config,
|
||||
initialPrompt: text,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
});
|
||||
@@ -206,25 +173,6 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
});
|
||||
|
||||
const draftCommandConfig = useMemo(() => {
|
||||
return {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedProvider,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
@@ -265,11 +213,12 @@ export function WorkspaceDraftAgentTab({
|
||||
</View>
|
||||
|
||||
<View style={styles.inputAreaWrapper}>
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
allowEmptySubmit={false}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
@@ -279,24 +228,9 @@ export function WorkspaceDraftAgentTab({
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
statusControls={{
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider: setProviderFromUser,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode: setModeFromUser,
|
||||
models: availableModels,
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
...composerState.statusControls,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildWorkspacePaneContentModel({
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}:${tab.kind}`,
|
||||
Component: registration.component,
|
||||
paneContextValue: {
|
||||
serverId: normalizedServerId,
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
@@ -107,11 +106,7 @@ import {
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||
type NewTabOptionId = typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID;
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
type WorkspaceScreenProps = {
|
||||
@@ -136,6 +131,9 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -149,6 +147,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
}
|
||||
|
||||
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -798,10 +799,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[workspaceLayout],
|
||||
);
|
||||
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
||||
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent);
|
||||
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
@@ -809,11 +813,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
const pinnedAgentIds = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey
|
||||
? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS)
|
||||
: EMPTY_PINNED_AGENT_IDS,
|
||||
);
|
||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
const { closingTabIds, closeTab } = useCloseTabs();
|
||||
const closeWorkspaceTabWithCleanup = useCallback(
|
||||
@@ -917,7 +916,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
||||
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
||||
if (tab.target.kind !== "draft") {
|
||||
return false;
|
||||
@@ -926,57 +924,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||
});
|
||||
|
||||
for (const agentId of workspaceAgentVisibility.activeAgentIds) {
|
||||
const representedByTarget = uiTabs.some(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === agentId,
|
||||
);
|
||||
const representedByDeterministicTabId = uiTabs.some(
|
||||
(tab) => tab.tabId === `agent_${agentId}`,
|
||||
);
|
||||
if (
|
||||
hasActivePendingDraftCreateInWorkspace &&
|
||||
!representedByTarget &&
|
||||
!representedByDeterministicTabId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ensureWorkspaceTab({ kind: "agent", agentId });
|
||||
}
|
||||
for (const terminal of terminals) {
|
||||
ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
|
||||
}
|
||||
|
||||
const canPruneAgentTabs = hasHydratedAgents;
|
||||
const canPruneTerminalTabs = terminalsQuery.isSuccess;
|
||||
for (const tab of uiTabs) {
|
||||
if (
|
||||
canPruneAgentTabs &&
|
||||
tab.target.kind === "agent" &&
|
||||
!pinnedAgentIds.has(tab.target.agentId) &&
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: tab.target.agentId,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
|
||||
})
|
||||
) {
|
||||
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
if (
|
||||
canPruneTerminalTabs &&
|
||||
tab.target.kind === "terminal" &&
|
||||
!terminalIds.has(tab.target.terminalId)
|
||||
) {
|
||||
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
}
|
||||
reconcileWorkspaceTabs(persistenceKey, {
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
|
||||
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
});
|
||||
}, [
|
||||
closeWorkspaceTabWithCleanup,
|
||||
ensureWorkspaceTab,
|
||||
hasHydratedAgents,
|
||||
pendingByDraftId,
|
||||
pinnedAgentIds,
|
||||
persistenceKey,
|
||||
reconcileWorkspaceTabs,
|
||||
terminals,
|
||||
terminalsQuery.isSuccess,
|
||||
uiTabs,
|
||||
@@ -986,13 +946,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const activeTabId = focusedPaneTabState.activeTabId;
|
||||
const activeTab = focusedPaneTabState.activeTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||
|
||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||
[focusedPaneTabState.tabs],
|
||||
@@ -1104,6 +1057,25 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
openWorkspaceDraftTab();
|
||||
}, [openWorkspaceDraftTab]);
|
||||
|
||||
const handleCreateLauncherTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input?.paneId) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
|
||||
const tabId = openWorkspaceLauncherTab(persistenceKey);
|
||||
if (tabId) {
|
||||
focusWorkspaceTab(persistenceKey, tabId);
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
[focusWorkspacePane, focusWorkspaceTab, openWorkspaceLauncherTab, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateTerminal = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
if (createTerminalMutation.isPending) {
|
||||
@@ -1124,21 +1096,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[navigateToTabId],
|
||||
);
|
||||
|
||||
const handleSelectNewTabOption = useCallback(
|
||||
(selection: { optionId: NewTabOptionId; paneId?: string }) => {
|
||||
if (selection.paneId && persistenceKey) {
|
||||
focusWorkspacePane(persistenceKey, selection.paneId);
|
||||
}
|
||||
if (selection.optionId === NEW_TAB_AGENT_OPTION_ID) {
|
||||
handleCreateDraftTab();
|
||||
} else if (selection.optionId === NEW_TAB_TERMINAL_OPTION_ID) {
|
||||
handleCreateTerminal({ paneId: selection.paneId });
|
||||
}
|
||||
},
|
||||
[focusWorkspacePane, handleCreateDraftTab, handleCreateTerminal, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateDraftSplit = useCallback(
|
||||
const handleCreateLauncherSplit = useCallback(
|
||||
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -1149,10 +1107,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
openWorkspaceDraftTab();
|
||||
handleCreateLauncherTab({ paneId });
|
||||
},
|
||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey, splitWorkspacePaneEmpty],
|
||||
[handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty],
|
||||
);
|
||||
|
||||
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
||||
@@ -1267,29 +1224,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||
);
|
||||
|
||||
const prevCloseTabDeps = useRef({
|
||||
allTabDescriptorsById,
|
||||
handleCloseAgentTab,
|
||||
handleCloseDraftOrFileTab,
|
||||
handleCloseTerminalTab,
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = prevCloseTabDeps.current;
|
||||
const changed: string[] = [];
|
||||
if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById");
|
||||
if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab");
|
||||
if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab)
|
||||
changed.push("handleCloseDraftOrFileTab");
|
||||
if (prev.handleCloseTerminalTab !== handleCloseTerminalTab)
|
||||
changed.push("handleCloseTerminalTab");
|
||||
if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", "));
|
||||
prevCloseTabDeps.current = {
|
||||
allTabDescriptorsById,
|
||||
handleCloseAgentTab,
|
||||
handleCloseDraftOrFileTab,
|
||||
handleCloseTerminalTab,
|
||||
};
|
||||
});
|
||||
const handleCopyAgentId = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
@@ -1523,7 +1457,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
switch (action.id) {
|
||||
case "workspace.tab.new":
|
||||
handleCreateDraftTab();
|
||||
handleCreateLauncherTab();
|
||||
return true;
|
||||
case "workspace.terminal.new":
|
||||
handleCreateTerminal();
|
||||
@@ -1556,7 +1490,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
||||
[
|
||||
activeTabId,
|
||||
handleCloseTabById,
|
||||
handleCreateLauncherTab,
|
||||
handleCreateTerminal,
|
||||
navigateToTabId,
|
||||
tabs,
|
||||
],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
@@ -1571,7 +1512,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.right") {
|
||||
handleCreateDraftSplit({
|
||||
handleCreateLauncherSplit({
|
||||
targetPaneId: focusedPane.id,
|
||||
position: "right",
|
||||
});
|
||||
@@ -1579,7 +1520,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.down") {
|
||||
handleCreateDraftSplit({
|
||||
handleCreateLauncherSplit({
|
||||
targetPaneId: focusedPane.id,
|
||||
position: "bottom",
|
||||
});
|
||||
@@ -1649,7 +1590,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
focusWorkspacePane,
|
||||
handleCreateDraftSplit,
|
||||
handleCreateLauncherSplit,
|
||||
moveWorkspaceTabToPane,
|
||||
persistenceKey,
|
||||
focusedPaneTabState.activeTabId,
|
||||
@@ -1732,6 +1673,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (input.tab.kind === "draft" && target.kind === "agent") {
|
||||
convertWorkspaceDraftToAgent(persistenceKey, input.tab.tabId, target.agentId);
|
||||
return;
|
||||
}
|
||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||
},
|
||||
onOpenWorkspaceFile: (filePath) => {
|
||||
@@ -1750,47 +1695,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
convertWorkspaceDraftToAgent,
|
||||
retargetWorkspaceTab,
|
||||
],
|
||||
);
|
||||
const prevBuildDeps = useRef({
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
retargetWorkspaceTab,
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = prevBuildDeps.current;
|
||||
const changed: string[] = [];
|
||||
if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById");
|
||||
if (prev.handleOpenFileFromChat !== handleOpenFileFromChat)
|
||||
changed.push("handleOpenFileFromChat");
|
||||
if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane");
|
||||
if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId");
|
||||
if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId");
|
||||
if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId");
|
||||
if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab");
|
||||
if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey");
|
||||
if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab");
|
||||
if (changed.length > 0)
|
||||
console.log("[buildPaneContentModel] deps changed:", changed.join(", "));
|
||||
prevBuildDeps.current = {
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
retargetWorkspaceTab,
|
||||
};
|
||||
});
|
||||
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
|
||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||
@@ -2205,13 +2113,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
onCreateLauncherTab={handleCreateLauncherTab}
|
||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||
onFocusPane={handleFocusPane}
|
||||
onNewTerminalTab={handleCreateTerminal}
|
||||
onSplitPane={handleSplitPane}
|
||||
onSplitPaneEmpty={handleCreateDraftSplit}
|
||||
onSplitPaneEmpty={handleCreateLauncherSplit}
|
||||
onMoveTabToPane={handleMoveTabToPane}
|
||||
onResizeSplit={handleResizePaneSplit}
|
||||
onReorderTabsInPane={handleReorderTabsInPane}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("workspace source of truth consumption", () => {
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
|
||||
@@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
if (tab.target.kind === "launcher") {
|
||||
return `workspace-launcher-close-${tab.target.launcherId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function WorkspaceTabPresentationResolver({
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolverInner
|
||||
key={tab.kind}
|
||||
key={`${tab.key}:${tab.kind}`}
|
||||
registration={registration}
|
||||
tab={tab}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
const DRAFT_STORE_VERSION = 2;
|
||||
const DRAFT_STORE_VERSION = 4;
|
||||
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type LegacyDraftImage = {
|
||||
|
||||
59
packages/app/src/stores/provider-recency-store.test.ts
Normal file
59
packages/app/src/stores/provider-recency-store.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
__providerRecencyStoreTestUtils,
|
||||
sortProvidersByRecency,
|
||||
useProviderRecencyStore,
|
||||
} from "./provider-recency-store";
|
||||
|
||||
describe("provider-recency-store", () => {
|
||||
beforeEach(() => {
|
||||
useProviderRecencyStore.setState({
|
||||
recentProviderIds: [],
|
||||
recordUsage: useProviderRecencyStore.getState().recordUsage,
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts used providers first and keeps unused providers in default order", () => {
|
||||
const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]);
|
||||
|
||||
expect(sorted.map((provider) => provider.id)).toEqual(["codex", "claude", "opencode"]);
|
||||
});
|
||||
|
||||
it("moves the latest provider to the front without duplicating prior entries", () => {
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
useProviderRecencyStore.getState().recordUsage("opencode");
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
|
||||
expect(useProviderRecencyStore.getState().recentProviderIds).toEqual([
|
||||
"codex",
|
||||
"opencode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters invalid and duplicate providers during migration", () => {
|
||||
expect(
|
||||
__providerRecencyStoreTestUtils.migratePersistedState({
|
||||
recentProviderIds: ["codex", "invalid", "codex", "claude"],
|
||||
}),
|
||||
).toEqual({
|
||||
recentProviderIds: ["codex", "claude"],
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/app/src/stores/provider-recency-store.ts
Normal file
129
packages/app/src/stores/provider-recency-store.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
isValidAgentProvider,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
const PROVIDER_RECENCY_STORE_VERSION = 1;
|
||||
|
||||
interface ProviderRecencyStoreState {
|
||||
recentProviderIds: AgentProvider[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
}
|
||||
|
||||
function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] {
|
||||
if (!providerIds || providerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<AgentProvider>();
|
||||
const sanitized: AgentProvider[] = [];
|
||||
for (const providerId of providerIds) {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(providerId);
|
||||
sanitized.push(providerId);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sortProvidersByRecency<T extends { id: string }>(
|
||||
providers: readonly T[],
|
||||
recentProviderIds: readonly string[],
|
||||
): T[] {
|
||||
if (providers.length <= 1) {
|
||||
return [...providers];
|
||||
}
|
||||
|
||||
const recentRank = new Map<string, number>();
|
||||
for (const providerId of recentProviderIds) {
|
||||
if (recentRank.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
recentRank.set(providerId, recentRank.size);
|
||||
}
|
||||
|
||||
return providers
|
||||
.map((provider, defaultIndex) => ({
|
||||
provider,
|
||||
defaultIndex,
|
||||
recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.recentIndex !== right.recentIndex) {
|
||||
return left.recentIndex - right.recentIndex;
|
||||
}
|
||||
return left.defaultIndex - right.defaultIndex;
|
||||
})
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
function migratePersistedState(state: unknown): Pick<ProviderRecencyStoreState, "recentProviderIds"> {
|
||||
const record = state as { recentProviderIds?: string[] } | null | undefined;
|
||||
return {
|
||||
recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds),
|
||||
};
|
||||
}
|
||||
|
||||
export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
recentProviderIds: [],
|
||||
recordUsage: (providerId) => {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
recentProviderIds: [
|
||||
providerId,
|
||||
...state.recentProviderIds.filter((id) => id !== providerId),
|
||||
],
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "terminal-agent-provider-recency",
|
||||
version: PROVIDER_RECENCY_STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
recentProviderIds: state.recentProviderIds,
|
||||
}),
|
||||
migrate: (persistedState) => migratePersistedState(persistedState),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useProviderRecency(
|
||||
availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
|
||||
): {
|
||||
providers: AgentProviderDefinition[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
} {
|
||||
const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds);
|
||||
const recordUsage = useProviderRecencyStore((state) => state.recordUsage);
|
||||
|
||||
const providers = useMemo(
|
||||
() => sortProvidersByRecency(availableProviders, recentProviderIds),
|
||||
[availableProviders, recentProviderIds],
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
recordUsage,
|
||||
};
|
||||
}
|
||||
|
||||
export const __providerRecencyStoreTestUtils = {
|
||||
migratePersistedState,
|
||||
sanitizeRecentProviderIds,
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
GitSetupOptions,
|
||||
ProjectPlacementPayload,
|
||||
ServerCapabilities,
|
||||
AgentSnapshotPayload,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
@@ -79,10 +80,13 @@ export interface AgentRuntimeInfo {
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
|
||||
|
||||
export interface Agent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
terminal: boolean;
|
||||
status: AgentLifecycleStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -96,6 +100,7 @@ export interface Agent {
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string | null;
|
||||
terminalExit?: TerminalExitDetails | null;
|
||||
title: string | null;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
@@ -126,8 +131,8 @@ export function normalizeWorkspaceDescriptor(
|
||||
): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
|
||||
projectId: String(payload.projectId),
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
@@ -191,7 +196,6 @@ export type DaemonServerInfo = {
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
@@ -1119,6 +1123,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
id: agent.id,
|
||||
serverId,
|
||||
title: agent.title ?? null,
|
||||
terminal: agent.terminal,
|
||||
status: agent.status,
|
||||
lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
52
packages/app/src/stores/terminal-agent-reopen-store.ts
Normal file
52
packages/app/src/stores/terminal-agent-reopen-store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface BuildTerminalAgentReopenKeyInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface RequestTerminalAgentReopenInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface TerminalAgentReopenStore {
|
||||
reopenIntentVersionByAgentKey: Record<string, number>;
|
||||
requestReopen: (input: RequestTerminalAgentReopenInput) => void;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function buildTerminalAgentReopenKey(
|
||||
input: BuildTerminalAgentReopenKeyInput,
|
||||
): string | null {
|
||||
const serverId = trimNonEmpty(input.serverId);
|
||||
const agentId = trimNonEmpty(input.agentId);
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
export const useTerminalAgentReopenStore = create<TerminalAgentReopenStore>()((set) => ({
|
||||
reopenIntentVersionByAgentKey: {},
|
||||
requestReopen: ({ serverId, agentId }) => {
|
||||
const key = buildTerminalAgentReopenKey({ serverId, agentId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
reopenIntentVersionByAgentKey: {
|
||||
...state.reopenIntentVersionByAgentKey,
|
||||
[key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
createLauncherId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
@@ -110,6 +111,11 @@ interface OpenTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface OpenLauncherTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
now: number;
|
||||
}
|
||||
|
||||
interface RetargetTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutResult {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ReorderFocusedPaneTabsInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabIds: string[];
|
||||
@@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput {
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceTabReconcileState {
|
||||
layout: WorkspaceLayout;
|
||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabSnapshot {
|
||||
agentsHydrated: boolean;
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
knownAgentIds: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
const MIN_SPLIT_SIZE = 0.1;
|
||||
|
||||
@@ -750,6 +781,37 @@ function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput):
|
||||
});
|
||||
}
|
||||
|
||||
function replaceTabInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: {
|
||||
tabId: string;
|
||||
nextTabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
},
|
||||
): SplitNodeInternal {
|
||||
const panePath = findPanePathContainingTab(root, input.tabId);
|
||||
invariant(panePath, `Tab not found: ${input.tabId}`);
|
||||
return replaceNodeAtPath(root, panePath, (node) => {
|
||||
invariant(node.kind === "pane", "Expected pane while replacing tab");
|
||||
return {
|
||||
kind: "pane",
|
||||
pane: normalizePaneAfterTabChange({
|
||||
...node.pane,
|
||||
tabs: node.pane.tabs.map((tab) =>
|
||||
tab.tabId === input.tabId
|
||||
? {
|
||||
...tab,
|
||||
tabId: input.nextTabId,
|
||||
target: input.target,
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupSizesInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: UpdateGroupSizesInTreeInput,
|
||||
@@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
||||
return detachTabFromTree(asInternalNode(root), { tabId }).root;
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
function insertNewTabIntoFocusedPane(input: {
|
||||
layout: WorkspaceLayout;
|
||||
target: WorkspaceTabTarget;
|
||||
now: number;
|
||||
}): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedPane =
|
||||
findPaneById(layout.root, layout.focusedPaneId) ??
|
||||
collectAllPanes(layout.root)[0] ??
|
||||
@@ -999,6 +1051,38 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes
|
||||
};
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
return insertNewTabIntoFocusedPane(input);
|
||||
}
|
||||
|
||||
export function openLauncherTabInLayout(
|
||||
input: OpenLauncherTabInLayoutInput,
|
||||
): OpenTabInLayoutResult {
|
||||
return insertNewTabIntoFocusedPane({
|
||||
layout: input.layout,
|
||||
target: {
|
||||
kind: "launcher",
|
||||
launcherId: createLauncherId(),
|
||||
},
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||
const internalLayout = asInternalLayout(input.layout);
|
||||
const pane = findPaneContainingTab(internalLayout.root, input.tabId);
|
||||
@@ -1054,11 +1138,34 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
const existingTargetTab =
|
||||
collectAllTabs(layout.root).find(
|
||||
(tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target),
|
||||
) ?? null;
|
||||
if (existingTargetTab) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Preserve the existing tab id so launcher->entity transitions keep the same
|
||||
// React key during the first render. Reconciliation can canonicalize later.
|
||||
tabId: input.tabId,
|
||||
layout: {
|
||||
root: updateTabInTree(layout.root, {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: input.tabId,
|
||||
target: input.target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
@@ -1066,6 +1173,52 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
export function convertDraftToAgentInLayout(
|
||||
input: ConvertDraftToAgentInLayoutInput,
|
||||
): ConvertDraftToAgentInLayoutResult | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
|
||||
if (!currentTab || currentTab.target.kind !== "draft") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = {
|
||||
kind: "agent",
|
||||
agentId: input.agentId,
|
||||
};
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(target);
|
||||
const existingCanonicalTab =
|
||||
collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null;
|
||||
|
||||
if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabId: canonicalTabId,
|
||||
layout: {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderFocusedPaneTabsInLayout(
|
||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||
): WorkspaceLayout | null {
|
||||
@@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout(
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (normalized) {
|
||||
next.add(normalized);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEntityTarget(
|
||||
target: WorkspaceTabTarget,
|
||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||
return target.kind === "agent" || target.kind === "terminal";
|
||||
}
|
||||
|
||||
function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } {
|
||||
return tab.target.kind === "agent";
|
||||
}
|
||||
|
||||
function isTerminalTab(
|
||||
tab: WorkspaceTab,
|
||||
): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } {
|
||||
return tab.target.kind === "terminal";
|
||||
}
|
||||
|
||||
function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout {
|
||||
const internalLayout = asInternalLayout(layout);
|
||||
const focusedPane =
|
||||
findPaneById(internalLayout.root, internalLayout.focusedPaneId) ??
|
||||
collectAllPanes(internalLayout.root)[0] ??
|
||||
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
|
||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||
return {
|
||||
root: insertTabIntoPane(internalLayout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: {
|
||||
tabId,
|
||||
target,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||
}),
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileWorkspaceTabs(
|
||||
state: WorkspaceTabReconcileState,
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): WorkspaceTabReconcileState {
|
||||
let nextLayout = state.layout;
|
||||
const originalFocusedTabId =
|
||||
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
|
||||
let reconciledFocusedTabId = originalFocusedTabId;
|
||||
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const visibleAgentIds = new Set(activeAgentIds);
|
||||
for (const agentId of pinnedAgentIds) {
|
||||
if (knownAgentIds.has(agentId)) {
|
||||
visibleAgentIds.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
const initialTabs = collectAllTabs(nextLayout.root);
|
||||
const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId));
|
||||
|
||||
const entityGroups = new Map<
|
||||
string,
|
||||
{
|
||||
target: WorkspaceTabTarget;
|
||||
tabs: WorkspaceTab[];
|
||||
}
|
||||
>();
|
||||
for (const tab of initialTabs) {
|
||||
if (!isEntityTarget(tab.target)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!canonicalTarget) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget);
|
||||
const currentGroup = entityGroups.get(canonicalTabId);
|
||||
if (currentGroup) {
|
||||
currentGroup.tabs.push(tab);
|
||||
continue;
|
||||
}
|
||||
entityGroups.set(canonicalTabId, {
|
||||
target: canonicalTarget,
|
||||
tabs: [tab],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [canonicalTabId, group] of entityGroups) {
|
||||
const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null;
|
||||
if (!keeper) {
|
||||
continue;
|
||||
}
|
||||
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
|
||||
reconciledFocusedTabId = canonicalTabId;
|
||||
}
|
||||
if (
|
||||
keeper.tabId !== canonicalTabId ||
|
||||
!workspaceTabTargetsEqual(keeper.target, group.target)
|
||||
) {
|
||||
nextLayout = {
|
||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||
tabId: keeper.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target: group.target,
|
||||
}),
|
||||
focusedPaneId: nextLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.tabId === keeper.tabId) {
|
||||
continue;
|
||||
}
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tab of collectAllTabs(nextLayout.root)) {
|
||||
if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
||||
const currentAgentIds = new Set(
|
||||
currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId),
|
||||
);
|
||||
const currentTerminalIds = new Set(
|
||||
currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId),
|
||||
);
|
||||
|
||||
const sortedVisibleAgentIds = [...visibleAgentIds].sort();
|
||||
for (const agentId of sortedVisibleAgentIds) {
|
||||
if (currentAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
if (snapshot.hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "agent",
|
||||
agentId,
|
||||
});
|
||||
currentAgentIds.add(agentId);
|
||||
}
|
||||
|
||||
const sortedTerminalIds = [...standaloneTerminalIds].sort();
|
||||
for (const terminalId of sortedTerminalIds) {
|
||||
if (currentTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "terminal",
|
||||
terminalId,
|
||||
});
|
||||
currentTerminalIds.add(terminalId);
|
||||
}
|
||||
|
||||
if (reconciledFocusedTabId) {
|
||||
nextLayout =
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: reconciledFocusedTabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
layout: nextLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +262,58 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openLauncherTab(workspaceKey);
|
||||
const secondTabId = store.openLauncherTab(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
|
||||
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
|
||||
expect(firstTabId).not.toBe(secondTabId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: firstTabId,
|
||||
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
{
|
||||
tabId: secondTabId,
|
||||
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("splitPaneEmpty plus openLauncherTab opens a launcher tab in the new pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||
expect(launcherTabId).toMatch(/^launcher_/);
|
||||
expect(layout.focusedPaneId).toBe(newPaneId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
||||
expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([launcherTabId!]);
|
||||
expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId);
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
@@ -288,7 +340,7 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212",
|
||||
);
|
||||
@@ -296,30 +348,125 @@ describe("workspace-layout-store actions", () => {
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
expect(splitPane?.tabIds).toEqual(["agent_agent-1"]);
|
||||
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
||||
expect(convertedTab).toEqual({
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("retargetTab keeps a launcher tab in place while updating its target", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/launcher.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333");
|
||||
expect(nextTabId).toBe(launcherTabId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: launcherTabId!,
|
||||
target: { kind: "file", path: "/repo/worktree/launcher.ts" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargetTab closes a launcher tab and focuses the existing canonical target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const existingFileTabId = store.openTab(workspaceKey, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: launcherTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||
expect(launcherTabId).toBe("launcher_44444444-4444-4444-4444-444444444444");
|
||||
expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
expect(secondLauncherTabId).toMatch(/^launcher_/);
|
||||
expect(secondLauncherTabId).not.toBe(launcherTabId);
|
||||
expect(nextTabId).toBe(existingFileTabId);
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
||||
existingFileTabId!,
|
||||
launcherTabId!,
|
||||
]);
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId);
|
||||
});
|
||||
|
||||
it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
expect(nextTabId).toBe(secondTabId);
|
||||
expect(splitPane?.tabIds).toEqual([secondTabId!]);
|
||||
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
|
||||
expect(retargetedTab).toEqual({
|
||||
tabId: secondTabId,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(firstAgentTabId).toBe(firstLauncherTabId);
|
||||
expect(nextTabId).toBe(firstLauncherTabId);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: firstLauncherTabId!,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId);
|
||||
});
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
@@ -702,4 +849,102 @@ describe("workspace-layout-store actions", () => {
|
||||
splitSizesByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"67676767-6767-6767-6767-676767676767",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-existing" });
|
||||
const agentTabId = store.openTab(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: agentTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[workspaceKey]: {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["draft_agent", "agent_agent-1", "terminal_orphan", "draft-1"],
|
||||
focusedTabId: "draft_agent",
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_agent",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
tabId: "terminal_orphan",
|
||||
target: { kind: "terminal", terminalId: "term-stale" },
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
tabId: "draft-1",
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
createdAt: 4,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
},
|
||||
},
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
[workspaceKey]: new Set<string>(["agent-2"]),
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
knownAgentIds: ["agent-1", "agent-2"],
|
||||
standaloneTerminalIds: ["term-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
"agent_agent-1",
|
||||
"draft-1",
|
||||
"agent_agent-2",
|
||||
"terminal_term-1",
|
||||
]);
|
||||
expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 2,
|
||||
});
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
closeTabInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
convertDraftToAgentInLayout,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
insertSplit,
|
||||
moveTabToPaneInLayout,
|
||||
normalizeLayout,
|
||||
openLauncherTabInLayout,
|
||||
openTabInLayout,
|
||||
reconcileWorkspaceTabs,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
type WorkspaceTabReconcileState,
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
||||
@@ -48,16 +53,26 @@ export {
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
};
|
||||
export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
|
||||
export type {
|
||||
SplitGroup,
|
||||
SplitNode,
|
||||
SplitPane,
|
||||
WorkspaceLayout,
|
||||
WorkspaceTabReconcileState,
|
||||
WorkspaceTabSnapshot,
|
||||
};
|
||||
|
||||
interface WorkspaceLayoutStore {
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openLauncherTab: (workspaceKey: string) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
||||
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
||||
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
||||
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
||||
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
||||
splitPane: (
|
||||
@@ -128,6 +143,26 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openLauncherTab: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openLauncherTabInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
closeTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
@@ -202,6 +237,59 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = convertDraftToAgentInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
agentId: normalizedAgentId,
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
reconcileTabs: (workspaceKey, snapshot) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const currentLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const nextState = reconcileWorkspaceTabs(
|
||||
{
|
||||
layout: currentLayout,
|
||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
},
|
||||
snapshot,
|
||||
);
|
||||
if (nextState.layout === currentLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextState.layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
reorderTabs: (workspaceKey, tabIds) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
|
||||
39
packages/app/src/stores/workspace-setup-store.test.ts
Normal file
39
packages/app/src/stores/workspace-setup-store.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useWorkspaceSetupStore } from "./workspace-setup-store";
|
||||
|
||||
describe("workspace-setup-store", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
|
||||
});
|
||||
|
||||
it("tracks deferred project setup by path instead of a created workspace", () => {
|
||||
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
projectName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
projectName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears pending setup state", () => {
|
||||
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
|
||||
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
|
||||
});
|
||||
});
|
||||
28
packages/app/src/stores/workspace-setup-store.ts
Normal file
28
packages/app/src/stores/workspace-setup-store.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
export interface PendingWorkspaceSetup {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
projectName?: string;
|
||||
creationMethod: WorkspaceCreationMethod;
|
||||
navigationMethod: WorkspaceSetupNavigationMethod;
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
|
||||
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
|
||||
clearWorkspaceSetup: () => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
pendingWorkspaceSetup: null,
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({ pendingWorkspaceSetup: value });
|
||||
},
|
||||
clearWorkspaceSetup: () => {
|
||||
set({ pendingWorkspaceSetup: null });
|
||||
},
|
||||
}));
|
||||
@@ -140,6 +140,41 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("openLauncherTab creates distinct launcher tabs without deduplicating", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const state = useWorkspaceTabsStore.getState();
|
||||
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
|
||||
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
|
||||
expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]);
|
||||
expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([
|
||||
{
|
||||
tabId: "launcher_11111111-1111-1111-1111-111111111111",
|
||||
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
{
|
||||
tabId: "launcher_22222222-2222-2222-2222-222222222222",
|
||||
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
createLauncherId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "launcher"; launcherId: string };
|
||||
|
||||
export type WorkspaceTab = {
|
||||
tabId: string;
|
||||
@@ -38,63 +45,6 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
|
||||
}
|
||||
|
||||
function normalizeTabTarget(
|
||||
value: WorkspaceTabTarget | null | undefined,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "draft") {
|
||||
const draftId = trimNonEmpty(value.draftId);
|
||||
return draftId ? { kind: "draft", draftId } : null;
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
function normalizeTabOrder(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
@@ -133,6 +83,7 @@ type WorkspaceTabsState = {
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -169,19 +120,20 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
},
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
|
||||
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
let resolvedTabId = deterministicTabId;
|
||||
const now = Date.now();
|
||||
|
||||
set((state) => {
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ??
|
||||
null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
resolvedTabId = effectiveTabId;
|
||||
|
||||
@@ -196,7 +148,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
@@ -218,6 +170,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
|
||||
return resolvedTabId;
|
||||
},
|
||||
openLauncherTab: ({ serverId, workspaceId }) => {
|
||||
return get().openOrFocusTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "launcher", launcherId: createLauncherId() },
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
||||
if (!tabId) {
|
||||
@@ -310,7 +269,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
@@ -325,7 +284,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
}
|
||||
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -390,7 +349,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeTabTarget(tab.target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
@@ -479,13 +438,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
||||
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
||||
if (!normalizedTarget) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget);
|
||||
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
if (!usedOrder.has(tabId)) {
|
||||
usedOrder.add(tabId);
|
||||
orderFromTabs.push(tabId);
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AgentDirectoryEntry = Pick<
|
||||
| "id"
|
||||
| "serverId"
|
||||
| "title"
|
||||
| "terminal"
|
||||
| "status"
|
||||
| "lastActivityAt"
|
||||
| "cwd"
|
||||
|
||||
@@ -31,6 +31,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
terminal: snapshot.terminal === true,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@@ -44,6 +45,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
terminalExit: snapshot.terminalExit ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model ?? null,
|
||||
|
||||
6
packages/app/src/utils/error-messages.ts
Normal file
6
packages/app/src/utils/error-messages.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -89,4 +90,10 @@ describe("workspace route parsing", () => {
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds workspace routes with a one-shot open intent", () => {
|
||||
expect(buildHostWorkspaceOpenRoute("local", "/tmp/repo", "draft:new")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +281,19 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string):
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceOpenRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
openIntent: string,
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedOpenIntent = trimNonEmpty(openIntent);
|
||||
if (base === "/" || !normalizedOpenIntent) {
|
||||
return base;
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}`;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -292,11 +305,7 @@ export function buildHostAgentDetailRoute(
|
||||
if (!normalizedAgentId) {
|
||||
return "/";
|
||||
}
|
||||
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
|
||||
return buildHostWorkspaceOpenRoute(serverId, normalizedWorkspaceId, `agent:${normalizedAgentId}`);
|
||||
}
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
|
||||
@@ -13,7 +13,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
workspaceKey: "srv:/repo",
|
||||
serverId: "srv",
|
||||
workspaceId: "/repo",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "paseo",
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -41,13 +42,13 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: "/repo/non-git",
|
||||
workspaceKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
statusBucket: "running",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -70,7 +71,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -87,25 +88,23 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
it("keeps single-workspace git projects as sections with the new worktree action", () => {
|
||||
const onlyWorkspace = workspace({
|
||||
workspaceId: "/repo/main",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
workspaces: [onlyWorkspace],
|
||||
}),
|
||||
collapsed: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
@@ -115,7 +114,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }),
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }),
|
||||
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
|
||||
],
|
||||
}),
|
||||
@@ -131,12 +130,12 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true for single-workspace projects regardless of kind", () => {
|
||||
it("returns true only for single-workspace directory projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
|
||||
isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceKind: "local_checkout",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -76,7 +77,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("ignores collapsed state for flattened single-workspace projects", () => {
|
||||
it("respects collapsed state for single-workspace git projects", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
@@ -84,7 +85,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
});
|
||||
|
||||
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.visibleTargets).toEqual([]);
|
||||
expect(model.shortcutTargets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,4 +50,18 @@ describe("terminal-list", () => {
|
||||
{ id: "term-2", name: "Renamed Terminal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves terminal titles from create responses", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [],
|
||||
terminal: {
|
||||
id: "term-3",
|
||||
name: "Terminal 3",
|
||||
title: "Build Output",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: "term-3", name: "Terminal 3", title: "Build Output" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalList
|
||||
return {
|
||||
id: input.terminal.id,
|
||||
name: input.terminal.name,
|
||||
...(input.terminal.title ? { title: input.terminal.title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function workspace(
|
||||
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
it("redirects an archived worktree to the visible local checkout for the same project", () => {
|
||||
const workspaces = [
|
||||
workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }),
|
||||
workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }),
|
||||
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
|
||||
];
|
||||
|
||||
@@ -60,8 +60,8 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
id: "/notes",
|
||||
projectId: "notes",
|
||||
projectRootPath: "/notes",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
|
||||
const rootCheckoutWorkspace =
|
||||
sameProjectWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
|
||||
workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id,
|
||||
) ?? null;
|
||||
if (rootCheckoutWorkspace) {
|
||||
return rootCheckoutWorkspace.id;
|
||||
|
||||
71
packages/app/src/utils/workspace-navigation.test.ts
Normal file
71
packages/app/src/utils/workspace-navigation.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildTerminalAgentReopenKey,
|
||||
useTerminalAgentReopenStore,
|
||||
} from "@/stores/terminal-agent-reopen-store";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
describe("prepareWorkspaceTab", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
useTerminalAgentReopenStore.setState({
|
||||
reopenIntentVersionByAgentKey: {},
|
||||
requestReopen: useTerminalAgentReopenStore.getState().requestReopen,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes a reopen intent when requested for an agent tab", () => {
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
requestReopen: true,
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU");
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not publish a reopen intent unless explicitly requested", () => {
|
||||
prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { router } from "expo-router";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTabTarget,
|
||||
@@ -11,6 +13,11 @@ interface PrepareWorkspaceTabInput {
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
requestReopen?: boolean;
|
||||
}
|
||||
|
||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||
navigationMethod?: "navigate" | "replace";
|
||||
}
|
||||
|
||||
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
@@ -38,5 +45,22 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
|
||||
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
|
||||
}
|
||||
|
||||
if (input.requestReopen && target.kind === "agent") {
|
||||
useTerminalAgentReopenStore.getState().requestReopen({
|
||||
serverId: input.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
}
|
||||
|
||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||
const route = prepareWorkspaceTab(input);
|
||||
if (input.navigationMethod === "replace") {
|
||||
router.replace(route as any);
|
||||
} else {
|
||||
router.navigate(route as any);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "launcher") {
|
||||
const launcherId = trimNonEmpty(value.launcherId);
|
||||
return launcherId ? { kind: "launcher", launcherId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,10 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "launcher" && right.kind === "launcher") {
|
||||
// Launcher tabs are intentionally always unique, even when reopened repeatedly.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,9 +65,19 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "launcher") {
|
||||
return `launcher_${target.launcherId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
export function createLauncherId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AgentListItem {
|
||||
shortId: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
terminal: boolean;
|
||||
thinking: string;
|
||||
status: string;
|
||||
cwd: string;
|
||||
@@ -66,6 +67,7 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
{ header: "AGENT ID", field: "shortId", width: 12 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "PROVIDER", field: "provider", width: 15 },
|
||||
{ header: "TERM", field: "terminal", width: 6 },
|
||||
{ header: "THINKING", field: "thinking", width: 12 },
|
||||
{
|
||||
header: "STATUS",
|
||||
@@ -91,6 +93,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
shortId: agent.id.slice(0, 7),
|
||||
name: agent.title ?? "-",
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
terminal: agent.terminal === true,
|
||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
|
||||
@@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: {
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 200,
|
||||
});
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface AgentSendResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isTerminalAgentSendError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /terminal agents do not support structured send operations/i.test(message);
|
||||
}
|
||||
|
||||
/** Schema for agent send output */
|
||||
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
idField: "agentId",
|
||||
@@ -260,6 +265,15 @@ export async function runSendCommand(
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
if (isTerminalAgentSendError(err)) {
|
||||
const error: CommandError = {
|
||||
code: "TERMINAL_AGENT_UNSUPPORTED",
|
||||
message: "Cannot send messages to terminal agents",
|
||||
details: "Open the terminal agent from the Sessions UI and interact through its terminal.",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { SingleResult } from "../../output/index.js";
|
||||
import {
|
||||
attachAgentNamesToMessages,
|
||||
connectChatClient,
|
||||
resolveChatAuthorAgentId,
|
||||
toChatCommandError,
|
||||
type ChatCommandOptions,
|
||||
} from "./shared.js";
|
||||
@@ -24,7 +23,6 @@ export async function runPostCommand(
|
||||
const payload = await client.postChatMessage({
|
||||
room,
|
||||
body,
|
||||
authorAgentId: resolveChatAuthorAgentId(),
|
||||
replyToMessageId: options.replyTo,
|
||||
});
|
||||
const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
|
||||
|
||||
@@ -23,11 +23,29 @@ const PROVIDERS: ProviderListItem[] = [
|
||||
defaultMode: "auto",
|
||||
modes: "read-only, auto, full-access",
|
||||
},
|
||||
{
|
||||
provider: "gemini",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "amp",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "aider",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
defaultMode: "build",
|
||||
modes: "build, plan",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems(
|
||||
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": [
|
||||
"./dist/index.js",
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
9
packages/server/drizzle.config.ts
Normal file
9
packages/server/drizzle.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./packages/server/src/server/db/schema.ts",
|
||||
out: "./packages/server/src/server/db/migrations",
|
||||
dialect: "sqlite",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user