Compare commits

..

5 Commits

Author SHA1 Message Date
github-actions[bot]
e026223c80 fix: update lockfile signatures and Nix hash 2026-03-30 10:31:47 +00:00
Mohamed Boudra
2190910e6f fix: resolve type errors from main merge
- Remove orphaned PID lock code from bootstrap (moved to supervisor)
- Fix worktree archive adapter to look up workspace by directory
- Replace registerWorktreeWorkspaceRecord with inline SQLite implementation
- Remove unused imports (stat, createPersistedWorkspaceRecord, PersistedProjectRecord)
2026-03-30 17:30:31 +07:00
Mohamed Boudra
0d3f59feed Merge branch 'main' into storage-terminal-ui-dev 2026-03-30 17:14:03 +07:00
github-actions[bot]
edcd2fe5d5 fix: update lockfile signatures and Nix hash 2026-03-29 16:22:05 +00:00
Mohamed Boudra
7d02e24d84 feat: add sqlite storage and terminal ui 2026-03-29 23:20:18 +07:00
254 changed files with 18336 additions and 7571 deletions

View File

@@ -149,119 +149,6 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
- name: Upload manifest artifact
if: env.IS_SMOKE_TAG != 'true'
uses: actions/upload-artifact@v4
with:
name: mac-manifest-${{ matrix.electron_arch }}
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
retention-days: 1
finalize-mac-manifest:
needs: [publish-macos]
if: ${{ needs.publish-macos.result == 'success' }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[3]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
if [[ "$source_tag" == *gha-smoke* ]]; then
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
else
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
fi
- name: Download manifest artifacts
if: env.IS_SMOKE_TAG != 'true'
uses: actions/download-artifact@v4
with:
pattern: mac-manifest-*
- name: Merge manifests
if: env.IS_SMOKE_TAG != 'true'
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('node:fs');
// Simple YAML parser for electron-builder's latest-mac.yml format
function parseManifest(text) {
const lines = text.split('\n');
const result = { files: [] };
let currentFile = null;
for (const line of lines) {
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
else if (line.trim().startsWith('- url:')) {
currentFile = { url: line.trim().replace('- url: ', '') };
result.files.push(currentFile);
} else if (line.trim().startsWith('sha512:') && currentFile) {
currentFile.sha512 = line.trim().split(': ')[1].trim();
} else if (line.trim().startsWith('size:') && currentFile) {
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
currentFile = null;
}
}
return result;
}
function toYaml(manifest) {
let out = `version: ${manifest.version}\n`;
out += `files:\n`;
for (const f of manifest.files) {
out += ` - url: ${f.url}\n`;
out += ` sha512: ${f.sha512}\n`;
out += ` size: ${f.size}\n`;
}
out += `path: ${manifest.path}\n`;
out += `sha512: ${manifest.sha512}\n`;
out += `releaseDate: '${manifest.releaseDate}'\n`;
return out;
}
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
const arm64 = parseManifest(arm64Text);
const x64 = parseManifest(x64Text);
// Merge: all files from both, default path points to arm64 zip
const merged = {
version: arm64.version,
files: [...arm64.files, ...x64.files],
path: arm64.path,
sha512: arm64.sha512,
releaseDate: arm64.releaseDate || x64.releaseDate,
};
const output = toYaml(merged);
fs.writeFileSync('latest-mac.yml', output);
console.log('Merged manifest:\n' + output);
NODE
- name: Upload merged manifest to release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
permissions:

View File

@@ -1,31 +1,5 @@
# Changelog
## 0.1.40 - 2026-04-01
### Added
- Workspace tabs can now be closed in batches.
### Improved
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
### Fixed
- OpenCode reasoning content no longer appears duplicated as assistant text.
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
- Archive tab now correctly reconciles agent visibility after archiving.
- File diff tracking in workspaces now works correctly on Linux.
- iPad layout now renders correctly in desktop mode.
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed

View File

@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run db:query # Show DB table row counts
npm run db:query -- "SELECT ..." # Run arbitrary SQL against SQLite
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.

View File

@@ -35,6 +35,25 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the SQLite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## Build sync gotchas
### Relay → Daemon

217
docs/STORAGE_REVAMP_PLAN.md Normal file
View 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
View 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)

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
npmDepsHash = "sha256-r9y8rUyT/56wHFUp8D/yA7mjy715jjezSYaEuj1D4TQ=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

1527
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.40",
"version": "0.1.38",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -37,6 +37,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",

View File

@@ -1,106 +0,0 @@
import { randomUUID } from "node:crypto";
import { test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
archiveAgentFromDaemon,
archiveAgentFromSessions,
connectArchiveTabDaemonClient,
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
resetSeededPageState,
reloadWorkspace,
} from "./helpers/archive-tab";
test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
client = await connectArchiveTabDaemonClient();
});
test.afterAll(async () => {
await client?.close();
await tempRepo?.cleanup();
});
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `cli-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openSessions(page);
await expectSessionRowVisible(page, archived.title);
await expectSessionRowVisible(page, surviving.title);
await openSessions(passivePage);
await expectSessionRowVisible(passivePage, archived.title);
await expectSessionRowVisible(passivePage, surviving.title);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
const archived = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-archive-${randomUUID().slice(0, 8)}`,
});
const surviving = await createIdleAgent(client, {
cwd: tempRepo.path,
title: `ui-control-${randomUUID().slice(0, 8)}`,
});
const passivePage = await page.context().newPage();
try {
await primeAdditionalPage(passivePage);
await resetSeededPageState(page);
await resetSeededPageState(passivePage);
await openWorkspaceWithAgents(page, [archived, surviving]);
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
} finally {
await passivePage.close();
}
});
});

View File

