mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'storage-terminal-ui-dev' into dev
# Conflicts: # nix/package.nix # packages/app/src/app/_layout.tsx # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/hooks/use-command-center.ts # packages/app/src/screens/agent/draft-agent-screen.tsx # packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx # packages/app/src/screens/workspace/workspace-screen.tsx # packages/server/src/server/session.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/terminal/terminal.test.ts # packages/server/src/terminal/terminal.ts
This commit is contained in:
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
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.
|
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.
|
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
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### 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.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-RqH1tJ6+pS0XJ4yxxfQ6BRQJfSdsPcxXwCpy+BJ/dhY=";
|
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# 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",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"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",
|
"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",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
|
|||||||
206
packages/app/e2e/helpers/launcher.ts
Normal file
206
packages/app/e2e/helpers/launcher.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
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-"]:not([data-testid^="workspace-tab-context-"])',
|
||||||
|
);
|
||||||
|
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-"]:not([data-testid^="workspace-tab-context-"])[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", exact: true }).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-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|||||||
export type TerminalPerfDaemonClient = {
|
export type TerminalPerfDaemonClient = {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
openProject(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{
|
||||||
|
workspace: { id: number; name: string; projectRootPath: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
createTerminal(
|
createTerminal(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
|
|||||||
51
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
51
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
clickNewChat,
|
||||||
|
clickProviderTile,
|
||||||
|
clickTerminal,
|
||||||
|
countTabsOfKind,
|
||||||
|
getTabTestIds,
|
||||||
|
waitForTabWithTitle,
|
||||||
|
} from "./launcher";
|
||||||
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||||
|
|
||||||
|
function terminalSurface(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
function composerInput(page: Page) {
|
||||||
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
|
||||||
|
const terminal = terminalSurface(page);
|
||||||
|
await expect(terminal).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.click();
|
||||||
|
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
|
||||||
|
await terminal.pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||||
|
const tabIdsBefore = await getTabTestIds(page);
|
||||||
|
const launcherCountBefore = await countTabsOfKind(page, "launcher");
|
||||||
|
await clickTerminal(page);
|
||||||
|
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect.poll(() => countTabsOfKind(page, "launcher")).toBe(launcherCountBefore - 1);
|
||||||
|
await expect.poll(async () => (await getTabTestIds(page)).length).toBe(tabIdsBefore.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTerminalAgentFromLauncher(page: Page, providerLabel: string): Promise<void> {
|
||||||
|
await clickProviderTile(page, providerLabel);
|
||||||
|
await expect(page.getByTestId("terminal-agent-loading")).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
await expect(terminalSurface(page)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await waitForTabWithTitle(page, /new agent/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||||
|
await clickNewChat(page);
|
||||||
|
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "New Chat" })).toHaveCount(0);
|
||||||
|
}
|
||||||
348
packages/app/e2e/helpers/workspace-setup.ts
Normal file
348
packages/app/e2e/helpers/workspace-setup.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||||
|
import { gotoAppShell } from "./app";
|
||||||
|
|
||||||
|
type WorkspaceSetupProgressPayload = {
|
||||||
|
status: "running" | "completed" | "failed";
|
||||||
|
detail: { commands: string[]; log: string };
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceSetupRawMessage = {
|
||||||
|
type: string;
|
||||||
|
payload?: WorkspaceSetupProgressPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceSetupDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
openProject(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{
|
||||||
|
workspace: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
} | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
createPaseoWorktree(
|
||||||
|
input: { cwd: string; worktreeSlug?: string },
|
||||||
|
): Promise<{
|
||||||
|
workspace: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
} | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
fetchWorkspaces(): Promise<{
|
||||||
|
entries: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
fetchAgents(): Promise<{
|
||||||
|
entries: Array<{
|
||||||
|
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
fetchAgent(
|
||||||
|
agentId: string,
|
||||||
|
): Promise<{
|
||||||
|
agent: { id: string; cwd: string } | null;
|
||||||
|
project: unknown | null;
|
||||||
|
} | null>;
|
||||||
|
listTerminals(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{
|
||||||
|
cwd?: string;
|
||||||
|
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||||
|
error?: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void;
|
||||||
|
};
|
||||||
|
export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload };
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
}) => WorkspaceSetupDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `workspace-setup-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProjectForWorkspaceSetup(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await client.openProject(repoPath);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
|
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||||
|
return page.getByRole("button", {
|
||||||
|
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||||
|
.first()
|
||||||
|
.hover();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||||
|
const button = createWorkspaceButton(page, repoPath);
|
||||||
|
await revealWorkspaceButton(page, repoPath);
|
||||||
|
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await button.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
await expect(page.getByTestId("workspace-setup-dialog")).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const workspaceId =
|
||||||
|
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error(`Expected a workspace route but found ${page.url()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceSetupDialog(page: Page) {
|
||||||
|
return page.getByTestId("workspace-setup-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChatAgentFromWorkspaceSetup(
|
||||||
|
page: Page,
|
||||||
|
input: { message: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const dialog = workspaceSetupDialog(page);
|
||||||
|
await dialog.getByRole("button", { name: /Chat Agent/i }).click();
|
||||||
|
|
||||||
|
const messageInput = dialog.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
await expect(messageInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await messageInput.fill(input.message);
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Send message" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTerminalAgentFromWorkspaceSetup(
|
||||||
|
page: Page,
|
||||||
|
input: { providerLabel: string; prompt?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const dialog = workspaceSetupDialog(page);
|
||||||
|
await dialog.getByRole("button", { name: /Terminal Agent/i }).click();
|
||||||
|
|
||||||
|
const providerButton = dialog.getByRole("button", { name: new RegExp(`^${input.providerLabel}$`, "i") }).first();
|
||||||
|
await expect(providerButton).toBeVisible({ timeout: 15_000 });
|
||||||
|
await providerButton.click();
|
||||||
|
|
||||||
|
if (input.prompt) {
|
||||||
|
const promptInput = dialog.getByPlaceholder("Optional").first();
|
||||||
|
await expect(promptInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await promptInput.fill(input.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Launch" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
|
||||||
|
await workspaceSetupDialog(page)
|
||||||
|
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise<void> {
|
||||||
|
const dialog = workspaceSetupDialog(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
|
||||||
|
} catch (error) {
|
||||||
|
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
|
||||||
|
throw new Error(
|
||||||
|
dialogText
|
||||||
|
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
|
||||||
|
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupStatus(
|
||||||
|
page: Page,
|
||||||
|
status: "Running" | "Completed" | "Failed",
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceThroughDaemon(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
input: { cwd: string; worktreeSlug: string },
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const result = await client.createPaseoWorktree(input);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(result.workspace.id),
|
||||||
|
name: result.workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findWorktreeWorkspaceForProject(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
}> {
|
||||||
|
const payload = await client.fetchWorkspaces();
|
||||||
|
const workspace =
|
||||||
|
payload.entries.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.projectRootPath === repoPath && entry.workspaceDirectory !== repoPath,
|
||||||
|
) ?? null;
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(workspace.id),
|
||||||
|
name: workspace.name,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkspaceById(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
}> {
|
||||||
|
const parsedWorkspaceId = Number(workspaceId);
|
||||||
|
if (!Number.isInteger(parsedWorkspaceId)) {
|
||||||
|
throw new Error(`Workspace id is not numeric: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await client.fetchWorkspaces();
|
||||||
|
const workspace = payload.entries.find((entry) => entry.id === parsedWorkspaceId) ?? null;
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceSetupProgress(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<WorkspaceSetupProgressPayload> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
unsubscribe();
|
||||||
|
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||||
|
if (message.type !== "workspace_setup_progress") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message.payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!predicate(message.payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
resolve(message.payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ type TempRepo = {
|
|||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean },
|
options?: {
|
||||||
|
withRemote?: boolean;
|
||||||
|
paseoConfig?: Record<string, unknown>;
|
||||||
|
files?: Array<{ path: string; content: string }>;
|
||||||
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||||
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
|
|||||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(repoPath, "paseo.json"),
|
||||||
|
JSON.stringify(options.paseoConfig, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
const filePath = path.join(repoPath, file.path);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, file.content);
|
||||||
|
}
|
||||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
|
|||||||
350
packages/app/e2e/launcher-tab.spec.ts
Normal file
350
packages/app/e2e/launcher-tab.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
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> };
|
||||||
|
let workspaceId: string;
|
||||||
|
let seedClient: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||||
|
seedClient = await connectTerminalClient();
|
||||||
|
const result = await seedClient.openProject(tempRepo.path);
|
||||||
|
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||||
|
workspaceId = String(result.workspace.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (seedClient) await seedClient.close();
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
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, workspaceId);
|
||||||
|
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, workspaceId);
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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, workspaceId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
181
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||||
|
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||||
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGitHubRemote(repoPath: string): void {
|
||||||
|
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
||||||
|
const dirPath = await mkdtemp(path.join(process.platform === "win32" ? tmpdir() : "/tmp", prefix));
|
||||||
|
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||||
|
return {
|
||||||
|
path: dirPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(dirPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectViaDaemon(
|
||||||
|
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const result = await client.openProject(cwd);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(result.workspace.id),
|
||||||
|
name: result.workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) {
|
||||||
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSidebarProject(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
projectName: string,
|
||||||
|
) {
|
||||||
|
const row = page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: new RegExp(escapeRegex(projectName), "i"),
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
||||||
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Sidebar workspace list", () => {
|
||||||
|
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGitHubRemote(repo.path);
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
|
||||||
|
const projectRow = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: "test-owner/test-repo" })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project shows workspace under it", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await waitForSidebarProject(page, path.basename(repo.path));
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const project = await createTempDirectory("sidebar-directory-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openProjectViaDaemon(client, project.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
||||||
|
await expect(projectRow).toContainText(path.basename(project.path));
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await project.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGitHubRemote(repo.path);
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
await openWorkspaceFromSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspace.name,
|
||||||
|
subtitle: "test-owner/test-repo",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-branch-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, path.basename(repo.path));
|
||||||
|
|
||||||
|
expect(workspace.name).toBe("main");
|
||||||
|
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,9 @@ test.describe("Terminal wire performance", () => {
|
|||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("perf-");
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
client = await connectTerminalClient();
|
client = await connectTerminalClient();
|
||||||
|
// Seed the workspace in the daemon so the app can resolve the path
|
||||||
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
|
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
|
|||||||
107
packages/app/e2e/workspace-cwd.spec.ts
Normal file
107
packages/app/e2e/workspace-cwd.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import {
|
||||||
|
clickNewTabButton,
|
||||||
|
clickTerminal,
|
||||||
|
gotoWorkspace,
|
||||||
|
waitForLauncherPanel,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
waitForTerminalContent,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
test.describe("Workspace cwd correctness", () => {
|
||||||
|
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
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, `PWD_READY_${Date.now()}`);
|
||||||
|
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
"/tmp",
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `workspace-cwd-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
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, `PWD_READY_${Date.now()}`);
|
||||||
|
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
242
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
242
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test } from "./fixtures";
|
||||||
|
import {
|
||||||
|
clickNewTabButton,
|
||||||
|
gotoWorkspace,
|
||||||
|
waitForLauncherPanel,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
createAgentChatFromLauncher,
|
||||||
|
createStandaloneTerminalFromLauncher,
|
||||||
|
createTerminalAgentFromLauncher,
|
||||||
|
expectTerminalCwd,
|
||||||
|
} from "./helpers/workspace-lifecycle";
|
||||||
|
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
test.describe("Workspace lifecycle", () => {
|
||||||
|
// The first test after a spec-file switch can intermittently fail because
|
||||||
|
// the shared daemon still holds stale sessions from the previous spec.
|
||||||
|
// One retry is enough for the daemon to stabilize.
|
||||||
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
|
test.describe("Main checkout", () => {
|
||||||
|
test("creates a terminal agent via provider tile", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-main-agent-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createTerminalAgentFromLauncher(page, "Claude");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createAgentChatFromLauncher(page);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, repo.path);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Worktree workspace", () => {
|
||||||
|
test("creates a terminal agent via provider tile", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-wt-agent-");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
"/tmp",
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `lifecycle-wt-agent-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createTerminalAgentFromLauncher(page, "Claude");
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
"/tmp",
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createAgentChatFromLauncher(page);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
"/tmp",
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
await waitForLauncherPanel(page);
|
||||||
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, worktreePath);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
171
packages/app/e2e/workspace-setup-runtime.spec.ts
Normal file
171
packages/app/e2e/workspace-setup-runtime.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
createChatAgentFromWorkspaceSetup,
|
||||||
|
createStandaloneTerminalFromWorkspaceSetup,
|
||||||
|
createTerminalAgentFromWorkspaceSetup,
|
||||||
|
createWorkspaceFromSidebar,
|
||||||
|
findWorktreeWorkspaceForProject,
|
||||||
|
openHomeWithProject,
|
||||||
|
type WorkspaceSetupDaemonClient,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
async function openWorkspaceSetupDialogFromSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await openHomeWithProject(page, repoPath);
|
||||||
|
await createWorkspaceFromSidebar(page, repoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCreatedWorkspaceRoute(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
originalProjectPath: string,
|
||||||
|
) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await findWorktreeWorkspaceForProject(client, originalProjectPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const workspace = await findWorktreeWorkspaceForProject(client, originalProjectPath);
|
||||||
|
|
||||||
|
expect(workspace.workspaceDirectory).not.toBe(originalProjectPath);
|
||||||
|
expect(existsSync(workspace.workspaceDirectory)).toBe(true);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForNewWorkspaceAgent(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
expectedWorkspaceDirectory: string,
|
||||||
|
agentIdsBefore: Set<string>,
|
||||||
|
) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const payload = await client.fetchAgents();
|
||||||
|
return (
|
||||||
|
payload.entries.find(
|
||||||
|
(entry) =>
|
||||||
|
!agentIdsBefore.has(entry.agent.id) &&
|
||||||
|
entry.agent.cwd === expectedWorkspaceDirectory,
|
||||||
|
)?.agent ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const payload = await client.fetchAgents();
|
||||||
|
const agent =
|
||||||
|
payload.entries.find(
|
||||||
|
(entry) =>
|
||||||
|
!agentIdsBefore.has(entry.agent.id) && entry.agent.cwd === expectedWorkspaceDirectory,
|
||||||
|
)?.agent ?? null;
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error(`Expected a new agent for workspace ${expectedWorkspaceDirectory}`);
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace setup runtime authority", () => {
|
||||||
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
|
test("first chat agent attaches to the created workspace", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-setup-chat-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
|
||||||
|
const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id));
|
||||||
|
|
||||||
|
await createChatAgentFromWorkspaceSetup(page, {
|
||||||
|
message: `workspace-setup-chat-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
|
||||||
|
const agent = await waitForNewWorkspaceAgent(
|
||||||
|
client,
|
||||||
|
workspace.workspaceDirectory,
|
||||||
|
agentIdsBefore,
|
||||||
|
);
|
||||||
|
expect(agent.cwd).toBe(workspace.workspaceDirectory);
|
||||||
|
expect(agent.cwd).not.toBe(repo.path);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first terminal agent attaches to the created workspace", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-setup-terminal-agent-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
|
||||||
|
const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id));
|
||||||
|
|
||||||
|
await createTerminalAgentFromWorkspaceSetup(page, {
|
||||||
|
providerLabel: "Claude",
|
||||||
|
prompt: `workspace-setup-terminal-agent-${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
|
||||||
|
const agent = await waitForNewWorkspaceAgent(
|
||||||
|
client,
|
||||||
|
workspace.workspaceDirectory,
|
||||||
|
agentIdsBefore,
|
||||||
|
);
|
||||||
|
expect(agent.cwd).toBe(workspace.workspaceDirectory);
|
||||||
|
expect(agent.cwd).not.toBe(repo.path);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first terminal attaches to the created workspace", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
|
||||||
|
|
||||||
|
await createStandaloneTerminalFromWorkspaceSetup(page);
|
||||||
|
|
||||||
|
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await client.listTerminals(workspace.workspaceDirectory)).terminals.length > 0,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
expect(
|
||||||
|
(await client.listTerminals(repo.path)).terminals.length,
|
||||||
|
).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,7 @@ import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
|||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +77,7 @@ import {
|
|||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
@@ -372,6 +374,7 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -607,7 +610,6 @@ function FaviconStatusSync() {
|
|||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -621,7 +623,23 @@ function RootStack() {
|
|||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
<Stack.Screen
|
||||||
|
name="h/[serverId]/workspace/[workspaceId]"
|
||||||
|
getId={({ params }) => {
|
||||||
|
const serverValue = Array.isArray(params?.serverId)
|
||||||
|
? params.serverId[0]
|
||||||
|
: params?.serverId;
|
||||||
|
const workspaceValue = Array.isArray(params?.workspaceId)
|
||||||
|
? params.workspaceId[0]
|
||||||
|
: params?.workspaceId;
|
||||||
|
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||||
|
const workspaceId =
|
||||||
|
typeof workspaceValue === "string"
|
||||||
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim())
|
||||||
|
: "";
|
||||||
|
return `${serverId}:${workspaceId}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="h/[serverId]/agent/[agentId]"
|
name="h/[serverId]/agent/[agentId]"
|
||||||
options={{ gestureEnabled: false }}
|
options={{ gestureEnabled: false }}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -22,6 +23,21 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||||
});
|
});
|
||||||
|
const sessionWorkspaces = useSessionStore((state) =>
|
||||||
|
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||||
|
);
|
||||||
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
|
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
|
);
|
||||||
|
const resolvedWorkspaceId = useSessionStore((state) => {
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -33,18 +49,17 @@ export default function HostAgentReadyRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCwd = agentCwd?.trim();
|
if (resolvedWorkspaceId) {
|
||||||
if (normalizedCwd) {
|
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: normalizedCwd,
|
workspaceId: resolvedWorkspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -53,14 +68,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentCwd?.trim()) {
|
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -78,12 +93,19 @@ export default function HostAgentReadyRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: sessionWorkspaces?.values(),
|
||||||
|
workspaceDirectory: cwd,
|
||||||
|
});
|
||||||
|
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (workspaceId) {
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
@@ -102,7 +124,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceOpenRoute,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
|
function getCurrentPathname(fallbackPathname: string): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallbackPathname;
|
||||||
|
}
|
||||||
|
return window.location.pathname || fallbackPathname;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -32,11 +41,13 @@ export default function HostIndexRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rootRoute = buildHostRootRoute(serverId);
|
const rootRoute = buildHostRootRoute(serverId);
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
const currentPathname = getCurrentPathname(pathname);
|
||||||
|
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
const latestPathname = getCurrentPathname(pathname);
|
||||||
|
if (latestPathname !== rootRoute && latestPathname !== `${rootRoute}/`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,20 +66,30 @@ export default function HostIndexRoute() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: sessionWorkspaces?.values(),
|
||||||
|
workspaceDirectory: primaryAgent?.cwd,
|
||||||
|
});
|
||||||
|
if (primaryAgent && primaryAgentWorkspaceId) {
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: primaryAgent.cwd.trim(),
|
workspaceId: primaryAgentWorkspaceId,
|
||||||
target: { kind: "agent", agentId: primaryAgent.id },
|
target: { kind: "agent", agentId: primaryAgent.id },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (primaryAgent) {
|
||||||
|
router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -37,18 +37,13 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const params = useLocalSearchParams<{
|
const pathname = usePathname();
|
||||||
serverId?: string | string[];
|
|
||||||
workspaceId?: string | string[];
|
|
||||||
}>();
|
|
||||||
const globalParams = useGlobalSearchParams<{
|
const globalParams = useGlobalSearchParams<{
|
||||||
open?: string | string[];
|
open?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
const serverId = getParamValue(params.serverId);
|
const parsedWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||||
const workspaceValue = getParamValue(params.workspaceId);
|
const serverId = parsedWorkspaceRoute?.serverId ?? "";
|
||||||
const workspaceId = workspaceValue
|
const workspaceId = parsedWorkspaceRoute?.workspaceId ?? "";
|
||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
|
||||||
: "";
|
|
||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import { buildHostRootRoute } from "@/utils/host-routes";
|
|||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
const WELCOME_ROUTE = "/welcome";
|
||||||
|
|
||||||
|
function getCurrentPathname(fallbackPathname: string): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return fallbackPathname;
|
||||||
|
}
|
||||||
|
return window.location.pathname || fallbackPathname;
|
||||||
|
}
|
||||||
|
|
||||||
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
@@ -51,7 +58,8 @@ export default function Index() {
|
|||||||
if (!storeReady) {
|
if (!storeReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathname !== "/" && pathname !== "") {
|
const currentPathname = getCurrentPathname(pathname);
|
||||||
|
if (currentPathname !== "/" && currentPathname !== "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -130,6 +133,7 @@ function SessionRow({
|
|||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const statusLabel = formatStatusLabel(agent.status);
|
const statusLabel = formatStatusLabel(agent.status);
|
||||||
const projectPath = shortenPath(agent.cwd);
|
const projectPath = shortenPath(agent.cwd);
|
||||||
|
const ProviderIcon = getProviderIcon(agent.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -145,12 +149,21 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<View style={styles.rowTitleRow}>
|
<View style={styles.rowTitleRow}>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{agent.title || "New session"}
|
{agent.title || "New session"}
|
||||||
</Text>
|
</Text>
|
||||||
|
{agent.terminal ? (
|
||||||
|
<SessionBadge
|
||||||
|
label="Terminal"
|
||||||
|
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{agent.archivedAt ? (
|
{agent.archivedAt ? (
|
||||||
<SessionBadge
|
<SessionBadge
|
||||||
label="Archived"
|
label="Archived"
|
||||||
@@ -231,14 +244,24 @@ export function AgentList({
|
|||||||
|
|
||||||
const serverId = agent.serverId;
|
const serverId = agent.serverId;
|
||||||
const agentId = agent.id;
|
const agentId = agent.id;
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: agent.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
|
requestReopen: agent.terminal && agent.status === "closed",
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
},
|
},
|
||||||
@@ -433,6 +456,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
rowMetaRow: {
|
rowMetaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import {
|
import {
|
||||||
@@ -132,10 +133,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaceRoot = agent.cwd?.trim() || "";
|
const workspaceRoot = agent.cwd?.trim() || "";
|
||||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: workspaceRoot,
|
||||||
|
});
|
||||||
const { requestDirectoryListing } = useFileExplorerActions({
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId,
|
workspaceId: workspaceId ?? undefined,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
});
|
});
|
||||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||||
@@ -175,12 +179,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
if (workspaceId) {
|
||||||
serverId: resolvedServerId,
|
const route = prepareWorkspaceTab({
|
||||||
workspaceId,
|
serverId: resolvedServerId,
|
||||||
target: { kind: "file", path: normalized.file },
|
workspaceId,
|
||||||
});
|
target: { kind: "file", path: normalized.file },
|
||||||
router.navigate(route as any);
|
});
|
||||||
|
router.navigate(route as any);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||||
|
|
||||||
describe("resolveStatusControlMode", () => {
|
describe("resolveStatusControlMode", () => {
|
||||||
it("uses ready mode when no controlled status controls are provided", () => {
|
it("uses ready mode when no controlled status controls are provided", () => {
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} 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 { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -56,11 +56,12 @@ type QueuedMessage = {
|
|||||||
|
|
||||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isInputActive: boolean;
|
isInputActive: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** 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 DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
|
||||||
export function AgentInputArea({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isInputActive,
|
isInputActive,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
|
allowEmptySubmit = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -109,8 +111,8 @@ export function AgentInputArea({
|
|||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
statusControls,
|
statusControls,
|
||||||
}: AgentInputAreaProps) {
|
}: ComposerProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -480,7 +482,7 @@ export function AgentInputArea({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
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 =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
@@ -677,6 +679,7 @@ export function AgentInputArea({
|
|||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
images={selectedImages}
|
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;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmit: (payload: MessagePayload) => void;
|
onSubmit: (payload: MessagePayload) => void;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
isSubmitDisabled?: boolean;
|
isSubmitDisabled?: boolean;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
images?: ImageAttachment[];
|
images?: ImageAttachment[];
|
||||||
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
allowEmptySubmit = false,
|
||||||
isSubmitDisabled = false,
|
isSubmitDisabled = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
images = [],
|
images = [],
|
||||||
@@ -851,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
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 shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
const recommendedPaths = useMemo(() => {
|
const recommendedPaths = useMemo(() => {
|
||||||
if (!workspaces) return [];
|
if (!workspaces) return [];
|
||||||
return Array.from(workspaces.values()).map(
|
return Array.from(workspaces.values())
|
||||||
(workspace) => workspace.projectRootPath || workspace.id,
|
.map((workspace) => workspace.projectRootPath)
|
||||||
);
|
.filter((path) => path.length > 0);
|
||||||
}, [workspaces]);
|
}, [workspaces]);
|
||||||
|
|
||||||
const directorySuggestionsQuery = useQuery({
|
const directorySuggestionsQuery = useQuery({
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Bot } from "lucide-react-native";
|
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 { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-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> = {
|
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
codex: CodexIcon 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 {
|
export function getProviderIcon(provider: string): typeof Bot {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
import { useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -45,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
|
|||||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
@@ -83,10 +82,14 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import {
|
||||||
|
requireWorkspaceExecutionDirectory,
|
||||||
|
resolveWorkspaceExecutionDirectory,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
|
|
||||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -239,7 +242,7 @@ function WorkspaceStatusIndicator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KindIcon =
|
const KindIcon =
|
||||||
workspaceKind === "local_checkout"
|
workspaceKind === "checkout"
|
||||||
? Monitor
|
? Monitor
|
||||||
: workspaceKind === "worktree"
|
: workspaceKind === "worktree"
|
||||||
? FolderGit2
|
? FolderGit2
|
||||||
@@ -654,7 +657,6 @@ function ProjectHeaderRow({
|
|||||||
canCreateWorktree,
|
canCreateWorktree,
|
||||||
isProjectActive = false,
|
isProjectActive = false,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onWorktreeCreated,
|
|
||||||
shortcutNumber = null,
|
shortcutNumber = null,
|
||||||
showShortcutBadge = false,
|
showShortcutBadge = false,
|
||||||
drag,
|
drag,
|
||||||
@@ -665,50 +667,30 @@ function ProjectHeaderRow({
|
|||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint = isCompactFormFactor();
|
const isMobileBreakpoint = isCompactFormFactor();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
|
||||||
const toast = useToast();
|
|
||||||
|
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||||
|
if (!serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onWorkspacePress?.();
|
||||||
|
beginWorkspaceSetup({
|
||||||
|
serverId,
|
||||||
|
sourceDirectory: project.iconWorkingDir,
|
||||||
|
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({
|
useKeyboardActionHandler({
|
||||||
handlerId: `worktree-new-${project.projectKey}`,
|
handlerId: `worktree-new-${project.projectKey}`,
|
||||||
actions: ["worktree.new"],
|
actions: ["worktree.new"],
|
||||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
enabled: isProjectActive && canCreateWorktree,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
handle: () => {
|
handle: () => {
|
||||||
createWorktreeMutation.mutate();
|
handleBeginWorkspaceSetup();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -752,9 +734,9 @@ function ProjectHeaderRow({
|
|||||||
{canCreateWorktree ? (
|
{canCreateWorktree ? (
|
||||||
<NewWorktreeButton
|
<NewWorktreeButton
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onPress={() => createWorktreeMutation.mutate()}
|
onPress={handleBeginWorkspaceSetup}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
visible={isHovered || isMobileBreakpoint}
|
||||||
loading={createWorktreeMutation.isPending}
|
loading={false}
|
||||||
showShortcutHint={isProjectActive}
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
/>
|
/>
|
||||||
@@ -836,10 +818,13 @@ function WorkspaceRowInner({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isTouchPlatform = Platform.OS !== "web";
|
const isTouchPlatform = Platform.OS !== "web";
|
||||||
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
const prHint = useWorkspacePrHint({
|
const prHint = useWorkspacePrHint({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspaceDirectory ?? "",
|
||||||
enabled: workspace.workspaceKind !== "directory",
|
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
|
||||||
});
|
});
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
@@ -1003,12 +988,17 @@ function WorkspaceRowWithMenu({
|
|||||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||||
);
|
);
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({
|
workspaceDirectory
|
||||||
serverId: workspace.serverId,
|
? state.getStatus({
|
||||||
cwd: workspace.workspaceId,
|
serverId: workspace.serverId,
|
||||||
actionId: "archive-worktree",
|
cwd: workspaceDirectory,
|
||||||
}),
|
actionId: "archive-worktree",
|
||||||
|
})
|
||||||
|
: "idle",
|
||||||
);
|
);
|
||||||
const isWorktree = workspace.workspaceKind === "worktree";
|
const isWorktree = workspace.workspaceKind === "worktree";
|
||||||
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
|
||||||
@@ -1046,11 +1036,26 @@ function WorkspaceRowWithMenu({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let workspaceDirectory: string;
|
||||||
|
try {
|
||||||
|
workspaceDirectory = requireWorkspaceExecutionDirectory({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
toast.error("Workspace path not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void archiveWorktree({
|
void archiveWorktree({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspaceDirectory,
|
||||||
worktreePath: workspace.workspaceId,
|
worktreePath: workspaceDirectory,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
redirectAfterArchive();
|
redirectAfterArchive();
|
||||||
@@ -1066,6 +1071,7 @@ function WorkspaceRowWithMenu({
|
|||||||
redirectAfterArchive,
|
redirectAfterArchive,
|
||||||
toast,
|
toast,
|
||||||
workspace.name,
|
workspace.name,
|
||||||
|
workspace.workspaceDirectory,
|
||||||
workspace.serverId,
|
workspace.serverId,
|
||||||
workspace.workspaceId,
|
workspace.workspaceId,
|
||||||
]);
|
]);
|
||||||
@@ -1095,7 +1101,7 @@ function WorkspaceRowWithMenu({
|
|||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -1116,9 +1122,19 @@ function WorkspaceRowWithMenu({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCopyPath = useCallback(() => {
|
const handleCopyPath = useCallback(() => {
|
||||||
void Clipboard.setStringAsync(workspace.workspaceId);
|
let workspaceDirectory: string;
|
||||||
|
try {
|
||||||
|
workspaceDirectory = requireWorkspaceExecutionDirectory({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Clipboard.setStringAsync(workspaceDirectory);
|
||||||
toast.copied("Path copied");
|
toast.copied("Path copied");
|
||||||
}, [toast, workspace.workspaceId]);
|
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||||
|
|
||||||
const handleCopyBranchName = useCallback(() => {
|
const handleCopyBranchName = useCallback(() => {
|
||||||
void Clipboard.setStringAsync(workspace.name);
|
void Clipboard.setStringAsync(workspace.name);
|
||||||
@@ -1240,7 +1256,7 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
|
|
||||||
setIsArchivingWorkspace(true);
|
setIsArchivingWorkspace(true);
|
||||||
try {
|
try {
|
||||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -1351,7 +1367,7 @@ function FlattenedProjectRow({
|
|||||||
dragHandleProps?: DraggableListDragHandleProps;
|
dragHandleProps?: DraggableListDragHandleProps;
|
||||||
isProjectActive?: boolean;
|
isProjectActive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (project.projectKind === "non_git") {
|
if (project.projectKind === "directory") {
|
||||||
return (
|
return (
|
||||||
<NonGitProjectRowWithMenu
|
<NonGitProjectRowWithMenu
|
||||||
project={project}
|
project={project}
|
||||||
|
|||||||
@@ -87,12 +87,7 @@ interface SplitContainerProps {
|
|||||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (selection: {
|
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||||
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
|
||||||
paneId?: string;
|
|
||||||
}) => void;
|
|
||||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
|
||||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
|
||||||
buildPaneContentModel: (input: {
|
buildPaneContentModel: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
@@ -268,9 +263,7 @@ export function SplitContainer({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId = "__new_tab_agent__",
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -537,9 +530,7 @@ export function SplitContainer({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -659,9 +650,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId,
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -694,9 +683,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -744,9 +731,7 @@ function SplitNodeView({
|
|||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId}
|
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
onFocusPane={onFocusPane}
|
onFocusPane={onFocusPane}
|
||||||
onSplitPane={onSplitPane}
|
onSplitPane={onSplitPane}
|
||||||
@@ -793,9 +778,7 @@ function SplitPaneView({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
onNewTerminalTab,
|
|
||||||
newTabAgentOptionId,
|
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
onFocusPane,
|
onFocusPane,
|
||||||
onSplitPane,
|
onSplitPane,
|
||||||
@@ -904,9 +887,7 @@ function SplitPaneView({
|
|||||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||||
onSelectNewTabOption={onSelectNewTabOption}
|
onCreateLauncherTab={onCreateLauncherTab}
|
||||||
onNewTerminalTab={onNewTerminalTab}
|
|
||||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
|
||||||
onReorderTabs={(nextTabs) => {
|
onReorderTabs={(nextTabs) => {
|
||||||
onReorderTabsInPane(
|
onReorderTabsInPane(
|
||||||
pane.id,
|
pane.id,
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentPathname =
|
||||||
|
typeof window === "undefined" ? null : (window.location.pathname || null);
|
||||||
|
if (currentPathname && currentPathname !== "/welcome") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!anyOnlineServerId) {
|
if (!anyOnlineServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
726
packages/app/src/components/workspace-setup-dialog.tsx
Normal file
726
packages/app/src/components/workspace-setup-dialog.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
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 {
|
||||||
|
requireWorkspaceExecutionAuthority,
|
||||||
|
requireWorkspaceRecordId,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
|
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 sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
||||||
|
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
|
||||||
|
const workspace = createdWorkspace;
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const chatDraft = useAgentInputDraft({
|
||||||
|
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
|
||||||
|
composer: {
|
||||||
|
initialServerId: serverId || null,
|
||||||
|
initialValues: workspace?.workspaceDirectory
|
||||||
|
? { workingDir: workspace.workspaceDirectory }
|
||||||
|
: undefined,
|
||||||
|
isVisible: pendingWorkspaceSetup !== null,
|
||||||
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
|
lockedWorkingDir: workspace?.workspaceDirectory || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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, serverId, sourceDirectory]);
|
||||||
|
|
||||||
|
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.sourceDirectory,
|
||||||
|
worktreeSlug: createNameId(),
|
||||||
|
})
|
||||||
|
: await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory);
|
||||||
|
|
||||||
|
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?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory &&
|
||||||
|
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
pendingWorkspaceSetup?.creationMethod,
|
||||||
|
pendingWorkspaceSetup?.serverId,
|
||||||
|
pendingWorkspaceSetup?.sourceDirectory,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
||||||
|
const agent = await connectedClient.createAgent({
|
||||||
|
provider: composerState.selectedProvider,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
workspaceId: requireWorkspaceRecordId(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 workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
||||||
|
const agent = await connectedClient.createAgent({
|
||||||
|
provider: composerState.selectedProvider,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
workspaceId: requireWorkspaceRecordId(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 workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
|
||||||
|
|
||||||
|
const payload = await connectedClient.createTerminal(workspaceDirectory);
|
||||||
|
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 ||
|
||||||
|
displayName ||
|
||||||
|
sourceDirectory.split(/[\\/]/).filter(Boolean).pop() ||
|
||||||
|
sourceDirectory;
|
||||||
|
const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch.";
|
||||||
|
|
||||||
|
if (!pendingWorkspaceSetup || !sourceDirectory) {
|
||||||
|
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}:${sourceDirectory}`}
|
||||||
|
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
|
void client
|
||||||
.fetchAgentTimeline(agentId, {
|
.fetchAgentTimeline(agentId, {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
cursor: { seq: cursor.endSeq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, 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(
|
const requestCanonicalCatchUp = useCallback(
|
||||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
(agentId: string, cursor: { endSeq: number }) => {
|
||||||
void client
|
void client
|
||||||
.fetchAgentTimeline(agentId, {
|
.fetchAgentTimeline(agentId, {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
cursor: { seq: cursor.endSeq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||||
@@ -858,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
current.epoch === result.cursor.epoch &&
|
|
||||||
current.startSeq === result.cursor.startSeq &&
|
current.startSeq === result.cursor.startSeq &&
|
||||||
current.endSeq === result.cursor.endSeq
|
current.endSeq === result.cursor.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -963,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
|
|
||||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||||
if (message.type !== "agent_stream") return;
|
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 parsedTimestamp = new Date(timestamp);
|
||||||
const streamEvent = event as AgentStreamEventPayload;
|
const streamEvent = event as AgentStreamEventPayload;
|
||||||
if (
|
if (
|
||||||
@@ -1005,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
event: streamEvent,
|
event: streamEvent,
|
||||||
seq,
|
seq,
|
||||||
epoch,
|
|
||||||
currentTail,
|
currentTail,
|
||||||
currentHead,
|
currentHead,
|
||||||
currentCursor,
|
currentCursor,
|
||||||
@@ -1029,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
typeof seq === "number" &&
|
typeof seq === "number" &&
|
||||||
typeof epoch === "string" &&
|
|
||||||
current.epoch === epoch &&
|
|
||||||
seq >= current.startSeq &&
|
seq >= current.startSeq &&
|
||||||
seq <= current.endSeq
|
seq <= current.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -1039,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current &&
|
current &&
|
||||||
current.epoch === nextCursor.epoch &&
|
|
||||||
current.startSeq === nextCursor.startSeq &&
|
current.startSeq === nextCursor.startSeq &&
|
||||||
current.endSeq === nextCursor.endSeq
|
current.endSeq === nextCursor.endSeq
|
||||||
) {
|
) {
|
||||||
@@ -1090,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||||
if (message.type !== "workspace_update") return;
|
if (message.type !== "workspace_update") return;
|
||||||
if (message.payload.kind === "remove") {
|
if (message.payload.kind === "remove") {
|
||||||
removeWorkspace(serverId, message.payload.id);
|
removeWorkspace(serverId, String(message.payload.id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function createAgent(status: Agent["status"]): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
terminal: false,
|
||||||
status,
|
status,
|
||||||
createdAt: new Date(0),
|
createdAt: new Date(0),
|
||||||
updatedAt: new Date(0),
|
updatedAt: new Date(0),
|
||||||
@@ -19,6 +20,7 @@ function createAgent(status: Agent["status"]): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -9,13 +9,9 @@ import {
|
|||||||
type TimelineCursor,
|
type TimelineCursor,
|
||||||
} from "./session-stream-reducers";
|
} from "./session-stream-reducers";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||||
return {
|
return {
|
||||||
seqStart: seq,
|
seq,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: { type, text },
|
item: { type, text },
|
||||||
timestamp: new Date(1000 + seq).toISOString(),
|
timestamp: new Date(1000 + seq).toISOString(),
|
||||||
@@ -33,22 +29,30 @@ function makeTimelineEvent(
|
|||||||
} as AgentStreamEventPayload;
|
} as AgentStreamEventPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||||
return {
|
return {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
item: { type: "user_message", text },
|
item: {
|
||||||
} as AgentStreamEventPayload;
|
type: "tool_call",
|
||||||
|
callId: "call-1",
|
||||||
|
name: "shell",
|
||||||
|
status,
|
||||||
|
detail: {
|
||||||
|
type: "shell",
|
||||||
|
command: "pwd",
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
direction: "after",
|
direction: "after",
|
||||||
reset: false,
|
startSeq: null,
|
||||||
epoch: "epoch-1",
|
endSeq: null,
|
||||||
startCursor: null,
|
|
||||||
endCursor: null,
|
|
||||||
entries: [],
|
entries: [],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
|||||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||||
event: makeTimelineEvent("hello"),
|
event: makeTimelineEvent("hello"),
|
||||||
seq: undefined,
|
seq: undefined,
|
||||||
epoch: undefined,
|
|
||||||
currentTail: [],
|
currentTail: [],
|
||||||
currentHead: [],
|
currentHead: [],
|
||||||
currentCursor: undefined,
|
currentCursor: undefined,
|
||||||
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
|||||||
timestamp: new Date(2000),
|
timestamp: new Date(2000),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processTimelineResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("processTimelineResponse", () => {
|
describe("processTimelineResponse", () => {
|
||||||
it("returns error path when payload.error is set", () => {
|
it("returns error path when payload.error is set", () => {
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
|
|||||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||||
expect(result.cursorChanged).toBe(false);
|
expect(result.cursorChanged).toBe(false);
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error with no init resolution when no deferred exists", () => {
|
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||||
const result = processTimelineResponse({
|
const provisionalHead: StreamItem[] = [
|
||||||
...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[] = [
|
|
||||||
{
|
{
|
||||||
kind: "assistant_message",
|
kind: "assistant_message",
|
||||||
id: "head-1",
|
id: "head-1",
|
||||||
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
|
|||||||
|
|
||||||
const result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
currentTail: existingTail,
|
currentHead: provisionalHead,
|
||||||
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,
|
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
hasActiveInitDeferred: true,
|
hasActiveInitDeferred: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
epoch: "epoch-1",
|
startSeq: 1,
|
||||||
startCursor: { seq: 1 },
|
endSeq: 5,
|
||||||
endCursor: { seq: 5 },
|
|
||||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bootstrap tail replaces
|
|
||||||
expect(result.tail.length).toBeGreaterThan(0);
|
expect(result.tail.length).toBeGreaterThan(0);
|
||||||
expect(result.head).toEqual([]);
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 1,
|
startSeq: 1,
|
||||||
endSeq: 5,
|
endSeq: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should have catch-up side effect
|
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
expect(catchUp).toEqual({
|
||||||
expect(catchUp).toBeDefined();
|
type: "catch_up",
|
||||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
cursor: { endSeq: 5 },
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 5,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends incrementally for contiguous seqs", () => {
|
it("prepends older committed history for before pagination", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const currentTail: StreamItem[] = [
|
||||||
epoch: "epoch-1",
|
{
|
||||||
startSeq: 1,
|
kind: "assistant_message",
|
||||||
endSeq: 3,
|
id: "tail-3",
|
||||||
};
|
text: "newer",
|
||||||
|
timestamp: new Date(3000),
|
||||||
const result = processTimelineResponse({
|
|
||||||
...baseTimelineInput,
|
|
||||||
currentCursor: existingCursor,
|
|
||||||
payload: {
|
|
||||||
...baseTimelineInput.payload,
|
|
||||||
epoch: "epoch-1",
|
|
||||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
|
||||||
},
|
},
|
||||||
});
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||||
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 result = processTimelineResponse({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
currentCursor: existingCursor,
|
currentTail,
|
||||||
payload: {
|
currentCursor,
|
||||||
...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",
|
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "before",
|
direction: "before",
|
||||||
entries: [],
|
startSeq: 1,
|
||||||
|
endSeq: 2,
|
||||||
|
entries: [
|
||||||
|
makeTimelineEntry(1, "hello", "user_message"),
|
||||||
|
makeTimelineEntry(2, "older"),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// "before" direction doesn't match "after" initRequestDirection,
|
expect(result.cursorChanged).toBe(true);
|
||||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
expect(result.cursor).toEqual({
|
||||||
expect(result.initResolution).toBe(null);
|
startSeq: 1,
|
||||||
expect(result.clearInitializing).toBe(false);
|
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({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
isInitializing: true,
|
currentHead,
|
||||||
hasActiveInitDeferred: false,
|
currentCursor,
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
direction: "after",
|
direction: "after",
|
||||||
entries: [],
|
startSeq: 121,
|
||||||
},
|
endSeq: 121,
|
||||||
});
|
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||||
|
|
||||||
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")],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 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", () => {
|
describe("processAgentStreamEvent", () => {
|
||||||
it("passes through non-timeline events without cursor changes", () => {
|
it("treats seq-less timeline events as provisional head updates", () => {
|
||||||
const turnEvent: AgentStreamEventPayload = {
|
|
||||||
type: "turn_completed",
|
|
||||||
provider: "claude",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: turnEvent,
|
event: makeTimelineEvent("partial"),
|
||||||
seq: undefined,
|
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.cursorChanged).toBe(false);
|
||||||
expect(result.cursor).toBe(null);
|
|
||||||
expect(result.sideEffects).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts timeline event with cursor advance", () => {
|
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const currentHead: StreamItem[] = [
|
||||||
epoch: "epoch-1",
|
{
|
||||||
startSeq: 1,
|
kind: "assistant_message",
|
||||||
endSeq: 4,
|
id: "head-assistant",
|
||||||
};
|
text: "partial",
|
||||||
|
timestamp: new Date(1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||||
|
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: makeTimelineEvent("new chunk"),
|
event: makeTimelineEvent("finalized reply"),
|
||||||
seq: 5,
|
seq: 121,
|
||||||
epoch: "epoch-1",
|
currentHead,
|
||||||
currentCursor: existingCursor,
|
currentCursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.changedTail).toBe(true);
|
||||||
|
expect(result.changedHead).toBe(true);
|
||||||
|
expect(result.head).toEqual([]);
|
||||||
expect(result.cursorChanged).toBe(true);
|
expect(result.cursorChanged).toBe(true);
|
||||||
expect(result.cursor).toEqual({
|
expect(result.cursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
startSeq: 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", () => {
|
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||||
const existingCursor: TimelineCursor = {
|
const provisional = processAgentStreamEvent({
|
||||||
epoch: "epoch-1",
|
...baseStreamInput,
|
||||||
startSeq: 1,
|
event: makeToolCallEvent("running"),
|
||||||
endSeq: 4,
|
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({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
event: makeTimelineEvent("far ahead"),
|
event: makeTimelineEvent("far ahead"),
|
||||||
seq: 10,
|
seq: 125,
|
||||||
epoch: "epoch-1",
|
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||||
currentCursor: existingCursor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.cursorChanged).toBe(false);
|
|
||||||
expect(result.changedTail).toBe(false);
|
expect(result.changedTail).toBe(false);
|
||||||
expect(result.changedHead).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.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.changedTail).toBe(false);
|
||||||
expect(result.changedHead).toBe(false);
|
expect(result.head).toEqual([]);
|
||||||
expect(result.sideEffects).toEqual([]);
|
expect(result.tail).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
classifySessionTimelineSeq,
|
classifySessionTimelineSeq,
|
||||||
type SessionTimelineSeqDecision,
|
type SessionTimelineSeqDecision,
|
||||||
@@ -12,38 +12,25 @@ import {
|
|||||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared cursor type
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TimelineCursor = {
|
export type TimelineCursor = {
|
||||||
epoch: string;
|
|
||||||
startSeq: number;
|
startSeq: number;
|
||||||
endSeq: number;
|
endSeq: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Side-effect discriminated unions
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type TimelineReducerSideEffect =
|
export type TimelineReducerSideEffect =
|
||||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||||
| { type: "flush_pending_updates" };
|
| { type: "flush_pending_updates" };
|
||||||
|
|
||||||
export type AgentStreamReducerSideEffect = {
|
export type AgentStreamReducerSideEffect = {
|
||||||
type: "catch_up";
|
type: "catch_up";
|
||||||
cursor: { epoch: string; endSeq: number };
|
cursor: { endSeq: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// processTimelineResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type TimelineDirection = "tail" | "before" | "after";
|
type TimelineDirection = "tail" | "before" | "after";
|
||||||
type InitRequestDirection = "tail" | "after";
|
type InitRequestDirection = "tail" | "after";
|
||||||
|
|
||||||
type TimelineResponseEntry = {
|
type TimelineResponseEntry = {
|
||||||
seqStart: number;
|
seq: number;
|
||||||
provider: string;
|
provider: string;
|
||||||
item: Record<string, unknown>;
|
item: Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
|
|||||||
payload: {
|
payload: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
reset: boolean;
|
startSeq: number | null;
|
||||||
epoch: string;
|
endSeq: number | null;
|
||||||
startCursor: { seq: number } | null;
|
|
||||||
endCursor: { seq: number } | null;
|
|
||||||
entries: TimelineResponseEntry[];
|
entries: TimelineResponseEntry[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
|
|||||||
sideEffects: TimelineReducerSideEffect[];
|
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 {
|
export interface ProcessAgentStreamEventInput {
|
||||||
event: AgentStreamEventPayload;
|
event: AgentStreamEventPayload;
|
||||||
seq: number | undefined;
|
seq: number | undefined;
|
||||||
epoch: string | undefined;
|
|
||||||
currentTail: StreamItem[];
|
currentTail: StreamItem[];
|
||||||
currentHead: StreamItem[];
|
currentHead: StreamItem[];
|
||||||
currentCursor: TimelineCursor | undefined;
|
currentCursor: TimelineCursor | undefined;
|
||||||
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
|
|||||||
sideEffects: AgentStreamReducerSideEffect[];
|
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(
|
export function processAgentStreamEvent(
|
||||||
input: ProcessAgentStreamEventInput,
|
input: ProcessAgentStreamEventInput,
|
||||||
): ProcessAgentStreamEventOutput {
|
): ProcessAgentStreamEventOutput {
|
||||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||||
input;
|
|
||||||
|
|
||||||
let shouldApplyStreamEvent = true;
|
let nextTail = currentTail;
|
||||||
|
let nextHead = currentHead;
|
||||||
|
let changedTail = false;
|
||||||
|
let changedHead = false;
|
||||||
let nextTimelineCursor: TimelineCursor | null = null;
|
let nextTimelineCursor: TimelineCursor | null = null;
|
||||||
let cursorChanged = false;
|
let cursorChanged = false;
|
||||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
if (event.type === "timeline" && typeof seq === "number") {
|
||||||
// Timeline sequencing gate
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
|
||||||
const decision = classifySessionTimelineSeq({
|
const decision = classifySessionTimelineSeq({
|
||||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||||
epoch,
|
|
||||||
seq,
|
seq,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (decision === "init") {
|
if (decision === "gap") {
|
||||||
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 (currentCursor) {
|
if (currentCursor) {
|
||||||
sideEffects.push({
|
sideEffects.push({
|
||||||
type: "catch_up",
|
type: "catch_up",
|
||||||
cursor: {
|
cursor: { endSeq: currentCursor.endSeq },
|
||||||
epoch: currentCursor.epoch,
|
|
||||||
endSeq: currentCursor.endSeq,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else if (decision !== "drop_stale") {
|
||||||
// drop_stale or drop_epoch
|
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||||
shouldApplyStreamEvent = false;
|
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 agentPatch: AgentPatch | null = null;
|
||||||
let agentChanged = false;
|
let agentChanged = false;
|
||||||
|
|
||||||
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tail,
|
tail: nextTail,
|
||||||
head,
|
head: nextHead,
|
||||||
changedTail,
|
changedTail,
|
||||||
changedHead,
|
changedHead,
|
||||||
cursor: nextTimelineCursor,
|
cursor: nextTimelineCursor,
|
||||||
|
|||||||
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||||
import {
|
import {
|
||||||
deriveBootstrapTailTimelinePolicy,
|
deriveBootstrapTailTimelinePolicy,
|
||||||
|
deriveInitialTimelineRequest,
|
||||||
shouldResolveTimelineInit,
|
shouldResolveTimelineInit,
|
||||||
} from "./session-timeline-bootstrap-policy";
|
} from "./session-timeline-bootstrap-policy";
|
||||||
|
|
||||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
describe("deriveInitialTimelineRequest", () => {
|
||||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
it("uses tail bootstrap when history has not synced yet", () => {
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
expect(
|
||||||
direction: "after",
|
deriveInitialTimelineRequest({
|
||||||
reset: true,
|
cursor: { seq: 42 },
|
||||||
epoch: "epoch-1",
|
hasAuthoritativeHistory: false,
|
||||||
endCursor: { seq: 200 },
|
initialTimelineLimit: 200,
|
||||||
isInitializing: false,
|
}),
|
||||||
hasActiveInitDeferred: false,
|
).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", () => {
|
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 tailSeqStart = 101;
|
||||||
const tailSeqEnd = 200;
|
const tailSeqEnd = 200;
|
||||||
|
|
||||||
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||||
const decision = classifySessionTimelineSeq({
|
const decision = classifySessionTimelineSeq({
|
||||||
cursor: advancedCursor,
|
cursor: advancedCursor,
|
||||||
epoch: "epoch-1",
|
|
||||||
seq,
|
seq,
|
||||||
});
|
});
|
||||||
if (decision === "accept" || decision === "init") {
|
if (decision === "accept" || decision === "init") {
|
||||||
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
|
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
const policy = deriveBootstrapTailTimelinePolicy({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
reset: false,
|
endSeq: 200,
|
||||||
epoch: "epoch-1",
|
|
||||||
endCursor: { seq: 200 },
|
|
||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
hasActiveInitDeferred: true,
|
hasActiveInitDeferred: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(policy.replace).toBe(true);
|
expect(policy.replace).toBe(true);
|
||||||
expect(policy.catchUpCursor).toEqual({
|
expect(policy.catchUpCursor).toEqual({
|
||||||
epoch: "epoch-1",
|
|
||||||
endSeq: 200,
|
endSeq: 200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
|||||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||||
const policy = deriveBootstrapTailTimelinePolicy({
|
const policy = deriveBootstrapTailTimelinePolicy({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
reset: false,
|
endSeq: 200,
|
||||||
epoch: "epoch-1",
|
|
||||||
endCursor: { seq: 200 },
|
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
hasActiveInitDeferred: false,
|
hasActiveInitDeferred: false,
|
||||||
});
|
});
|
||||||
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
responseDirection: "tail",
|
responseDirection: "tail",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "tail",
|
initRequestDirection: "tail",
|
||||||
responseDirection: "after",
|
responseDirection: "after",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
initRequestDirection: "after",
|
initRequestDirection: "after",
|
||||||
responseDirection: "after",
|
responseDirection: "after",
|
||||||
reset: false,
|
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type InitialTimelineCursor = {
|
type InitialTimelineCursor = {
|
||||||
epoch: string;
|
|
||||||
seq: number;
|
seq: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
|
|||||||
initialTimelineLimit: number;
|
initialTimelineLimit: number;
|
||||||
}): {
|
}): {
|
||||||
direction: "tail" | "after";
|
direction: "tail" | "after";
|
||||||
cursor?: { epoch: string; seq: number };
|
cursor?: { seq: number };
|
||||||
limit: number;
|
limit: number;
|
||||||
projection: "canonical";
|
|
||||||
} {
|
} {
|
||||||
if (!hasAuthoritativeHistory || !cursor) {
|
if (!hasAuthoritativeHistory || !cursor) {
|
||||||
return {
|
return {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: initialTimelineLimit,
|
limit: initialTimelineLimit,
|
||||||
projection: "canonical",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
cursor: { seq: cursor.seq },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveBootstrapTailTimelinePolicy({
|
export function deriveBootstrapTailTimelinePolicy({
|
||||||
direction,
|
direction,
|
||||||
reset,
|
endSeq,
|
||||||
epoch,
|
|
||||||
endCursor,
|
|
||||||
isInitializing,
|
isInitializing,
|
||||||
hasActiveInitDeferred,
|
hasActiveInitDeferred,
|
||||||
}: {
|
}: {
|
||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
reset: boolean;
|
endSeq: number | null;
|
||||||
epoch: string;
|
|
||||||
endCursor: BootstrapTailCursor;
|
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
hasActiveInitDeferred: boolean;
|
hasActiveInitDeferred: boolean;
|
||||||
}): {
|
}): {
|
||||||
replace: 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;
|
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||||
if (!isBootstrapTailInit) {
|
if (!isBootstrapTailInit) {
|
||||||
return { replace: false, catchUpCursor: null };
|
return { replace: false, catchUpCursor: null };
|
||||||
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
replace: true,
|
replace: true,
|
||||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
|
|||||||
isInitializing,
|
isInitializing,
|
||||||
initRequestDirection,
|
initRequestDirection,
|
||||||
responseDirection,
|
responseDirection,
|
||||||
reset,
|
|
||||||
}: {
|
}: {
|
||||||
hasActiveInitDeferred: boolean;
|
hasActiveInitDeferred: boolean;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
initRequestDirection: InitRequestDirection;
|
initRequestDirection: InitRequestDirection;
|
||||||
responseDirection: TimelineDirection;
|
responseDirection: TimelineDirection;
|
||||||
reset: boolean;
|
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!hasActiveInitDeferred || !isInitializing) {
|
if (!hasActiveInitDeferred || !isInitializing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (reset) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return responseDirection === initRequestDirection;
|
return responseDirection === initRequestDirection;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("accepts contiguous forward seq", () => {
|
it("accepts contiguous forward seq", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
cursor: { endSeq: 4 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 5,
|
seq: 5,
|
||||||
}),
|
}),
|
||||||
).toBe("accept");
|
).toBe("accept");
|
||||||
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("drops stale seq older than the current end", () => {
|
it("drops stale seq older than the current end", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
cursor: { endSeq: 8 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 7,
|
seq: 7,
|
||||||
}),
|
}),
|
||||||
).toBe("drop_stale");
|
).toBe("drop_stale");
|
||||||
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("drops duplicate replay seq equal to the current end", () => {
|
it("drops duplicate replay seq equal to the current end", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
cursor: { endSeq: 8 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 8,
|
seq: 8,
|
||||||
}),
|
}),
|
||||||
).toBe("drop_stale");
|
).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", () => {
|
it("initializes when cursor is null", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: null,
|
cursor: null,
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 1,
|
seq: 1,
|
||||||
}),
|
}),
|
||||||
).toBe("init");
|
).toBe("init");
|
||||||
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
|
|||||||
it("classifies forward gaps", () => {
|
it("classifies forward gaps", () => {
|
||||||
expect(
|
expect(
|
||||||
classifySessionTimelineSeq({
|
classifySessionTimelineSeq({
|
||||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
cursor: { endSeq: 4 },
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 9,
|
seq: 9,
|
||||||
}),
|
}),
|
||||||
).toBe("gap");
|
).toBe("gap");
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
export type SessionTimelineSeqCursor =
|
export type SessionTimelineSeqCursor =
|
||||||
| {
|
| {
|
||||||
epoch: string;
|
|
||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||||
|
|
||||||
export function classifySessionTimelineSeq({
|
export function classifySessionTimelineSeq({
|
||||||
cursor,
|
cursor,
|
||||||
epoch,
|
|
||||||
seq,
|
seq,
|
||||||
}: {
|
}: {
|
||||||
cursor: SessionTimelineSeqCursor;
|
cursor: SessionTimelineSeqCursor;
|
||||||
epoch: string;
|
|
||||||
seq: number;
|
seq: number;
|
||||||
}): SessionTimelineSeqDecision {
|
}): SessionTimelineSeqDecision {
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
return "init";
|
return "init";
|
||||||
}
|
}
|
||||||
if (cursor.epoch !== epoch) {
|
|
||||||
return "drop_epoch";
|
|
||||||
}
|
|
||||||
if (seq <= cursor.endSeq) {
|
if (seq <= cursor.endSeq) {
|
||||||
return "drop_stale";
|
return "drop_stale";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
|
|||||||
onlineServerIds?: string[];
|
onlineServerIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseAgentFormStateResult = {
|
export type UseAgentFormStateResult = {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
setSelectedServerId: (value: string | null) => void;
|
setSelectedServerId: (value: string | null) => void;
|
||||||
setSelectedServerIdFromUser: (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";
|
import { __private__ } from "./use-agent-initialization";
|
||||||
|
|
||||||
describe("useAgentInitialization timeline request policy", () => {
|
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(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: {
|
cursor: {
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 42,
|
seq: 42,
|
||||||
},
|
},
|
||||||
hasAuthoritativeHistory: false,
|
hasAuthoritativeHistory: false,
|
||||||
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 200,
|
limit: 200,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: null,
|
cursor: null,
|
||||||
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 200,
|
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(
|
expect(
|
||||||
__private__.deriveInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: {
|
cursor: {
|
||||||
epoch: "epoch-1",
|
|
||||||
seq: 42,
|
seq: 42,
|
||||||
},
|
},
|
||||||
hasAuthoritativeHistory: true,
|
hasAuthoritativeHistory: true,
|
||||||
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: "epoch-1", seq: 42 },
|
cursor: { seq: 42 },
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 0,
|
limit: 0,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
|||||||
const hasAuthoritativeHistory =
|
const hasAuthoritativeHistory =
|
||||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||||
const timelineRequest = deriveInitialTimelineRequest({
|
const timelineRequest = deriveInitialTimelineRequest({
|
||||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||||
hasAuthoritativeHistory,
|
hasAuthoritativeHistory,
|
||||||
initialTimelineLimit,
|
initialTimelineLimit,
|
||||||
});
|
});
|
||||||
@@ -107,7 +107,6 @@ export function useAgentInitialization({
|
|||||||
await client.fetchAgentTimeline(agentId, {
|
await client.fetchAgentTimeline(agentId, {
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: initialTimelineLimit,
|
limit: initialTimelineLimit,
|
||||||
projection: "canonical",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAgentInitializing(agentId, false);
|
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 { 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 { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
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 {
|
interface AgentInputDraft {
|
||||||
text: string;
|
text: string;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
@@ -11,6 +46,7 @@ interface AgentInputDraft {
|
|||||||
setImages: (updater: ImageUpdater) => void;
|
setImages: (updater: ImageUpdater) => void;
|
||||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
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 [text, setText] = useState("");
|
||||||
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
|||||||
});
|
});
|
||||||
}, [draftKey, images, text]);
|
}, [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 {
|
return {
|
||||||
text,
|
text,
|
||||||
setText,
|
setText,
|
||||||
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
|||||||
setImages,
|
setImages,
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
|
composerState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __private__ = {
|
||||||
|
resolveDraftKey,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
buildDraftComposerCommandConfig,
|
||||||
|
buildDraftStatusControls,
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id,
|
id,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
|
terminal: false,
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export interface AgentScreenAgent {
|
|||||||
id: string;
|
id: string;
|
||||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
lastError?: string | null;
|
||||||
|
terminalExit?: {
|
||||||
|
command: string;
|
||||||
|
message: string;
|
||||||
|
exitCode: number | null;
|
||||||
|
signal: number | null;
|
||||||
|
outputLines: string[];
|
||||||
|
} | null;
|
||||||
projectPlacement?: {
|
projectPlacement?: {
|
||||||
checkout?: {
|
checkout?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
|
|||||||
serverId,
|
serverId,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
|
terminal: agent.terminal,
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
lastActivityAt: agent.lastActivityAt,
|
lastActivityAt: agent.lastActivityAt,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id: input?.id ?? "agent-1",
|
id: input?.id ?? "agent-1",
|
||||||
provider: input?.provider ?? "codex",
|
provider: input?.provider ?? "codex",
|
||||||
|
terminal: input?.terminal ?? false,
|
||||||
status: input?.status ?? "idle",
|
status: input?.status ?? "idle",
|
||||||
createdAt: input?.createdAt ?? timestamp,
|
createdAt: input?.createdAt ?? timestamp,
|
||||||
updatedAt: input?.updatedAt ?? timestamp,
|
updatedAt: input?.updatedAt ?? timestamp,
|
||||||
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: input?.currentModeId ?? null,
|
currentModeId: input?.currentModeId ?? null,
|
||||||
availableModes: input?.availableModes ?? [],
|
availableModes: input?.availableModes ?? [],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
|
|||||||
serverId: params.serverId,
|
serverId: params.serverId,
|
||||||
serverLabel: params.serverLabel,
|
serverLabel: params.serverLabel,
|
||||||
title: source.title ?? null,
|
title: source.title ?? null,
|
||||||
|
terminal: source.terminal,
|
||||||
status: source.status,
|
status: source.status,
|
||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: source.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { TextInput } from "react-native";
|
import type { TextInput } from "react-native";
|
||||||
import { router, usePathname, type Href } from "expo-router";
|
import { router, usePathname, type Href } from "expo-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
@@ -11,13 +12,18 @@ import {
|
|||||||
clearCommandCenterFocusRestoreElement,
|
clearCommandCenterFocusRestoreElement,
|
||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostSettingsRoute,
|
||||||
|
parseServerIdFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
|
|
||||||
@@ -215,9 +221,17 @@ export function useCommandCenter() {
|
|||||||
// Don't restore focus back to the prior element after we navigate.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: agent.cwd,
|
||||||
|
});
|
||||||
|
if (!workspaceId) {
|
||||||
|
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId: agent.id },
|
target: { kind: "agent", agentId: agent.id },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ type CreateRequestContext = {
|
|||||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
getPendingServerId: () => string | null;
|
getPendingServerId: () => string | null;
|
||||||
|
allowEmptyText?: boolean;
|
||||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||||
onCreateStart?: () => void;
|
onCreateStart?: () => void;
|
||||||
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
|||||||
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId,
|
getPendingServerId,
|
||||||
|
allowEmptyText = false,
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
onBeforeSubmit,
|
onBeforeSubmit,
|
||||||
onCreateStart,
|
onCreateStart,
|
||||||
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
return EMPTY_STREAM_ITEMS;
|
return EMPTY_STREAM_ITEMS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
|
||||||
|
return EMPTY_STREAM_ITEMS;
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||||
|
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
if (!trimmedPrompt) {
|
if (!trimmedPrompt && !allowEmptyText) {
|
||||||
const error = new Error("Initial prompt is required");
|
const error = new Error("Initial prompt is required");
|
||||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
setPendingCreateAttempt,
|
setPendingCreateAttempt,
|
||||||
updatePendingAgentId,
|
updatePendingAgentId,
|
||||||
validateBeforeSubmit,
|
validateBeforeSubmit,
|
||||||
|
allowEmptyText,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
139
packages/app/src/hooks/use-open-project.test.ts
Normal file
139
packages/app/src/hooks/use-open-project.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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,
|
||||||
|
workspaceDirectory: 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,
|
||||||
|
workspaceDirectory: 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 { useCallback } from "react";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { router } from "expo-router";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
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> {
|
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
|
||||||
const normalizedServerId = serverId?.trim() ?? "";
|
const normalizedServerId = serverId?.trim() ?? "";
|
||||||
const toast = useToast();
|
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
const trimmedPath = path.trim();
|
return openProjectDirectly({
|
||||||
if (!trimmedPath || !client || !normalizedServerId) {
|
serverId: normalizedServerId,
|
||||||
return false;
|
projectPath: path,
|
||||||
}
|
isConnected,
|
||||||
|
client,
|
||||||
try {
|
mergeWorkspaces,
|
||||||
const payload = await client.openProject(trimmedPath);
|
setHasHydratedWorkspaces,
|
||||||
if (payload.error || !payload.workspace) {
|
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||||
throw new Error(payload.error || "Failed to open project");
|
replaceRoute: (route) => {
|
||||||
}
|
router.replace(route as any);
|
||||||
|
},
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
|
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ function workspace(
|
|||||||
Partial<
|
Partial<
|
||||||
Pick<
|
Pick<
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
| "projectDisplayName"
|
||||||
|
| "projectRootPath"
|
||||||
|
| "workspaceDirectory"
|
||||||
|
| "projectKind"
|
||||||
|
| "workspaceKind"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
@@ -28,8 +32,9 @@ function workspace(
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||||
projectRootPath: input.projectRootPath ?? input.id,
|
projectRootPath: input.projectRootPath ?? input.id,
|
||||||
|
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
workspaceKind: input.workspaceKind ?? "checkout",
|
||||||
name: input.name,
|
name: input.name,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
activityAt: input.activityAt,
|
activityAt: input.activityAt,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export interface SidebarWorkspaceEntry {
|
|||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
projectRootPath?: string;
|
||||||
|
workspaceDirectory?: string;
|
||||||
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
activityAt: Date | null;
|
activityAt: Date | null;
|
||||||
@@ -118,7 +121,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
|||||||
projectName:
|
projectName:
|
||||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||||
projectKind: workspace.projectKind,
|
projectKind: workspace.projectKind,
|
||||||
iconWorkingDir: workspace.projectRootPath || workspace.id,
|
iconWorkingDir: workspace.projectRootPath,
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
totalWorkspaces: 0,
|
totalWorkspaces: 0,
|
||||||
@@ -130,6 +133,9 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
|||||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
workspaceKind: workspace.workspaceKind,
|
workspaceKind: workspace.workspaceKind,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
activityAt: workspace.activityAt,
|
activityAt: workspace.activityAt,
|
||||||
@@ -248,10 +254,11 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toWorkspaceDescriptor(payload: {
|
function toWorkspaceDescriptor(payload: {
|
||||||
id: string;
|
id: number;
|
||||||
projectId: string;
|
projectId: number;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-new",
|
id: "workspace-tab-new",
|
||||||
section: "tabs-panes",
|
section: "tabs-panes",
|
||||||
label: "New agent tab",
|
label: "New tab",
|
||||||
keys: ["mod", "T"],
|
keys: ["mod", "T"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -216,7 +216,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
help: {
|
help: {
|
||||||
id: "workspace-tab-new",
|
id: "workspace-tab-new",
|
||||||
section: "tabs-panes",
|
section: "tabs-panes",
|
||||||
label: "New agent tab",
|
label: "New tab",
|
||||||
keys: ["mod", "T"],
|
keys: ["mod", "T"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import ReanimatedAnimated from "react-native-reanimated";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { Bot } from "lucide-react-native";
|
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
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 { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
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 { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +26,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
|
||||||
import {
|
import {
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeConnectionStatus,
|
useHostRuntimeConnectionStatus,
|
||||||
@@ -96,7 +95,7 @@ function useAgentPanelDescriptor(
|
|||||||
);
|
);
|
||||||
const provider = descriptorState.provider;
|
const provider = descriptorState.provider;
|
||||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
const icon = getProviderIcon(provider);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: label ?? "",
|
label: label ?? "",
|
||||||
@@ -155,6 +154,12 @@ function isNotFoundErrorMessage(message: string): boolean {
|
|||||||
return /agent not found|not found/i.test(message);
|
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({
|
function AgentPanelContent({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -227,6 +232,219 @@ function AgentPanelBody({
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
connectionStatus: HostRuntimeConnectionStatus;
|
connectionStatus: HostRuntimeConnectionStatus;
|
||||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
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 { theme } = useUnistyles();
|
||||||
const panelToast = useToastHost();
|
const panelToast = useToastHost();
|
||||||
@@ -241,12 +459,12 @@ function AgentPanelBody({
|
|||||||
routeKey: string;
|
routeKey: string;
|
||||||
reason: "initial-entry" | "resume";
|
reason: "initial-entry" | "resume";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const agentInputDraft = useAgentInputDraft(
|
const agentInputDraft = useAgentInputDraft({
|
||||||
buildDraftStoreKey({
|
draftKey: buildDraftStoreKey({
|
||||||
serverId,
|
serverId,
|
||||||
agentId: agentId ?? "__pending__",
|
agentId: agentId ?? "__pending__",
|
||||||
}),
|
}),
|
||||||
);
|
});
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
addImagesRef.current?.(files);
|
addImagesRef.current?.(files);
|
||||||
@@ -262,8 +480,11 @@ function AgentPanelBody({
|
|||||||
return {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
|
terminal: agent?.terminal ?? false,
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
cwd: agent?.cwd ?? null,
|
cwd: agent?.cwd ?? null,
|
||||||
|
lastError: agent?.lastError ?? null,
|
||||||
|
terminalExit: agent?.terminalExit ?? null,
|
||||||
archivedAt: agent?.archivedAt ?? null,
|
archivedAt: agent?.archivedAt ?? null,
|
||||||
requiresAttention: agent?.requiresAttention ?? false,
|
requiresAttention: agent?.requiresAttention ?? false,
|
||||||
attentionReason: agent?.attentionReason ?? null,
|
attentionReason: agent?.attentionReason ?? null,
|
||||||
@@ -494,6 +715,8 @@ function AgentPanelBody({
|
|||||||
id: agentState.id,
|
id: agentState.id,
|
||||||
status: agentState.status,
|
status: agentState.status,
|
||||||
cwd: agentState.cwd,
|
cwd: agentState.cwd,
|
||||||
|
lastError: agentState.lastError ?? null,
|
||||||
|
terminalExit: agentState.terminalExit ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -615,98 +838,6 @@ function AgentPanelBody({
|
|||||||
setMissingAgentState({ kind: "idle" });
|
setMissingAgentState({ kind: "idle" });
|
||||||
}, [agentId, serverId]);
|
}, [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 =
|
const isHistoryRefreshCatchingUp =
|
||||||
viewState.tag === "ready" &&
|
viewState.tag === "ready" &&
|
||||||
viewState.sync.status === "catching_up" &&
|
viewState.sync.status === "catching_up" &&
|
||||||
@@ -781,7 +912,7 @@ function AgentPanelBody({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||||
<AgentInputArea
|
<Composer
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={isPaneFocused}
|
isInputActive={isPaneFocused}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
import { FileText } from "lucide-react-native";
|
import { FileText } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { FilePane } from "@/components/file-pane";
|
import { FilePane } from "@/components/file-pane";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { resolveWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||||
|
|
||||||
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||||
@@ -17,8 +20,27 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
|||||||
|
|
||||||
function FilePanel() {
|
function FilePanel() {
|
||||||
const { serverId, workspaceId, target } = usePaneContext();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
|
const authority = useSessionStore((state) =>
|
||||||
|
resolveWorkspaceExecutionAuthority({
|
||||||
|
workspaces: state.sessions[serverId]?.workspaces,
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
invariant(target.kind === "file", "FilePanel requires file target");
|
invariant(target.kind === "file", "FilePanel requires file target");
|
||||||
return <FilePane serverId={serverId} workspaceRoot={workspaceId} filePath={target.path} />;
|
if (!authority) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
||||||
|
<Text>Workspace execution directory not found.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FilePane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceRoot={authority.workspaceDirectory}
|
||||||
|
filePath={target.path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||||
|
|||||||
485
packages/app/src/panels/launcher-panel.tsx
Normal file
485
packages/app/src/panels/launcher-panel.tsx
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
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";
|
||||||
|
import {
|
||||||
|
getWorkspaceExecutionAuthority,
|
||||||
|
requireWorkspaceRecordId,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
|
|
||||||
|
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 workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||||
|
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
||||||
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
|
: null;
|
||||||
|
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 || !workspaceDirectory) {
|
||||||
|
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workspaceAuthority.ok) {
|
||||||
|
setErrorMessage(workspaceAuthority.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId);
|
||||||
|
|
||||||
|
setPendingAction(providerId);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
provider: providerId,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
workspaceId: persistedWorkspaceId,
|
||||||
|
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,
|
||||||
|
workspaceAuthority,
|
||||||
|
workspaceDirectory,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDraftTab = useCallback(() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setPendingAction("draft");
|
||||||
|
retargetCurrentTab({
|
||||||
|
kind: "draft",
|
||||||
|
draftId: generateDraftId(),
|
||||||
|
});
|
||||||
|
setPendingAction(null);
|
||||||
|
}, [retargetCurrentTab]);
|
||||||
|
|
||||||
|
const openTerminalTab = useCallback(async () => {
|
||||||
|
if (!client || !isConnected || !workspaceDirectory) {
|
||||||
|
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAction("terminal");
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await client.createTerminal(workspaceDirectory);
|
||||||
|
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, workspaceDirectory]);
|
||||||
|
|
||||||
|
const actionsDisabled = pendingAction !== null;
|
||||||
|
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.content, styles.loadingContent]}>
|
||||||
|
<Text style={styles.errorText}>
|
||||||
|
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
loadingContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
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 { agentPanelRegistration } from "@/panels/agent-panel";
|
||||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||||
import { filePanelRegistration } from "@/panels/file-panel";
|
import { filePanelRegistration } from "@/panels/file-panel";
|
||||||
|
import { launcherPanelRegistration } from "@/panels/launcher-panel";
|
||||||
import { registerPanel } from "@/panels/panel-registry";
|
import { registerPanel } from "@/panels/panel-registry";
|
||||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||||
|
|
||||||
@@ -14,5 +15,6 @@ export function ensurePanelsRegistered(): void {
|
|||||||
registerPanel(agentPanelRegistration);
|
registerPanel(agentPanelRegistration);
|
||||||
registerPanel(terminalPanelRegistration);
|
registerPanel(terminalPanelRegistration);
|
||||||
registerPanel(filePanelRegistration);
|
registerPanel(filePanelRegistration);
|
||||||
|
registerPanel(launcherPanelRegistration);
|
||||||
panelsRegistered = true;
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Terminal } from "lucide-react-native";
|
import { Terminal } from "lucide-react-native";
|
||||||
import { View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
@@ -8,6 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane";
|
|||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||||
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
@@ -24,14 +25,24 @@ function useTerminalPanelDescriptor(
|
|||||||
context: { serverId: string; workspaceId: string },
|
context: { serverId: string; workspaceId: string },
|
||||||
): PanelDescriptor {
|
): PanelDescriptor {
|
||||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||||
|
const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces);
|
||||||
|
const workspaceAuthority = getWorkspaceExecutionAuthority({
|
||||||
|
workspaces,
|
||||||
|
workspaceId: context.workspaceId,
|
||||||
|
});
|
||||||
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
|
: null;
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
|
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||||
enabled: Boolean(client && context.workspaceId),
|
enabled: Boolean(client && workspaceDirectory),
|
||||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
|
throw new Error(
|
||||||
|
workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return client.listTerminals(context.workspaceId);
|
return client.listTerminals(workspaceDirectory);
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
});
|
});
|
||||||
@@ -39,7 +50,7 @@ function useTerminalPanelDescriptor(
|
|||||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
|
||||||
subtitle: "Terminal",
|
subtitle: "Terminal",
|
||||||
titleState: "ready",
|
titleState: "ready",
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
@@ -50,16 +61,31 @@ function useTerminalPanelDescriptor(
|
|||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||||
|
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||||
|
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
||||||
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
|
: null;
|
||||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
return <View style={{ flex: 1 }} />;
|
return <View style={{ flex: 1 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
||||||
|
<Text>
|
||||||
|
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalPane
|
<TerminalPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
cwd={workspaceId}
|
cwd={workspaceDirectory}
|
||||||
terminalId={target.terminalId}
|
terminalId={target.terminalId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ function makeFetchAgentsEntry(input: {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -11,15 +11,14 @@ import Animated from "react-native-reanimated";
|
|||||||
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
|
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
|
||||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
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 { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
|
||||||
import {
|
import {
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
@@ -27,6 +26,7 @@ import {
|
|||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
@@ -50,6 +50,7 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
@@ -65,6 +66,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
|
supportsTerminalMode: false,
|
||||||
};
|
};
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
const PROVIDER_DEFINITION_MAP = new Map(
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||||
@@ -202,37 +204,45 @@ function DraftAgentScreenContent({
|
|||||||
return values;
|
return values;
|
||||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
|
}, [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 {
|
const {
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
setSelectedServerIdFromUser,
|
setSelectedServerIdFromUser,
|
||||||
selectedProvider,
|
providerDefinitions,
|
||||||
setProviderFromUser,
|
|
||||||
selectedMode,
|
|
||||||
setModeFromUser,
|
|
||||||
selectedModel,
|
|
||||||
setModelFromUser,
|
|
||||||
selectedThinkingOptionId,
|
|
||||||
setThinkingOptionFromUser,
|
|
||||||
workingDir,
|
workingDir,
|
||||||
setWorkingDirFromUser,
|
setWorkingDirFromUser,
|
||||||
providerDefinitions,
|
|
||||||
modeOptions,
|
modeOptions,
|
||||||
availableModels,
|
|
||||||
allProviderModels,
|
|
||||||
isAllModelsLoading,
|
|
||||||
availableThinkingOptions,
|
|
||||||
isModelLoading,
|
isModelLoading,
|
||||||
modelError,
|
modelError,
|
||||||
refreshProviderModels,
|
refreshProviderModels,
|
||||||
setProviderAndModelFromUser,
|
|
||||||
persistFormPreferences,
|
persistFormPreferences,
|
||||||
} = useAgentFormState({
|
effectiveModelId,
|
||||||
initialServerId: resolvedServerId ?? null,
|
effectiveThinkingOptionId,
|
||||||
initialValues,
|
commandDraftConfig,
|
||||||
isVisible,
|
statusControls,
|
||||||
isCreateFlow: true,
|
} = composerState;
|
||||||
onlineServerIds,
|
|
||||||
});
|
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = isCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
@@ -244,15 +254,6 @@ function DraftAgentScreenContent({
|
|||||||
(state) => state.activateExplorerTabForCheckout,
|
(state) => state.activateExplorerTabForCheckout,
|
||||||
);
|
);
|
||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
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">(
|
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
||||||
initialWorktreeMode,
|
initialWorktreeMode,
|
||||||
@@ -632,6 +633,16 @@ function DraftAgentScreenContent({
|
|||||||
isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true,
|
isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true,
|
||||||
};
|
};
|
||||||
}, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]);
|
}, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]);
|
||||||
|
const draftExplorerWorkspaceId = useSessionStore(
|
||||||
|
useCallback(
|
||||||
|
(state) =>
|
||||||
|
resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null,
|
||||||
|
workspaceDirectory: explorerCwd,
|
||||||
|
}),
|
||||||
|
[explorerCwd, selectedServerId],
|
||||||
|
),
|
||||||
|
);
|
||||||
const canOpenExplorer = draftExplorerCheckout !== null;
|
const canOpenExplorer = draftExplorerCheckout !== null;
|
||||||
const openExplorerForDraftCheckout = useCallback(() => {
|
const openExplorerForDraftCheckout = useCallback(() => {
|
||||||
if (!draftExplorerCheckout) {
|
if (!draftExplorerCheckout) {
|
||||||
@@ -743,47 +754,6 @@ function DraftAgentScreenContent({
|
|||||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||||
|
|
||||||
const createAgentClient = sessionClient;
|
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 {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -791,7 +761,7 @@ function DraftAgentScreenContent({
|
|||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
|
} = useDraftAgentCreateFlow<Agent, { id: string; workspaceId: string | null }>({
|
||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
getPendingServerId: () => selectedServerId,
|
getPendingServerId: () => selectedServerId,
|
||||||
validateBeforeSubmit: ({ text }) => {
|
validateBeforeSubmit: ({ text }) => {
|
||||||
@@ -817,7 +787,7 @@ function DraftAgentScreenContent({
|
|||||||
if (isModelLoading) {
|
if (isModelLoading) {
|
||||||
return "Model defaults are still loading";
|
return "Model defaults are still loading";
|
||||||
}
|
}
|
||||||
if (!effectiveDraftModelId) {
|
if (!effectiveModelId) {
|
||||||
return "No model is available for the selected provider";
|
return "No model is available for the selected provider";
|
||||||
}
|
}
|
||||||
if (isAttachWorktree && !selectedWorktreePath) {
|
if (isAttachWorktree && !selectedWorktreePath) {
|
||||||
@@ -850,15 +820,19 @@ function DraftAgentScreenContent({
|
|||||||
const cwd =
|
const cwd =
|
||||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||||
".";
|
".";
|
||||||
const provider = selectedProvider;
|
const provider = composerState.selectedProvider;
|
||||||
const model = effectiveDraftModelId || null;
|
const model = effectiveModelId || null;
|
||||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId =
|
||||||
|
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
|
? composerState.selectedMode
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
id: draftAgentIdRef.current,
|
id: draftAgentIdRef.current,
|
||||||
provider,
|
provider,
|
||||||
|
terminal: false,
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -887,14 +861,17 @@ function DraftAgentScreenContent({
|
|||||||
const resolvedWorkingDir =
|
const resolvedWorkingDir =
|
||||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
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 = {
|
const config: AgentSessionConfig = {
|
||||||
provider: selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||||
...(effectiveDraftThinkingOptionId
|
...(effectiveThinkingOptionId
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
? { thinkingOptionId: effectiveThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -942,20 +919,29 @@ function DraftAgentScreenContent({
|
|||||||
|
|
||||||
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
||||||
const configuredWorkingDir = config.cwd.trim();
|
const configuredWorkingDir = config.cwd.trim();
|
||||||
const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir;
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agentId: result.id,
|
agentId: result.id,
|
||||||
result: {
|
result: {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
cwd: workspaceId,
|
workspaceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onCreateSuccess: ({ result }) => {
|
onCreateSuccess: ({ result }) => {
|
||||||
|
if (!result.workspaceId) {
|
||||||
|
router.replace(
|
||||||
|
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId: selectedServerId as string,
|
serverId: selectedServerId as string,
|
||||||
workspaceId: result.cwd,
|
workspaceId: result.workspaceId,
|
||||||
target: { kind: "agent", agentId: result.id },
|
target: { kind: "agent", agentId: result.id },
|
||||||
});
|
});
|
||||||
router.replace(route as any);
|
router.replace(route as any);
|
||||||
@@ -1216,7 +1202,7 @@ function DraftAgentScreenContent({
|
|||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<View style={styles.inputAreaWrapper}>
|
<View style={styles.inputAreaWrapper}>
|
||||||
<AgentInputArea
|
<Composer
|
||||||
agentId={draftAgentIdRef.current}
|
agentId={draftAgentIdRef.current}
|
||||||
serverId={selectedServerId ?? ""}
|
serverId={selectedServerId ?? ""}
|
||||||
isInputActive={isFocused}
|
isInputActive={isFocused}
|
||||||
@@ -1230,24 +1216,9 @@ function DraftAgentScreenContent({
|
|||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={!isSubmitting}
|
autoFocus={!isSubmitting}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={commandDraftConfig}
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
...statusControls,
|
||||||
selectedProvider,
|
|
||||||
onSelectProvider: setProviderFromUser,
|
|
||||||
modeOptions,
|
|
||||||
selectedMode,
|
|
||||||
onSelectMode: setModeFromUser,
|
|
||||||
models: availableModels,
|
|
||||||
selectedModel,
|
|
||||||
onSelectModel: setModelFromUser,
|
|
||||||
isModelLoading,
|
|
||||||
allProviderModels,
|
|
||||||
isAllModelsLoading,
|
|
||||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
|
||||||
thinkingOptions: availableThinkingOptions,
|
|
||||||
selectedThinkingOptionId,
|
|
||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1257,7 +1228,7 @@ function DraftAgentScreenContent({
|
|||||||
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
|
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
|
||||||
<ExplorerSidebar
|
<ExplorerSidebar
|
||||||
serverId={explorerServerId}
|
serverId={explorerServerId}
|
||||||
workspaceId={draftExplorerCheckout.cwd}
|
workspaceId={draftExplorerWorkspaceId}
|
||||||
workspaceRoot={draftExplorerCheckout.cwd}
|
workspaceRoot={draftExplorerCheckout.cwd}
|
||||||
isGit={explorerIsGit}
|
isGit={explorerIsGit}
|
||||||
/>
|
/>
|
||||||
@@ -1280,7 +1251,7 @@ function DraftAgentScreenContent({
|
|||||||
{isMobile && explorerServerId && draftExplorerCheckout ? (
|
{isMobile && explorerServerId && draftExplorerCheckout ? (
|
||||||
<ExplorerSidebar
|
<ExplorerSidebar
|
||||||
serverId={explorerServerId}
|
serverId={explorerServerId}
|
||||||
workspaceId={draftExplorerCheckout.cwd}
|
workspaceId={draftExplorerWorkspaceId}
|
||||||
workspaceRoot={draftExplorerCheckout.cwd}
|
workspaceRoot={draftExplorerCheckout.cwd}
|
||||||
isGit={explorerIsGit}
|
isGit={explorerIsGit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function makeAgent(input: {
|
|||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
id: input.id,
|
id: input.id,
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
terminal: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
@@ -31,6 +32,7 @@ function makeAgent(input: {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsTerminalMode: false,
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -79,7 +81,7 @@ describe("workspace agent visibility", () => {
|
|||||||
|
|
||||||
const result = deriveWorkspaceAgentVisibility({
|
const result = deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents,
|
sessionAgents,
|
||||||
workspaceId,
|
workspaceDirectory: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
||||||
@@ -151,13 +153,33 @@ describe("workspace agent visibility", () => {
|
|||||||
|
|
||||||
const result = deriveWorkspaceAgentVisibility({
|
const result = deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents,
|
sessionAgents,
|
||||||
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||||
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
|
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
|
||||||
|
const sessionAgents = new Map<string, Agent>([
|
||||||
|
[
|
||||||
|
"terminal-agent",
|
||||||
|
makeAgent({
|
||||||
|
id: "terminal-agent",
|
||||||
|
cwd: "/tmp/workspace-lifecycle-main",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = deriveWorkspaceAgentVisibility({
|
||||||
|
sessionAgents,
|
||||||
|
workspaceDirectory: "/tmp/workspace-lifecycle-main",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"]));
|
||||||
|
expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"]));
|
||||||
|
});
|
||||||
|
|
||||||
describe("workspaceAgentVisibilityEqual", () => {
|
describe("workspaceAgentVisibilityEqual", () => {
|
||||||
it("returns true for identical sets", () => {
|
it("returns true for identical sets", () => {
|
||||||
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };
|
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility {
|
|||||||
|
|
||||||
export function deriveWorkspaceAgentVisibility(input: {
|
export function deriveWorkspaceAgentVisibility(input: {
|
||||||
sessionAgents: Map<string, Agent> | undefined;
|
sessionAgents: Map<string, Agent> | undefined;
|
||||||
workspaceId: string;
|
workspaceDirectory: string | null | undefined;
|
||||||
}): WorkspaceAgentVisibility {
|
}): WorkspaceAgentVisibility {
|
||||||
const { sessionAgents, workspaceId } = input;
|
const { sessionAgents, workspaceDirectory } = input;
|
||||||
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||||
if (!sessionAgents || !workspaceId) {
|
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
||||||
return {
|
return {
|
||||||
activeAgentIds: new Set<string>(),
|
activeAgentIds: new Set<string>(),
|
||||||
knownAgentIds: new Set<string>(),
|
knownAgentIds: new Set<string>(),
|
||||||
@@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
|||||||
const activeAgentIds = new Set<string>();
|
const activeAgentIds = new Set<string>();
|
||||||
const knownAgentIds = new Set<string>();
|
const knownAgentIds = new Set<string>();
|
||||||
for (const agent of sessionAgents.values()) {
|
for (const agent of sessionAgents.values()) {
|
||||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
|
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
knownAgentIds.add(agent.id);
|
knownAgentIds.add(agent.id);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
Columns2,
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Plus,
|
||||||
Rows2,
|
Rows2,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -46,11 +47,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
|||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||||
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
|
||||||
type NewTabSelection = {
|
|
||||||
optionId: NewTabOptionId;
|
|
||||||
paneId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface WorkspaceDesktopTabRowItem {
|
export interface WorkspaceDesktopTabRowItem {
|
||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
@@ -74,10 +70,8 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||||
newTabAgentOptionId: NewTabOptionId;
|
|
||||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
|
||||||
onSplitRight: () => void;
|
onSplitRight: () => void;
|
||||||
onSplitDown: () => void;
|
onSplitDown: () => void;
|
||||||
externalDndContext?: boolean;
|
externalDndContext?: boolean;
|
||||||
@@ -87,6 +81,9 @@ type WorkspaceDesktopTabsRowProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||||
|
if (tab.target.kind === "launcher") {
|
||||||
|
return "New Tab";
|
||||||
|
}
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
@@ -338,10 +335,8 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
onSelectNewTabOption,
|
onCreateLauncherTab,
|
||||||
newTabAgentOptionId,
|
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
onNewTerminalTab,
|
|
||||||
onSplitRight,
|
onSplitRight,
|
||||||
onSplitDown,
|
onSplitDown,
|
||||||
externalDndContext = false,
|
externalDndContext = false,
|
||||||
@@ -350,8 +345,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
showPaneSplitActions = true,
|
showPaneSplitActions = true,
|
||||||
}: WorkspaceDesktopTabsRowProps) {
|
}: WorkspaceDesktopTabsRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
|
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||||
const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
|
|
||||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
@@ -478,48 +472,22 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
testID="workspace-new-agent-tab"
|
testID="workspace-new-tab"
|
||||||
onPress={() =>
|
onPress={() => onCreateLauncherTab({ paneId })}
|
||||||
onSelectNewTabOption({
|
|
||||||
optionId: newTabAgentOptionId,
|
|
||||||
paneId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New agent tab"
|
accessibilityLabel="New tab"
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
styles.newTabActionButton,
|
styles.newTabActionButton,
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.newTabTooltipRow}>
|
<View style={styles.newTabTooltipRow}>
|
||||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
<Text style={styles.newTabTooltipText}>New tab</Text>
|
||||||
{newAgentTabKeys ? (
|
{newTabKeys ? (
|
||||||
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
<Shortcut chord={newTabKeys} 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} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</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,24 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import invariant from "tiny-invariant";
|
||||||
|
import { Composer } from "@/components/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
|
import {
|
||||||
|
getWorkspaceExecutionAuthority,
|
||||||
|
requireWorkspaceRecordId,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type {
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
AgentCapabilityFlags,
|
|
||||||
AgentSessionConfig,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
@@ -27,6 +29,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
|
supportsTerminalMode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceDraftAgentTabProps = {
|
type WorkspaceDraftAgentTabProps = {
|
||||||
@@ -50,66 +53,36 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}: WorkspaceDraftAgentTabProps) {
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||||
|
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
||||||
|
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
||||||
|
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
const draftInput = useAgentInputDraft(
|
const draftStoreKey = useMemo(
|
||||||
buildDraftStoreKey({
|
() =>
|
||||||
serverId,
|
buildDraftStoreKey({
|
||||||
agentId: tabId,
|
serverId,
|
||||||
draftId,
|
agentId: tabId,
|
||||||
}),
|
draftId,
|
||||||
|
}),
|
||||||
|
[draftId, serverId, tabId],
|
||||||
);
|
);
|
||||||
|
const draftInput = useAgentInputDraft(
|
||||||
const {
|
{
|
||||||
selectedProvider,
|
draftKey: draftStoreKey,
|
||||||
setProviderFromUser,
|
composer: {
|
||||||
selectedMode,
|
initialServerId: serverId,
|
||||||
setModeFromUser,
|
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
|
||||||
selectedModel,
|
isVisible: true,
|
||||||
setModelFromUser,
|
onlineServerIds: isConnected ? [serverId] : [],
|
||||||
selectedThinkingOptionId,
|
lockedWorkingDir: workspaceDirectory ?? undefined,
|
||||||
setThinkingOptionFromUser,
|
},
|
||||||
workingDir,
|
},
|
||||||
setWorkingDir,
|
);
|
||||||
providerDefinitions,
|
const composerState = draftInput.composerState;
|
||||||
modeOptions,
|
if (!composerState) {
|
||||||
availableModels,
|
throw new Error("Workspace draft composer state is required");
|
||||||
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 {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -124,36 +97,44 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return "Initial prompt is required";
|
return "Initial prompt is required";
|
||||||
}
|
}
|
||||||
if (providerDefinitions.length === 0) {
|
if (composerState.providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
if (isModelLoading) {
|
if (composerState.isModelLoading) {
|
||||||
return "Model defaults are still loading";
|
return "Model defaults are still loading";
|
||||||
}
|
}
|
||||||
if (!effectiveDraftModelId) {
|
if (!composerState.effectiveModelId) {
|
||||||
return "No model is available for the selected provider";
|
return "No model is available for the selected provider";
|
||||||
}
|
}
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return "Workspace directory not found";
|
||||||
|
}
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return "Host is not connected";
|
return "Host is not connected";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onBeforeSubmit: () => {
|
onBeforeSubmit: () => {
|
||||||
void persistFormPreferences();
|
void composerState.persistFormPreferences();
|
||||||
if (Platform.OS === "web") {
|
if (Platform.OS === "web") {
|
||||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
}
|
}
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
},
|
},
|
||||||
buildDraftAgent: (attempt) => {
|
buildDraftAgent: (attempt) => {
|
||||||
|
invariant(workspaceDirectory, "Workspace directory is required");
|
||||||
const now = attempt.timestamp;
|
const now = attempt.timestamp;
|
||||||
const model = effectiveDraftModelId || null;
|
const model = composerState.effectiveModelId || null;
|
||||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
const modeId =
|
||||||
|
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
|
? composerState.selectedMode
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
id: tabId,
|
id: tabId,
|
||||||
provider: selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
|
terminal: false,
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -164,34 +145,36 @@ export function WorkspaceDraftAgentTab({
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
persistence: null,
|
persistence: null,
|
||||||
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: workspaceId,
|
cwd: workspaceDirectory,
|
||||||
model,
|
model,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
createRequest: async ({ attempt, text, images }) => {
|
createRequest: async ({ attempt, text, images }) => {
|
||||||
|
invariant(workspaceDirectory, "Workspace directory is required");
|
||||||
|
invariant(workspaceExecutionAuthority, "Workspace authority is required");
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
const config = buildWorkspaceDraftAgentConfig({
|
||||||
const config: AgentSessionConfig = {
|
provider: composerState.selectedProvider,
|
||||||
provider: selectedProvider,
|
cwd: workspaceDirectory,
|
||||||
cwd: workspaceId,
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
...(modeId ? { modeId } : {}),
|
? { modeId: composerState.selectedMode }
|
||||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
|
||||||
...(effectiveDraftThinkingOptionId
|
|
||||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
|
||||||
: {}),
|
: {}),
|
||||||
};
|
model: composerState.effectiveModelId || undefined,
|
||||||
|
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
const result = await client.createAgent({
|
const result = await client.createAgent({
|
||||||
config,
|
config,
|
||||||
initialPrompt: text,
|
workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId),
|
||||||
|
...(text ? { initialPrompt: text } : {}),
|
||||||
clientMessageId: attempt.clientMessageId,
|
clientMessageId: attempt.clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
});
|
});
|
||||||
@@ -206,25 +189,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[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
addImagesRef.current?.(files);
|
addImagesRef.current?.(files);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -265,11 +229,12 @@ export function WorkspaceDraftAgentTab({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.inputAreaWrapper}>
|
<View style={styles.inputAreaWrapper}>
|
||||||
<AgentInputArea
|
<Composer
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isInputActive={isPaneFocused}
|
isInputActive={isPaneFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
|
allowEmptySubmit={false}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={draftInput.text}
|
value={draftInput.text}
|
||||||
@@ -279,24 +244,9 @@ export function WorkspaceDraftAgentTab({
|
|||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={composerState.commandDraftConfig}
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
...composerState.statusControls,
|
||||||
selectedProvider,
|
|
||||||
onSelectProvider: setProviderFromUser,
|
|
||||||
modeOptions,
|
|
||||||
selectedMode,
|
|
||||||
onSelectMode: setModeFromUser,
|
|
||||||
models: availableModels,
|
|
||||||
selectedModel,
|
|
||||||
onSelectModel: setModelFromUser,
|
|
||||||
isModelLoading,
|
|
||||||
allProviderModels,
|
|
||||||
isAllModelsLoading,
|
|
||||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
|
||||||
thinkingOptions: availableThinkingOptions,
|
|
||||||
selectedThinkingOptionId,
|
|
||||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
|
||||||
disabled: isSubmitting,
|
disabled: isSubmitting,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
|
||||||
|
|
||||||
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,7 +6,7 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
|
|||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
title: input.workspace.name,
|
title: input.workspace.name,
|
||||||
subtitle: projectDisplayNameFromProjectId(input.workspace.projectId),
|
subtitle: input.workspace.projectDisplayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function buildWorkspacePaneContentModel({
|
|||||||
const registration = getPanelRegistration(tab.kind);
|
const registration = getPanelRegistration(tab.kind);
|
||||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||||
return {
|
return {
|
||||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}:${tab.kind}`,
|
||||||
Component: registration.component,
|
Component: registration.component,
|
||||||
paneContextValue: {
|
paneContextValue: {
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
|
|||||||
@@ -62,8 +62,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
|||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
@@ -81,6 +79,10 @@ import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-arc
|
|||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
|
import {
|
||||||
|
resolveWorkspaceExecutionAuthority,
|
||||||
|
resolveWorkspaceRouteId,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabPresentationResolver,
|
WorkspaceTabPresentationResolver,
|
||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
@@ -98,7 +100,6 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-header-source";
|
} from "@/screens/workspace/workspace-header-source";
|
||||||
import {
|
import {
|
||||||
deriveWorkspaceAgentVisibility,
|
deriveWorkspaceAgentVisibility,
|
||||||
shouldPruneWorkspaceAgentTab,
|
|
||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
} from "@/screens/workspace/workspace-agent-visibility";
|
} from "@/screens/workspace/workspace-agent-visibility";
|
||||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||||
@@ -117,11 +118,7 @@ import { findAdjacentPane } from "@/utils/split-navigation";
|
|||||||
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
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_UI_TABS: WorkspaceTab[] = [];
|
||||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
|
||||||
const EMPTY_SET = new Set<string>();
|
const EMPTY_SET = new Set<string>();
|
||||||
|
|
||||||
type WorkspaceScreenProps = {
|
type WorkspaceScreenProps = {
|
||||||
@@ -146,6 +143,9 @@ function decodeSegment(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||||
|
if (tab.target.kind === "launcher") {
|
||||||
|
return "New Tab";
|
||||||
|
}
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||||
|
if (tab.target.kind === "launcher") {
|
||||||
|
return "New Tab";
|
||||||
|
}
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return "New Agent";
|
return "New Agent";
|
||||||
}
|
}
|
||||||
@@ -585,7 +588,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
|
|
||||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||||
const normalizedWorkspaceId =
|
const normalizedWorkspaceId =
|
||||||
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
resolveWorkspaceRouteId({
|
||||||
|
routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId),
|
||||||
|
}) ?? "";
|
||||||
|
const sessionWorkspaces = useSessionStore(
|
||||||
|
(state) => state.sessions[normalizedServerId]?.workspaces,
|
||||||
|
);
|
||||||
|
|
||||||
const workspaceTerminalScopeKey =
|
const workspaceTerminalScopeKey =
|
||||||
normalizedServerId && normalizedWorkspaceId
|
normalizedServerId && normalizedWorkspaceId
|
||||||
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
||||||
@@ -597,43 +606,53 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
|
const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null;
|
||||||
|
const workspaceAuthority = useMemo(
|
||||||
|
() =>
|
||||||
|
resolveWorkspaceExecutionAuthority({
|
||||||
|
workspaces: sessionWorkspaces,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
[normalizedWorkspaceId, sessionWorkspaces],
|
||||||
|
);
|
||||||
|
const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null;
|
||||||
|
const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority);
|
||||||
|
|
||||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) =>
|
||||||
deriveWorkspaceAgentVisibility({
|
deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceDirectory,
|
||||||
}),
|
}),
|
||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
);
|
);
|
||||||
|
|
||||||
const terminalsQueryKey = useMemo(
|
const terminalsQueryKey = useMemo(
|
||||||
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
|
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
[normalizedServerId, workspaceDirectory],
|
||||||
);
|
);
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: terminalsQueryKey,
|
queryKey: terminalsQueryKey,
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
Boolean(workspaceDirectory),
|
||||||
isAbsolutePath(normalizedWorkspaceId),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.listTerminals(normalizedWorkspaceId);
|
return await client.listTerminals(workspaceDirectory);
|
||||||
},
|
},
|
||||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||||
});
|
});
|
||||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async (input?: { paneId?: string }) => {
|
mutationFn: async (input?: { paneId?: string }) => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.createTerminal(normalizedWorkspaceId);
|
return await client.createTerminal(workspaceDirectory);
|
||||||
},
|
},
|
||||||
onSuccess: (payload, input) => {
|
onSuccess: (payload, input) => {
|
||||||
const createdTerminal = payload.terminal;
|
const createdTerminal = payload.terminal;
|
||||||
@@ -643,8 +662,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
terminals: current?.terminals ?? [],
|
terminals: current?.terminals ?? [],
|
||||||
terminal: createdTerminal,
|
terminal: createdTerminal,
|
||||||
});
|
});
|
||||||
|
const cwd = current?.cwd ?? workspaceDirectory;
|
||||||
return {
|
return {
|
||||||
cwd: current?.cwd ?? normalizedWorkspaceId,
|
...(cwd ? { cwd } : {}),
|
||||||
terminals: nextTerminals,
|
terminals: nextTerminals,
|
||||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||||
};
|
};
|
||||||
@@ -687,7 +707,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !isAbsolutePath(normalizedWorkspaceId)) {
|
if (!client || !isConnected || !workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +715,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (message.type !== "terminals_changed") {
|
if (message.type !== "terminals_changed") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.cwd !== normalizedWorkspaceId) {
|
if (message.payload.cwd !== workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,32 +726,30 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeChanged();
|
unsubscribeChanged();
|
||||||
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||||
};
|
};
|
||||||
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||||
|
|
||||||
const checkoutQuery = useQuery({
|
const checkoutQuery = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
queryKey: checkoutStatusQueryKey(
|
||||||
|
normalizedServerId,
|
||||||
|
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
||||||
|
),
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
Boolean(workspaceDirectory),
|
||||||
isAbsolutePath(normalizedWorkspaceId),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return (await client.getCheckoutStatus(normalizedWorkspaceId)) as CheckoutStatusPayload;
|
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||||
},
|
},
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspaceDescriptor = useSessionStore(
|
|
||||||
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore(
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||||
);
|
);
|
||||||
@@ -761,15 +779,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||||
if (!normalizedServerId || !isAbsolutePath(normalizedWorkspaceId)) {
|
if (!normalizedServerId || !workspaceDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
cwd: normalizedWorkspaceId,
|
cwd: workspaceDirectory,
|
||||||
isGit: isGitCheckout,
|
isGit: isGitCheckout,
|
||||||
};
|
};
|
||||||
}, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]);
|
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||||
@@ -829,10 +847,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
[workspaceLayout],
|
[workspaceLayout],
|
||||||
);
|
);
|
||||||
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
|
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||||
|
const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab);
|
||||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
|
||||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
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 splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||||
@@ -840,11 +861,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const paneFocusSuppressedRef = useRef(false);
|
const paneFocusSuppressedRef = useRef(false);
|
||||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
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 pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const { closingTabIds, closeTab } = useCloseTabs();
|
const { closingTabIds, closeTab } = useCloseTabs();
|
||||||
const closeWorkspaceTabWithCleanup = useCallback(
|
const closeWorkspaceTabWithCleanup = useCallback(
|
||||||
@@ -948,7 +964,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
|
||||||
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
||||||
if (tab.target.kind !== "draft") {
|
if (tab.target.kind !== "draft") {
|
||||||
return false;
|
return false;
|
||||||
@@ -957,56 +972,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const agentId of workspaceAgentVisibility.activeAgentIds) {
|
reconcileWorkspaceTabs(persistenceKey, {
|
||||||
const representedByTarget = uiTabs.some(
|
agentsHydrated: hasHydratedAgents,
|
||||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === agentId,
|
terminalsHydrated: terminalsQuery.isSuccess,
|
||||||
);
|
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
|
||||||
const representedByDeterministicTabId = uiTabs.some(
|
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||||
(tab) => tab.tabId === `agent_${agentId}`,
|
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
|
||||||
);
|
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||||
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" &&
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
closeWorkspaceTabWithCleanup,
|
|
||||||
ensureWorkspaceTab,
|
|
||||||
hasHydratedAgents,
|
hasHydratedAgents,
|
||||||
pendingByDraftId,
|
pendingByDraftId,
|
||||||
pinnedAgentIds,
|
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
|
reconcileWorkspaceTabs,
|
||||||
terminals,
|
terminals,
|
||||||
terminalsQuery.isSuccess,
|
terminalsQuery.isSuccess,
|
||||||
uiTabs,
|
uiTabs,
|
||||||
@@ -1016,13 +994,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const activeTabId = focusedPaneTabState.activeTabId;
|
const activeTabId = focusedPaneTabState.activeTabId;
|
||||||
const activeTab = focusedPaneTabState.activeTab;
|
const activeTab = focusedPaneTabState.activeTab;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeTabId || !persistenceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
|
||||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
|
||||||
|
|
||||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||||
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
|
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||||
[focusedPaneTabState.tabs],
|
[focusedPaneTabState.tabs],
|
||||||
@@ -1134,17 +1105,36 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
openWorkspaceDraftTab();
|
openWorkspaceDraftTab();
|
||||||
}, [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(
|
const handleCreateTerminal = useCallback(
|
||||||
(input?: { paneId?: string }) => {
|
(input?: { paneId?: string }) => {
|
||||||
if (createTerminalMutation.isPending) {
|
if (createTerminalMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
if (!workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createTerminalMutation.mutate(input);
|
createTerminalMutation.mutate(input);
|
||||||
},
|
},
|
||||||
[createTerminalMutation, normalizedWorkspaceId],
|
[createTerminalMutation, workspaceDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectSwitcherTab = useCallback(
|
const handleSelectSwitcherTab = useCallback(
|
||||||
@@ -1154,21 +1144,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
[navigateToTabId],
|
[navigateToTabId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectNewTabOption = useCallback(
|
const handleCreateLauncherSplit = 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(
|
|
||||||
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -1179,10 +1155,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
focusWorkspacePane(persistenceKey, paneId);
|
handleCreateLauncherTab({ paneId });
|
||||||
openWorkspaceDraftTab();
|
|
||||||
},
|
},
|
||||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey, splitWorkspacePaneEmpty],
|
[handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty],
|
||||||
);
|
);
|
||||||
|
|
||||||
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
||||||
@@ -1297,29 +1272,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
[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(
|
const handleCopyAgentId = useCallback(
|
||||||
async (agentId: string) => {
|
async (agentId: string) => {
|
||||||
if (!agentId) return;
|
if (!agentId) return;
|
||||||
@@ -1366,18 +1318,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
if (!isAbsolutePath(normalizedWorkspaceId)) {
|
if (!workspaceDirectory) {
|
||||||
toast.error("Workspace path not available");
|
toast.error("Workspace path not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Clipboard.setStringAsync(normalizedWorkspaceId);
|
await Clipboard.setStringAsync(workspaceDirectory);
|
||||||
toast.copied("Workspace path");
|
toast.copied("Workspace path");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Copy failed");
|
toast.error("Copy failed");
|
||||||
}
|
}
|
||||||
}, [normalizedWorkspaceId, toast]);
|
}, [toast, workspaceDirectory]);
|
||||||
|
|
||||||
const handleCopyBranchName = useCallback(async () => {
|
const handleCopyBranchName = useCallback(async () => {
|
||||||
if (!currentBranchName) {
|
if (!currentBranchName) {
|
||||||
@@ -1535,7 +1487,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
case "workspace.tab.new":
|
case "workspace.tab.new":
|
||||||
handleCreateDraftTab();
|
handleCreateLauncherTab();
|
||||||
return true;
|
return true;
|
||||||
case "workspace.terminal.new":
|
case "workspace.terminal.new":
|
||||||
handleCreateTerminal();
|
handleCreateTerminal();
|
||||||
@@ -1568,7 +1520,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
[
|
||||||
|
activeTabId,
|
||||||
|
handleCloseTabById,
|
||||||
|
handleCreateLauncherTab,
|
||||||
|
handleCreateTerminal,
|
||||||
|
navigateToTabId,
|
||||||
|
tabs,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkspacePaneAction = useCallback(
|
const handleWorkspacePaneAction = useCallback(
|
||||||
@@ -1583,7 +1542,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.right") {
|
if (action.id === "workspace.pane.split.right") {
|
||||||
handleCreateDraftSplit({
|
handleCreateLauncherSplit({
|
||||||
targetPaneId: focusedPane.id,
|
targetPaneId: focusedPane.id,
|
||||||
position: "right",
|
position: "right",
|
||||||
});
|
});
|
||||||
@@ -1591,7 +1550,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.id === "workspace.pane.split.down") {
|
if (action.id === "workspace.pane.split.down") {
|
||||||
handleCreateDraftSplit({
|
handleCreateLauncherSplit({
|
||||||
targetPaneId: focusedPane.id,
|
targetPaneId: focusedPane.id,
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
});
|
});
|
||||||
@@ -1661,7 +1620,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
allTabDescriptorsById,
|
allTabDescriptorsById,
|
||||||
closeWorkspaceTabWithCleanup,
|
closeWorkspaceTabWithCleanup,
|
||||||
focusWorkspacePane,
|
focusWorkspacePane,
|
||||||
handleCreateDraftSplit,
|
handleCreateLauncherSplit,
|
||||||
moveWorkspaceTabToPane,
|
moveWorkspaceTabToPane,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
focusedPaneTabState.activeTabId,
|
focusedPaneTabState.activeTabId,
|
||||||
@@ -1746,6 +1705,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (input.tab.kind === "draft" && target.kind === "agent") {
|
||||||
|
convertWorkspaceDraftToAgent(persistenceKey, input.tab.tabId, target.agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||||
},
|
},
|
||||||
onOpenWorkspaceFile: (filePath) => {
|
onOpenWorkspaceFile: (filePath) => {
|
||||||
@@ -1764,47 +1727,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
openWorkspaceTab,
|
openWorkspaceTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
|
convertWorkspaceDraftToAgent,
|
||||||
retargetWorkspaceTab,
|
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 focusedPaneId = focusedPaneTabState.pane?.id ?? null;
|
||||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||||
@@ -1839,6 +1765,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
<ActivityIndicator color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
|
) : isMissingWorkspaceExecutionAuthority ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateText}>
|
||||||
|
Workspace execution directory is missing. Reload workspace data before opening tabs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : !activeTabDescriptor ? (
|
) : !activeTabDescriptor ? (
|
||||||
!hasHydratedAgents ? (
|
!hasHydratedAgents ? (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
@@ -2057,7 +1989,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID="workspace-header-copy-path"
|
testID="workspace-header-copy-path"
|
||||||
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
||||||
disabled={!isAbsolutePath(normalizedWorkspaceId)}
|
disabled={!workspaceDirectory}
|
||||||
onSelect={handleCopyWorkspacePath}
|
onSelect={handleCopyWorkspacePath}
|
||||||
>
|
>
|
||||||
Copy workspace path
|
Copy workspace path
|
||||||
@@ -2080,10 +2012,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
{!isMobile && isGitCheckout ? (
|
{!isMobile && isGitCheckout ? (
|
||||||
<>
|
<>
|
||||||
<WorkspaceGitActions
|
{workspaceDirectory ? (
|
||||||
serverId={normalizedServerId}
|
<WorkspaceGitActions
|
||||||
cwd={normalizedWorkspaceId}
|
serverId={normalizedServerId}
|
||||||
/>
|
cwd={workspaceDirectory}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -2233,10 +2167,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||||
onCloseTabsToRight={handleCloseTabsToRight}
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
onSelectNewTabOption={handleSelectNewTabOption}
|
onCreateLauncherTab={handleCreateLauncherTab}
|
||||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
|
||||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||||
onNewTerminalTab={handleCreateTerminal}
|
|
||||||
onSplitRight={() => {}}
|
onSplitRight={() => {}}
|
||||||
onSplitDown={() => {}}
|
onSplitDown={() => {}}
|
||||||
showPaneSplitActions={false}
|
showPaneSplitActions={false}
|
||||||
@@ -2269,13 +2201,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||||
onSelectNewTabOption={handleSelectNewTabOption}
|
onCreateLauncherTab={handleCreateLauncherTab}
|
||||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
|
||||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||||
onFocusPane={handleFocusPane}
|
onFocusPane={handleFocusPane}
|
||||||
onNewTerminalTab={handleCreateTerminal}
|
|
||||||
onSplitPane={handleSplitPane}
|
onSplitPane={handleSplitPane}
|
||||||
onSplitPaneEmpty={handleCreateDraftSplit}
|
onSplitPaneEmpty={handleCreateLauncherSplit}
|
||||||
onMoveTabToPane={handleMoveTabToPane}
|
onMoveTabToPane={handleMoveTabToPane}
|
||||||
onResizeSplit={handleResizePaneSplit}
|
onResizeSplit={handleResizePaneSplit}
|
||||||
onReorderTabsInPane={handleReorderTabsInPane}
|
onReorderTabsInPane={handleReorderTabsInPane}
|
||||||
@@ -2290,13 +2220,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(!isFocusModeEnabled || isMobile) && (
|
{(!isFocusModeEnabled || isMobile) && (
|
||||||
<ExplorerSidebar
|
workspaceDirectory ? (
|
||||||
serverId={normalizedServerId}
|
<ExplorerSidebar
|
||||||
workspaceId={normalizedWorkspaceId}
|
serverId={normalizedServerId}
|
||||||
workspaceRoot={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
isGit={isGitCheckout}
|
workspaceRoot={workspaceDirectory}
|
||||||
onOpenFile={handleOpenFileFromExplorer}
|
isGit={isGitCheckout}
|
||||||
/>
|
onOpenFile={handleOpenFileFromExplorer}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ describe("workspace source of truth consumption", () => {
|
|||||||
projectId: "remote:github.com/getpaseo/paseo",
|
projectId: "remote:github.com/getpaseo/paseo",
|
||||||
projectDisplayName: "getpaseo/paseo",
|
projectDisplayName: "getpaseo/paseo",
|
||||||
projectRootPath: "/repo/main",
|
projectRootPath: "/repo/main",
|
||||||
|
workspaceDirectory: "/repo/main",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "local_checkout",
|
workspaceKind: "checkout",
|
||||||
name: "feat/workspace-sot",
|
name: "feat/workspace-sot",
|
||||||
status: "running",
|
status: "running",
|
||||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return `workspace-draft-close-${tab.target.draftId}`;
|
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)}`;
|
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function WorkspaceTabPresentationResolver({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceTabPresentationResolverInner
|
<WorkspaceTabPresentationResolverInner
|
||||||
key={tab.kind}
|
key={`${tab.key}:${tab.kind}`}
|
||||||
registration={registration}
|
registration={registration}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { useSessionStore } from "@/stores/session-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;
|
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
type LegacyDraftImage = {
|
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,
|
GitSetupOptions,
|
||||||
ProjectPlacementPayload,
|
ProjectPlacementPayload,
|
||||||
ServerCapabilities,
|
ServerCapabilities,
|
||||||
|
AgentSnapshotPayload,
|
||||||
WorkspaceDescriptorPayload,
|
WorkspaceDescriptorPayload,
|
||||||
} from "@server/shared/messages";
|
} from "@server/shared/messages";
|
||||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||||
@@ -79,10 +80,13 @@ export interface AgentRuntimeInfo {
|
|||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
|
terminal: boolean;
|
||||||
status: AgentLifecycleStatus;
|
status: AgentLifecycleStatus;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -96,6 +100,7 @@ export interface Agent {
|
|||||||
runtimeInfo?: AgentRuntimeInfo;
|
runtimeInfo?: AgentRuntimeInfo;
|
||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
|
terminalExit?: TerminalExitDetails | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -113,6 +118,7 @@ export interface WorkspaceDescriptor {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
@@ -126,10 +132,11 @@ export function normalizeWorkspaceDescriptor(
|
|||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
|
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
|
||||||
return {
|
return {
|
||||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
|
||||||
projectId: payload.projectId,
|
projectId: String(payload.projectId),
|
||||||
projectDisplayName: payload.projectDisplayName,
|
projectDisplayName: payload.projectDisplayName,
|
||||||
projectRootPath: payload.projectRootPath,
|
projectRootPath: payload.projectRootPath,
|
||||||
|
workspaceDirectory: payload.workspaceDirectory,
|
||||||
projectKind: payload.projectKind,
|
projectKind: payload.projectKind,
|
||||||
workspaceKind: payload.workspaceKind,
|
workspaceKind: payload.workspaceKind,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
@@ -191,7 +198,6 @@ export type DaemonServerInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface AgentTimelineCursorState {
|
export interface AgentTimelineCursorState {
|
||||||
epoch: string;
|
|
||||||
startSeq: number;
|
startSeq: number;
|
||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
@@ -1119,6 +1125,7 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
serverId,
|
serverId,
|
||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
|
terminal: agent.terminal,
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
cwd: agent.cwd,
|
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 type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import {
|
import {
|
||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
|
createLauncherId,
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
} from "@/utils/workspace-tab-identity";
|
} from "@/utils/workspace-tab-identity";
|
||||||
@@ -110,6 +111,11 @@ interface OpenTabInLayoutResult {
|
|||||||
tabId: string;
|
tabId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenLauncherTabInLayoutInput {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
now: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RetargetTabInLayoutInput {
|
interface RetargetTabInLayoutInput {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult {
|
|||||||
tabId: string;
|
tabId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConvertDraftToAgentInLayoutInput {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
tabId: string;
|
||||||
|
agentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConvertDraftToAgentInLayoutResult {
|
||||||
|
layout: WorkspaceLayout;
|
||||||
|
tabId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReorderFocusedPaneTabsInLayoutInput {
|
interface ReorderFocusedPaneTabsInLayoutInput {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
tabIds: string[];
|
tabIds: string[];
|
||||||
@@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput {
|
|||||||
tabIds: string[];
|
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 DEFAULT_PANE_ID = "main";
|
||||||
const MIN_SPLIT_SIZE = 0.1;
|
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(
|
function updateGroupSizesInTree(
|
||||||
root: SplitNodeInternal,
|
root: SplitNodeInternal,
|
||||||
input: UpdateGroupSizesInTreeInput,
|
input: UpdateGroupSizesInTreeInput,
|
||||||
@@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
|||||||
return detachTabFromTree(asInternalNode(root), { tabId }).root;
|
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 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 =
|
const focusedPane =
|
||||||
findPaneById(layout.root, layout.focusedPaneId) ??
|
findPaneById(layout.root, layout.focusedPaneId) ??
|
||||||
collectAllPanes(layout.root)[0] ??
|
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 {
|
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||||
const internalLayout = asInternalLayout(input.layout);
|
const internalLayout = asInternalLayout(input.layout);
|
||||||
const pane = findPaneContainingTab(internalLayout.root, input.tabId);
|
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 {
|
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,
|
tabId: input.tabId,
|
||||||
layout: {
|
layout: {
|
||||||
root: updateTabInTree(layout.root, {
|
root: replaceTabInTree(layout.root, {
|
||||||
tabId: input.tabId,
|
tabId: input.tabId,
|
||||||
|
nextTabId: input.tabId,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
}),
|
}),
|
||||||
focusedPaneId: layout.focusedPaneId,
|
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(
|
export function reorderFocusedPaneTabsInLayout(
|
||||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||||
): WorkspaceLayout | null {
|
): WorkspaceLayout | null {
|
||||||
@@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout(
|
|||||||
focusedPaneId: layout.focusedPaneId,
|
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", () => {
|
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
@@ -288,7 +340,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
|
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(
|
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||||
"12121212-1212-1212-1212-121212121212",
|
"12121212-1212-1212-1212-121212121212",
|
||||||
);
|
);
|
||||||
@@ -296,30 +348,125 @@ describe("workspace-layout-store actions", () => {
|
|||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
|
||||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
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, {
|
const splitPaneId = store.splitPane(workspaceKey, {
|
||||||
tabId: secondTabId!,
|
tabId: secondTabId!,
|
||||||
targetPaneId: "main",
|
targetPaneId: "main",
|
||||||
position: "right",
|
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",
|
kind: "agent",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
});
|
});
|
||||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
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(firstAgentTabId).toBe(firstLauncherTabId);
|
||||||
expect(nextTabId).toBe(secondTabId);
|
expect(nextTabId).toBe(firstLauncherTabId);
|
||||||
expect(splitPane?.tabIds).toEqual([secondTabId!]);
|
expect(collectAllTabs(layout.root)).toEqual([
|
||||||
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
|
{
|
||||||
expect(retargetedTab).toEqual({
|
tabId: firstLauncherTabId!,
|
||||||
tabId: secondTabId,
|
target: { kind: "agent", agentId: "agent-1" },
|
||||||
target: { kind: "agent", agentId: "agent-1" },
|
createdAt: expect.any(Number),
|
||||||
createdAt: expect.any(Number),
|
},
|
||||||
});
|
]);
|
||||||
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||||
@@ -702,4 +849,102 @@ describe("workspace-layout-store actions", () => {
|
|||||||
splitSizesByWorkspace: {},
|
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,
|
closeTabInLayout,
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
|
convertDraftToAgentInLayout,
|
||||||
createDefaultLayout,
|
createDefaultLayout,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
findPaneContainingTab,
|
findPaneContainingTab,
|
||||||
@@ -20,7 +21,9 @@ import {
|
|||||||
insertSplit,
|
insertSplit,
|
||||||
moveTabToPaneInLayout,
|
moveTabToPaneInLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
|
openLauncherTabInLayout,
|
||||||
openTabInLayout,
|
openTabInLayout,
|
||||||
|
reconcileWorkspaceTabs,
|
||||||
removePaneFromTree,
|
removePaneFromTree,
|
||||||
removeTabFromTree,
|
removeTabFromTree,
|
||||||
reorderFocusedPaneTabsInLayout,
|
reorderFocusedPaneTabsInLayout,
|
||||||
@@ -31,6 +34,8 @@ import {
|
|||||||
type SplitGroup,
|
type SplitGroup,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
type SplitPane,
|
type SplitPane,
|
||||||
|
type WorkspaceTabReconcileState,
|
||||||
|
type WorkspaceTabSnapshot,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-actions";
|
} from "@/stores/workspace-layout-actions";
|
||||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
||||||
@@ -48,16 +53,26 @@ export {
|
|||||||
removePaneFromTree,
|
removePaneFromTree,
|
||||||
removeTabFromTree,
|
removeTabFromTree,
|
||||||
};
|
};
|
||||||
export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
|
export type {
|
||||||
|
SplitGroup,
|
||||||
|
SplitNode,
|
||||||
|
SplitPane,
|
||||||
|
WorkspaceLayout,
|
||||||
|
WorkspaceTabReconcileState,
|
||||||
|
WorkspaceTabSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
interface WorkspaceLayoutStore {
|
interface WorkspaceLayoutStore {
|
||||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||||
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
|
openLauncherTab: (workspaceKey: string) => string | null;
|
||||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
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;
|
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
||||||
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
||||||
splitPane: (
|
splitPane: (
|
||||||
@@ -128,6 +143,26 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
|
|
||||||
return result.tabId;
|
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) => {
|
closeTab: (workspaceKey, tabId) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
@@ -202,6 +237,59 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
|||||||
|
|
||||||
return result.tabId;
|
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) => {
|
reorderTabs: (workspaceKey, tabIds) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
if (!normalizedWorkspaceKey) {
|
if (!normalizedWorkspaceKey) {
|
||||||
|
|||||||
41
packages/app/src/stores/workspace-setup-store.test.ts
Normal file
41
packages/app/src/stores/workspace-setup-store.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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 workspace setup by source directory and optional workspace id", () => {
|
||||||
|
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||||
|
serverId: "server-1",
|
||||||
|
sourceDirectory: "/Users/test/project",
|
||||||
|
sourceWorkspaceId: "42",
|
||||||
|
displayName: "project",
|
||||||
|
creationMethod: "open_project",
|
||||||
|
navigationMethod: "replace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
sourceDirectory: "/Users/test/project",
|
||||||
|
sourceWorkspaceId: "42",
|
||||||
|
displayName: "project",
|
||||||
|
creationMethod: "open_project",
|
||||||
|
navigationMethod: "replace",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears pending setup state", () => {
|
||||||
|
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||||
|
serverId: "server-1",
|
||||||
|
sourceDirectory: "/Users/test/project",
|
||||||
|
creationMethod: "create_worktree",
|
||||||
|
navigationMethod: "navigate",
|
||||||
|
});
|
||||||
|
|
||||||
|
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
|
||||||
|
|
||||||
|
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/app/src/stores/workspace-setup-store.ts
Normal file
29
packages/app/src/stores/workspace-setup-store.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||||
|
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||||
|
|
||||||
|
export interface PendingWorkspaceSetup {
|
||||||
|
serverId: string;
|
||||||
|
sourceDirectory: string;
|
||||||
|
sourceWorkspaceId?: string;
|
||||||
|
displayName?: 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]);
|
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", () => {
|
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||||
const draftTabId = "draft_background";
|
const draftTabId = "draft_background";
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import {
|
||||||
|
buildDeterministicWorkspaceTabId,
|
||||||
|
createLauncherId,
|
||||||
|
normalizeWorkspaceTabTarget,
|
||||||
|
workspaceTabTargetsEqual,
|
||||||
|
} from "@/utils/workspace-tab-identity";
|
||||||
|
|
||||||
export type WorkspaceTabTarget =
|
export type WorkspaceTabTarget =
|
||||||
| { kind: "draft"; draftId: string }
|
| { kind: "draft"; draftId: string }
|
||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "file"; path: string };
|
| { kind: "file"; path: string }
|
||||||
|
| { kind: "launcher"; launcherId: string };
|
||||||
|
|
||||||
export type WorkspaceTab = {
|
export type WorkspaceTab = {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
@@ -38,63 +45,6 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
|||||||
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
|
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[] {
|
function normalizeTabOrder(list: unknown): string[] {
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -133,6 +83,7 @@ type WorkspaceTabsState = {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
}) => string | null;
|
}) => string | null;
|
||||||
|
openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null;
|
||||||
openOrFocusTab: (input: {
|
openOrFocusTab: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -169,19 +120,20 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
},
|
},
|
||||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const normalizedTarget = normalizeTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
if (!key || !normalizedTarget) {
|
if (!key || !normalizedTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
|
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||||
let resolvedTabId = deterministicTabId;
|
let resolvedTabId = deterministicTabId;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||||
const tabWithSameTarget =
|
const tabWithSameTarget =
|
||||||
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ??
|
||||||
|
null;
|
||||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||||
resolvedTabId = effectiveTabId;
|
resolvedTabId = effectiveTabId;
|
||||||
|
|
||||||
@@ -196,7 +148,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
const existing = currentTabs[existingIndex];
|
const existing = currentTabs[existingIndex];
|
||||||
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
|
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
|
||||||
return currentTabs;
|
return currentTabs;
|
||||||
}
|
}
|
||||||
return currentTabs.map((tab, index) =>
|
return currentTabs.map((tab, index) =>
|
||||||
@@ -218,6 +170,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
|
|
||||||
return resolvedTabId;
|
return resolvedTabId;
|
||||||
},
|
},
|
||||||
|
openLauncherTab: ({ serverId, workspaceId }) => {
|
||||||
|
return get().openOrFocusTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
target: { kind: "launcher", launcherId: createLauncherId() },
|
||||||
|
});
|
||||||
|
},
|
||||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||||
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
||||||
if (!tabId) {
|
if (!tabId) {
|
||||||
@@ -310,7 +269,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
const normalizedTarget = normalizeTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -325,7 +284,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTarget = currentTabs[index]?.target;
|
const currentTarget = currentTabs[index]?.target;
|
||||||
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
|
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +349,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
for (const key in state.uiTabsByWorkspace) {
|
for (const key in state.uiTabsByWorkspace) {
|
||||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||||
.map((tab) => {
|
.map((tab) => {
|
||||||
const normalizedTarget = normalizeTabTarget(tab.target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||||
if (!normalizedTarget || !normalizedTabId) {
|
if (!normalizedTarget || !normalizedTabId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -479,13 +438,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target);
|
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
||||||
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
||||||
if (!normalizedTarget) {
|
if (!normalizedTarget) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget);
|
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||||
if (!usedOrder.has(tabId)) {
|
if (!usedOrder.has(tabId)) {
|
||||||
usedOrder.add(tabId);
|
usedOrder.add(tabId);
|
||||||
orderFromTabs.push(tabId);
|
orderFromTabs.push(tabId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type AgentDirectoryEntry = Pick<
|
|||||||
| "id"
|
| "id"
|
||||||
| "serverId"
|
| "serverId"
|
||||||
| "title"
|
| "title"
|
||||||
|
| "terminal"
|
||||||
| "status"
|
| "status"
|
||||||
| "lastActivityAt"
|
| "lastActivityAt"
|
||||||
| "cwd"
|
| "cwd"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
serverId,
|
serverId,
|
||||||
id: snapshot.id,
|
id: snapshot.id,
|
||||||
provider: snapshot.provider,
|
provider: snapshot.provider,
|
||||||
|
terminal: snapshot.terminal === true,
|
||||||
status: snapshot.status as AgentLifecycleStatus,
|
status: snapshot.status as AgentLifecycleStatus,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -44,6 +45,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
runtimeInfo: snapshot.runtimeInfo,
|
runtimeInfo: snapshot.runtimeInfo,
|
||||||
lastUsage: snapshot.lastUsage,
|
lastUsage: snapshot.lastUsage,
|
||||||
lastError: snapshot.lastError ?? null,
|
lastError: snapshot.lastError ?? null,
|
||||||
|
terminalExit: snapshot.terminalExit ?? null,
|
||||||
title: snapshot.title ?? null,
|
title: snapshot.title ?? null,
|
||||||
cwd: snapshot.cwd,
|
cwd: snapshot.cwd,
|
||||||
model: snapshot.model ?? null,
|
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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user