@@ -1,257 +0,0 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
export type ArchiveTabAgent = {
id: string;
title: string;
cwd: string;
};
type ArchiveTabDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
provider: string;
model: string;
thinkingOptionId: string;
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
if (daemonPort === "6767") {
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
}
return daemonPort;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function getDaemonWsUrl(): string {
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
}
function buildSeededStoragePayload() {
const nowIso = new Date().toISOString();
return {
daemon: buildSeededHost({
serverId: getServerId(),
endpoint: `127.0.0.1:${getDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => ArchiveTabDaemonClient
> {
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";
}) => ArchiveTabDaemonClient;
};
return mod.DaemonClient;
}
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `app-e2e-archive-tab-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export async function createIdleAgent(
client: ArchiveTabDaemonClient,
input: { cwd: string; title: string },
): Promise<ArchiveTabAgent> {
const created = await client.createAgent({
provider: "codex",
model: "gpt-5.1-codex-mini",
thinkingOptionId: "low",
modeId: "full-access",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
}
return {
id: created.id,
title: input.title,
cwd: input.cwd,
};
}
export async function archiveAgentFromDaemon(
client: ArchiveTabDaemonClient,
agentId: string,
): Promise<void> {
await client.archiveAgent(agentId);
}
export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
await page.route(/:(6767)\b/, (route) => route.abort());
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ daemon, preferences, seedNonce },
);
await page.goto("/");
}
export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
);
await page.goto("/");
}
export async function openWorkspaceWithAgents(
page: Page,
agents: [ArchiveTabAgent, ArchiveTabAgent],
): Promise<void> {
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
}
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
timeout: 30_000,
});
}
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function expectWorkspaceArchiveOutcome(
page: Page,
input: { archivedAgentId: string; survivingAgentId: string },
): Promise<void> {
await expectWorkspaceTabHidden(page, input.archivedAgentId);
await expectWorkspaceTabVisible(page, input.survivingAgentId);
}
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
await waitForWorkspaceTabsVisible(page);
}
export async function openSessions(page: Page): Promise<void> {
const sessionsButton = page.getByTestId("sidebar-sessions");
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
await sessionsButton.click();
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000,
});
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
timeout: 30_000,
});
}
function getSessionRowByTitle(page: Page, title: string) {
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
}
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
}
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
}
export async function archiveAgentFromSessions(
page: Page,
input: { agentId: string; title: string },
): Promise<void> {
const row = getSessionRowByTitle(page, input.title);
await expect(row).toBeVisible({ timeout: 30_000 });
const box = await row.boundingBox();
if (!box) {
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
await expectSessionRowArchived(page, input.title);
}

View File

@@ -0,0 +1,196 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
await page.goto(route);
await waitForTabBar(page);
}
// ─── Tab bar queries ───────────────────────────────────────────────────────
/** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator('[data-testid^="workspace-tab-"]');
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const testId = await tabs.nth(i).getAttribute("data-testid");
if (testId) ids.push(testId);
}
return ids;
}
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
const ids = await getTabTestIds(page);
return ids.filter((id) => id.includes(kind)).length;
}
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
export async function getActiveTabTestId(page: Page): Promise<string | null> {
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
}
// Fallback: the tab with focused styling
return null;
}
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the '+' button in the tab bar to open a new launcher tab. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
await page.keyboard.press("Meta+t");
}
// ─── Launcher panel assertions ─────────────────────────────────────────────
/** Wait for the launcher panel to render with its primary tiles. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
timeout: 15_000,
});
}
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
export async function assertProviderTilesVisible(page: Page): Promise<void> {
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
timeout: 10_000,
});
}
/** Assert the launcher panel has a "New Chat" tile. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
}
/** Assert the launcher panel has a "Terminal" tile. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
}
// ─── Launcher tile clicks ──────────────────────────────────────────────────
/** Click the "New Chat" tile on the launcher panel. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "New Chat" }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the "Terminal" tile on the launcher panel. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByRole("button", { name: "Terminal" }).first();
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
const tile = page.getByRole("button", { name: providerLabel }).first();
await expect(tile).toBeVisible({ timeout: 10_000 });
await tile.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */
export async function waitForTabWithTitle(
page: Page,
title: string | RegExp,
timeout = 30_000,
): Promise<void> {
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
.toBeVisible({ timeout });
}
/** Assert the new-tab '+' button is visible and there is only one. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-tab");
// There might be multiple panes, each with a "+" button
// But within a single pane there should only be one
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
* Returns elapsed milliseconds.
*/
export async function measureTileTransition(
page: Page,
clickAction: () => Promise<void>,
successLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<number> {
const start = Date.now();
await clickAction();
await expect(successLocator).toBeVisible({ timeout });
return Date.now() - start;
}
/**
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
* Returns all unique snapshots observed.
*/
export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
intervalMs = 30,
): Promise<string[][]> {
const snapshots: string[][] = [];
const startSampling = async () => {
const start = Date.now();
while (Date.now() - start < durationMs) {
snapshots.push(await getTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
};
const samplingPromise = startSampling();
await action();
await samplingPromise;
return snapshots;
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
return createTempGitRepo(prefix);
}

View File

@@ -0,0 +1,343 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
waitForLauncherPanel,
assertProviderTilesVisible,
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickTerminal,
clickProviderTile,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
});
test.afterAll(async () => {
if (tempRepo) await tempRepo.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
// Launcher Tab Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Launcher tab", () => {
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
page,
}) => {
await gotoWorkspace(page, tempRepo.path);
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
await assertProviderTilesVisible(page);
});
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
const countAfterFirst = await countTabsOfKind(page, "launcher");
await pressNewTabShortcut(page);
await waitForLauncherPanel(page);
const countAfterSecond = await countTabsOfKind(page, "launcher");
expect(countAfterSecond).toBe(countAfterFirst + 1);
});
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
await clickNewChat(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
// Launcher tab should have been replaced (not added alongside)
const tabsAfter = await getTabTestIds(page);
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
// Total tab count should stay the same (replaced, not added)
expect(tabsAfter.length).toBe(tabsBefore.length);
});
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
await clickTerminal(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Tab count stays the same (in-place replacement)
const tabsAfter = await getTabTestIds(page);
expect(tabsAfter.length).toBe(tabsBefore.length);
// The launcher tab is gone, a terminal tab exists
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const tabsBefore = await getTabTestIds(page);
// Click the first visible provider tile under "Terminal Agents"
const providerTiles = page.locator('[role="button"]').filter({
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
});
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
// The provider grid follows the label. Click the first provider tile.
const providerGrid = terminalAgentsLabel.locator("~ *").first();
const firstProvider = providerGrid.getByRole("button").first();
if (await firstProvider.isVisible().catch(() => false)) {
await firstProvider.click();
} else {
// Fallback: look for any provider button after the section label
const allButtons = page.getByRole("button");
const count = await allButtons.count();
let clicked = false;
for (let i = 0; i < count; i++) {
const btn = allButtons.nth(i);
const text = await btn.innerText().catch(() => "");
// Skip known non-provider buttons
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
if (!text.trim()) continue;
await btn.click();
clicked = true;
break;
}
if (!clicked) {
test.skip(true, "No provider tiles available");
return;
}
}
// Should see an agent panel (terminal surface or agent stream)
const agentOrTerminal = page.locator(
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
);
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
// Tab count stays the same (replaced, not added)
const tabsAfter = await getTabTestIds(page);
expect(tabsAfter.length).toBe(tabsBefore.length);
});
test("tab bar shows a single + button per pane", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await assertSingleNewTabButton(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open the terminal
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminal
.first()
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminal
.first()
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// No-Flash Transition Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Launcher transitions (no flash)", () => {
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(
page,
() => clickNewChat(page),
2_000,
30,
);
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never increase (no duplicate flash from add-then-remove)
const counts = snapshots.map((s) => s.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
// Allow at most +1 transient tab (tolerance for React render batching)
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickTerminal(page),
terminal.first(),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
await gotoWorkspace(page, tempRepo.path);
await clickNewTabButton(page);
await waitForLauncherPanel(page);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(
page,
() => clickNewChat(page),
composer,
10_000,
);
// Draft replacement is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.40",
"version": "0.1.38",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.40",
"@getpaseo/highlight": "0.1.40",
"@getpaseo/server": "0.1.40",
"@getpaseo/expo-two-way-audio": "0.1.38",
"@getpaseo/highlight": "0.1.38",
"@getpaseo/server": "0.1.38",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -57,10 +57,11 @@ import {
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
@@ -103,7 +104,7 @@ function PushNotificationRouter() {
let removeDesktopNotificationListener: (() => void) | null = null;
let cancelled = false;
if (getIsElectronRuntime()) {
if (getIsDesktop()) {
void ensureOsNotificationPermission();
const unlistenResult = getDesktopHost()?.events?.on?.(
@@ -342,12 +343,12 @@ function AppContainer({
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile: isCompactLayout,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
@@ -363,21 +364,20 @@ function AppContainer({
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
<LeftSidebar selectedAgentId={selectedAgentId} />
)}
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>{children}</View>
</View>
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
</View>
);
if (!isCompactLayout) {
if (!isMobile) {
return content;
}

View File

@@ -5,6 +5,7 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
@@ -68,7 +69,9 @@ export default function HostIndexRoute() {
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
router.replace(
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
);
return;
}

View File

@@ -1,12 +1,12 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") {
if (isElectronRuntime()) {
if (isDesktop()) {
const { createDesktopAttachmentStore } = await import(
"../desktop/attachments/desktop-attachment-store"
);

View File

@@ -15,7 +15,8 @@ import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { Archive, SquareTerminal } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -130,6 +131,7 @@ function SessionRow({
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
return (
<Pressable
@@ -145,12 +147,21 @@ function SessionRow({
>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
{agent.title || "New session"}
</Text>
{agent.terminal ? (
<SessionBadge
label="Terminal"
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
@@ -239,6 +250,7 @@ export function AgentList({
workspaceId: agent.cwd,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
requestReopen: agent.terminal && agent.status === "closed",
});
router.navigate(route as any);
},
@@ -433,6 +445,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerIconWrap: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowMetaRow: {
flexDirection: "row",
alignItems: "center",

View File

@@ -1,31 +0,0 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
const queryClient = new QueryClient();
const observer = new QueryObserver(queryClient, {
queryKey: ["providerModels", "server-1", "__missing_provider__"],
enabled: false,
queryFn: async () => [],
});
const result = observer.getCurrentResult();
expect(result.isPending).toBe(true);
expect(result.isLoading).toBe(false);
expect(result.isFetching).toBe(false);
expect(isProviderModelsQueryLoading(result)).toBe(false);
});
it("treats an active fetch as loading", () => {
expect(
isProviderModelsQueryLoading({
isLoading: false,
isFetching: true,
}),
).toBe(true);
});
});

View File

@@ -1,8 +0,0 @@
interface ProviderModelsQueryState {
isFetching: boolean;
isLoading: boolean;
}
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
return input.isLoading || input.isFetching;
}

View File

@@ -33,7 +33,6 @@ import {
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = {
id: string;
@@ -635,7 +634,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
queryKey: [
"providerModels",
serverId,
agent?.provider ?? "__missing_provider__",
agent?.cwd ?? "__missing_cwd__",
],
enabled: Boolean(client && agent?.provider),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
@@ -749,7 +753,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
disabled={!client}
/>
);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
import { resolveStatusControlMode } from "./composer.status-controls";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {

View File

@@ -41,7 +41,7 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -56,11 +56,12 @@ type QueuedMessage = {
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
interface AgentInputAreaProps {
interface ComposerProps {
agentId: string;
serverId: string;
isInputActive: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
allowEmptySubmit?: boolean;
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** When true, blurs the input immediately when submitting. */
@@ -89,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
export function Composer({
agentId,
serverId,
isInputActive,
onSubmitMessage,
allowEmptySubmit = false,
isSubmitLoading = false,
blurOnSubmit = false,
value,
@@ -109,8 +111,8 @@ export function AgentInputArea({
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
@@ -480,7 +482,7 @@ export function AgentInputArea({
return;
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
console.error("[Composer] Failed to start voice mode", error);
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) {
@@ -677,6 +679,7 @@ export function AgentInputArea({
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
allowEmptySubmit={allowEmptySubmit}
isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages}

View File

@@ -4,7 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsFocused } from "@react-navigation/native";
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -13,7 +13,7 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -40,7 +40,7 @@ export function ExplorerSidebar({
const { theme } = useUnistyles();
const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);

View File

@@ -119,7 +119,7 @@ function FilePreviewBody({
filePath,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";

View File

@@ -86,7 +86,7 @@ interface HighlightedTextProps {
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
// Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {

View File

@@ -1,11 +1,10 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { isCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
@@ -44,7 +43,7 @@ export function SidebarMenuToggle({
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);

View File

@@ -1,12 +1,11 @@
import type { ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
@@ -25,7 +24,7 @@ interface ScreenHeaderProps {
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const padding = useWindowControlsPadding("header");
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -13,7 +13,7 @@ export function KeyboardShortcutsDialog() {
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
[isDesktopApp, isMac],

View File

@@ -20,7 +20,7 @@ import Animated, {
useSharedValue,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -39,11 +39,7 @@ import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/deskto
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
isCompactFormFactor,
} from "@/constants/layout";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
@@ -98,7 +94,6 @@ interface MobileSidebarProps extends SidebarSharedProps {
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
isOpen: boolean;
handleViewMore: () => void;
}
@@ -110,7 +105,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
@@ -180,7 +175,7 @@ export const LeftSidebar = memo(function LeftSidebar({
const hostTriggerRef = useRef<View | null>(null);
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
serverId: activeServerId,
@@ -270,7 +265,7 @@ export const LeftSidebar = memo(function LeftSidebar({
handleHostSelect,
};
if (isCompactLayout) {
if (isMobile) {
return (
<MobileSidebar
{...sharedProps}
@@ -288,7 +283,6 @@ export const LeftSidebar = memo(function LeftSidebar({
return (
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleSettings={handleSettingsDesktop}
@@ -633,7 +627,6 @@ function DesktopSidebar({
handleHostSelect,
handleOpenProject,
handleSettings,
insetsTop,
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
@@ -688,7 +681,7 @@ function DesktopSidebar({
}
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>

View File

@@ -53,6 +53,7 @@ export interface MessageInputProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
allowEmptySubmit?: boolean;
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
images?: ImageAttachment[];
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
value,
onChangeText,
onSubmit,
allowEmptySubmit = false,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
@@ -851,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =

View File

@@ -1,10 +1,18 @@
import { Bot } from "lucide-react-native";
import { AiderIcon } from "@/components/icons/aider-icon";
import { AmpIcon } from "@/components/icons/amp-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { GeminiIcon } from "@/components/icons/gemini-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
const PROVIDER_ICONS: Record<string, typeof Bot> = {
claude: ClaudeIcon as unknown as typeof Bot,
codex: CodexIcon as unknown as typeof Bot,
gemini: GeminiIcon as unknown as typeof Bot,
amp: AmpIcon as unknown as typeof Bot,
aider: AiderIcon as unknown as typeof Bot,
opencode: OpenCodeIcon as unknown as typeof Bot,
};
export function getProviderIcon(provider: string): typeof Bot {

View File

@@ -10,7 +10,7 @@ import {
type GestureResponderEvent,
} from "react-native";
import * as Haptics from "expo-haptics";
import { useMutation, useQueries } from "@tanstack/react-query";
import { useQueries } from "@tanstack/react-query";
import {
useCallback,
useMemo,
@@ -22,7 +22,7 @@ import {
} from "react";
import { router, usePathname } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
@@ -42,10 +42,9 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -83,8 +82,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { createNameId } from "mnemonic-id";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -239,7 +238,7 @@ function WorkspaceStatusIndicator({
}
const KindIcon =
workspaceKind === "local_checkout"
workspaceKind === "checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
@@ -654,7 +653,6 @@ function ProjectHeaderRow({
canCreateWorktree,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null,
showShortcutBadge = false,
drag,
@@ -664,51 +662,32 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = isCompactFormFactor();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
const isMobileBreakpoint =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId) {
return;
}
onWorkspacePress?.();
beginWorkspaceSetup({
serverId,
projectPath: project.iconWorkingDir,
projectName: displayName,
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const createWorktreeMutation = useMutation({
mutationFn: async () => {
if (!serverId) {
throw new Error("No server");
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
throw new Error("Host is not connected");
}
const payload = await client.createPaseoWorktree({
cwd: project.iconWorkingDir,
worktreeSlug: createNameId(),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
}
return payload.workspace;
},
onSuccess: (workspace) => {
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
onWorktreeCreated?.(workspace.id);
onWorkspacePress?.();
router.navigate(
prepareWorkspaceTab({
serverId: serverId!,
workspaceId: workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any,
);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : String(error));
},
});
useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
enabled: isProjectActive && canCreateWorktree,
priority: 0,
handle: () => {
createWorktreeMutation.mutate();
handleBeginWorkspaceSetup();
return true;
},
});
@@ -752,9 +731,9 @@ function ProjectHeaderRow({
{canCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
onPress={handleBeginWorkspaceSetup}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
loading={false}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
@@ -835,11 +814,11 @@ function WorkspaceRowInner({
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = Platform.OS !== "web";
const isMobile = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
enabled: workspace.projectKind === "git",
});
const interaction = useLongPressDragInteraction({
drag,
@@ -900,7 +879,7 @@ function WorkspaceRowInner({
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? (
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
@@ -1095,7 +1074,7 @@ function WorkspaceRowWithMenu({
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
if (payload.error) {
throw new Error(payload.error);
}
@@ -1240,7 +1219,7 @@ function NonGitProjectRowWithMenuContent({
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
if (payload.error) {
throw new Error(payload.error);
}
@@ -1351,7 +1330,7 @@ function FlattenedProjectRow({
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
}) {
if (project.projectKind === "non_git") {
if (project.projectKind === "directory") {
return (
<NonGitProjectRowWithMenu
project={project}
@@ -1637,7 +1616,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isNative = Platform.OS !== "web";
const pathname = usePathname();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
@@ -1645,7 +1624,7 @@ export function SidebarWorkspaceList({
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);

View File

@@ -86,12 +86,7 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: {
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
paneId?: string;
}) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
onCreateLauncherTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
@@ -267,9 +262,7 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__",
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -536,9 +529,7 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -658,9 +649,7 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -693,9 +682,7 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -743,9 +730,7 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateLauncherTab={onCreateLauncherTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -792,9 +777,7 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateLauncherTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -902,9 +885,7 @@ function SplitPaneView({
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onCreateLauncherTab={onCreateLauncherTab}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(
pane.id,

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -21,7 +21,6 @@ import {
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { isCompactFormFactor } from "@/constants/layout";
interface TerminalPaneProps {
serverId: string;
@@ -92,7 +91,7 @@ export function TerminalPane({
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);

View File

@@ -350,7 +350,7 @@ export function WebDesktopScrollbarOverlay({
? HANDLE_OPACITY_VISIBLE
: 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const isDark = theme.colors.surface0 === "#181B1A";
const isDark = theme.colors.surface0 === "#18181c";
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
const handleCursor = isDragging ? "grabbing" : "grab";
const handleTravelDurationMs =

View File

@@ -0,0 +1,715 @@
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "./message-input";
type SetupStep = "choose" | "chat" | "terminal-agent";
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [step, setStep] = useState<SetupStep>("choose");
const [terminalPrompt, setTerminalPrompt] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
null,
);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
const workspace = createdWorkspace;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${projectPath}`,
composer: {
initialServerId: serverId || null,
initialValues: projectPath ? { workingDir: projectPath } : undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.id ?? projectPath,
},
});
const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) {
throw new Error("Workspace setup composer state is required");
}
const { providers: sortedProviders, recordUsage } = useProviderRecency(
composerState?.providerDefinitions ?? [],
);
useEffect(() => {
setStep("choose");
setTerminalPrompt("");
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
}, [clearWorkspaceSetup]);
const navigateAfterCreation = useCallback(
(
workspaceId: string,
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
) => {
if (!pendingWorkspaceSetup) {
return;
}
clearWorkspaceSetup();
navigateToPreparedWorkspaceTab({
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
target,
navigationMethod: pendingWorkspaceSetup.navigationMethod,
});
},
[clearWorkspaceSetup, pendingWorkspaceSetup],
);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
throw new Error("Host is not connected");
}
return client;
}, [client, isConnected]);
const ensureWorkspace = useCallback(async () => {
if (!pendingWorkspaceSetup) {
throw new Error("No workspace setup is pending");
}
if (createdWorkspace) {
return createdWorkspace;
}
const connectedClient = withConnectedClient();
const payload =
pendingWorkspaceSetup.creationMethod === "create_worktree"
? await connectedClient.createPaseoWorktree({
cwd: pendingWorkspaceSetup.projectPath,
worktreeSlug: createNameId(),
})
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
if (payload.error || !payload.workspace) {
throw new Error(
payload.error ??
(pendingWorkspaceSetup.creationMethod === "create_worktree"
? "Failed to create worktree"
: "Failed to open project"),
);
}
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
if (pendingWorkspaceSetup.creationMethod === "open_project") {
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
}
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
}, [
createdWorkspace,
mergeWorkspaces,
pendingWorkspaceSetup,
setHasHydratedWorkspaces,
withConnectedClient,
]);
const getIsStillActive = useCallback(() => {
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
return (
current?.serverId === pendingWorkspaceSetup?.serverId &&
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
);
}, [
pendingWorkspaceSetup?.creationMethod,
pendingWorkspaceSetup?.projectPath,
pendingWorkspaceSetup?.serverId,
]);
const handleCreateChatAgent = useCallback(
async ({ text, images }: MessagePayload) => {
try {
setPendingAction("chat");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
const encodedImages = await encodeImages(images);
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(text.trim() ? { initialPrompt: text.trim() } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
});
if (!getIsStillActive()) {
return;
}
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
},
[
composerState,
getIsStillActive,
navigateAfterCreation,
serverId,
setAgents,
ensureWorkspace,
toast,
withConnectedClient,
],
);
const handleCreateTerminalAgent = useCallback(async () => {
try {
setPendingAction("terminal-agent");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
}
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspace.id,
terminal: true,
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
});
if (!getIsStillActive()) {
return;
}
recordUsage(composerState.selectedProvider);
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
}, [
composerState,
getIsStillActive,
navigateAfterCreation,
recordUsage,
serverId,
setAgents,
ensureWorkspace,
terminalPrompt,
toast,
withConnectedClient,
]);
const handleCreateTerminal = useCallback(async () => {
try {
setPendingAction("terminal");
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
const payload = await connectedClient.createTerminal(workspace.id);
if (payload.error || !payload.terminal) {
throw new Error(payload.error ?? "Failed to open terminal");
}
if (!getIsStillActive()) {
return;
}
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
toast.error(message);
} finally {
if (getIsStillActive()) {
setPendingAction(null);
}
}
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||
projectName ||
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
projectPath;
const workspacePath = workspace?.projectRootPath || projectPath;
if (!pendingWorkspaceSetup || !projectPath) {
return null;
}
return (
<AdaptiveModalSheet
title="Set up workspace"
visible={true}
onClose={handleClose}
snapPoints={["82%", "94%"]}
testID="workspace-setup-dialog"
>
<View style={styles.header}>
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
<Text style={styles.workspacePath}>{workspacePath}</Text>
</View>
{step === "choose" ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>What do you want to open?</Text>
<View style={styles.choiceGrid}>
<ChoiceCard
title="Chat Agent"
description="Open this workspace with a prompt-first chat agent."
Icon={MessagesSquare}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("chat");
}}
/>
<ChoiceCard
title="Terminal Agent"
description="Launch an agent-backed terminal in an agent tab."
Icon={Bot}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("terminal-agent");
}}
/>
<ChoiceCard
title="Terminal"
description="Create the workspace, then open a standalone terminal tab."
Icon={SquareTerminal}
disabled={pendingAction !== null}
pending={pendingAction === "terminal"}
onPress={() => {
void handleCreateTerminal();
}}
/>
</View>
</View>
) : null}
{step === "chat" ? (
<View style={styles.section}>
<StepHeader
title="Chat Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
</Text>
<View style={styles.composerCard}>
<Composer
agentId={`workspace-setup:${serverId}:${projectPath}`}
serverId={serverId}
isInputActive={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
images={chatDraft.images}
onChangeImages={chatDraft.setImages}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
}
: undefined
}
/>
</View>
</View>
) : null}
{step === "terminal-agent" ? (
<View style={styles.section}>
<StepHeader
title="Terminal Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
</Text>
<View style={styles.providerGrid}>
{sortedProviders.map((provider) => (
<ProviderOption
key={provider.id}
provider={provider}
selected={provider.id === composerState?.selectedProvider}
disabled={pendingAction !== null}
onPress={() => composerState?.setProviderFromUser(provider.id)}
/>
))}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Initial prompt</Text>
<AdaptiveTextInput
value={terminalPrompt}
onChangeText={setTerminalPrompt}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
multiline
autoCapitalize="sentences"
autoCorrect={false}
/>
</View>
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={handleClose}
>
Cancel
</Button>
<Button
variant="default"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={() => {
void handleCreateTerminalAgent();
}}
>
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
</Button>
</View>
</View>
) : null}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
);
}
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
const { theme } = useUnistyles();
return (
<View style={styles.stepHeader}>
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<Text style={styles.sectionTitle}>{title}</Text>
</View>
);
}
function ChoiceCard({
title,
description,
Icon,
disabled,
pending = false,
onPress,
}: {
title: string;
description: string;
Icon: ComponentType<{ size: number; color: string }>;
disabled: boolean;
pending?: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.choiceCard,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.choiceIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<View style={styles.choiceBody}>
<Text style={styles.choiceTitle}>{title}</Text>
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
</View>
</Pressable>
);
}
function ProviderOption({
provider,
selected,
disabled,
onPress,
}: {
provider: { id: AgentProvider; label: string; description: string };
selected: boolean;
disabled: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerCard,
selected ? styles.providerCardSelected : null,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
<Icon size={16} color={theme.colors.foreground} />
</View>
<View style={styles.providerBody}>
<Text style={styles.providerTitle}>{provider.label}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
gap: theme.spacing[1],
},
workspaceTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
workspacePath: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
section: {
gap: theme.spacing[3],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
helper: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
lineHeight: 20,
},
choiceGrid: {
gap: theme.spacing[2],
},
choiceCard: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
},
choiceCardHovered: {
backgroundColor: theme.colors.surface2,
},
cardDisabled: {
opacity: theme.opacity[50],
},
choiceIconWrap: {
width: 32,
height: 32,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
choiceBody: {
flex: 1,
gap: 2,
},
choiceTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
choiceDescription: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
composerCard: {
minHeight: 180,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
stepHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
backButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerCard: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerCardSelected: {
borderColor: theme.colors.accent,
backgroundColor: theme.colors.surface2,
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerBody: {
flex: 1,
},
providerTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
field: {
gap: theme.spacing[2],
},
fieldLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
input: {
minHeight: 80,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface1,
color: theme.colors.foreground,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
textAlignVertical: "top",
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
flex: 1,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
lineHeight: 20,
},
}));

View File

@@ -1,6 +1,5 @@
import { Platform } from "react-native";
import { UnistylesRuntime } from "react-native-unistyles";
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
import { isDesktop, isDesktopMac } from "@/desktop/host";
export const FOOTER_HEIGHT = 75;
@@ -24,59 +23,40 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in the Electron desktop runtime (any OS)
function isElectronDesktopRuntime(): boolean {
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntime();
return isDesktop();
}
// Check if running in the Electron desktop runtime on macOS
function isElectronDesktopRuntimeMac(): boolean {
// Check if running in desktop host on macOS
function isDesktopEnvironmentMac(): boolean {
if (Platform.OS !== "web") return false;
return isElectronRuntimeMac();
return isDesktopMac();
}
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
let _isElectronRuntimeMacCached: boolean | null = null;
let _isElectronRuntimeCached: boolean | null = null;
let _isDesktopMacCached: boolean | null = null;
let _isDesktopCached: boolean | null = null;
export function getIsElectronRuntimeMac(): boolean {
if (_isElectronRuntimeMacCached === true) {
export function getIsDesktopMac(): boolean {
if (_isDesktopMacCached === true) {
return true;
}
const result = isElectronDesktopRuntimeMac();
const result = isDesktopEnvironmentMac();
if (result) {
_isElectronRuntimeMacCached = true;
_isDesktopMacCached = true;
}
return result;
}
export function getIsElectronRuntime(): boolean {
if (_isElectronRuntimeCached === true) {
export function getIsDesktop(): boolean {
if (_isDesktopCached === true) {
return true;
}
const result = isElectronDesktopRuntime();
const result = isDesktopEnvironment();
if (result) {
_isElectronRuntimeCached = true;
_isDesktopCached = true;
}
return result;
}
export function isCompactFormFactor(): boolean {
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
}
export function isDesktopFormFactor(): boolean {
return !isCompactFormFactor();
}
export function isTouchDesktopFormFactor(): boolean {
return Platform.OS !== "web" && isDesktopFormFactor();
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
// Keep that capability distinct from desktop-width layout so touch tablets
// can use the desktop shell without entering web-only code paths.
export function supportsDesktopPaneSplits(): boolean {
return Platform.OS === "web";
}

View File

@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
@@ -27,12 +27,12 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });

View File

@@ -521,9 +521,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
@@ -749,13 +748,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
(agentId: string, cursor: { endSeq: number }) => {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
@@ -858,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === result.cursor.epoch &&
current.startSeq === result.cursor.startSeq &&
current.endSeq === result.cursor.endSeq
) {
@@ -963,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const { agentId, event, timestamp, seq } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event as AgentStreamEventPayload;
if (
@@ -1005,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const result = processAgentStreamEvent({
event: streamEvent,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
@@ -1029,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (
current &&
typeof seq === "number" &&
typeof epoch === "string" &&
current.epoch === epoch &&
seq >= current.startSeq &&
seq <= current.endSeq
) {
@@ -1039,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
@@ -1090,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
removeWorkspace(serverId, message.payload.id);
removeWorkspace(serverId, String(message.payload.id));
return;
}
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);

View File

@@ -7,6 +7,7 @@ function createAgent(status: Agent["status"]): Agent {
serverId: "server-1",
id: "agent-1",
provider: "codex",
terminal: false,
status,
createdAt: new Date(0),
updatedAt: new Date(0),
@@ -19,6 +20,7 @@ function createAgent(status: Agent["status"]): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],

View File

@@ -9,13 +9,9 @@ import {
type TimelineCursor,
} from "./session-stream-reducers";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
return {
seqStart: seq,
seq,
provider: "claude",
item: { type, text },
timestamp: new Date(1000 + seq).toISOString(),
@@ -33,22 +29,30 @@ function makeTimelineEvent(
} as AgentStreamEventPayload;
}
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: { type: "user_message", text },
} as AgentStreamEventPayload;
item: {
type: "tool_call",
callId: "call-1",
name: "shell",
status,
detail: {
type: "shell",
command: "pwd",
},
error: null,
},
};
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
direction: "after",
reset: false,
epoch: "epoch-1",
startCursor: null,
endCursor: null,
startSeq: null,
endSeq: null,
entries: [],
error: null,
},
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
const baseStreamInput: ProcessAgentStreamEventInput = {
event: makeTimelineEvent("hello"),
seq: undefined,
epoch: undefined,
currentTail: [],
currentHead: [],
currentCursor: undefined,
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
timestamp: new Date(2000),
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
describe("processTimelineResponse", () => {
it("returns error path when payload.error is set", () => {
const result = processTimelineResponse({
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.head).toBe(baseTimelineInput.currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("returns error with no init resolution when no deferred exists", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
payload: {
...baseTimelineInput.payload,
error: "timeout",
},
});
expect(result.error).toBe("timeout");
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(true);
});
it("replaces tail and clears head when reset=true", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "old",
text: "old message",
timestamp: new Date(500),
},
];
const existingHead: StreamItem[] = [
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
const provisionalHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-1",
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentHead: existingHead,
payload: {
...baseTimelineInput.payload,
reset: true,
startCursor: { seq: 1 },
endCursor: { seq: 3 },
entries: [
makeTimelineEntry(1, "first"),
makeTimelineEntry(2, "second"),
makeTimelineEntry(3, "third"),
],
},
});
expect(result.tail).not.toBe(existingTail);
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
});
expect(result.error).toBe(null);
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
reset: true,
entries: [],
},
});
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
});
it("performs bootstrap tail init with catch-up side effect", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: provisionalHead,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 5 },
startSeq: 1,
endSeq: 5,
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
},
});
// Bootstrap tail replaces
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
// Should have catch-up side effect
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 5,
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
expect(catchUp).toEqual({
type: "catch_up",
cursor: { endSeq: 5 },
});
});
it("appends incrementally for contiguous seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
it("prepends older committed history for before pagination", () => {
const currentTail: StreamItem[] = [
{
kind: "assistant_message",
id: "tail-3",
text: "newer",
timestamp: new Date(3000),
},
});
expect(result.tail.length).toBeGreaterThan(0);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
expect(result.error).toBe(null);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
];
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(10, "far ahead")],
},
});
// Gap should trigger catch-up
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 3,
});
});
it("drops stale entries silently", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
},
});
// No new items appended (all dropped as stale)
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("drops entries with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-2",
entries: [makeTimelineEntry(6, "different epoch")],
},
});
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("resolves init when deferred matches direction", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.initResolution).toBe("resolve");
expect(result.clearInitializing).toBe(true);
});
it("does not resolve init when directions differ (before vs after)", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
currentTail,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
entries: [],
startSeq: 1,
endSeq: 2,
entries: [
makeTimelineEntry(1, "hello", "user_message"),
makeTimelineEntry(2, "older"),
],
},
});
// "before" direction doesn't match "after" initRequestDirection,
// and "before" is not a bootstrap tail path, so init should NOT resolve
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(false);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
startSeq: 1,
endSeq: 4,
});
expect(result.tail).toHaveLength(3);
expect(result.tail[0]?.kind).toBe("user_message");
expect(result.tail[1]?.kind).toBe("assistant_message");
expect(result.tail[2]).toBe(currentTail[0]);
});
it("clears initializing even without deferred", () => {
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.clearInitializing).toBe(true);
expect(result.initResolution).toBe(null);
});
it("always includes flush_pending_updates side effect on success", () => {
const result = processTimelineResponse({
...baseTimelineInput,
payload: {
...baseTimelineInput.payload,
entries: [],
},
});
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("initializes cursor when no existing cursor on first entries", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: undefined,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
startSeq: 121,
endSeq: 121,
entries: [makeTimelineEntry(121, "finalized reply")],
},
});
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 2,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
});
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "still streaming",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: null,
endSeq: null,
entries: [],
},
});
expect(result.head).toBe(currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
});
it("requests catch-up when committed rows arrive with a forward gap", () => {
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: 125,
endSeq: 125,
entries: [makeTimelineEntry(125, "far ahead")],
},
});
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
});
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
describe("processAgentStreamEvent", () => {
it("passes through non-timeline events without cursor changes", () => {
const turnEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
it("treats seq-less timeline events as provisional head updates", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnEvent,
event: makeTimelineEvent("partial"),
seq: undefined,
epoch: undefined,
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "partial",
});
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBe(null);
expect(result.sideEffects).toEqual([]);
});
it("accepts timeline event with cursor advance", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(1000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("new chunk"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
event: makeTimelineEvent("finalized reply"),
seq: 121,
currentHead,
currentCursor,
});
expect(result.changedTail).toBe(true);
expect(result.changedHead).toBe(true);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
expect(result.sideEffects).toEqual([]);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("replaces provisional tool progress when the committed tool row arrives", () => {
const provisional = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("running"),
seq: undefined,
});
const committed = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("completed"),
seq: 8,
currentHead: provisional.head,
currentTail: provisional.tail,
currentCursor: { startSeq: 1, endSeq: 7 },
});
expect(committed.head).toEqual([]);
expect(committed.tail).toHaveLength(1);
expect(committed.tail[0]).toMatchObject({
kind: "tool_call",
payload: {
source: "agent",
data: {
callId: "call-1",
status: "completed",
},
},
});
});
it("requests catch-up when a committed live row skips ahead", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("far ahead"),
seq: 10,
epoch: "epoch-1",
currentCursor: existingCursor,
seq: 125,
currentCursor: { startSeq: 1, endSeq: 120 },
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 4,
});
});
it("drops stale timeline event", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("old"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
it("clears provisional head on terminal turn events without committing it to tail", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "turn_completed",
provider: "claude",
},
currentHead: [
{
kind: "thought",
id: "reasoning-1",
text: "thinking",
timestamp: new Date(1000),
status: "loading",
},
],
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("drops timeline event with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("wrong epoch"),
seq: 6,
epoch: "epoch-2",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("first"),
seq: 1,
epoch: "epoch-1",
currentCursor: undefined,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 1,
});
});
it("derives optimistic idle status on turn_completed for running agent", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent).not.toBe(null);
expect(result.agent!.status).toBe("idle");
expect(result.agent!.updatedAt.getTime()).toBe(2000);
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
});
it("derives optimistic error status on turn_failed for running agent", () => {
const turnFailedEvent: AgentStreamEventPayload = {
type: "turn_failed",
provider: "claude",
error: "something broke",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnFailedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.status).toBe("error");
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "idle",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when no agent is provided", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: null,
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("preserves updatedAt when agent timestamp is newer than event", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(5000),
lastActivityAt: new Date(5000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.updatedAt.getTime()).toBe(5000);
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
});
it("does not produce agent patch for non-terminal events", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("just text"),
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
seq: 1,
epoch: "epoch-1",
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
expect(result.head).toEqual([]);
expect(result.tail).toEqual([]);
});
});

View File

@@ -1,7 +1,7 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -12,38 +12,25 @@ import {
} from "@/contexts/session-timeline-bootstrap-policy";
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
// ---------------------------------------------------------------------------
// Shared cursor type
// ---------------------------------------------------------------------------
export type TimelineCursor = {
epoch: string;
startSeq: number;
endSeq: number;
};
// ---------------------------------------------------------------------------
// Side-effect discriminated unions
// ---------------------------------------------------------------------------
export type TimelineReducerSideEffect =
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
| { type: "catch_up"; cursor: { endSeq: number } }
| { type: "flush_pending_updates" };
export type AgentStreamReducerSideEffect = {
type: "catch_up";
cursor: { epoch: string; endSeq: number };
cursor: { endSeq: number };
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
type TimelineDirection = "tail" | "before" | "after";
type InitRequestDirection = "tail" | "after";
type TimelineResponseEntry = {
seqStart: number;
seq: number;
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
payload: {
agentId: string;
direction: TimelineDirection;
reset: boolean;
epoch: string;
startCursor: { seq: number } | null;
endCursor: { seq: number } | null;
startSeq: number | null;
endSeq: number | null;
entries: TimelineResponseEntry[];
error: string | null;
};
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
sideEffects: TimelineReducerSideEffect[];
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
// ------------------------------------------------------------------
// Error path: reject init and leave stream state unchanged
// ------------------------------------------------------------------
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
// ------------------------------------------------------------------
// Convert entries to timeline units
// ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const toHydratedEvents = (
units: typeof timelineUnits,
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
units.map(({ event, timestamp }) => ({ event, timestamp }));
// ------------------------------------------------------------------
// Derive bootstrap policy (replace vs incremental)
// ------------------------------------------------------------------
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
reset: payload.reset,
epoch: payload.epoch,
endCursor: payload.endCursor,
isInitializing,
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (replace) {
// ----------------------------------------------------------------
// Replace path: full hydration from scratch
// ----------------------------------------------------------------
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
source: "canonical",
});
nextHead = [];
if (payload.startCursor && payload.endCursor) {
nextCursor = {
epoch: payload.epoch,
startSeq: payload.startCursor.seq,
endSeq: payload.endCursor.seq,
};
cursorChanged = true;
} else {
nextCursor = null;
cursorChanged = true;
}
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (timelineUnits.length > 0) {
// ----------------------------------------------------------------
// Incremental append path
// ----------------------------------------------------------------
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { epoch: string; endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
epoch: payload.epoch,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale" || decision === "drop_epoch") {
continue;
}
acceptedUnits.push(unit);
if (decision === "init") {
cursor = {
epoch: payload.epoch,
startSeq: unit.seq,
endSeq: unit.seq,
};
continue;
}
if (!cursor) {
continue;
}
cursor = {
...cursor,
endSeq: unit.seq,
};
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (
cursor &&
(!currentCursor ||
currentCursor.epoch !== cursor.epoch ||
currentCursor.startSeq !== cursor.startSeq ||
currentCursor.endSeq !== cursor.endSeq)
) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
// ------------------------------------------------------------------
// Flush pending agent updates side effect
// ------------------------------------------------------------------
sideEffects.push({ type: "flush_pending_updates" });
// ------------------------------------------------------------------
// Init resolution
// ------------------------------------------------------------------
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
reset: payload.reset,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution,
clearInitializing,
error: null,
sideEffects,
};
}
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
export interface ProcessAgentStreamEventInput {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
sideEffects: AgentStreamReducerSideEffect[];
}
function cursorsEqual(
left: TimelineCursor | null | undefined,
right: TimelineCursor | null | undefined,
): boolean {
if (!left || !right) {
return left === right;
}
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
}
function removeSupersededProvisionalItems(
head: StreamItem[],
event: AgentStreamEventPayload,
): StreamItem[] {
if (head.length === 0 || event.type !== "timeline") {
return head;
}
let nextHead = head;
if (event.item.type === "assistant_message") {
nextHead = head.filter((item) => item.kind !== "assistant_message");
} else if (event.item.type === "tool_call") {
const committedToolCall = event.item;
nextHead = head.filter(
(item) =>
item.kind !== "tool_call" ||
item.payload.source !== "agent" ||
item.payload.data.callId !== committedToolCall.callId,
);
}
return nextHead.length === head.length ? head : nextHead;
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seq,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
endSeq: payload.endSeq,
isInitializing,
hasActiveInitDeferred,
});
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (bootstrapPolicy.replace) {
nextTail = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextHead = [];
nextCursor =
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: payload.endSeq,
}
: null;
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (payload.direction === "before") {
const prepended = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
const derivedCursor =
typeof payload.startSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
}
: currentCursor;
nextCursor = derivedCursor;
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
} else if (timelineUnits.length > 0) {
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { endSeq: cursor.endSeq } : null,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale") {
continue;
}
acceptedUnits.push(unit);
cursor =
decision === "init"
? { startSeq: unit.seq, endSeq: unit.seq }
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (cursor && !cursorsEqual(currentCursor, cursor)) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
sideEffects.push({ type: "flush_pending_updates" });
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution: shouldResolveDeferredInit ? "resolve" : null,
clearInitializing,
error: null,
sideEffects,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
let shouldApplyStreamEvent = true;
let nextTail = currentTail;
let nextHead = currentHead;
let changedTail = false;
let changedHead = false;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
if (event.type === "timeline" && typeof seq === "number") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (decision === "gap") {
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
cursor: { endSeq: currentCursor.endSeq },
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
} else if (decision !== "drop_stale") {
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
source: "canonical",
});
changedTail = nextTail !== currentTail;
nextHead = removeSupersededProvisionalItems(currentHead, event);
changedHead = nextHead !== currentHead;
nextTimelineCursor =
decision === "init"
? { startSeq: seq, endSeq: seq }
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
}
} else if (event.type === "timeline") {
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
source: "live",
});
changedHead = nextHead !== currentHead;
} else if (
(event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed") &&
currentHead.length > 0
) {
nextHead = [];
changedHead = true;
}
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
event,
timestamp,
source: "live",
})
: {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
// ------------------------------------------------------------------
// Optimistic lifecycle status
// ------------------------------------------------------------------
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
}
return {
tail,
head,
tail: nextTail,
head: nextHead,
changedTail,
changedHead,
cursor: nextTimelineCursor,

View File

@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
import {
deriveBootstrapTailTimelinePolicy,
deriveInitialTimelineRequest,
shouldResolveTimelineInit,
} from "./session-timeline-bootstrap-policy";
describe("deriveBootstrapTailTimelinePolicy", () => {
it("always replaces on explicit reset without catch-up cursor", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "after",
reset: true,
epoch: "epoch-1",
endCursor: { seq: 200 },
isInitializing: false,
hasActiveInitDeferred: false,
describe("deriveInitialTimelineRequest", () => {
it("uses tail bootstrap when history has not synced yet", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "tail",
limit: 200,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toBeNull();
});
it("uses catch-up after the committed cursor once history is synced", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "after",
cursor: { seq: 42 },
limit: 0,
});
});
});
describe("deriveBootstrapTailTimelinePolicy", () => {
it("forces baseline replace and canonical catch-up for init tail race", () => {
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
const advancedCursor = { endSeq: 205 };
const tailSeqStart = 101;
const tailSeqEnd = 200;
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
const decision = classifySessionTimelineSeq({
cursor: advancedCursor,
epoch: "epoch-1",
seq,
});
if (decision === "accept" || decision === "init") {
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: true,
hasActiveInitDeferred: true,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toEqual({
epoch: "epoch-1",
endSeq: 200,
});
});
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
it("does not replace non-bootstrap, non-reset responses", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: false,
hasActiveInitDeferred: false,
});
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "tail",
reset: false,
}),
).toBe(true);
});
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "after",
reset: false,
}),
).toBe(false);
});
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "after",
responseDirection: "after",
reset: false,
}),
).toBe(true);
});

View File

@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
} | null;
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
cursor?: { seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
cursor: { seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
export function deriveBootstrapTailTimelinePolicy({
direction,
reset,
epoch,
endCursor,
endSeq,
isInitializing,
hasActiveInitDeferred,
}: {
direction: TimelineDirection;
reset: boolean;
epoch: string;
endCursor: BootstrapTailCursor;
endSeq: number | null;
isInitializing: boolean;
hasActiveInitDeferred: boolean;
}): {
replace: boolean;
catchUpCursor: { epoch: string; endSeq: number } | null;
catchUpCursor: { endSeq: number } | null;
} {
if (reset) {
return { replace: true, catchUpCursor: null };
}
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
if (!isBootstrapTailInit) {
return { replace: false, catchUpCursor: null };
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
return {
replace: true,
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
};
}
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
isInitializing,
initRequestDirection,
responseDirection,
reset,
}: {
hasActiveInitDeferred: boolean;
isInitializing: boolean;
initRequestDirection: InitRequestDirection;
responseDirection: TimelineDirection;
reset: boolean;
}): boolean {
if (!hasActiveInitDeferred || !isInitializing) {
return false;
}
if (reset) {
return true;
}
return responseDirection === initRequestDirection;
}

View File

@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
it("accepts contiguous forward seq", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 5,
}),
).toBe("accept");
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
it("drops stale seq older than the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 7,
}),
).toBe("drop_stale");
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
it("drops duplicate replay seq equal to the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 8,
}),
).toBe("drop_stale");
});
it("drops epoch mismatch", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-2",
seq: 5,
}),
).toBe("drop_epoch");
});
it("initializes when cursor is null", () => {
expect(
classifySessionTimelineSeq({
cursor: null,
epoch: "epoch-1",
seq: 1,
}),
).toBe("init");
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
it("classifies forward gaps", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 9,
}),
).toBe("gap");

View File

@@ -1,28 +1,22 @@
export type SessionTimelineSeqCursor =
| {
epoch: string;
endSeq: number;
}
| null
| undefined;
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
export function classifySessionTimelineSeq({
cursor,
epoch,
seq,
}: {
cursor: SessionTimelineSeqCursor;
epoch: string;
seq: number;
}): SessionTimelineSeqDecision {
if (!cursor) {
return "init";
}
if (cursor.epoch !== epoch) {
return "drop_epoch";
}
if (seq <= cursor.endSeq) {
return "drop_stale";
}

View File

@@ -10,7 +10,7 @@ import {
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { isCompactFormFactor } from "@/constants/layout";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
@@ -34,12 +34,12 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
// Derive isOpen from the unified panel state
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });

View File

@@ -9,7 +9,7 @@ import { settingsStyles } from "@/styles/settings";
export function DesktopPermissionsSection() {
const { theme } = useUnistyles();
const {
isDesktopApp,
isDesktop,
snapshot,
isRefreshing,
requestingPermission,
@@ -20,7 +20,7 @@ export function DesktopPermissionsSection() {
sendTestNotification,
} = useDesktopPermissions();
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}

View File

@@ -1,4 +1,4 @@
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { getDesktopHost, isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
@@ -123,7 +123,7 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
}
export function shouldUseDesktopDaemon(): boolean {
return isElectronRuntime();
return isDesktop();
}
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {

View File

@@ -97,12 +97,12 @@ export function getDesktopHost(): DesktopHostBridge | null {
return getElectronHost();
}
export function isElectronRuntime(): boolean {
export function isDesktop(): boolean {
return getDesktopHost() !== null;
}
export function isElectronRuntimeMac(): boolean {
if (!isElectronRuntime()) {
export function isDesktopMac(): boolean {
if (!isDesktop()) {
return false;
}
if (typeof navigator === "undefined") {

View File

@@ -9,7 +9,7 @@ import {
import { sendOsNotification } from "@/utils/os-notifications";
export interface UseDesktopPermissionsReturn {
isDesktopApp: boolean;
isDesktop: boolean;
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
@@ -31,7 +31,7 @@ const EMPTY_MICROPHONE_STATUS = {
};
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const isDesktopApp = shouldShowDesktopPermissionSection();
const isDesktop = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true);
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -47,7 +47,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
}, []);
const refreshPermissions = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -65,11 +65,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsRefreshing(false);
}
}
}, [isDesktopApp]);
}, [isDesktop]);
const requestPermission = useCallback(
async (kind: DesktopPermissionKind) => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -110,13 +110,13 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
await refreshPermissions();
}
},
[isDesktopApp, refreshPermissions],
[isDesktop, refreshPermissions],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -137,18 +137,18 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
setIsSendingTestNotification(false);
}
}
}, [isDesktopApp]);
}, [isDesktop]);
useEffect(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
void refreshPermissions();
}, [isDesktopApp, refreshPermissions]);
}, [isDesktop, refreshPermissions]);
return {
isDesktopApp,
isDesktop,
snapshot,
isRefreshing,
requestingPermission,

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
export interface DesktopAppUpdateCheckResult {
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
}
export function shouldShowDesktopUpdateSection(): boolean {
return Platform.OS === "web" && isElectronRuntime();
return Platform.OS === "web" && isDesktop();
}
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {

View File

@@ -11,7 +11,7 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktopApp,
isDesktop,
status,
availableUpdate,
errorMessage,
@@ -23,7 +23,7 @@ export function UpdateBanner() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!isDesktopApp) return;
if (!isDesktop) return;
void checkForUpdates({ silent: true });
@@ -36,9 +36,9 @@ export function UpdateBanner() {
clearInterval(intervalRef.current);
}
};
}, [isDesktopApp, checkForUpdates]);
}, [isDesktop, checkForUpdates]);
if (!isDesktopApp) return null;
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;

View File

@@ -18,7 +18,7 @@ export type DesktopAppUpdateStatus =
| "error";
export interface UseDesktopAppUpdaterReturn {
isDesktopApp: boolean;
isDesktop: boolean;
status: DesktopAppUpdateStatus;
statusText: string;
availableUpdate: DesktopAppUpdateCheckResult | null;
@@ -75,7 +75,7 @@ function formatStatusText(input: {
}
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const isDesktop = shouldShowDesktopUpdateSection();
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
@@ -85,7 +85,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -130,11 +130,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
return null;
}
},
[isDesktopApp],
[isDesktop],
);
const installUpdate = useCallback(async () => {
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -162,10 +162,10 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp]);
}, [isDesktop]);
return {
isDesktopApp,
isDesktop,
status,
statusText: formatStatusText({
status,

View File

@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
onlineServerIds?: string[];
};
type UseAgentFormStateResult = {
export type UseAgentFormStateResult = {
selectedServerId: string | null;
setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void;
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, [formState.workingDir]);
const providerModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
isVisible &&
isTargetDaemonReady &&
@@ -446,7 +446,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const allProviderModelQueries = useQueries({
queries: providerDefinitions.map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),

View File

@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
it("uses committed tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical tail bootstrap when cursor is missing", () => {
it("uses committed tail bootstrap when cursor is missing", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor once history is synced", () => {
it("uses committed catch-up after the current cursor once history is synced", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: true,
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
cursor: { seq: 42 },
limit: 0,
projection: "canonical",
});
});
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});

View File

@@ -60,7 +60,7 @@ export function useAgentInitialization({
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
cursor: cursor ? { seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
@@ -107,7 +107,6 @@ export function useAgentInitialization({
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
setAgentInitializing(agentId, false);

View 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: [] },
});
});
});

View 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",
});
});
});
});

View File

@@ -1,9 +1,44 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AttachmentMetadata } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
type AgentInputDraftComposerOptions = {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
isVisible?: boolean;
onlineServerIds?: string[];
lockedWorkingDir?: string;
};
type DraftKeyContext = {
selectedServerId: string | null;
};
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
type UseAgentInputDraftInput = {
draftKey: DraftKeyInput;
composer?: AgentInputDraftComposerOptions;
};
type DraftComposerState = UseAgentFormStateResult & {
workingDir: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
statusControls: DraftAgentStatusBarProps;
commandDraftConfig: DraftCommandConfig | undefined;
};
interface AgentInputDraft {
text: string;
setText: (text: string) => void;
@@ -11,6 +46,7 @@ interface AgentInputDraft {
setImages: (updater: ImageUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
composerState: DraftComposerState | null;
}
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
@@ -36,7 +72,108 @@ function areImagesEqual(input: {
});
}
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
const selectedModel = input.selectedModel.trim();
if (selectedModel) {
return selectedModel;
}
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
}
function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
function buildDraftComposerCommandConfig(input: {
provider: DraftAgentStatusBarProps["selectedProvider"];
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
};
}
function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
}): DraftAgentStatusBarProps {
const { formState } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
allProviderModels: formState.allProviderModels,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
initialServerId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true,
onlineServerIds: composerOptions?.onlineServerIds ?? [],
});
const draftKey = useMemo(
() =>
resolveDraftKey({
draftKey: input.draftKey,
selectedServerId: formState.selectedServerId,
}),
[formState.selectedServerId, input.draftKey],
);
const [text, setText] = useState("");
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
});
}, [draftKey, images, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
return;
}
if (formState.workingDir.trim() === lockedWorkingDir) {
return;
}
formState.setWorkingDir(lockedWorkingDir);
}, [composerOptions, formState, lockedWorkingDir]);
const effectiveModelId = useMemo(
() =>
resolveEffectiveComposerModelId({
selectedModel: formState.selectedModel,
availableModels: formState.availableModels,
}),
[formState.availableModels, formState.selectedModel],
);
const effectiveThinkingOptionId = useMemo(
() =>
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
effectiveModelId,
}),
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
);
const workingDir = lockedWorkingDir || formState.workingDir;
const commandDraftConfig = useMemo(
() =>
composerOptions
? buildDraftComposerCommandConfig({
provider: formState.selectedProvider,
cwd: workingDir,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
effectiveModelId,
effectiveThinkingOptionId,
})
: undefined,
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
workingDir,
formState.modeOptions,
formState.selectedMode,
formState.selectedProvider,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
if (!composerOptions) {
return null;
}
return {
...formState,
workingDir,
effectiveModelId,
effectiveThinkingOptionId,
statusControls: buildDraftStatusControls({ formState }),
commandDraftConfig,
};
}, [
commandDraftConfig,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
formState,
workingDir,
]);
return {
text,
setText,
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
setImages,
clear,
isHydrated,
composerState,
};
}
export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
};

View File

@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
serverId: "server-1",
id,
provider: "claude",
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],

View File

@@ -5,6 +5,14 @@ export interface AgentScreenAgent {
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
lastError?: string | null;
terminalExit?: {
command: string;
message: string;
exitCode: number | null;
signal: number | null;
outputLines: string[];
} | null;
projectPlacement?: {
checkout?: {
cwd?: string;

View File

@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
serverId,
serverLabel,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,

View File

@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
serverId: "server-1",
id: input?.id ?? "agent-1",
provider: input?.provider ?? "codex",
terminal: input?.terminal ?? false,
status: input?.status ?? "idle",
createdAt: input?.createdAt ?? timestamp,
updatedAt: input?.updatedAt ?? timestamp,
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: input?.currentModeId ?? null,
availableModes: input?.availableModes ?? [],

View File

@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
serverId: params.serverId,
serverLabel: params.serverLabel,
title: source.title ?? null,
terminal: source.terminal,
status: source.status,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,

View File

@@ -1,46 +1,8 @@
import type { DaemonClient } from "@server/client/daemon-client";
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
serverId: "server-a",
id: "agent-1",
provider: "codex",
status: "running",
createdAt: new Date("2026-04-01T03:00:00.000Z"),
updatedAt: new Date("2026-04-01T03:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-04-01T03:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
cwd: "/repo",
model: null,
labels: {},
archivedAt: null,
...overrides,
};
}
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-archive-agent";
describe("useArchiveAgent", () => {
beforeEach(() => {
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
it("tracks pending archive state in shared react-query cache", () => {
const queryClient = new QueryClient();
@@ -101,42 +63,4 @@ describe("useArchiveAgent", () => {
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
});
it("applies archived agent close results to session state and cached lists", async () => {
const queryClient = new QueryClient();
useSessionStore
.getState()
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
queryClient.setQueryData(["allAgents", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
applyArchivedAgentCloseResults({
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
});
expect(
useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
).toBe("2026-04-01T04:00:00.000Z");
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
entries: [{ agent: { id: "agent-2" } }],
});
});
});

View File

@@ -9,11 +9,6 @@ export interface ArchiveAgentInput {
agentId: string;
}
export interface ArchivedAgentCloseResult {
agentId: string;
archivedAt: string;
}
type ArchiveAgentPendingState = Record<string, true>;
interface SetAgentArchivingInput extends ArchiveAgentInput {
@@ -135,39 +130,6 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
});
}
interface ApplyArchivedAgentCloseResultsInput {
queryClient: QueryClient;
serverId: string;
results: ArchivedAgentCloseResult[];
}
export function applyArchivedAgentCloseResults(
input: ApplyArchivedAgentCloseResultsInput,
): void {
if (input.results.length === 0) {
return;
}
for (const result of input.results) {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(input.queryClient, {
serverId: input.serverId,
agentId: result.agentId,
});
}
void input.queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void input.queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({
...input,
@@ -203,10 +165,17 @@ export function useArchiveAgent() {
});
},
onSuccess: (result, input) => {
applyArchivedAgentCloseResults({
queryClient,
markAgentArchivedInStore({
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt: result.archivedAt }],
agentId: input.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(queryClient, input);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
},
onSettled: (_result, _error, input) => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
export interface AudioCaptureConfig {
sampleRate?: number;
@@ -157,7 +157,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isElectronRuntime();
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");

View File

@@ -17,7 +17,7 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -110,8 +110,8 @@ function resolveActionShortcutKeys(
): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const platform = { isMac, isDesktop: isDesktopApp };
const isDesktop = getIsDesktop();
const platform = { isMac, isDesktop };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parsePcm16Wav } from "@/utils/pcm16-wav";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import type {
DictationAudioSource,
@@ -168,7 +168,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
: true;
const currentOrigin =
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
const isDesktopApp = isElectronRuntime();
const isDesktopApp = isDesktop();
if (missingNavigator) {
throw new Error("Microphone capture is not supported in this environment");

View File

@@ -72,6 +72,7 @@ type CreateRequestContext = {
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onCreateStart?: () => void;
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
return EMPTY_STREAM_ITEMS;
}
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
if (!trimmedPrompt) {
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
allowEmptyText,
],
);

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { getIsDesktopMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
import { getDesktopHost } from "@/desktop/host";
@@ -96,7 +96,7 @@ function getSystemColorScheme(): ColorScheme {
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {

View File

@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import { Alert } from "react-native";
import { Platform } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop } from "@/desktop/host";
import {
normalizePickedImageAssets,
openImagePathsWithDesktopDialog,
@@ -45,7 +45,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isElectronRuntime()) {
if (Platform.OS === "web" && isDesktop()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;

View File

@@ -17,12 +17,12 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
export function useIsLocalDaemon(serverId: string): boolean {
const normalizedServerId = serverId.trim();
const isDesktopApp = shouldUseDesktopDaemon();
const isDesktop = shouldUseDesktopDaemon();
const query = useQuery({
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
queryFn: loadDesktopDaemonServerId,
enabled: isDesktopApp,
enabled: isDesktop,
staleTime: Infinity,
gcTime: Infinity,
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
@@ -32,7 +32,7 @@ export function useIsLocalDaemon(serverId: string): boolean {
retry: false,
});
if (!isDesktopApp || normalizedServerId.length === 0) {
if (!isDesktop || normalizedServerId.length === 0) {
return false;
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -66,7 +66,7 @@ export function useKeyboardShortcuts({
if (Platform.OS !== "web") return;
if (isMobile) return;
const isDesktopApp = getIsElectronRuntime();
const isDesktopApp = getIsDesktop();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {

View File

@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
const { replaceRoute } = vi.hoisted(() => ({
replaceRoute: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
replace: replaceRoute,
},
}));
import { openProjectDirectly } from "@/hooks/use-open-project";
import { useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/project";
describe("openProjectDirectly", () => {
beforeEach(() => {
replaceRoute.mockReset();
useSessionStore.setState({
sessions: {},
});
useSessionStore.getState().initializeSession(SERVER_ID, {} as never);
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
});
vi.restoreAllMocks();
});
it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"11111111-1111-1111-1111-111111111111",
);
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: WORKSPACE_ID,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
requestId: "request-1",
error: null,
workspace: {
id: 1,
projectId: 1,
projectDisplayName: "project",
projectRootPath: WORKSPACE_ID,
projectKind: "git" as const,
workspaceKind: "checkout" as const,
name: "project",
status: "done" as const,
activityAt: null,
diffStat: null,
},
})),
},
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute,
});
expect(result).toBe(true);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
expect.objectContaining({ id: "1", projectId: "1", projectRootPath: WORKSPACE_ID }),
]);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: "1",
});
expect(workspaceKey).toBeTruthy();
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string];
expect(layout.root.kind).toBe("pane");
const tabs = collectAllTabs(layout.root);
expect(tabs).toHaveLength(1);
expect(tabs[0]?.target).toEqual({
kind: "launcher",
launcherId: "11111111-1111-1111-1111-111111111111",
});
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ");
});
it("does not navigate or seed tabs when openProject fails", async () => {
const result = await openProjectDirectly({
serverId: SERVER_ID,
projectPath: WORKSPACE_ID,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
requestId: "request-2",
error: "Failed to open project",
workspace: null,
})),
},
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute,
});
expect(result).toBe(false);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false);
expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0);
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({});
expect(replaceRoute).not.toHaveBeenCalled();
});
});

View File

@@ -1,45 +1,76 @@
import { router } from "expo-router";
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { router } from "expo-router";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface OpenProjectDirectlyInput {
serverId: string;
projectPath: string;
isConnected: boolean;
client: Pick<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
openLauncherTab: (workspaceKey: string) => string | null;
replaceRoute: (route: string) => void;
}
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
const normalizedServerId = input.serverId.trim();
const trimmedPath = input.projectPath.trim();
if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
return false;
}
const payload = await input.client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
return false;
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
input.mergeWorkspaces(normalizedServerId, [workspace]);
input.setHasHydratedWorkspaces(normalizedServerId, true);
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: normalizedServerId,
workspaceId: workspace.id,
});
if (!workspaceKey) {
return false;
}
input.openLauncherTab(workspaceKey);
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
return true;
}
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
const normalizedServerId = serverId?.trim() ?? "";
const toast = useToast();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
async (path: string) => {
const trimmedPath = path.trim();
if (!trimmedPath || !client || !normalizedServerId) {
return false;
}
try {
const payload = await client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
throw new Error(payload.error || "Failed to open project");
}
mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]);
setHasHydratedWorkspaces(normalizedServerId, true);
router.replace(
prepareWorkspaceTab({
serverId: normalizedServerId,
workspaceId: payload.workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any,
);
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to open project");
return false;
}
return openProjectDirectly({
serverId: normalizedServerId,
projectPath: path,
isConnected,
client,
mergeWorkspaces,
setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
replaceRoute: (route) => {
router.replace(route as any);
},
});
},
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
);
}

View File

@@ -4,15 +4,15 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const isDesktop = getIsDesktop();
return useMemo(() => {
const platform = { isMac, isDesktop: isDesktopApp };
const platform = { isMac, isDesktop };
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return null;
@@ -23,5 +23,5 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktopApp]);
}, [actionId, overrides, isMac, isDesktop]);
}

View File

@@ -29,7 +29,7 @@ function workspace(
projectDisplayName: input.projectDisplayName ?? input.projectId,
projectRootPath: input.projectRootPath ?? input.id,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name,
status: input.status,
activityAt: input.activityAt,

View File

@@ -15,6 +15,7 @@ export interface SidebarWorkspaceEntry {
workspaceKey: string;
serverId: string;
workspaceId: string;
projectKind: WorkspaceDescriptor["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
activityAt: Date | null;
@@ -130,6 +131,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
workspaceKey: `${input.serverId}:${workspace.id}`,
serverId: input.serverId,
workspaceId: workspace.id,
projectKind: workspace.projectKind,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
activityAt: workspace.activityAt,
@@ -248,8 +250,8 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
}
function toWorkspaceDescriptor(payload: {
id: string;
projectId: string;
id: number;
projectId: number;
projectDisplayName: string;
projectRootPath: string;
projectKind: WorkspaceDescriptor["projectKind"];

View File

@@ -204,7 +204,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
help: {
id: "workspace-tab-new",
section: "tabs-panes",
label: "New agent tab",
label: "New tab",
keys: ["mod", "T"],
},
},
@@ -216,7 +216,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
help: {
id: "workspace-tab-new",
section: "tabs-panes",
label: "New agent tab",
label: "New tab",
keys: ["mod", "T"],
},
},

View File

@@ -4,16 +4,14 @@ import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Bot } from "lucide-react-native";
import invariant from "tiny-invariant";
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { Composer } from "@/components/composer";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { FileDropZone } from "@/components/file-drop-zone";
import { getProviderIcon } from "@/components/provider-icons";
import type { ImageAttachment } from "@/components/message-input";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import {
@@ -28,6 +26,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
import {
useHostRuntimeClient,
useHostRuntimeConnectionStatus,
@@ -96,7 +95,7 @@ function useAgentPanelDescriptor(
);
const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
const icon = getProviderIcon(provider);
return {
label: label ?? "",
@@ -155,6 +154,12 @@ function isNotFoundErrorMessage(message: string): boolean {
return /agent not found|not found/i.test(message);
}
type AgentLookupState =
| { tag: "idle" }
| { tag: "loading" }
| { tag: "not_found"; message: string }
| { tag: "error"; message: string };
function AgentPanelContent({
serverId,
agentId,
@@ -227,6 +232,219 @@ function AgentPanelBody({
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const { isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const setAgents = useSessionStore((state) => state.setAgents);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) =>
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const agentState = useSessionStore(
useShallow((state) => {
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
};
}),
);
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
useEffect(() => {
lookupAttemptTokenRef.current += 1;
setLookupState({ tag: "idle" });
}, [agentId, serverId]);
useEffect(() => {
if (!agentId) {
return;
}
if (agentState.id) {
if (lookupState.tag !== "idle") {
setLookupState({ tag: "idle" });
}
return;
}
if (!isConnected || !hasSession) {
return;
}
if (lookupState.tag === "loading" || lookupState.tag === "not_found") {
return;
}
setLookupState({ tag: "loading" });
const attemptToken = ++lookupAttemptTokenRef.current;
client
.fetchAgent(agentId)
.then((result) => {
if (attemptToken !== lookupAttemptTokenRef.current) {
return;
}
if (!result) {
setLookupState({
tag: "not_found",
message: `Agent not found: ${agentId}`,
});
return;
}
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: result.project,
};
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
setLookupState({ tag: "idle" });
})
.catch((error) => {
if (attemptToken !== lookupAttemptTokenRef.current) {
return;
}
const message = toErrorMessage(error);
if (isNotFoundErrorMessage(message)) {
setLookupState({ tag: "not_found", message });
return;
}
setLookupState({ tag: "error", message });
});
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
serverId,
setAgents,
setPendingPermissions,
]);
if (lookupState.tag === "not_found") {
return (
<View style={styles.container} testID="agent-not-found">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
if (lookupState.tag === "error") {
return (
<View style={styles.container} testID="agent-load-error">
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{lookupState.message}</Text>
</View>
</View>
);
}
const agent: AgentScreenAgent | null =
agentState.serverId && agentState.id && agentState.status && agentState.cwd
? {
serverId: agentState.serverId,
id: agentState.id,
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
if (!agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
</View>
);
}
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
if (agentState.terminal) {
return (
<View style={styles.root}>
<TerminalAgentPanel
serverId={serverId}
client={client}
agent={agent}
isPaneFocused={isPaneFocused}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
);
}
return (
<ChatAgentContent
serverId={serverId}
agentId={agentId}
isPaneFocused={isPaneFocused}
client={client}
isConnected={isConnected}
connectionStatus={connectionStatus}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function ChatAgentContent({
serverId,
agentId,
isPaneFocused,
client,
isConnected,
connectionStatus,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId?: string;
isPaneFocused: boolean;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const { theme } = useUnistyles();
const panelToast = useToastHost();
@@ -241,12 +459,12 @@ function AgentPanelBody({
routeKey: string;
reason: "initial-entry" | "resume";
} | null>(null);
const agentInputDraft = useAgentInputDraft(
buildDraftStoreKey({
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId: agentId ?? "__pending__",
}),
);
});
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
@@ -262,8 +480,11 @@ function AgentPanelBody({
return {
serverId: agent?.serverId ?? null,
id: agent?.id ?? null,
terminal: agent?.terminal ?? false,
status: agent?.status ?? null,
cwd: agent?.cwd ?? null,
lastError: agent?.lastError ?? null,
terminalExit: agent?.terminalExit ?? null,
archivedAt: agent?.archivedAt ?? null,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -494,6 +715,8 @@ function AgentPanelBody({
id: agentState.id,
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
@@ -615,98 +838,6 @@ function AgentPanelBody({
setMissingAgentState({ kind: "idle" });
}, [agentId, serverId]);
useEffect(() => {
if (!agentId) {
return;
}
if (agentState.id || shouldUseOptimisticStream) {
if (missingAgentState.kind !== "idle") {
setMissingAgentState({ kind: "idle" });
}
return;
}
if (!isConnected || !hasSession) {
return;
}
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
return;
}
setMissingAgentState({ kind: "resolving" });
const attemptToken = ++initAttemptTokenRef.current;
ensureAgentIsInitialized(agentId)
.then(async () => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
if (!currentAgent) {
const result = await client.fetchAgent(agentId);
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
if (!result) {
setMissingAgentState({
kind: "not_found",
message: `Agent not found: ${agentId}`,
});
return;
}
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: result.project,
};
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
setPendingPermissions(serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
setMissingAgentState({ kind: "idle" });
})
.catch((error) => {
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const message = toErrorMessage(error);
if (isNotFoundErrorMessage(message)) {
setMissingAgentState({ kind: "not_found", message });
return;
}
setMissingAgentState({ kind: "error", message });
});
}, [
agentState.id,
agentId,
client,
ensureAgentIsInitialized,
hasSession,
isConnected,
missingAgentState.kind,
serverId,
setAgents,
setPendingPermissions,
shouldUseOptimisticStream,
]);
const isHistoryRefreshCatchingUp =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -781,7 +912,7 @@ function AgentPanelBody({
</View>
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
<AgentInputArea
<Composer
agentId={agentId}
serverId={serverId}
isInputActive={isPaneFocused}

View File

@@ -0,0 +1,446 @@
import { useCallback, useMemo, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getProviderIcon } from "@/components/provider-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { generateDraftId } from "@/stores/draft-keys";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { toErrorMessage } from "@/utils/error-messages";
const MAX_VISIBLE_PROVIDER_TILES = 4;
function useLauncherPanelDescriptor() {
return {
label: "New Tab",
subtitle: "New Tab",
titleState: "ready" as const,
icon: Plus,
statusBucket: null,
};
}
function LauncherPanel() {
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
const visibleProviders = useMemo(
() => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const overflowProviders = useMemo(
() => providers.slice(MAX_VISIBLE_PROVIDER_TILES),
[providers],
);
const launchTerminalAgent = useCallback(
async (providerId: AgentProvider) => {
if (!client || !isConnected) {
setErrorMessage("Host is not connected");
return;
}
setPendingAction(providerId);
setErrorMessage(null);
try {
const agent = await client.createAgent({
provider: providerId,
cwd: workspaceId,
terminal: true,
});
recordUsage(providerId);
// Retarget first so the launcher converts in place before session reconciliation
// can materialize the new agent as a separate tab.
retargetCurrentTab({ kind: "agent", agentId: agent.id });
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
} catch (error) {
setErrorMessage(toErrorMessage(error));
} finally {
setPendingAction((current) => (current === providerId ? null : current));
}
},
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
);
const openDraftTab = useCallback(() => {
setErrorMessage(null);
setPendingAction("draft");
retargetCurrentTab({
kind: "draft",
draftId: generateDraftId(),
});
setPendingAction(null);
}, [retargetCurrentTab]);
const openTerminalTab = useCallback(async () => {
if (!client || !isConnected) {
setErrorMessage("Host is not connected");
return;
}
setPendingAction("terminal");
setErrorMessage(null);
try {
const payload = await client.createTerminal(workspaceId);
if (payload.error || !payload.terminal) {
throw new Error(payload.error ?? "Failed to open terminal");
}
retargetCurrentTab({
kind: "terminal",
terminalId: payload.terminal.id,
});
} catch (error) {
setErrorMessage(toErrorMessage(error));
} finally {
setPendingAction((current) => (current === "terminal" ? null : current));
}
}, [client, isConnected, retargetCurrentTab, workspaceId]);
const actionsDisabled = pendingAction !== null;
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={[
styles.content,
!isPaneFocused ? styles.contentUnfocused : null,
]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.inner}>
<View style={styles.primaryRow}>
<LauncherTile
title="New Chat"
Icon={SquarePen}
accent
disabled={actionsDisabled}
pending={pendingAction === "draft"}
onPress={openDraftTab}
/>
<LauncherTile
title="Terminal"
Icon={SquareTerminal}
disabled={actionsDisabled}
pending={pendingAction === "terminal"}
onPress={() => {
void openTerminalTab();
}}
/>
</View>
<Text style={styles.sectionLabel}>Terminal Agents</Text>
<View style={styles.providerGrid}>
{visibleProviders.map((provider) => (
<ProviderTile
key={provider.id}
provider={provider}
disabled={actionsDisabled}
pending={pendingAction === provider.id}
onPress={() => {
void launchTerminalAgent(provider.id);
}}
/>
))}
{overflowProviders.length > 0 ? (
<ViewAllProvidersTile
providers={overflowProviders}
disabled={actionsDisabled}
pendingProviderId={pendingAction}
onSelectProvider={(providerId) => {
void launchTerminalAgent(providerId);
}}
/>
) : null}
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ScrollView>
</View>
);
}
function LauncherTile({
title,
Icon,
accent = false,
disabled,
pending,
onPress,
}: {
title: string;
Icon: ComponentType<{ size: number; color: string }>;
accent?: boolean;
disabled: boolean;
pending: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const iconColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
const titleColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.primaryTile,
accent ? styles.primaryTileAccent : null,
(hovered || pressed) && !disabled
? accent
? styles.primaryTileAccentInteractive
: styles.tileInteractive
: null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={[styles.primaryIconWrap, accent ? styles.primaryIconWrapAccent : null]}>
{pending ? (
<ActivityIndicator
size="small"
color={accent ? theme.colors.accentForeground : theme.colors.foreground}
/>
) : (
<Icon size={16} color={iconColor} />
)}
</View>
<Text style={[styles.primaryTileTitle, { color: titleColor }]}>{title}</Text>
</Pressable>
);
}
function ProviderTile({
provider,
disabled,
pending,
onPress,
}: {
provider: { id: string; label: string; description: string };
disabled: boolean;
pending: boolean;
onPress: () => void;
}) {
const { theme } = useUnistyles();
const Icon = getProviderIcon(provider.id);
return (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerTile,
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<Text style={styles.providerLabel}>{provider.label}</Text>
</Pressable>
);
}
function ViewAllProvidersTile({
providers,
disabled,
pendingProviderId,
onSelectProvider,
}: {
providers: Array<{ id: string; label: string; description: string }>;
disabled: boolean;
pendingProviderId: string | null;
onSelectProvider: (providerId: AgentProvider) => void;
}) {
const { theme } = useUnistyles();
return (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
{({ open }) => (
<>
<View style={styles.providerIconWrap}>
<Bot size={16} color={theme.colors.foreground} />
</View>
<Text style={styles.providerLabel}>More</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
{open ? <View style={styles.dropdownOutline} /> : null}
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260}>
{providers.map((provider) => {
const Icon = getProviderIcon(provider.id);
return (
<DropdownMenuItem
key={provider.id}
description={provider.description}
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
status={pendingProviderId === provider.id ? "pending" : "idle"}
pendingLabel={`Launching ${provider.label}...`}
>
{provider.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
kind: "launcher",
component: LauncherPanel,
useDescriptor: useLauncherPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[8],
},
contentUnfocused: {
opacity: 0.96,
},
inner: {
width: "100%",
maxWidth: 360,
gap: theme.spacing[4],
},
primaryRow: {
flexDirection: "row",
gap: theme.spacing[2],
},
tileInteractive: {
backgroundColor: theme.colors.surface2,
},
tileDisabled: {
opacity: theme.opacity[50],
},
primaryTile: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
primaryTileAccent: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
primaryTileAccentInteractive: {
backgroundColor: theme.colors.accentBright,
borderColor: theme.colors.accentBright,
},
primaryIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
primaryIconWrapAccent: {
backgroundColor: "rgba(255,255,255,0.14)",
},
primaryTileTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
sectionLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
providerGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerTile: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
providerIconWrap: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.surface2,
},
providerLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
dropdownOutline: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.accent,
},
errorText: {
fontSize: theme.fontSize.sm,
color: theme.colors.destructive,
},
}));

View File

@@ -1,6 +1,7 @@
import { agentPanelRegistration } from "@/panels/agent-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { launcherPanelRegistration } from "@/panels/launcher-panel";
import { registerPanel } from "@/panels/panel-registry";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
@@ -14,5 +15,6 @@ export function ensurePanelsRegistered(): void {
registerPanel(agentPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration);
registerPanel(launcherPanelRegistration);
panelsRegistered = true;
}

View 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,
},
}));

View File

@@ -39,7 +39,7 @@ function useTerminalPanelDescriptor(
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
return {
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
subtitle: "Terminal",
titleState: "ready",
icon: Terminal,

View File

@@ -6,7 +6,7 @@ import type {
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
HostRuntimeController,
HostRuntimeStore,
@@ -112,7 +112,6 @@ function makeFetchAgentsEntry(input: {
title?: string | null;
requiresAttention?: boolean;
attentionReason?: "permission" | "error" | null;
archivedAt?: string | null;
}): FetchAgentsEntry {
return {
agent: {
@@ -134,6 +133,7 @@ function makeFetchAgentsEntry(input: {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -146,7 +146,7 @@ function makeFetchAgentsEntry(input: {
requiresAttention: input.requiresAttention ?? false,
attentionReason: input.attentionReason ?? null,
attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null,
archivedAt: input.archivedAt ?? null,
archivedAt: null,
labels: {},
},
project: {
@@ -1135,93 +1135,6 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
const host = makeHost({
serverId: "srv_archived_rehydrate",
connections: [
{
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
},
],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(
makeFetchAgentsPayload({
entries: [
makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:30:00.000Z",
archivedAt: "2026-03-30T15:31:00.000Z",
title: "Archived remotely",
}),
],
subscriptionId: "app:srv_archived_rehydrate",
}),
);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
useSessionStore.getState().setAgents(host.serverId, () => {
const stale = makeFetchAgentsEntry({
id: "agent-archived",
cwd: "/Users/moboudra/dev/paseo",
updatedAt: "2026-03-30T15:29:00.000Z",
archivedAt: null,
title: "Stale active copy",
}).agent;
const staleAgent: Agent = {
...stale,
serverId: host.serverId,
createdAt: new Date(stale.createdAt),
updatedAt: new Date(stale.updatedAt),
lastUserMessageAt: null,
lastActivityAt: new Date(stale.updatedAt),
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
};
return new Map([
[
stale.id,
staleAgent,
],
]);
});
store.syncHosts([host]);
const timeoutAt = Date.now() + 300;
let archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
while (!archivedAt && Date.now() < timeoutAt) {
await new Promise((resolve) => setTimeout(resolve, 0));
archivedAt =
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
?.archivedAt ?? null;
}
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("records unavailable startup probes when no connection can be established", async () => {
const host = makeHost({
connections: [

View File

@@ -4,22 +4,21 @@ import type { ImageAttachment } from "@/components/message-input";
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useIsFocused } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureDetector } from "react-native-gesture-handler";
import Animated from "react-native-reanimated";
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { AgentInputArea } from "@/components/agent-input-area";
import { Composer } from "@/components/composer";
import { AgentStreamView } from "@/components/agent-stream-view";
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { Combobox } from "@/components/ui/combobox";
import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
@@ -40,7 +39,7 @@ import {
} from "@/runtime/host-runtime";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context";
import { encodeImages } from "@/utils/encode-images";
@@ -65,6 +64,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
@@ -202,38 +202,46 @@ function DraftAgentScreenContent({
return values;
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId());
const draftInput = useAgentInputDraft(
{
draftKey: ({ selectedServerId }) =>
buildDraftStoreKey({
serverId: selectedServerId ?? "",
agentId: draftAgentIdRef.current,
draftId: draftIdRef.current,
}),
composer: {
initialServerId: resolvedServerId ?? null,
initialValues,
isVisible,
onlineServerIds,
},
},
);
const composerState = draftInput.composerState;
if (!composerState) {
throw new Error("Draft agent composer state is required");
}
const {
selectedServerId,
setSelectedServerIdFromUser,
selectedProvider,
setProviderFromUser,
selectedMode,
setModeFromUser,
selectedModel,
setModelFromUser,
selectedThinkingOptionId,
setThinkingOptionFromUser,
providerDefinitions,
workingDir,
setWorkingDirFromUser,
providerDefinitions,
modeOptions,
availableModels,
allProviderModels,
isAllModelsLoading,
availableThinkingOptions,
isModelLoading,
modelError,
refreshProviderModels,
setProviderAndModelFromUser,
persistFormPreferences,
} = useAgentFormState({
initialServerId: resolvedServerId ?? null,
initialValues,
isVisible,
isCreateFlow: true,
onlineServerIds,
});
const isMobile = isCompactFormFactor();
effectiveModelId,
effectiveThinkingOptionId,
commandDraftConfig,
statusControls,
} = composerState;
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -245,15 +253,6 @@ function DraftAgentScreenContent({
);
const dragHandlers = useDesktopDragHandlers();
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const draftIdRef = useRef(generateDraftId());
const draftAgentIdRef = useRef(generateDraftId());
const draftInput = useAgentInputDraft(
buildDraftStoreKey({
serverId: selectedServerId ?? "",
agentId: draftAgentIdRef.current,
draftId: draftIdRef.current,
}),
);
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
initialWorktreeMode,
@@ -744,47 +743,6 @@ function DraftAgentScreenContent({
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
const createAgentClient = sessionClient;
const effectiveDraftModelId = useMemo(() => {
if (selectedModel.trim()) {
return selectedModel.trim();
}
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
}, [availableModels, selectedModel]);
const effectiveDraftThinkingOptionId = useMemo(() => {
if (selectedThinkingOptionId.trim()) {
return selectedThinkingOptionId.trim();
}
const selectedModelDefinition =
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
).trim();
if (!cwd) {
return undefined;
}
return {
provider: selectedProvider,
cwd,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
};
}, [
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedProvider,
selectedWorktreePath,
workingDir,
]);
const {
formErrorMessage,
@@ -818,7 +776,7 @@ function DraftAgentScreenContent({
if (isModelLoading) {
return "Model defaults are still loading";
}
if (!effectiveDraftModelId) {
if (!effectiveModelId) {
return "No model is available for the selected provider";
}
if (isAttachWorktree && !selectedWorktreePath) {
@@ -851,15 +809,19 @@ function DraftAgentScreenContent({
const cwd =
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
".";
const provider = selectedProvider;
const model = effectiveDraftModelId || null;
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
const provider = composerState.selectedProvider;
const model = effectiveModelId || null;
const thinkingOptionId = effectiveThinkingOptionId || null;
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: null;
return {
serverId,
id: draftAgentIdRef.current,
provider,
terminal: false,
status: "running",
createdAt: now,
updatedAt: now,
@@ -888,14 +850,17 @@ function DraftAgentScreenContent({
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: undefined;
const config: AgentSessionConfig = {
provider: selectedProvider,
provider: composerState.selectedProvider,
cwd: resolvedWorkingDir,
...(modeId ? { modeId } : {}),
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
...(effectiveModelId ? { model: effectiveModelId } : {}),
...(effectiveThinkingOptionId
? { thinkingOptionId: effectiveThinkingOptionId }
: {}),
};
@@ -1216,7 +1181,7 @@ function DraftAgentScreenContent({
)}
</Animated.View>
<View style={styles.inputAreaWrapper}>
<AgentInputArea
<Composer
agentId={draftAgentIdRef.current}
serverId={selectedServerId ?? ""}
isInputActive={isFocused}
@@ -1230,24 +1195,9 @@ function DraftAgentScreenContent({
clearDraft={draftInput.clear}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
commandDraftConfig={commandDraftConfig}
statusControls={{
providerDefinitions,
selectedProvider,
onSelectProvider: setProviderFromUser,
modeOptions,
selectedMode,
onSelectMode: setModeFromUser,
models: availableModels,
selectedModel,
onSelectModel: setModelFromUser,
isModelLoading,
allProviderModels,
isAllModelsLoading,
onSelectProviderAndModel: setProviderAndModelFromUser,
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
...statusControls,
disabled: isSubmitting,
}}
/>

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
@@ -8,7 +8,6 @@ import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { isCompactFormFactor } from "@/constants/layout";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
@@ -17,14 +16,14 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isCompactLayout = isCompactFormFactor();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
if (!isCompactLayout) {
if (!isMobile) {
openAgentList();
}
}, [isCompactLayout, openAgentList]);
}, [isMobile, openAgentList]);
return (
<View style={styles.container}>

View File

@@ -4,7 +4,7 @@ import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native
import { router, useLocalSearchParams } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Buffer } from "buffer";
import {
Sun,
@@ -51,7 +51,7 @@ import {
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { isElectronRuntime } from "@/desktop/host";
import { isDesktop as isDesktopHost } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { resolveAppVersion } from "@/utils/app-version";
@@ -59,7 +59,6 @@ import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { isCompactFormFactor } from "@/constants/layout";
// ---------------------------------------------------------------------------
// Section definitions
@@ -80,7 +79,7 @@ interface SettingsSectionDef {
icon: ComponentType<{ size: number; color: string }>;
}
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
@@ -89,7 +88,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
{ id: "about", label: "About", icon: Info },
];
if (context.isDesktopApp) {
if (context.isDesktop) {
sections.push(
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "daemon", label: "Daemon", icon: Settings },
@@ -175,6 +174,7 @@ interface HostsSectionProps {
daemons: HostProfile[];
settings: AppSettings;
routeServerId: string;
isDesktop: boolean;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
@@ -464,10 +464,10 @@ function DiagnosticsSection({
interface AboutSectionProps {
appVersionText: string;
isDesktopApp: boolean;
isDesktop: boolean;
}
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>About</Text>
@@ -478,7 +478,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
{isDesktop ? <DesktopAppUpdateRow /> : null}
</View>
</View>
);
@@ -496,7 +496,7 @@ interface SettingsSectionContentProps {
aboutProps: AboutSectionProps;
appVersion: string | null;
isLocalDaemon: boolean;
isDesktopApp: boolean;
isDesktop: boolean;
}
function SettingsSectionContent({
@@ -507,7 +507,7 @@ function SettingsSectionContent({
aboutProps,
appVersion,
isLocalDaemon,
isDesktopApp,
isDesktop,
}: SettingsSectionContentProps) {
switch (sectionId) {
case "hosts":
@@ -521,9 +521,9 @@ function SettingsSectionContent({
case "about":
return <AboutSection {...aboutProps} />;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
return isDesktop ? <DesktopPermissionsSection /> : null;
case "daemon":
return isDesktopApp ? (
return isDesktop ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
) : null;
}
@@ -619,7 +619,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
function DesktopAppUpdateRow() {
const {
isDesktopApp,
isDesktop,
statusText,
availableUpdate,
errorMessage,
@@ -631,23 +631,23 @@ function DesktopAppUpdateRow() {
useFocusEffect(
useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return undefined;
}
void checkForUpdates({ silent: true });
return undefined;
}, [checkForUpdates, isDesktopApp]),
}, [checkForUpdates, isDesktop]),
);
const handleCheckForUpdates = useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
void checkForUpdates();
}, [checkForUpdates, isDesktopApp]);
}, [checkForUpdates, isDesktop]);
const handleInstallUpdate = useCallback(() => {
if (!isDesktopApp) {
if (!isDesktop) {
return;
}
@@ -667,9 +667,9 @@ function DesktopAppUpdateRow() {
console.error("[Settings] Failed to open app update confirmation", error);
Alert.alert("Error", "Unable to open the update confirmation dialog.");
});
}, [installUpdate, isDesktopApp]);
}, [installUpdate, isDesktop]);
if (!isDesktopApp) {
if (!isDesktop) {
return null;
}
@@ -745,7 +745,7 @@ export default function SettingsScreen() {
const isLoading = settingsLoading;
const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const isDesktop = isDesktopHost();
const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -929,13 +929,14 @@ export default function SettingsScreen() {
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
const isCompactLayout = isCompactFormFactor();
const sections = getSettingsSections({ isDesktopApp });
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const sections = getSettingsSections({ isDesktop });
const hostsProps: HostsSectionProps = {
daemons,
settings,
routeServerId,
isDesktop,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
@@ -984,7 +985,7 @@ export default function SettingsScreen() {
const aboutProps: AboutSectionProps = {
appVersionText,
isDesktopApp,
isDesktop,
};
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
@@ -994,7 +995,7 @@ export default function SettingsScreen() {
aboutProps,
appVersion,
isLocalDaemon,
isDesktopApp,
isDesktop,
};
if (isLoading) {
@@ -1008,7 +1009,7 @@ export default function SettingsScreen() {
return (
<View style={styles.container}>
<MenuHeader title="Settings" />
{isCompactLayout ? (
{isMobile ? (
<SettingsMobileLayout sections={sections} sectionContentProps={sectionContentProps} />
) : (
<SettingsDesktopLayout sections={sections} sectionContentProps={sectionContentProps} />

View File

@@ -17,7 +17,7 @@ import {
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { getIsDesktop } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
@@ -93,8 +93,8 @@ export function KeyboardShortcutsSection() {
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
@@ -180,10 +180,7 @@ export function KeyboardShortcutsSection() {
<Text style={styles.subsectionTitle}>{section.title}</Text>
<View style={settingsStyles.card}>
{section.rows.map(function (row, index) {
const bindingId = getBindingIdForAction(row.id, {
isMac,
isDesktop: isDesktopApp,
});
const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop });
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
return (

View File

@@ -19,6 +19,7 @@ function makeAgent(input: {
serverId: "srv",
id: input.id,
provider: "codex",
terminal: false,
status: "idle",
createdAt,
updatedAt: createdAt,
@@ -31,6 +32,7 @@ function makeAgent(input: {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -102,17 +104,6 @@ describe("workspace agent visibility", () => {
).toBe(true);
});
it("prunes pinned archived agent tabs because archive state is authoritative", () => {
expect(
shouldPruneWorkspaceAgentTab({
agentId: "archived-agent",
agentsHydrated: true,
knownAgentIds: new Set(["archived-agent"]),
activeAgentIds: new Set<string>(),
}),
).toBe(true);
});
it("does not prune active agent tabs", () => {
const knownAgentIds = new Set(["active-agent"]);
const activeAgentIds = new Set(["active-agent"]);

View File

@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
closeBulkWorkspaceTabs,
} from "@/screens/workspace/workspace-bulk-close";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -72,90 +71,4 @@ describe("workspace bulk close helpers", () => {
"This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.",
);
});
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeTerminalTab("t2"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const closeItems = vi.fn(async () => ({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
}));
const result = await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
logLabel: "all tabs",
});
expect(closeItems).toHaveBeenCalledTimes(1);
expect(closeItems).toHaveBeenCalledWith({
agentIds: ["a1"],
terminalIds: ["t1", "t2"],
});
expect(result).toEqual({
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
terminals: [
{ terminalId: "t1", success: true },
{ terminalId: "t2", success: false },
],
requestId: "req-1",
});
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
expect(cleanupCalls).toEqual([
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
{ tabId: "file_/repo/README.md" },
]);
});
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
const groups = classifyBulkClosableTabs([
makeAgentTab("a1"),
makeTerminalTab("t1"),
makeFileTab("/repo/README.md"),
]);
const closedTabIds: string[] = [];
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
const warn = vi.fn();
const result = await closeBulkWorkspaceTabs({
groups,
client: {
closeItems: async () => {
throw new Error("rpc failed");
},
},
closeTab: async (tabId, action) => {
closedTabIds.push(tabId);
await action();
},
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
warn,
logLabel: "others",
});
expect(warn).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
});
});

View File

@@ -1,4 +1,3 @@
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export type BulkClosableTabGroups = {
@@ -7,22 +6,6 @@ export type BulkClosableTabGroups = {
otherTabs: Array<{ tabId: string }>;
};
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
interface CloseWorkspaceTabWithCleanupInput {
tabId: string;
target?: WorkspaceTabDescriptor["target"];
}
interface CloseBulkWorkspaceTabsInput {
client: Pick<DaemonClient, "closeItems"> | null;
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
@@ -67,72 +50,3 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
}
return `This will archive ${agentTabs.length} agent(s).`;
}
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
}
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
return new Set(
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
[],
);
}
export async function closeBulkWorkspaceTabs(
input: CloseBulkWorkspaceTabsInput,
): Promise<CloseItemsPayload | null> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
let payload: CloseItemsPayload | null = null;
if (hasDestructiveTabs && client) {
try {
payload = await client.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
});
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
}
} else if (hasDestructiveTabs) {
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
error: new Error("Daemon client not available"),
});
}
const successfulAgentIds = toSuccessfulAgentIds(payload);
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
for (const { tabId, agentId } of groups.agentTabs) {
if (!successfulAgentIds.has(agentId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
if (!successfulTerminalIds.has(terminalId)) {
continue;
}
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId } of groups.otherTabs) {
await closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId });
});
}
return payload;
}

Some files were not shown because too many files have changed in this diff Show More