Merge branch 'master' into docs/remote-topology

This commit is contained in:
Çağın Dönmez 2026-04-17 01:21:24 +03:00 committed by GitHub
commit 56dc5fa382
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 11371 additions and 949 deletions

View file

@ -43,6 +43,7 @@ GATEWAY_URL=http://127.0.0.1:18789
# MEMORY_DIR=~/.openclaw/workspace/memory
# SESSIONS_DIR=~/.openclaw/agents/main/sessions
# USAGE_FILE=~/.openclaw/token-usage.json
# NERVE_WATCH_WORKSPACE_RECURSIVE=false # Disable full-workspace live file watching (default: enabled)
# ─── API Keys (optional — Edge TTS is always available as a free fallback) ───
OPENAI_API_KEY=

View file

@ -104,7 +104,7 @@ curl -fsSL https://raw.githubusercontent.com/daggerhashimoto/openclaw-nerve/mast
### Pick your setup
- **[Local](docs/DEPLOYMENT-A.md)** — Run Nerve and Gateway on one machine
- **[Local](docs/DEPLOYMENT-A.md)** — Run Nerve and Gateway on one machine. *Recommended default setup for reliability and simplicity.*
- **[Hybrid](docs/DEPLOYMENT-B.md)** — Keep Nerve local, run Gateway in the cloud
- **[Cloud](docs/DEPLOYMENT-C.md)** — Run Nerve and Gateway in the cloud

View file

@ -291,7 +291,7 @@ REPLICATE_BASE_URL=https://api.replicate.com/v1
| `SESSIONS_DIR` | `~/.openclaw/agents/main/sessions/` | Session transcript directory (scanned for token usage) |
| `USAGE_FILE` | `~/.openclaw/token-usage.json` | Persistent cumulative token usage data |
| `NERVE_VOICE_PHRASES_PATH` | `~/.nerve/voice-phrases.json` | Override location for per-language voice phrase overrides |
| `NERVE_WATCH_WORKSPACE_RECURSIVE` | `false` | Re-enables recursive `fs.watch` for full workspace `file.changed` SSE events outside `MEMORY.md` and `memory/`. Disabled by default to prevent Linux inotify `ENOSPC` watcher exhaustion. Memory watchers stay enabled for discovered agent workspaces even when this is `false`. |
| `NERVE_WATCH_WORKSPACE_RECURSIVE` | `true` | Enables recursive `fs.watch` for full workspace `file.changed` SSE events outside `MEMORY.md` and `memory/`. Set this to `false` to disable full-workspace watching if you hit Linux inotify `ENOSPC` watcher exhaustion. Memory watchers stay enabled for discovered agent workspaces even when this is `false`. |
```bash
FILE_BROWSER_ROOT=/home/user

View file

@ -243,7 +243,7 @@ Behavior by interactive profile:
- patches gateway allowed origins using the tailnet IP origin
- `Tailscale Serve`
- keeps Nerve on `127.0.0.1`
- asks whether to run `tailscale serve --bg 443 http://127.0.0.1:<PORT>`
- asks whether to run `tailscale serve --bg http://127.0.0.1:<PORT>`
- detects the resulting `https://<node>.tail<id>.ts.net` origin
- patches both Nerve and the gateway for that `*.ts.net` origin
- if Serve cannot be confirmed, asks whether to fall back to `tailnet IP` or stop

View file

@ -133,7 +133,7 @@ This keeps Nerve on localhost and lets Tailscale publish a private HTTPS URL.
On the Nerve machine:
```bash
tailscale serve --bg 443 http://127.0.0.1:3080
tailscale serve --bg http://127.0.0.1:3080
```
### 2. Find the Serve URL

View file

@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'server-dist']),
globalIgnores(['dist', 'server-dist', '.worktrees']),
{
files: ['**/*.{ts,tsx}'],
extends: [

View file

@ -1,102 +0,0 @@
---
goal: Make Kanban launches resolve a safe effective thinking level across primary and macOS fallback execution paths
version: 1.0
date_created: 2026-03-31
last_updated: 2026-03-31
owner: Jen
status: 'Completed'
tags: [feature, bug, kanban, execution, thinking, regression]
---
# Introduction
![Status: Completed](https://img.shields.io/badge/status-Completed-brightgreen)
This plan fixes Kanban execution failures caused by missing or unusable thinking-level resolution during task launch. The implementation preserves explicit task and board settings, avoids silent regressions, and applies the same launch-resolution rules to both the primary execution path and the macOS fallback path.
## 1. Requirements & Constraints
- **REQ-001**: Kanban task execution must resolve launch-time `thinking` using the same deterministic rules on both the primary `sessions_spawn` path and the macOS fallback `sessions.create` + `sessions.send` path.
- **REQ-002**: Launch-time resolution order must be: execute-time override → saved task settings → board defaults → safe automation fallback.
- **REQ-003**: If the effective thinking value is missing or `off`, launch-time thinking must be coerced to `low`.
- **REQ-004**: Explicit non-off task or board defaults must be preserved unchanged.
- **REQ-005**: Synthetic launch-time fallback thinking must not mutate persisted task state unless explicitly supplied by the caller.
- **REQ-006**: Existing model resolution behavior must remain intact unless the change is required for consistent shared launch-option handling.
- **CON-001**: No Kanban UI work is in scope for this patch.
- **CON-002**: No retry loop is allowed; the fix must prevent bad launches before they happen.
- **CON-003**: The implementation must minimize blast radius and avoid altering unrelated Kanban behavior.
- **PAT-001**: Follow strict TDD: add failing regression tests first, verify red, then implement minimal code, then verify green.
- **GUD-001**: Prefer a shared launch-resolution helper over duplicated ad hoc logic in route branches.
## 2. Implementation Steps
### Implementation Phase 1
- **GOAL-001**: Lock expected behavior with failing regression tests before production changes.
| Task | Description | Completed | Date |
|------|-------------|-----------|------|
| TASK-001 | Add a failing route test proving the macOS fallback path launches with `thinking: low` when request/task/config thinking are absent. | ✅ | 2026-03-31 |
| TASK-002 | Add a failing route test proving the primary path launches with `thinking: low` when request/task/config thinking are absent. | ✅ | 2026-03-31 |
| TASK-003 | Add passing-preservation tests proving explicit non-off thinking values still win over the fallback. | ✅ | 2026-03-31 |
### Implementation Phase 2
- **GOAL-002**: Implement shared launch-time option resolution with minimal code changes.
| Task | Description | Completed | Date |
|------|-------------|-----------|------|
| TASK-004 | Implement a shared helper for resolving effective launch model/thinking in `server/routes/kanban.ts` or a dedicated Kanban helper module. | ✅ | 2026-03-31 |
| TASK-005 | Wire the helper into both the primary and fallback execution branches without changing persisted task state for synthetic defaults. | ✅ | 2026-03-31 |
| TASK-006 | Keep existing explicit task/config model handling unchanged unless required for shared helper consistency. | ✅ | 2026-03-31 |
### Implementation Phase 3
- **GOAL-003**: Verify the patch is regression-safe and ready for review.
| Task | Description | Completed | Date |
|------|-------------|-----------|------|
| TASK-007 | Run focused Kanban route tests and confirm the new regression tests pass. | ✅ | 2026-03-31 |
| TASK-008 | Run broader Kanban/fallback-related tests and a server build to catch integration regressions. | ✅ | 2026-03-31 |
| TASK-009 | Summarize the fix, verification evidence, and any follow-up UI work that remains out of scope. | ✅ | 2026-03-31 |
## 3. Alternatives
- **ALT-001**: Patch only the macOS fallback path. Rejected because the underlying missing-thinking behavior exists conceptually in both launch paths.
- **ALT-002**: Add a retry on reasoning-required errors. Rejected because it treats the symptom after a bad launch instead of resolving launch options correctly up front.
- **ALT-003**: Block the fix on new Kanban UI controls for task/board thinking. Rejected because the server bug can be fixed immediately without expanding scope.
## 4. Dependencies
- **DEP-001**: Existing Kanban route test harness in `server/routes/kanban.test.ts`.
- **DEP-002**: Existing fallback launcher tests in `server/lib/kanban-subagent-fallback.test.ts` if helper extraction affects fallback integration.
- **DEP-003**: Existing Kanban store/config behavior in `server/lib/kanban-store.ts`.
## 5. Files
- **FILE-001**: `server/routes/kanban.ts` — shared launch-option resolution and execution wiring.
- **FILE-002**: `server/routes/kanban.test.ts` — route-level regression tests for both execution paths.
- **FILE-003**: `plan/feature-kanban-launch-thinking-resolution-1.md` — implementation plan and execution record.
## 6. Testing
- **TEST-001**: Verify fallback path sends `thinking: low` when no usable thinking is configured.
- **TEST-002**: Verify primary path sends `thinking: low` when no usable thinking is configured.
- **TEST-003**: Verify configured board thinking still wins over the fallback.
- **TEST-004**: Run focused Kanban route tests.
- **TEST-005**: Run fallback helper tests.
- **TEST-006**: Run `npm run build:server` and `npm run build`.
## 7. Risks & Assumptions
- **RISK-001**: Coercing `off` to `low` changes behavior for tasks that explicitly requested zero reasoning; accepted because strict providers otherwise reject the launch and current UI offers no Kanban thinking control.
- **RISK-002**: Future launch paths could drift if they bypass the shared resolver.
- **ASSUMPTION-001**: `low` is a safe cross-provider automation default within current Kanban semantics.
- **ASSUMPTION-002**: Persisted task state should reflect user intent, not synthetic launch-only defaults.
## 8. Related Specifications / Further Reading
- `server/routes/kanban.ts`
- `server/routes/kanban.test.ts`
- `server/lib/kanban-store.ts`
- Issue #207: https://github.com/daggerhashimoto/openclaw-nerve/issues/207

View file

@ -40,7 +40,7 @@ describe('buildAccessPlan', () => {
});
it('adds follow-up steps when tailscale-serve is selected without a confirmed ts.net origin', () => {
expect(buildAccessPlan({
const plan = buildAccessPlan({
profile: 'tailscale-serve',
port: '3080',
tailscale: {
@ -50,7 +50,10 @@ describe('buildAccessPlan', () => {
dnsName: null,
serveOrigins: [],
},
}).followUpSteps.length).toBeGreaterThan(0);
});
expect(plan.followUpSteps.length).toBeGreaterThan(0);
expect(plan.followUpSteps[0]).toContain('tailscale serve --bg http://127.0.0.1:3080');
expect(plan.followUpSteps[0]).not.toContain('--bg 443');
});
});

View file

@ -122,7 +122,7 @@ export function buildAccessPlan(input: BuildAccessPlanInput): AccessPlan {
const origin = tailscale?.serveOrigins?.[0] || null;
if (!origin) {
plan.followUpSteps = dedupe([
`Run: tailscale serve --bg 443 http://127.0.0.1:${port}`,
`Run: tailscale serve --bg http://127.0.0.1:${port}`,
'Confirm Tailscale Serve exposes a usable https://<node>.tail<id>.ts.net origin, then re-run setup.',
]);
return plan;

View file

@ -610,13 +610,13 @@ async function collectInteractive(
console.log('');
const configureServe = await confirm({
theme: promptTheme,
message: `Configure Tailscale Serve now? (tailscale serve --bg 443 http://127.0.0.1:${port})`,
message: `Configure Tailscale Serve now? (tailscale serve --bg http://127.0.0.1:${port})`,
default: true,
});
if (configureServe) {
try {
execSync(`tailscale serve --bg 443 http://127.0.0.1:${port}`, { stdio: 'pipe', timeout: 15000, encoding: 'utf8' });
execSync(`tailscale serve --bg http://127.0.0.1:${port}`, { stdio: 'pipe', timeout: 15000, encoding: 'utf8' });
success(`Tailscale Serve configured for http://127.0.0.1:${port}`);
} catch (err) {
const execErr = err as {
@ -640,7 +640,7 @@ async function collectInteractive(
warn(`Failed to configure Tailscale Serve automatically: ${detailWithStatus}`);
}
} else {
dim(`Run later: tailscale serve --bg 443 http://127.0.0.1:${port}`);
dim(`Run later: tailscale serve --bg http://127.0.0.1:${port}`);
}
tailscaleState = getTailscaleState();

View file

@ -45,7 +45,10 @@ import skillsRoutes from './routes/skills.js';
import filesRoutes from './routes/files.js';
import voicePhrasesRoutes from './routes/voice-phrases.js';
import fileBrowserRoutes from './routes/file-browser.js';
import uploadConfigRoutes from './routes/upload-config.js';
import uploadReferenceRoutes from './routes/upload-reference.js';
import kanbanRoutes from './routes/kanban.js';
import beadsRoutes from './routes/beads.js';
// activity routes removed — tab dropped from workspace panel
const app = new Hono();
@ -75,7 +78,7 @@ app.use(
app.use('*', authMiddleware);
// Apply compression to all routes except SSE (compression buffers chunks and breaks streaming)
app.use('*', async (c, next) => {
if (c.req.path === '/api/events') return next();
if (c.req.path === '/api/events' || c.req.path === '/api/files/raw') return next();
return compress()(c, next);
});
app.use('*', cacheHeaders);
@ -88,7 +91,7 @@ const routes = [
codexLimitsRoutes, claudeCodeLimitsRoutes, versionRoutes, versionCheckRoutes,
gatewayRoutes, connectDefaultsRoutes,
workspaceRoutes, cronsRoutes, sessionsRoutes, skillsRoutes, filesRoutes, apiKeysRoutes,
voicePhrasesRoutes, fileBrowserRoutes, channelsRoutes, kanbanRoutes,
voicePhrasesRoutes, fileBrowserRoutes, uploadConfigRoutes, uploadReferenceRoutes, channelsRoutes, kanbanRoutes, beadsRoutes,
];
for (const route of routes) app.route('/', route);

View file

@ -58,6 +58,47 @@ describe('agent-workspace', () => {
});
});
it('prefers explicitly configured agent workspaces from openclaw.json', async () => {
const configDir = path.join(homeDir, '.openclaw');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(path.join(configDir, 'openclaw.json'), JSON.stringify({
agents: {
defaults: { workspace: '/managed/workspaces' },
list: [
{ id: 'research', workspace: '/vaults/research' },
],
},
}, null, 2));
const { resolveAgentWorkspace } = await loadModule();
expect(resolveAgentWorkspace('research')).toEqual({
agentId: 'research',
workspaceRoot: '/vaults/research',
memoryPath: '/vaults/research/MEMORY.md',
memoryDir: '/vaults/research/memory',
});
});
it('uses agents.defaults.workspace for new non-main agents when configured', async () => {
const configDir = path.join(homeDir, '.openclaw');
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(path.join(configDir, 'openclaw.json'), JSON.stringify({
agents: {
defaults: { workspace: '/managed/workspaces' },
},
}, null, 2));
const { resolveAgentWorkspace } = await loadModule();
expect(resolveAgentWorkspace('research')).toEqual({
agentId: 'research',
workspaceRoot: '/managed/workspaces/research',
memoryPath: '/managed/workspaces/research/MEMORY.md',
memoryDir: '/managed/workspaces/research/memory',
});
});
it('returns workspaceRoot, memoryPath, and memoryDir together', async () => {
const { resolveAgentWorkspace } = await loadModule();

View file

@ -1,5 +1,6 @@
import path from 'node:path';
import { config } from './config.js';
import { buildDefaultAgentWorkspacePath, getConfiguredAgentWorkspace } from './openclaw-config.js';
export interface AgentWorkspace {
agentId: string;
@ -40,7 +41,8 @@ export function resolveAgentWorkspace(agentId?: string): AgentWorkspace {
};
}
const workspaceRoot = path.join(config.home, '.openclaw', `workspace-${normalizedAgentId}`);
const workspaceRoot = getConfiguredAgentWorkspace(normalizedAgentId)
|| buildDefaultAgentWorkspacePath(normalizedAgentId);
return {
agentId: normalizedAgentId,
workspaceRoot,

234
server/lib/beads.test.ts Normal file
View file

@ -0,0 +1,234 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const WORKSPACE_ROOT = path.resolve(path.sep, 'workspace');
const RESEARCH_WORKSPACE_ROOT = path.resolve(path.sep, 'workspace-research');
const REPO_ROOT = path.join(WORKSPACE_ROOT, 'repo', 'nerve');
const OUTSIDE_REPO_ROOT = path.resolve(path.sep, 'repos', 'demo');
const PROMISIFY_CUSTOM = Symbol.for('nodejs.util.promisify.custom');
const { execFileMock, findRepoPlanByBeadIdMock, resolveAgentWorkspaceMock } = vi.hoisted(() => {
const execFileMock = vi.fn();
execFileMock[Symbol.for('nodejs.util.promisify.custom')] = vi.fn();
return {
execFileMock,
findRepoPlanByBeadIdMock: vi.fn(),
resolveAgentWorkspaceMock: vi.fn((agentId?: string) => ({
agentId: agentId?.trim() || 'main',
workspaceRoot: agentId?.trim() === 'research' ? RESEARCH_WORKSPACE_ROOT : WORKSPACE_ROOT,
memoryPath: path.join(WORKSPACE_ROOT, 'MEMORY.md'),
memoryDir: path.join(WORKSPACE_ROOT, 'memory'),
})),
};
});
vi.mock('node:child_process', () => ({
execFile: execFileMock,
default: {
execFile: execFileMock,
},
}));
vi.mock('./plans.js', () => ({
findRepoPlanByBeadId: findRepoPlanByBeadIdMock,
}));
vi.mock('./agent-workspace.js', () => ({
resolveAgentWorkspace: resolveAgentWorkspaceMock,
}));
import { BeadValidationError, getBeadDetail, resolveBeadLookupRepoRoot } from './beads.js';
function resetMocks(): void {
vi.restoreAllMocks();
execFileMock.mockReset();
execFileMock[PROMISIFY_CUSTOM].mockReset();
findRepoPlanByBeadIdMock.mockReset();
resolveAgentWorkspaceMock.mockReset();
resolveAgentWorkspaceMock.mockImplementation((agentId?: string) => ({
agentId: agentId?.trim() || 'main',
workspaceRoot: agentId?.trim() === 'research' ? RESEARCH_WORKSPACE_ROOT : WORKSPACE_ROOT,
memoryPath: path.join(WORKSPACE_ROOT, 'MEMORY.md'),
memoryDir: path.join(WORKSPACE_ROOT, 'memory'),
}));
}
describe('resolveBeadLookupRepoRoot', () => {
beforeEach(() => {
resetMocks();
});
it('defaults legacy lookup to process cwd for the main workspace', () => {
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(REPO_ROOT);
expect(resolveBeadLookupRepoRoot()).toBe(REPO_ROOT);
cwdSpy.mockRestore();
});
it('maps the default repo root into the requested workspace when workspaceAgentId is provided', () => {
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(REPO_ROOT);
expect(resolveBeadLookupRepoRoot({ workspaceAgentId: 'research' })).toBe(
path.join(RESEARCH_WORKSPACE_ROOT, 'repo', 'nerve'),
);
cwdSpy.mockRestore();
});
it('anchors shorthand lookup to the requested workspace when cwd is outside the default workspace', () => {
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(OUTSIDE_REPO_ROOT);
expect(resolveBeadLookupRepoRoot({ workspaceAgentId: 'research' })).toBe(RESEARCH_WORKSPACE_ROOT);
cwdSpy.mockRestore();
});
it('anchors legacy shorthand lookup to the current document repo instead of cwd', () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'beads-legacy-context-'));
const workspaceRoot = path.join(tempRoot, 'workspace');
const repoOneRoot = path.join(workspaceRoot, 'repo-one');
const repoTwoRoot = path.join(workspaceRoot, 'repo-two');
const currentDocumentPath = path.join('repo-one', 'docs', 'beads.md');
mkdirSync(path.join(repoOneRoot, '.beads'), { recursive: true });
mkdirSync(path.join(repoOneRoot, 'docs'), { recursive: true });
mkdirSync(path.join(repoTwoRoot, '.beads'), { recursive: true });
mkdirSync(path.join(repoTwoRoot, 'docs'), { recursive: true });
resolveAgentWorkspaceMock.mockImplementation(() => ({
agentId: 'main',
workspaceRoot,
memoryPath: path.join(workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(workspaceRoot, 'memory'),
}));
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(repoTwoRoot);
try {
expect(resolveBeadLookupRepoRoot({ currentDocumentPath })).toBe(repoOneRoot);
} finally {
cwdSpy.mockRestore();
rmSync(tempRoot, { recursive: true, force: true });
}
});
it('uses explicit absolute repo roots directly when they stay within the workspace', () => {
expect(resolveBeadLookupRepoRoot({ targetPath: path.join(WORKSPACE_ROOT, 'repos', 'demo') })).toBe(
path.join(WORKSPACE_ROOT, 'repos', 'demo'),
);
});
it('normalizes explicit .beads targets to the owning repo root', () => {
expect(resolveBeadLookupRepoRoot({ targetPath: path.join(WORKSPACE_ROOT, 'repos', 'demo', '.beads') })).toBe(
path.join(WORKSPACE_ROOT, 'repos', 'demo'),
);
});
it('rejects explicit absolute targets outside the workspace root', () => {
expect(() => resolveBeadLookupRepoRoot({ targetPath: OUTSIDE_REPO_ROOT })).toThrow(BeadValidationError);
});
it('rejects explicit absolute targets whose real path escapes the workspace root through a symlink', () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'beads-symlink-'));
const workspaceRoot = path.join(tempRoot, 'workspace');
const outsideRoot = path.join(tempRoot, 'outside');
const linkedRepo = path.join(workspaceRoot, 'linked-repo');
mkdirSync(workspaceRoot, { recursive: true });
mkdirSync(path.join(outsideRoot, 'demo'), { recursive: true });
symlinkSync(path.join(outsideRoot, 'demo'), linkedRepo, 'dir');
resolveAgentWorkspaceMock.mockImplementation(() => ({
agentId: 'main',
workspaceRoot,
memoryPath: path.join(workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(workspaceRoot, 'memory'),
}));
try {
expect(() => resolveBeadLookupRepoRoot({ targetPath: linkedRepo })).toThrow(BeadValidationError);
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it('resolves relative explicit targets against the current markdown document directory', () => {
expect(resolveBeadLookupRepoRoot({
targetPath: '../projects/demo/.beads',
currentDocumentPath: path.join('docs', 'specs', 'links.md'),
})).toBe(path.resolve(WORKSPACE_ROOT, 'docs', 'projects', 'demo'));
});
it('uses the scoped workspace root when resolving relative explicit targets', () => {
expect(resolveBeadLookupRepoRoot({
targetPath: './repos/demo',
currentDocumentPath: path.join('notes', 'beads.md'),
workspaceAgentId: 'research',
})).toBe(path.resolve(RESEARCH_WORKSPACE_ROOT, 'notes', 'repos', 'demo'));
});
it('rejects absolute current document paths outside the workspace root', () => {
expect(() => resolveBeadLookupRepoRoot({
targetPath: './repos/demo',
currentDocumentPath: path.resolve(path.sep, 'tmp', 'beads.md'),
})).toThrow(BeadValidationError);
});
it('rejects resolved repo roots that escape the workspace root', () => {
expect(() => resolveBeadLookupRepoRoot({
targetPath: '../../../outside-repo',
currentDocumentPath: path.join('docs', 'specs', 'links.md'),
})).toThrow(BeadValidationError);
});
it('rejects relative explicit targets when no current document path is available', () => {
expect(() => resolveBeadLookupRepoRoot({ targetPath: '../projects/demo' })).toThrow(BeadValidationError);
});
});
describe('getBeadDetail', () => {
beforeEach(() => {
resetMocks();
});
it('rejects blank bead ids as validation errors', async () => {
await expect(getBeadDetail(' ')).rejects.toBeInstanceOf(BeadValidationError);
});
it('rejects missing repo roots before spawning bd', async () => {
await expect(getBeadDetail('nerve-fms2', {
targetPath: path.join(WORKSPACE_ROOT, 'repos', 'missing-demo'),
})).rejects.toBeInstanceOf(BeadValidationError);
expect(execFileMock[PROMISIFY_CUSTOM]).not.toHaveBeenCalled();
});
it('degrades linked plan enrichment failures to a null linkedPlan result', async () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'beads-detail-'));
const workspaceRoot = path.join(tempRoot, 'workspace');
const repoRoot = path.join(workspaceRoot, 'repo');
mkdirSync(repoRoot, { recursive: true });
resolveAgentWorkspaceMock.mockImplementation(() => ({
agentId: 'main',
workspaceRoot,
memoryPath: path.join(workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(workspaceRoot, 'memory'),
}));
execFileMock[PROMISIFY_CUSTOM].mockResolvedValue({
stdout: JSON.stringify({
id: 'nerve-fms2',
title: 'Demo bead',
status: 'open',
}),
stderr: '',
});
findRepoPlanByBeadIdMock.mockRejectedValue(new Error('plan lookup failed'));
try {
await expect(getBeadDetail('nerve-fms2', { targetPath: repoRoot })).resolves.toMatchObject({
id: 'nerve-fms2',
title: 'Demo bead',
linkedPlan: null,
});
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
});

390
server/lib/beads.ts Normal file
View file

@ -0,0 +1,390 @@
import { execFile as execFileCallback } from 'node:child_process';
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { findRepoPlanByBeadId } from './plans.js';
import { resolveAgentWorkspace } from './agent-workspace.js';
const execFile = promisify(execFileCallback);
const BD_TIMEOUT_MS = 15_000;
const BD_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
export class BeadNotFoundError extends Error {
constructor(beadId: string) {
super(`Bead not found: ${beadId}`);
this.name = 'BeadNotFoundError';
}
}
export class BeadAdapterError extends Error {
stderr: string;
constructor(message: string, stderr = '') {
super(message);
this.name = 'BeadAdapterError';
this.stderr = stderr;
}
}
export class BeadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'BeadValidationError';
}
}
export interface BeadRelationSummary {
id: string;
title: string | null;
status: string | null;
dependencyType: string | null;
}
export interface BeadLinkedPlanSummary {
path: string;
workspacePath: string | null;
title: string;
planId: string | null;
archived: boolean;
status: string | null;
updatedAt: number;
}
export interface BeadDetail {
id: string;
title: string;
notes: string | null;
status: string | null;
priority: number | null;
issueType: string | null;
owner: string | null;
createdAt: string | null;
updatedAt: string | null;
closedAt: string | null;
closeReason: string | null;
dependencies: BeadRelationSummary[];
dependents: BeadRelationSummary[];
linkedPlan: BeadLinkedPlanSummary | null;
}
export interface BeadLookupOptions {
targetPath?: string;
currentDocumentPath?: string;
workspaceAgentId?: string;
}
interface RawBeadRelation {
id?: unknown;
title?: unknown;
status?: unknown;
dependency_type?: unknown;
}
interface RawBeadRecord {
id?: unknown;
title?: unknown;
notes?: unknown;
status?: unknown;
priority?: unknown;
issue_type?: unknown;
owner?: unknown;
created_at?: unknown;
updated_at?: unknown;
closed_at?: unknown;
close_reason?: unknown;
dependencies?: RawBeadRelation[];
dependents?: RawBeadRelation[];
}
function normalizeString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function getPreferredLocalBinDirs(): string[] {
const home = process.env.HOME || os.homedir();
return [
path.join(home, '.local', 'bin'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.volta', 'bin'),
path.join(home, '.bun', 'bin'),
];
}
function buildRuntimePath(basePath?: string): string {
const segments = [...getPreferredLocalBinDirs(), ...(basePath || '').split(path.delimiter).filter(Boolean)];
return [...new Set(segments)].join(path.delimiter);
}
function resolveBdBin(): string {
if (process.env.BD_BIN?.trim()) return process.env.BD_BIN.trim();
for (const dir of getPreferredLocalBinDirs()) {
const candidate = path.join(dir, 'bd');
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// continue
}
}
return 'bd';
}
function parseJsonPayload(stdout: string): unknown {
const trimmed = stdout.trim();
if (!trimmed) return [];
try {
return JSON.parse(trimmed);
} catch {
// Fall through to warning-tolerant parsing.
}
for (let index = 0; index < trimmed.length; index += 1) {
const ch = trimmed[index];
if (ch !== '{' && ch !== '[') continue;
try {
return JSON.parse(trimmed.slice(index));
} catch {
// continue
}
}
throw new BeadAdapterError('Failed to parse bd JSON output');
}
function normalizeRelations(value: unknown): BeadRelationSummary[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const relation = entry as RawBeadRelation;
const id = normalizeString(relation.id);
if (!id) return [];
return [{
id,
title: normalizeString(relation.title),
status: normalizeString(relation.status),
dependencyType: normalizeString(relation.dependency_type),
} satisfies BeadRelationSummary];
});
}
function normalizeBeadRepoRoot(repoRoot: string): string {
const trimmed = repoRoot.trim();
if (!trimmed) return trimmed;
return path.basename(trimmed) === '.beads' ? path.dirname(trimmed) : trimmed;
}
function resolveExistingRealPath(candidatePath: string): string | null {
try {
return realpathSync.native(candidatePath);
} catch {
return null;
}
}
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
const normalizedCandidate = path.resolve(candidatePath);
const normalizedRoot = path.resolve(rootPath);
const candidateRealPath = resolveExistingRealPath(normalizedCandidate);
const rootRealPath = resolveExistingRealPath(normalizedRoot);
const containmentCandidate = candidateRealPath ?? normalizedCandidate;
const containmentRoot = rootRealPath ?? normalizedRoot;
const relative = path.relative(containmentRoot, containmentCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function assertPathWithinWorkspace(candidatePath: string, workspaceRoot: string, label: string): string {
const normalizedCandidate = path.resolve(candidatePath);
if (!isPathWithinRoot(normalizedCandidate, workspaceRoot)) {
throw new BeadValidationError(`${label} must stay within the workspace root`);
}
return normalizedCandidate;
}
function assertRepoRootReadableDirectory(repoRoot: string): string {
const normalizedRepoRoot = path.resolve(repoRoot);
try {
if (!statSync(normalizedRepoRoot).isDirectory()) {
throw new BeadValidationError('Resolved bead repo root must be an existing directory');
}
} catch (error) {
if (error instanceof BeadValidationError) {
throw error;
}
throw new BeadValidationError('Resolved bead repo root must be an existing directory');
}
return normalizedRepoRoot;
}
function resolveAbsoluteCurrentDocumentPath(currentDocumentPath: string, workspaceRoot: string): string {
return assertPathWithinWorkspace(
path.isAbsolute(currentDocumentPath)
? currentDocumentPath
: path.resolve(workspaceRoot, currentDocumentPath),
workspaceRoot,
'Current document path',
);
}
function resolveLegacyBeadLookupRepoRootFromCurrentDocumentPath(currentDocumentPath: string, workspaceRoot: string): string {
const absoluteDocumentPath = resolveAbsoluteCurrentDocumentPath(currentDocumentPath, workspaceRoot);
let currentDir = path.dirname(absoluteDocumentPath);
while (isPathWithinRoot(currentDir, workspaceRoot)) {
const beadDir = path.join(currentDir, '.beads');
try {
if (statSync(beadDir).isDirectory()) {
return currentDir;
}
} catch {
// Keep walking toward the workspace root.
}
if (currentDir === path.resolve(workspaceRoot)) {
break;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return path.dirname(absoluteDocumentPath);
}
export function resolveBeadLookupRepoRoot(options: BeadLookupOptions = {}): string {
const workspaceRoot = resolveAgentWorkspace(options.workspaceAgentId).workspaceRoot;
const currentDocumentPath = options.currentDocumentPath?.trim();
if (!options.targetPath?.trim()) {
if (currentDocumentPath) {
return normalizeBeadRepoRoot(resolveLegacyBeadLookupRepoRootFromCurrentDocumentPath(currentDocumentPath, workspaceRoot));
}
const cwd = process.cwd();
const defaultWorkspaceRoot = resolveAgentWorkspace().workspaceRoot;
if (!isPathWithinRoot(cwd, defaultWorkspaceRoot)) {
return options.workspaceAgentId ? normalizeBeadRepoRoot(workspaceRoot) : cwd;
}
return normalizeBeadRepoRoot(path.resolve(workspaceRoot, path.relative(defaultWorkspaceRoot, cwd)));
}
const targetPath = options.targetPath.trim();
if (path.isAbsolute(targetPath)) {
return normalizeBeadRepoRoot(assertPathWithinWorkspace(targetPath, workspaceRoot, 'Explicit bead target path'));
}
if (!currentDocumentPath) {
throw new BeadValidationError('Relative explicit bead URIs require a current document path');
}
const absoluteDocumentPath = resolveAbsoluteCurrentDocumentPath(currentDocumentPath, workspaceRoot);
const repoRoot = normalizeBeadRepoRoot(path.resolve(path.dirname(absoluteDocumentPath), targetPath));
return assertPathWithinWorkspace(repoRoot, workspaceRoot, 'Resolved bead repo root');
}
export async function getBeadDetail(beadId: string, options: BeadLookupOptions = {}): Promise<BeadDetail> {
const normalizedBeadId = beadId.trim();
if (!normalizedBeadId) {
throw new BeadValidationError('Bead id is required');
}
const repoRoot = assertRepoRootReadableDirectory(resolveBeadLookupRepoRoot(options));
let stdout = '';
let stderr = '';
try {
const result = await execFile(resolveBdBin(), ['show', normalizedBeadId, '--json'], {
cwd: repoRoot,
timeout: BD_TIMEOUT_MS,
maxBuffer: BD_MAX_BUFFER_BYTES,
env: {
...process.env,
PATH: buildRuntimePath(process.env.PATH),
},
});
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
const err = error as NodeJS.ErrnoException & { stderr?: string; code?: string; killed?: boolean; signal?: string };
const stderrLine = (err.stderr || '').trim().split('\n').find(Boolean) || '';
if (err.code === 'ENOENT') {
throw new BeadAdapterError('bd CLI not found in PATH', stderrLine);
}
if (err.killed && err.signal === 'SIGTERM') {
throw new BeadAdapterError(`bd show timed out after ${BD_TIMEOUT_MS}ms`, stderrLine);
}
if (stderrLine.toLowerCase().includes('not found') || stderrLine.toLowerCase().includes('no issue')) {
throw new BeadNotFoundError(normalizedBeadId);
}
throw new BeadAdapterError(stderrLine || err.message || 'Failed to read bead', stderrLine);
}
const payload = parseJsonPayload(stdout || stderr);
const records = Array.isArray(payload) ? payload : [payload];
const raw = records.find((entry) => normalizeString((entry as RawBeadRecord)?.id) === normalizedBeadId) as RawBeadRecord | undefined;
if (!raw || !normalizeString(raw.id) || !normalizeString(raw.title)) {
throw new BeadNotFoundError(normalizedBeadId);
}
let linkedPlan: Awaited<ReturnType<typeof findRepoPlanByBeadId>> = null;
let linkedPlanWorkspacePath: string | null = null;
try {
linkedPlan = await findRepoPlanByBeadId(normalizedBeadId, repoRoot);
if (linkedPlan) {
const workspaceRoot = resolveAgentWorkspace(options.workspaceAgentId).workspaceRoot;
const absoluteLinkedPlanPath = path.resolve(repoRoot, linkedPlan.path);
if (isPathWithinRoot(absoluteLinkedPlanPath, workspaceRoot)) {
const relativePath = path.relative(workspaceRoot, absoluteLinkedPlanPath).split(path.sep).join('/');
linkedPlanWorkspacePath = relativePath && relativePath !== '.' ? relativePath : null;
}
}
} catch {
linkedPlan = null;
linkedPlanWorkspacePath = null;
}
return {
id: normalizeString(raw.id) ?? normalizedBeadId,
title: normalizeString(raw.title) ?? normalizedBeadId,
notes: normalizeString(raw.notes),
status: normalizeString(raw.status),
priority: normalizeNumber(raw.priority),
issueType: normalizeString(raw.issue_type),
owner: normalizeString(raw.owner),
createdAt: normalizeString(raw.created_at),
updatedAt: normalizeString(raw.updated_at),
closedAt: normalizeString(raw.closed_at),
closeReason: normalizeString(raw.close_reason),
dependencies: normalizeRelations(raw.dependencies),
dependents: normalizeRelations(raw.dependents),
linkedPlan: linkedPlan ? {
path: linkedPlan.path,
workspacePath: linkedPlanWorkspacePath,
title: linkedPlan.title,
planId: linkedPlan.planId,
archived: linkedPlan.archived,
status: linkedPlan.status,
updatedAt: linkedPlan.updatedAt,
} : null,
};
}

View file

@ -58,6 +58,32 @@ describe('config module', () => {
});
});
describe('workspaceWatchRecursive', () => {
it('defaults to true when env var is unset', async () => {
vi.resetModules();
delete process.env.NERVE_WATCH_WORKSPACE_RECURSIVE;
const { config } = await import('./config.js');
expect(config.workspaceWatchRecursive).toBe(true);
});
it('can be disabled explicitly with false', async () => {
vi.resetModules();
process.env.NERVE_WATCH_WORKSPACE_RECURSIVE = 'false';
const { config } = await import('./config.js');
expect(config.workspaceWatchRecursive).toBe(false);
});
it('stays enabled when set to true', async () => {
vi.resetModules();
process.env.NERVE_WATCH_WORKSPACE_RECURSIVE = 'true';
const { config } = await import('./config.js');
expect(config.workspaceWatchRecursive).toBe(true);
});
});
describe('SESSION_COOKIE_NAME', () => {
it('includes the port number', async () => {
const { SESSION_COOKIE_NAME, config } = await import('./config.js');

View file

@ -84,7 +84,7 @@ export const config = {
memoryDir: process.env.MEMORY_DIR || path.join(HOME, '.openclaw', 'workspace', 'memory'),
sessionsDir: process.env.SESSIONS_DIR || path.join(HOME, '.openclaw', 'agents', 'main', 'sessions'),
usageFile: process.env.USAGE_FILE || path.join(HOME, '.openclaw', 'token-usage.json'),
workspaceWatchRecursive: process.env.NERVE_WATCH_WORKSPACE_RECURSIVE === 'true',
workspaceWatchRecursive: process.env.NERVE_WATCH_WORKSPACE_RECURSIVE !== 'false',
workspaceRemote: process.env.NERVE_WORKSPACE_REMOTE === 'true',
certPath: path.join(PROJECT_ROOT, 'certs', 'cert.pem'),
keyPath: path.join(PROJECT_ROOT, 'certs', 'key.pem'),

View file

@ -0,0 +1,136 @@
/** Tests for root workspace watcher discovery. */
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
type WatchCallback = (eventType: string, filename: string | Buffer | null) => void;
type WatchRecord = { target: string; callback: WatchCallback; close: ReturnType<typeof vi.fn> };
const runtime = vi.hoisted(() => ({
configPath: '/tmp/home/.openclaw/openclaw.json',
existing: new Set<string>(),
watched: [] as WatchRecord[],
listConfiguredAgentWorkspaces: vi.fn(() => [] as Array<{ agentId: string; workspaceRoot: string }>),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mock = {
...actual,
existsSync: (target: actual.PathLike) => runtime.existing.has(String(target)),
readdirSync: vi.fn(() => []),
watch: ((target: actual.PathLike, optionsOrListener: unknown, maybeListener?: unknown) => {
const callback = (typeof optionsOrListener === 'function' ? optionsOrListener : maybeListener) as WatchCallback;
const close = vi.fn();
runtime.watched.push({ target: String(target), callback, close });
return { close } as unknown as actual.FSWatcher;
}) satisfies typeof actual.watch,
};
return { ...mock, default: mock };
});
vi.mock('../routes/events.js', () => ({
broadcast: vi.fn(),
}));
vi.mock('./config.js', () => ({
config: {
home: '/tmp/home',
workspaceWatchRecursive: false,
memoryPath: '/tmp/home/workspace/MEMORY.md',
memoryDir: '/tmp/home/workspace/memory',
},
}));
vi.mock('./agent-workspace.js', () => ({
resolveAgentWorkspace: (agentId?: string) => {
const normalized = !agentId || agentId === 'main' ? 'main' : agentId;
const workspaceRoot = normalized === 'main'
? '/tmp/home/workspace'
: path.join('/tmp/home', `workspace-${normalized}`);
return {
agentId: normalized,
workspaceRoot,
memoryPath: path.join(workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(workspaceRoot, 'memory'),
};
},
}));
vi.mock('./file-utils.js', () => ({
isBinary: () => false,
isExcluded: () => false,
}));
vi.mock('./openclaw-config.js', () => ({
listConfiguredAgentWorkspaces: (...args: unknown[]) => runtime.listConfiguredAgentWorkspaces(...args),
resolveOpenClawConfigPath: () => runtime.configPath,
}));
vi.mock('./workspace-detect.js', () => ({
isWorkspaceLocal: vi.fn(async () => true),
}));
async function loadWatcherModule() {
vi.resetModules();
return import('./file-watcher.js');
}
afterEach(async () => {
const mod = await loadWatcherModule();
mod.stopFileWatcher();
runtime.watched = [];
runtime.existing.clear();
runtime.listConfiguredAgentWorkspaces.mockReset();
runtime.listConfiguredAgentWorkspaces.mockReturnValue([]);
vi.clearAllMocks();
});
describe('startFileWatcher', () => {
it('watches the custom config directory when OPENCLAW_CONFIG_PATH points outside ~/.openclaw', async () => {
runtime.configPath = '/tmp/custom-configs/nerve-openclaw.json';
runtime.existing = new Set(['/tmp/home/.openclaw', '/tmp/custom-configs']);
const mod = await loadWatcherModule();
await mod.startFileWatcher();
expect(runtime.watched.map((entry) => entry.target).sort()).toEqual([
'/tmp/custom-configs',
'/tmp/home/.openclaw',
]);
const initialCalls = runtime.listConfiguredAgentWorkspaces.mock.calls.length;
runtime.watched.find((entry) => entry.target === '/tmp/custom-configs')
?.callback('rename', 'nerve-openclaw.json');
expect(runtime.listConfiguredAgentWorkspaces.mock.calls.length).toBeGreaterThan(initialCalls);
});
it('refreshes when a custom config basename changes inside ~/.openclaw', async () => {
runtime.configPath = '/tmp/home/.openclaw/custom-openclaw.json';
runtime.existing = new Set(['/tmp/home/.openclaw']);
const mod = await loadWatcherModule();
await mod.startFileWatcher();
expect(runtime.watched.map((entry) => entry.target)).toEqual(['/tmp/home/.openclaw']);
const initialCalls = runtime.listConfiguredAgentWorkspaces.mock.calls.length;
runtime.watched[0]?.callback('rename', 'custom-openclaw.json');
expect(runtime.listConfiguredAgentWorkspaces.mock.calls.length).toBeGreaterThan(initialCalls);
});
it('still refreshes when legacy workspace-* directories change', async () => {
runtime.configPath = '/tmp/custom-configs/nerve-openclaw.json';
runtime.existing = new Set(['/tmp/home/.openclaw', '/tmp/custom-configs']);
const mod = await loadWatcherModule();
await mod.startFileWatcher();
const initialCalls = runtime.listConfiguredAgentWorkspaces.mock.calls.length;
runtime.watched.find((entry) => entry.target === '/tmp/home/.openclaw')
?.callback('rename', 'workspace-research');
expect(runtime.listConfiguredAgentWorkspaces.mock.calls.length).toBeGreaterThan(initialCalls);
});
});

View file

@ -16,9 +16,10 @@ import { broadcast } from '../routes/events.js';
import { config } from './config.js';
import { resolveAgentWorkspace, type AgentWorkspace } from './agent-workspace.js';
import { isBinary, isExcluded } from './file-utils.js';
import { listConfiguredAgentWorkspaces, resolveOpenClawConfigPath } from './openclaw-config.js';
import { isWorkspaceLocal } from './workspace-detect.js';
let rootDirWatcher: FSWatcher | null = null;
const rootDirWatchers = new Map<string, FSWatcher>();
const memoryWatchers = new Map<string, FSWatcher>();
const memoryDirWatchers = new Map<string, FSWatcher>();
const workspaceWatchers = new Map<string, FSWatcher>();
@ -73,6 +74,16 @@ function discoverWorkspaces(): AgentWorkspace[] {
const mainWorkspace = resolveAgentWorkspace('main');
workspaces.set(mainWorkspace.agentId, mainWorkspace);
for (const configured of listConfiguredAgentWorkspaces()) {
if (configured.agentId === 'main') continue;
workspaces.set(configured.agentId, {
agentId: configured.agentId,
workspaceRoot: configured.workspaceRoot,
memoryPath: path.join(configured.workspaceRoot, 'MEMORY.md'),
memoryDir: path.join(configured.workspaceRoot, 'memory'),
});
}
const openclawDir = path.join(config.home, '.openclaw');
if (!existsSync(openclawDir)) {
return [...workspaces.values()];
@ -190,16 +201,35 @@ function refreshWorkspaceWatchers(): void {
function startRootWorkspaceWatcher(): void {
const openclawDir = path.join(config.home, '.openclaw');
if (rootDirWatcher || !existsSync(openclawDir)) return;
if (rootDirWatchers.size > 0) return;
try {
rootDirWatcher = watch(openclawDir, (_eventType, filename) => {
const file = getWatchFilename(filename);
if (!file) return;
if (file === 'workspace' || file.startsWith(WORKSPACE_PREFIX)) {
refreshWorkspaceWatchers();
}
});
const configPath = resolveOpenClawConfigPath();
const configDir = path.dirname(configPath);
const configBasename = path.basename(configPath);
const watchTargets = new Set<string>();
if (existsSync(openclawDir)) watchTargets.add(openclawDir);
if (existsSync(configDir)) watchTargets.add(configDir);
for (const watchTarget of watchTargets) {
const watcher = watch(watchTarget, (_eventType, filename) => {
const file = getWatchFilename(filename);
if (!file) return;
const isConfigUpdate = watchTarget === configDir && file === configBasename;
const isWorkspaceDiscovery = watchTarget === openclawDir && (
file === 'workspace' ||
file.startsWith(WORKSPACE_PREFIX)
);
if (isConfigUpdate || isWorkspaceDiscovery) {
refreshWorkspaceWatchers();
}
});
rootDirWatchers.set(watchTarget, watcher);
}
} catch (err) {
console.warn('[file-watcher] Failed to watch workspace root for new agent workspaces:', (err as Error).message);
}
@ -229,7 +259,7 @@ export async function startFileWatcher(): Promise<void> {
startRootWorkspaceWatcher();
if (!config.workspaceWatchRecursive) {
console.log('[file-watcher] Workspace recursive watch disabled (default). Set NERVE_WATCH_WORKSPACE_RECURSIVE=true to re-enable SSE file.changed events outside memory/.');
console.log('[file-watcher] Workspace recursive watch disabled via NERVE_WATCH_WORKSPACE_RECURSIVE=false. SSE file.changed events outside memory/ are off.');
}
}
@ -238,8 +268,7 @@ export async function startFileWatcher(): Promise<void> {
* Call this during graceful shutdown.
*/
export function stopFileWatcher(): void {
rootDirWatcher?.close();
rootDirWatcher = null;
closeWatchers(rootDirWatchers);
closeWatchers(memoryWatchers);
closeWatchers(memoryDirWatchers);
closeWatchers(workspaceWatchers);

View file

@ -0,0 +1,117 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import JSON5 from 'json5';
import { config } from './config.js';
interface OpenClawAgentEntry {
id?: unknown;
workspace?: unknown;
}
interface OpenClawConfigShape {
agents?: {
defaults?: {
workspace?: unknown;
};
list?: OpenClawAgentEntry[];
};
}
type CachedConfig = {
path: string;
mtimeMs: number;
parsed: OpenClawConfigShape | null;
};
let cachedConfig: CachedConfig | null = null;
function getHomeDir(): string {
return config.home || process.env.HOME || os.homedir();
}
export function resolveOpenClawConfigPath(): string {
return process.env.OPENCLAW_CONFIG_PATH?.trim() || path.join(getHomeDir(), '.openclaw', 'openclaw.json');
}
function expandAndResolvePath(rawPath: string, configPath: string): string {
const trimmed = rawPath.trim();
if (!trimmed) return trimmed;
const expanded = trimmed === '~'
? getHomeDir()
: trimmed.startsWith('~/')
? path.join(getHomeDir(), trimmed.slice(2))
: trimmed;
if (path.isAbsolute(expanded)) return expanded;
return path.resolve(path.dirname(configPath), expanded);
}
function loadOpenClawConfig(): { configPath: string; parsed: OpenClawConfigShape | null } {
const configPath = resolveOpenClawConfigPath();
try {
const stat = fs.statSync(configPath);
if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtimeMs === stat.mtimeMs) {
return { configPath, parsed: cachedConfig.parsed };
}
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON5.parse(raw) as OpenClawConfigShape;
cachedConfig = { path: configPath, mtimeMs: stat.mtimeMs, parsed };
return { configPath, parsed };
} catch {
cachedConfig = { path: configPath, mtimeMs: -1, parsed: null };
return { configPath, parsed: null };
}
}
export function getConfiguredAgentWorkspace(agentId: string): string | null {
const { configPath, parsed } = loadOpenClawConfig();
const agents = parsed?.agents?.list;
if (!Array.isArray(agents)) return null;
const match = agents.find((entry) => typeof entry?.id === 'string' && entry.id === agentId);
if (!match || typeof match.workspace !== 'string' || !match.workspace.trim()) return null;
return expandAndResolvePath(match.workspace, configPath);
}
export function getDefaultAgentWorkspaceRoot(): string | null {
const { configPath, parsed } = loadOpenClawConfig();
const rawWorkspace = parsed?.agents?.defaults?.workspace;
if (typeof rawWorkspace !== 'string' || !rawWorkspace.trim()) return null;
return expandAndResolvePath(rawWorkspace, configPath);
}
export function buildDefaultAgentWorkspacePath(agentId: string): string {
const defaultWorkspaceRoot = getDefaultAgentWorkspaceRoot();
if (defaultWorkspaceRoot) {
return path.join(defaultWorkspaceRoot, agentId);
}
return path.join(getHomeDir(), '.openclaw', `workspace-${agentId}`);
}
export function listConfiguredAgentWorkspaces(): Array<{ agentId: string; workspaceRoot: string }> {
const { configPath, parsed } = loadOpenClawConfig();
const agents = parsed?.agents?.list;
if (!Array.isArray(agents)) return [];
const seen = new Set<string>();
const workspaces: Array<{ agentId: string; workspaceRoot: string }> = [];
for (const entry of agents) {
if (typeof entry?.id !== 'string' || !entry.id.trim()) continue;
if (typeof entry.workspace !== 'string' || !entry.workspace.trim()) continue;
if (seen.has(entry.id)) continue;
seen.add(entry.id);
workspaces.push({
agentId: entry.id,
workspaceRoot: expandAndResolvePath(entry.workspace, configPath),
});
}
return workspaces;
}

80
server/lib/plans.test.ts Normal file
View file

@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { findRepoPlanByBeadId, listRepoPlans } from './plans.js';
const tempDirs: string[] = [];
async function createTempRepo(): Promise<string> {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'nerve-plans-'));
tempDirs.push(repoRoot);
await fs.mkdir(path.join(repoRoot, '.plans'), { recursive: true });
return repoRoot;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('listRepoPlans', () => {
it('parses CRLF frontmatter and closing delimiters at EOF', async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(
path.join(repoRoot, '.plans', 'crlf-plan.md'),
[
'---',
'plan_id: plan-crlf',
'plan_title: CRLF Plan',
'status: In Progress',
'bead_ids:',
' - nerve-4gpd',
'---',
].join('\r\n'),
'utf8',
);
const plans = await listRepoPlans(repoRoot);
expect(plans).toHaveLength(1);
expect(plans[0]).toMatchObject({
path: '.plans/crlf-plan.md',
title: 'CRLF Plan',
planId: 'plan-crlf',
status: 'In Progress',
beadIds: ['nerve-4gpd'],
archived: false,
});
});
it('finds bead ids from frontmatter when the closing delimiter is the final line', async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(
path.join(repoRoot, '.plans', 'final-delimiter.md'),
'---\nplan_id: plan-final\nbead_ids: [nerve-4gpd]\n---',
'utf8',
);
await expect(findRepoPlanByBeadId('nerve-4gpd', repoRoot)).resolves.toMatchObject({
path: '.plans/final-delimiter.md',
planId: 'plan-final',
beadIds: ['nerve-4gpd'],
});
});
it('parses BOM-prefixed frontmatter', async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(
path.join(repoRoot, '.plans', 'bom-plan.md'),
'\uFEFF---\nplan_id: plan-bom\nplan_title: BOM Plan\nbead_ids:\n - nerve-bom1\n---\n# ignored title',
'utf8',
);
await expect(findRepoPlanByBeadId('nerve-bom1', repoRoot)).resolves.toMatchObject({
path: '.plans/bom-plan.md',
planId: 'plan-bom',
title: 'BOM Plan',
beadIds: ['nerve-bom1'],
});
});
});

182
server/lib/plans.ts Normal file
View file

@ -0,0 +1,182 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const PLAN_ROOT_NAME = '.plans';
function normalizeRepoRoot(repoRoot?: string): string {
return path.resolve(repoRoot || process.cwd());
}
export function getPlansRoot(repoRoot?: string): string {
return path.resolve(normalizeRepoRoot(repoRoot), PLAN_ROOT_NAME);
}
function isMarkdownFile(name: string): boolean {
return name.toLowerCase().endsWith('.md');
}
export function isArchivedPlanPath(relativePath: string): boolean {
return relativePath.split(/[\\/]+/).filter(Boolean).includes('archive');
}
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parsePlanContent(content: string): {
frontmatter: {
plan_id?: string;
plan_title?: string;
status?: string;
bead_ids?: string[];
};
body: string;
} {
const frontmatterMatch = content.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!frontmatterMatch) {
return { frontmatter: {}, body: content };
}
const rawFrontmatter = frontmatterMatch[1] ?? '';
const body = content.slice(frontmatterMatch[0].length);
const frontmatter: {
plan_id?: string;
plan_title?: string;
status?: string;
bead_ids?: string[];
} = {};
let activeArrayKey: 'bead_ids' | null = null;
for (const line of rawFrontmatter.split(/\r?\n/)) {
if (!line.trim()) continue;
const arrayMatch = line.match(/^\s+-\s+(.*)$/);
if (arrayMatch && activeArrayKey === 'bead_ids') {
const next = stripWrappingQuotes(arrayMatch[1] ?? '');
if (!frontmatter.bead_ids) frontmatter.bead_ids = [];
if (next) frontmatter.bead_ids.push(next);
continue;
}
const keyValueMatch = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
if (!keyValueMatch) {
activeArrayKey = null;
continue;
}
const [, key, rawValue] = keyValueMatch;
const value = rawValue.trim();
if (key === 'bead_ids') {
activeArrayKey = 'bead_ids';
if (!value) {
frontmatter.bead_ids = [];
} else if (value.startsWith('[') && value.endsWith(']')) {
frontmatter.bead_ids = value.slice(1, -1)
.split(',')
.map((item) => stripWrappingQuotes(item))
.filter(Boolean);
} else {
frontmatter.bead_ids = [stripWrappingQuotes(value)].filter(Boolean);
}
continue;
}
activeArrayKey = null;
if (key === 'plan_id' || key === 'plan_title' || key === 'status') {
frontmatter[key] = stripWrappingQuotes(value);
}
}
return { frontmatter, body };
}
function extractPlanTitle(content: string, frontmatter: { plan_title?: string }): string {
if (frontmatter.plan_title?.trim()) return frontmatter.plan_title.trim();
const { body } = parsePlanContent(content);
for (const line of body.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('# ')) return trimmed.slice(2).trim();
}
return 'Untitled plan';
}
async function collectPlanFiles(dirPath: string, relativeDir = ''): Promise<string[]> {
const items = await fs.readdir(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const item of items) {
const childRelative = relativeDir ? path.posix.join(relativeDir, item.name) : item.name;
const childAbsolute = path.join(dirPath, item.name);
if (item.isDirectory()) {
files.push(...await collectPlanFiles(childAbsolute, childRelative));
continue;
}
if (item.isFile() && isMarkdownFile(item.name)) {
files.push(path.posix.join(PLAN_ROOT_NAME, childRelative));
}
}
return files;
}
export interface RepoPlanSummary {
path: string;
title: string;
status: string | null;
planId: string | null;
beadIds: string[];
archived: boolean;
updatedAt: number;
}
export async function listRepoPlans(repoRoot?: string): Promise<RepoPlanSummary[]> {
const plansRoot = getPlansRoot(repoRoot);
try {
const stat = await fs.stat(plansRoot);
if (!stat.isDirectory()) return [];
} catch {
return [];
}
const relativePaths = await collectPlanFiles(plansRoot);
const plans = await Promise.all(relativePaths.map(async (relativePath) => {
const absolutePath = path.resolve(normalizeRepoRoot(repoRoot), relativePath);
const [content, stat] = await Promise.all([
fs.readFile(absolutePath, 'utf-8'),
fs.stat(absolutePath),
]);
const parsed = parsePlanContent(content);
return {
path: relativePath,
title: extractPlanTitle(content, parsed.frontmatter),
status: parsed.frontmatter.status?.trim() || null,
planId: parsed.frontmatter.plan_id?.trim() || null,
beadIds: parsed.frontmatter.bead_ids ?? [],
archived: isArchivedPlanPath(relativePath),
updatedAt: Math.floor(stat.mtimeMs),
} satisfies RepoPlanSummary;
}));
return plans.sort((left, right) => {
if (left.archived !== right.archived) return left.archived ? 1 : -1;
return right.updatedAt - left.updatedAt;
});
}
export async function findRepoPlanByBeadId(beadId: string, repoRoot?: string): Promise<RepoPlanSummary | null> {
const normalizedBeadId = beadId.trim();
if (!normalizedBeadId) return null;
const plans = await listRepoPlans(repoRoot);
return plans.find((plan) => plan.beadIds.includes(normalizedBeadId)) ?? null;
}

View file

@ -0,0 +1,387 @@
/** Tests for the server-side subagent spawn helper. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as gatewayRpc from './gateway-rpc.js';
import {
__resetSubagentSpawnTestState,
buildSpawnSubagentMarkerMessage,
buildSubagentParentCompletionMessage,
extractAssistantResultForLaunch,
isTopLevelRootSessionKey,
isUnsupportedDirectSpawnError,
pickMarkerSpawnedChildSession,
spawnSubagent,
} from './subagent-spawn.js';
describe('subagent-spawn helper', () => {
beforeEach(() => {
vi.useFakeTimers();
__resetSubagentSpawnTestState();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
__resetSubagentSpawnTestState();
});
it('recognizes top-level root session keys only', () => {
expect(isTopLevelRootSessionKey('agent:reviewer:main')).toBe(true);
expect(isTopLevelRootSessionKey('agent:reviewer:subagent:abc')).toBe(false);
expect(isTopLevelRootSessionKey('agent:reviewer:cron:daily')).toBe(false);
});
it('matches only narrow unsupported direct spawn errors', () => {
expect(isUnsupportedDirectSpawnError(new Error('unknown method: sessions.create'))).toBe(true);
expect(isUnsupportedDirectSpawnError(new Error('unknown method: sessions.send'))).toBe(true);
expect(isUnsupportedDirectSpawnError(new Error('unknown method: chat.send'))).toBe(false);
expect(isUnsupportedDirectSpawnError(new Error('internal server error'))).toBe(false);
});
it('builds the existing spawn-subagent marker message', () => {
expect(buildSpawnSubagentMarkerMessage({
task: 'Reply with exactly: OK',
label: 'audit-auth-flow',
model: 'openai/gpt-5',
thinking: 'high',
cleanup: 'delete',
})).toBe([
'[spawn-subagent]',
'task: Reply with exactly: OK',
'label: audit-auth-flow',
'model: openai/gpt-5',
'thinking: high',
'mode: run',
'cleanup: delete',
].join('\n'));
});
it('builds the generic parent completion report', () => {
const completed = buildSubagentParentCompletionMessage({
parentSessionKey: 'agent:reviewer:main',
childSessionKey: 'agent:reviewer:subagent:abc',
label: 'audit-auth-flow',
outcome: 'completed',
result: 'done',
});
expect(completed).toContain('Subagent child session completion report.');
expect(completed).toContain('Parent root: agent:reviewer:main');
expect(completed).toContain('Child session: agent:reviewer:subagent:abc');
expect(completed).toContain('Outcome: completed');
expect(completed).toContain('Result:');
const failed = buildSubagentParentCompletionMessage({
parentSessionKey: 'agent:reviewer:main',
childSessionKey: 'agent:reviewer:subagent:abc',
outcome: 'failed',
error: 'boom',
});
expect(failed).toContain('Outcome: failed');
expect(failed).toContain('Error:');
});
it('picks only a new child absent from the pre-send snapshot', () => {
const picked = pickMarkerSpawnedChildSession([
{ sessionKey: 'agent:reviewer:main' },
{ sessionKey: 'agent:reviewer:subagent:existing' },
{ sessionKey: 'agent:reviewer:subagent:new-child' },
], 'agent:reviewer:main', new Set(['agent:reviewer:main', 'agent:reviewer:subagent:existing']));
expect(picked?.sessionKey).toBe('agent:reviewer:subagent:new-child');
});
it('extracts a launched run by runId first, before later manual follow-ups', () => {
const extracted = extractAssistantResultForLaunch([
{ role: 'user', content: 'launch task', runId: 'run-1', timestamp: 100 },
{ role: 'assistant', content: 'launch result', runId: 'run-1', timestamp: 101 },
{ role: 'user', content: 'manual follow-up', timestamp: 102 },
{ role: 'assistant', content: 'manual answer', timestamp: 103 },
], { runId: 'run-1', launchTimestamp: 99 });
expect(extracted).toEqual({ started: true, resultText: 'launch result' });
});
it('extracts a launched run by launch boundary when runId is unavailable', () => {
const extracted = extractAssistantResultForLaunch([
{ role: 'user', content: 'older context', timestamp: 10 },
{ role: 'assistant', content: 'older answer', timestamp: 11 },
{ role: 'user', content: 'launch task', timestamp: 100 },
{ role: 'assistant', content: 'launch result', timestamp: 101 },
{ role: 'user', content: 'manual follow-up', timestamp: 102 },
{ role: 'assistant', content: 'manual answer', timestamp: 103 },
], { launchTimestamp: 100 });
expect(extracted).toEqual({ started: true, resultText: 'launch result' });
});
it('resolves the canonical child key returned by sessions.create', async () => {
const rpcMock = vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method) => {
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:canonical' };
if (method === 'sessions.send') return { runId: 'run-123' };
if (method === 'sessions.list') return { sessions: [] };
return {};
});
const result = await spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Reply with exactly: OK',
});
expect(result).toEqual({
sessionKey: 'agent:reviewer:subagent:canonical',
runId: 'run-123',
mode: 'direct',
});
expect(rpcMock).toHaveBeenNthCalledWith(1, 'sessions.create', expect.any(Object));
expect(rpcMock).toHaveBeenNthCalledWith(2, 'sessions.send', expect.objectContaining({
key: 'agent:reviewer:subagent:canonical',
message: 'Reply with exactly: OK',
}));
});
it('does not delete the child when sessions.send fails after create', async () => {
const rpcMock = vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method, params) => {
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:canonical' };
if (method === 'sessions.send' && params.key === 'agent:reviewer:subagent:canonical') {
throw new Error('send failed');
}
if (method === 'sessions.delete') return { ok: true };
throw new Error(`unexpected call: ${method}`);
});
await expect(spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Reply with exactly: OK',
})).rejects.toThrow('send failed');
expect(rpcMock).not.toHaveBeenCalledWith('sessions.delete', expect.anything());
});
it('reports completion back to the parent on direct success', async () => {
const calls: Array<{ method: string; params: Record<string, unknown> }> = [];
let listPolls = 0;
vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method, params) => {
calls.push({ method, params });
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:child-1' };
if (method === 'sessions.send' && params.key === 'agent:reviewer:subagent:child-1') return { runId: 'run-1' };
if (method === 'sessions.list') {
listPolls += 1;
if (listPolls === 1) {
return {
sessions: [{
sessionKey: 'agent:reviewer:subagent:child-1',
status: 'running',
busy: true,
runId: 'run-1',
}],
};
}
return {
sessions: [{
sessionKey: 'agent:reviewer:subagent:child-1',
status: 'done',
agentState: 'idle',
busy: false,
processing: false,
runId: 'run-1',
}],
};
}
if (method === 'sessions.get') {
return {
messages: [
{ role: 'user', content: 'Reply with exactly: OK', runId: 'run-1', timestamp: 100 },
{ role: 'assistant', content: 'OK', runId: 'run-1', timestamp: 101 },
{ role: 'user', content: 'manual follow-up', timestamp: 102 },
{ role: 'assistant', content: 'later answer', timestamp: 103 },
],
};
}
if (method === 'sessions.send' && params.key === 'agent:reviewer:main') return { ok: true };
throw new Error(`unexpected ${method}`);
});
await spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Reply with exactly: OK',
label: 'audit-auth-flow',
cleanup: 'keep',
});
await vi.advanceTimersByTimeAsync(20_000);
const parentReport = calls.find((call) => call.method === 'sessions.send' && call.params.key === 'agent:reviewer:main');
expect(parentReport).toBeTruthy();
expect(String(parentReport?.params.message ?? '')).toContain('Outcome: completed');
expect(String(parentReport?.params.message ?? '')).toContain('Label: audit-auth-flow');
expect(String(parentReport?.params.message ?? '')).toContain('Result:\nOK');
});
it('reports failure back to the parent when the child errors', async () => {
const calls: Array<{ method: string; params: Record<string, unknown> }> = [];
vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method, params) => {
calls.push({ method, params });
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:child-2' };
if (method === 'sessions.send' && params.key === 'agent:reviewer:subagent:child-2') return { runId: 'run-2' };
if (method === 'sessions.list') {
return {
sessions: [{
sessionKey: 'agent:reviewer:subagent:child-2',
status: 'failed',
error: 'worker crashed',
}],
};
}
if (method === 'sessions.send' && params.key === 'agent:reviewer:main') return { ok: true };
throw new Error(`unexpected ${method}`);
});
await spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
cleanup: 'keep',
});
await vi.advanceTimersByTimeAsync(10_000);
const parentReport = calls.find((call) => call.method === 'sessions.send' && call.params.key === 'agent:reviewer:main');
expect(parentReport).toBeTruthy();
expect(String(parentReport?.params.message ?? '')).toContain('Outcome: failed');
expect(String(parentReport?.params.message ?? '')).toContain('worker crashed');
});
it('deletes the child only after the parent report when cleanup=delete', async () => {
const callOrder: string[] = [];
vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method, params) => {
callOrder.push(`${method}:${String((params as Record<string, unknown>).key ?? '')}`);
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:child-3' };
if (method === 'sessions.send' && params.key === 'agent:reviewer:subagent:child-3') return { runId: 'run-3' };
if (method === 'sessions.list') {
return { sessions: [{ sessionKey: 'agent:reviewer:subagent:child-3', status: 'failed', error: 'boom' }] };
}
if (method === 'sessions.send' && params.key === 'agent:reviewer:main') return { ok: true };
if (method === 'sessions.delete') return { ok: true };
throw new Error(`unexpected ${method}`);
});
await spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
cleanup: 'delete',
});
await vi.advanceTimersByTimeAsync(10_000);
const reportIndex = callOrder.findIndex((entry) => entry === 'sessions.send:agent:reviewer:main');
const deleteIndex = callOrder.findIndex((entry) => entry === 'sessions.delete:agent:reviewer:subagent:child-3');
expect(reportIndex).toBeGreaterThan(-1);
expect(deleteIndex).toBeGreaterThan(reportIndex);
});
it('keeps the child when cleanup=keep', async () => {
const rpcMock = vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method, params) => {
if (method === 'sessions.create') return { key: 'agent:reviewer:subagent:child-4' };
if (method === 'sessions.send' && params.key === 'agent:reviewer:subagent:child-4') return { runId: 'run-4' };
if (method === 'sessions.list') {
return { sessions: [{ sessionKey: 'agent:reviewer:subagent:child-4', status: 'failed', error: 'boom' }] };
}
if (method === 'sessions.send' && params.key === 'agent:reviewer:main') return { ok: true };
throw new Error(`unexpected ${method}`);
});
await spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
cleanup: 'keep',
});
await vi.advanceTimersByTimeAsync(10_000);
expect(rpcMock).not.toHaveBeenCalledWith('sessions.delete', expect.anything());
});
it('falls back to marker mode only for narrow unsupported direct-RPC errors', async () => {
let listCallCount = 0;
const rpcMock = vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method) => {
if (method === 'sessions.create') throw new Error('unknown method: sessions.create');
if (method === 'sessions.list') {
listCallCount += 1;
if (listCallCount === 1) {
return { sessions: [{ sessionKey: 'agent:reviewer:main' }, { sessionKey: 'agent:reviewer:subagent:existing' }] };
}
return { sessions: [{ sessionKey: 'agent:reviewer:main' }, { sessionKey: 'agent:reviewer:subagent:existing' }, { sessionKey: 'agent:reviewer:subagent:new-child' }] };
}
if (method === 'chat.send') return { ok: true };
throw new Error(`unexpected ${method}`);
});
const resultPromise = spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
cleanup: 'delete',
});
await vi.advanceTimersByTimeAsync(2_000);
await expect(resultPromise).resolves.toEqual({
sessionKey: 'agent:reviewer:subagent:new-child',
mode: 'marker',
});
expect(rpcMock).toHaveBeenCalledWith('chat.send', expect.objectContaining({
sessionKey: 'agent:reviewer:main',
message: expect.stringContaining('[spawn-subagent]'),
}));
});
it('falls back to the first new child instead of timing out when multiple candidates appear', async () => {
let listCallCount = 0;
vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method) => {
if (method === 'sessions.create') throw new Error('unknown method: sessions.create');
if (method === 'sessions.list') {
listCallCount += 1;
if (listCallCount === 1) {
return { sessions: [{ sessionKey: 'agent:reviewer:main' }] };
}
return {
sessions: [
{ sessionKey: 'agent:reviewer:main' },
{ sessionKey: 'agent:reviewer:subagent:new-child-a' },
{ sessionKey: 'agent:reviewer:subagent:new-child-b' },
],
};
}
if (method === 'chat.send') return { ok: true };
throw new Error(`unexpected ${method}`);
});
const resultPromise = spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
cleanup: 'keep',
});
await vi.advanceTimersByTimeAsync(2_000);
await expect(resultPromise).resolves.toEqual({
sessionKey: 'agent:reviewer:subagent:new-child-a',
mode: 'marker',
});
});
it('does not hide generic direct-launch errors behind marker fallback', async () => {
vi.spyOn(gatewayRpc, 'gatewayRpcCall').mockImplementation(async (method) => {
if (method === 'sessions.create') throw new Error('parent root not found');
throw new Error(`unexpected ${method}`);
});
await expect(spawnSubagent({
parentSessionKey: 'agent:reviewer:main',
task: 'Do something',
})).rejects.toThrow('parent root not found');
});
});

View file

@ -0,0 +1,520 @@
/**
* Server-side subagent spawn helper.
*
* Owns the full lifecycle for direct child launches so the React client only
* needs to ask the server to spawn a child and then switch focus.
*
* @module
*/
import { randomUUID } from 'node:crypto';
import { gatewayRpcCall } from './gateway-rpc.js';
export type SubagentCleanupMode = 'keep' | 'delete';
export interface SpawnSubagentParams {
parentSessionKey: string;
task: string;
label?: string;
model?: string;
thinking?: string;
cleanup?: SubagentCleanupMode;
}
export interface SpawnSubagentResult {
sessionKey: string;
runId?: string;
mode: 'direct' | 'marker';
}
interface GatewaySessionSummary {
key?: string;
sessionKey?: string;
status?: string;
error?: string;
agentState?: string;
busy?: boolean;
processing?: boolean;
runId?: string;
currentRunId?: string;
latestRunId?: string;
}
interface LaunchMessage {
role?: string;
content?: unknown;
timestamp?: number;
ts?: number;
createdAt?: number;
runId?: string;
currentRunId?: string;
latestRunId?: string;
meta?: { runId?: string };
metadata?: { runId?: string };
}
interface ExtractedLaunchResult {
started: boolean;
resultText: string | null;
}
const ROOT_SESSION_RE = /^agent:[^:]+:main$/;
const POLL_SESSIONS_ACTIVE_MINUTES = 24 * 60;
const POLL_SESSIONS_LIMIT = 200;
const MONITOR_INITIAL_DELAY_MS = 3_000;
const MONITOR_POLL_INTERVAL_MS = 5_000;
const MONITOR_MAX_ATTEMPTS = 720;
const MARKER_DISCOVERY_TIMEOUT_MS = 60_000;
const MARKER_DISCOVERY_POLL_MS = 1_000;
const activeMonitors = new Set<string>();
function schedule(fn: () => void, ms: number): ReturnType<typeof setTimeout> {
const timer = setTimeout(fn, ms);
timer.unref?.();
return timer;
}
export function isTopLevelRootSessionKey(sessionKey: string): boolean {
return ROOT_SESSION_RE.test(sessionKey);
}
function isSubagentSessionKey(sessionKey: string): boolean {
return /^agent:[^:]+:subagent:/.test(sessionKey);
}
function isRootChildSession(sessionKey: string, parentSessionKey: string): boolean {
const parentMatch = parentSessionKey.match(/^agent:([^:]+):main$/);
if (!parentMatch) return false;
return sessionKey.startsWith(`agent:${parentMatch[1]}:subagent:`);
}
function buildRequestedChildSessionKey(parentSessionKey: string): string {
const match = parentSessionKey.match(/^agent:([^:]+):main$/);
if (!match) {
throw new Error(`Parent agent session must be a top-level root: ${parentSessionKey}`);
}
return `agent:${match[1]}:subagent:${randomUUID()}`;
}
function getSessionKey(session: GatewaySessionSummary): string | null {
if (typeof session.sessionKey === 'string' && session.sessionKey.trim()) return session.sessionKey;
if (typeof session.key === 'string' && session.key.trim()) return session.key;
return null;
}
function isBusySession(session: GatewaySessionSummary): boolean {
if (session.busy || session.processing) return true;
const status = String(session.status ?? '').toLowerCase();
const agentState = String(session.agentState ?? '').toLowerCase();
return ['running', 'thinking', 'tool_use', 'streaming', 'started', 'busy', 'working'].includes(status)
|| ['running', 'thinking', 'tool_use', 'streaming', 'busy', 'working'].includes(agentState);
}
function isTerminalFailure(session: GatewaySessionSummary): boolean {
const status = String(session.status ?? '').toLowerCase();
return status === 'error' || status === 'failed';
}
function isTerminalSuccess(session: GatewaySessionSummary): boolean {
const status = String(session.status ?? '').toLowerCase();
const agentState = String(session.agentState ?? '').toLowerCase();
return status === 'done' || (agentState === 'idle' && !session.busy && !session.processing);
}
function sessionMentionsRunId(session: GatewaySessionSummary, runId?: string): boolean {
if (!runId) return false;
return [session.runId, session.currentRunId, session.latestRunId].some((value) => value === runId);
}
function getMessageTimestamp(message: LaunchMessage): number | undefined {
const value = message.timestamp ?? message.ts ?? message.createdAt;
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function getMessageRunId(message: LaunchMessage): string | undefined {
const direct = [message.runId, message.currentRunId, message.latestRunId, message.meta?.runId, message.metadata?.runId]
.find((value) => typeof value === 'string' && value.trim());
return typeof direct === 'string' ? direct : undefined;
}
function getTextContent(content: unknown): string | null {
if (typeof content === 'string') {
const text = content.trim();
return text ? text : null;
}
if (!Array.isArray(content)) return null;
const text = content
.map((part) => {
if (!part || typeof part !== 'object') return null;
const candidate = part as { type?: string; text?: string };
if (candidate.type !== 'text' || typeof candidate.text !== 'string') return null;
return candidate.text;
})
.filter((value): value is string => Boolean(value))
.join('')
.trim();
return text || null;
}
function trimReportText(text: string, maxChars = 4_000): string {
const normalized = text.trim();
if (!normalized) return 'Completed (no result text)';
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, maxChars - 13).trimEnd()}\n\n[truncated]`;
}
export function buildSpawnSubagentMarkerMessage(params: {
task: string;
label?: string;
model?: string;
thinking?: string;
cleanup: SubagentCleanupMode;
}): string {
const lines = ['[spawn-subagent]'];
lines.push(`task: ${params.task}`);
if (params.label) lines.push(`label: ${params.label}`);
if (params.model) lines.push(`model: ${params.model}`);
if (params.thinking && params.thinking !== 'off') lines.push(`thinking: ${params.thinking}`);
lines.push('mode: run');
lines.push(`cleanup: ${params.cleanup}`);
return lines.join('\n');
}
export function isUnsupportedDirectSpawnError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const message = error.message.trim();
return message === 'unknown method: sessions.create' || message === 'unknown method: sessions.send';
}
export function buildSubagentParentCompletionMessage(params: {
parentSessionKey: string;
childSessionKey: string;
label?: string;
outcome: 'completed' | 'failed';
result?: string;
error?: string;
}): string {
const lines = [
'Subagent child session completion report.',
'',
'Use this as context from work that ran under this root. This is a completion update, not a fresh task unless follow-up is needed.',
'',
`Parent root: ${params.parentSessionKey}`,
`Child session: ${params.childSessionKey}`,
];
if (params.label) lines.push(`Label: ${params.label}`);
lines.push(`Outcome: ${params.outcome}`);
if (params.outcome === 'completed') {
lines.push('', 'Result:', trimReportText(params.result ?? 'Completed (no result text)'));
} else {
lines.push('', 'Error:', trimReportText(params.error ?? 'Child session failed'));
}
return lines.join('\n');
}
async function reportSubagentResultToParent(params: {
parentSessionKey: string;
childSessionKey: string;
label?: string;
outcome: 'completed' | 'failed';
result?: string;
error?: string;
}): Promise<void> {
const suffix = params.outcome === 'completed' ? 'done' : 'failed';
await gatewayRpcCall('sessions.send', {
key: params.parentSessionKey,
message: buildSubagentParentCompletionMessage(params),
idempotencyKey: `subagent-parent-report:${params.childSessionKey}:${suffix}`,
});
}
export function extractAssistantResultForLaunch(
rawMessages: Array<Record<string, unknown>>,
options: { runId?: string; launchTimestamp: number },
): ExtractedLaunchResult {
const messages = rawMessages as LaunchMessage[];
if (options.runId) {
const runMessages = messages.filter((message) => getMessageRunId(message) === options.runId);
const runAssistant = [...runMessages]
.reverse()
.map((message) => ({ role: message.role, text: getTextContent(message.content) }))
.find((message) => message.role === 'assistant' && message.text)?.text ?? null;
if (runAssistant) {
return { started: true, resultText: runAssistant };
}
if (runMessages.length > 0) {
return { started: true, resultText: null };
}
}
const firstPostLaunchIndex = messages.findIndex((message) => {
const timestamp = getMessageTimestamp(message);
if (typeof timestamp === 'number') return timestamp >= options.launchTimestamp;
return message.role === 'user';
});
if (firstPostLaunchIndex === -1) {
return { started: false, resultText: null };
}
let endIndex = messages.length;
for (let index = firstPostLaunchIndex + 1; index < messages.length; index += 1) {
if (messages[index]?.role === 'user') {
endIndex = index;
break;
}
}
const launchSlice = messages.slice(firstPostLaunchIndex, endIndex);
const lastAssistantText = [...launchSlice]
.reverse()
.map((message) => ({ role: message.role, text: getTextContent(message.content) }))
.find((message) => message.role === 'assistant' && message.text)?.text ?? null;
return {
started: launchSlice.length > 0,
resultText: lastAssistantText,
};
}
export function pickMarkerSpawnedChildSession(
sessions: GatewaySessionSummary[],
parentSessionKey: string,
knownSessionKeysBefore: Set<string>,
): GatewaySessionSummary | null {
const candidates = sessions.filter((session) => {
const sessionKey = getSessionKey(session);
if (!sessionKey) return false;
if (!isSubagentSessionKey(sessionKey)) return false;
if (!isRootChildSession(sessionKey, parentSessionKey)) return false;
return !knownSessionKeysBefore.has(sessionKey);
});
return candidates[0] ?? null;
}
function startCompletionMonitor(params: {
parentSessionKey: string;
childSessionKey: string;
label?: string;
cleanup: SubagentCleanupMode;
runId?: string;
launchTimestamp: number;
}): void {
if (activeMonitors.has(params.childSessionKey)) return;
activeMonitors.add(params.childSessionKey);
let attempts = 0;
let observedRunStart = false;
const finish = async (outcome: 'completed' | 'failed', details: { result?: string; error?: string }) => {
activeMonitors.delete(params.childSessionKey);
let reportSent = false;
try {
await reportSubagentResultToParent({
parentSessionKey: params.parentSessionKey,
childSessionKey: params.childSessionKey,
label: params.label,
outcome,
...details,
});
reportSent = true;
} catch (error) {
console.warn(`[subagent-spawn] Failed to report ${outcome} for ${params.childSessionKey}:`, error);
}
if (reportSent && params.cleanup === 'delete') {
try {
await gatewayRpcCall('sessions.delete', {
key: params.childSessionKey,
deleteTranscript: true,
});
} catch (error) {
console.warn(`[subagent-spawn] Failed to delete child ${params.childSessionKey}:`, error);
}
}
};
const poll = async () => {
attempts += 1;
if (attempts > MONITOR_MAX_ATTEMPTS) {
await finish('failed', { error: 'Subagent timed out (polling limit reached)' });
return;
}
try {
const listResponse = await gatewayRpcCall('sessions.list', {
activeMinutes: POLL_SESSIONS_ACTIVE_MINUTES,
limit: POLL_SESSIONS_LIMIT,
}) as { sessions?: GatewaySessionSummary[] };
const sessions = Array.isArray(listResponse.sessions) ? listResponse.sessions : [];
const session = sessions.find((candidate) => getSessionKey(candidate) === params.childSessionKey);
if (!session) {
schedule(() => { void poll(); }, MONITOR_POLL_INTERVAL_MS);
return;
}
if (sessionMentionsRunId(session, params.runId) || isBusySession(session)) {
observedRunStart = true;
}
if (isTerminalFailure(session)) {
await finish('failed', { error: session.error || 'Child session failed' });
return;
}
if (!isTerminalSuccess(session)) {
schedule(() => { void poll(); }, MONITOR_POLL_INTERVAL_MS);
return;
}
const historyResponse = await gatewayRpcCall('sessions.get', {
key: params.childSessionKey,
limit: 20,
includeTools: true,
}) as { messages?: Array<Record<string, unknown>> };
const messages = Array.isArray(historyResponse.messages) ? historyResponse.messages : [];
const extracted = extractAssistantResultForLaunch(messages, {
runId: params.runId,
launchTimestamp: params.launchTimestamp,
});
if (extracted.started) {
observedRunStart = true;
}
if (!observedRunStart) {
schedule(() => { void poll(); }, MONITOR_POLL_INTERVAL_MS);
return;
}
await finish('completed', {
result: extracted.resultText ?? 'Completed (no result text)',
});
} catch (error) {
console.warn(`[subagent-spawn] Poll error for ${params.childSessionKey}:`, error);
schedule(() => { void poll(); }, MONITOR_POLL_INTERVAL_MS);
}
};
schedule(() => { void poll(); }, MONITOR_INITIAL_DELAY_MS);
}
async function launchDirect(params: SpawnSubagentParams): Promise<SpawnSubagentResult> {
if (!isTopLevelRootSessionKey(params.parentSessionKey)) {
throw new Error(`parentSessionKey must be a top-level root session key (agent:<id>:main): ${params.parentSessionKey}`);
}
const requestedKey = buildRequestedChildSessionKey(params.parentSessionKey);
const createResponse = await gatewayRpcCall('sessions.create', {
key: requestedKey,
parentSessionKey: params.parentSessionKey,
...(params.label ? { label: params.label } : {}),
...(params.model ? { model: params.model } : {}),
}) as { key?: string; sessionKey?: string };
const sessionKey = typeof createResponse.key === 'string' && createResponse.key.trim()
? createResponse.key
: typeof createResponse.sessionKey === 'string' && createResponse.sessionKey.trim()
? createResponse.sessionKey
: requestedKey;
const launchTimestamp = Date.now();
const sendResponse = await gatewayRpcCall('sessions.send', {
key: sessionKey,
message: params.task,
...(params.thinking ? { thinking: params.thinking } : {}),
idempotencyKey: `subagent-spawn:${Date.now()}:${randomUUID().slice(0, 8)}`,
}) as { runId?: string };
startCompletionMonitor({
parentSessionKey: params.parentSessionKey,
childSessionKey: sessionKey,
label: params.label,
cleanup: params.cleanup ?? 'keep',
runId: sendResponse.runId,
launchTimestamp,
});
return {
sessionKey,
runId: sendResponse.runId,
mode: 'direct',
};
}
async function launchViaMarker(params: SpawnSubagentParams): Promise<SpawnSubagentResult> {
const snapshotResponse = await gatewayRpcCall('sessions.list', {
activeMinutes: POLL_SESSIONS_ACTIVE_MINUTES,
limit: POLL_SESSIONS_LIMIT,
}) as { sessions?: GatewaySessionSummary[] };
const snapshotSessions = Array.isArray(snapshotResponse.sessions) ? snapshotResponse.sessions : [];
const knownSessionKeysBefore = new Set(
snapshotSessions
.map(getSessionKey)
.filter((value): value is string => Boolean(value)),
);
await gatewayRpcCall('chat.send', {
sessionKey: params.parentSessionKey,
message: buildSpawnSubagentMarkerMessage({
task: params.task,
label: params.label,
model: params.model,
thinking: params.thinking,
cleanup: params.cleanup ?? 'keep',
}),
idempotencyKey: `subagent-marker:${Date.now()}:${randomUUID().slice(0, 8)}`,
});
const deadline = Date.now() + MARKER_DISCOVERY_TIMEOUT_MS;
while (Date.now() < deadline) {
await new Promise<void>((resolve) => {
schedule(resolve, MARKER_DISCOVERY_POLL_MS);
});
const listResponse = await gatewayRpcCall('sessions.list', {
activeMinutes: POLL_SESSIONS_ACTIVE_MINUTES,
limit: POLL_SESSIONS_LIMIT,
}) as { sessions?: GatewaySessionSummary[] };
const sessions = Array.isArray(listResponse.sessions) ? listResponse.sessions : [];
const spawned = pickMarkerSpawnedChildSession(sessions, params.parentSessionKey, knownSessionKeysBefore);
const spawnedKey = spawned ? getSessionKey(spawned) : null;
if (spawnedKey) {
return {
sessionKey: spawnedKey,
mode: 'marker',
};
}
}
throw new Error('Timed out waiting for the new subagent session to appear');
}
export async function spawnSubagent(params: SpawnSubagentParams): Promise<SpawnSubagentResult> {
if (!isTopLevelRootSessionKey(params.parentSessionKey)) {
throw new Error(`parentSessionKey must be a top-level root session key (agent:<id>:main): ${params.parentSessionKey}`);
}
try {
return await launchDirect(params);
} catch (error) {
if (!isUnsupportedDirectSpawnError(error)) {
throw error;
}
return launchViaMarker(params);
}
}
export function __resetSubagentSpawnTestState(): void {
activeMonitors.clear();
}

View file

@ -0,0 +1,77 @@
export interface UploadFeatureConfig {
twoModeEnabled: boolean;
inlineEnabled: boolean;
fileReferenceEnabled: boolean;
modeChooserEnabled: boolean;
inlineAttachmentMaxMb: number;
inlineImageContextMaxBytes: number;
inlineImageAutoDowngradeToFileReference: boolean;
inlineImageShrinkMinDimension: number;
inlineImageMaxDimension: number;
inlineImageWebpQuality: number;
exposeInlineBase64ToAgent: boolean;
}
const DEFAULT_UPLOAD_FEATURE_CONFIG: UploadFeatureConfig = {
twoModeEnabled: false,
inlineEnabled: true,
fileReferenceEnabled: false,
modeChooserEnabled: false,
inlineAttachmentMaxMb: 4,
inlineImageContextMaxBytes: 32_768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
};
function readBooleanEnv(name: string, fallback: boolean): boolean {
const value = process.env[name];
if (value == null) return fallback;
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function readNumberEnv(name: string, fallback: number): number {
const value = process.env[name];
if (value == null) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function getUploadFeatureConfig(): UploadFeatureConfig {
return {
twoModeEnabled: readBooleanEnv('NERVE_UPLOAD_TWO_MODE_ENABLED', DEFAULT_UPLOAD_FEATURE_CONFIG.twoModeEnabled),
inlineEnabled: readBooleanEnv('NERVE_UPLOAD_INLINE_ENABLED', DEFAULT_UPLOAD_FEATURE_CONFIG.inlineEnabled),
fileReferenceEnabled: readBooleanEnv('NERVE_UPLOAD_FILE_REFERENCE_ENABLED', DEFAULT_UPLOAD_FEATURE_CONFIG.fileReferenceEnabled),
modeChooserEnabled: readBooleanEnv('NERVE_UPLOAD_MODE_CHOOSER_ENABLED', DEFAULT_UPLOAD_FEATURE_CONFIG.modeChooserEnabled),
inlineAttachmentMaxMb: readNumberEnv('NERVE_UPLOAD_INLINE_ATTACHMENT_MAX_MB', DEFAULT_UPLOAD_FEATURE_CONFIG.inlineAttachmentMaxMb),
inlineImageContextMaxBytes: readNumberEnv('NERVE_UPLOAD_INLINE_IMAGE_CONTEXT_MAX_BYTES', DEFAULT_UPLOAD_FEATURE_CONFIG.inlineImageContextMaxBytes),
inlineImageAutoDowngradeToFileReference: readBooleanEnv(
'NERVE_UPLOAD_INLINE_IMAGE_AUTO_DOWNGRADE_TO_FILE_REFERENCE',
DEFAULT_UPLOAD_FEATURE_CONFIG.inlineImageAutoDowngradeToFileReference,
),
inlineImageShrinkMinDimension: readNumberEnv(
'NERVE_UPLOAD_INLINE_IMAGE_SHRINK_MIN_DIMENSION',
DEFAULT_UPLOAD_FEATURE_CONFIG.inlineImageShrinkMinDimension,
),
inlineImageMaxDimension: readNumberEnv(
'NERVE_UPLOAD_IMAGE_OPTIMIZATION_MAX_DIMENSION',
DEFAULT_UPLOAD_FEATURE_CONFIG.inlineImageMaxDimension,
),
inlineImageWebpQuality: readNumberEnv(
'NERVE_UPLOAD_IMAGE_OPTIMIZATION_WEBP_QUALITY',
DEFAULT_UPLOAD_FEATURE_CONFIG.inlineImageWebpQuality,
),
exposeInlineBase64ToAgent: readBooleanEnv(
'NERVE_UPLOAD_EXPOSE_INLINE_BASE64_TO_AGENT',
DEFAULT_UPLOAD_FEATURE_CONFIG.exposeInlineBase64ToAgent,
),
};
}

View file

@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
async function importHelpers() {
vi.resetModules();
return import('./upload-reference.js');
}
const originalHome = process.env.HOME;
const originalFileBrowserRoot = process.env.FILE_BROWSER_ROOT;
const originalUploadStagingTempDir = process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
const tempDirs = new Set<string>();
async function makeHomeWorkspace(): Promise<{ homeDir: string; workspaceRoot: string }> {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nerve-upload-reference-lib-home-'));
tempDirs.add(homeDir);
const workspaceRoot = path.join(homeDir, '.openclaw', 'workspace');
await fs.mkdir(workspaceRoot, { recursive: true });
process.env.HOME = homeDir;
delete process.env.FILE_BROWSER_ROOT;
delete process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
return { homeDir, workspaceRoot };
}
afterEach(async () => {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalFileBrowserRoot == null) {
delete process.env.FILE_BROWSER_ROOT;
} else {
process.env.FILE_BROWSER_ROOT = originalFileBrowserRoot;
}
if (originalUploadStagingTempDir == null) {
delete process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
} else {
process.env.NERVE_UPLOAD_STAGING_TEMP_DIR = originalUploadStagingTempDir;
}
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
tempDirs.delete(dir);
}
});
describe('upload-reference helpers', () => {
it('imports external uploads into canonical staged workspace references', async () => {
const { workspaceRoot } = await makeHomeWorkspace();
const { importExternalUploadToCanonicalReference } = await importHelpers();
const result = await importExternalUploadToCanonicalReference({
originalName: 'proof.txt',
mimeType: 'text/plain',
bytes: new TextEncoder().encode('hello import'),
});
expect(result.kind).toBe('imported_workspace_reference');
expect(result.canonicalPath).toMatch(/^\.temp\/nerve-uploads\/\d{4}\/\d{2}\/\d{2}\/proof-[a-f0-9]{8}\.txt$/);
expect(result.absolutePath).toBe(path.join(workspaceRoot, result.canonicalPath));
expect(result.mimeType).toBe('text/plain');
expect(result.sizeBytes).toBe(12);
expect(result.originalName).toBe('proof.txt');
await expect(fs.readFile(result.absolutePath, 'utf8')).resolves.toBe('hello import');
});
it('rejects imported staging output when the configured staging root escapes the workspace', async () => {
const { homeDir } = await makeHomeWorkspace();
const outsideStageRoot = path.join(homeDir, 'outside-stage');
process.env.NERVE_UPLOAD_STAGING_TEMP_DIR = outsideStageRoot;
const { importExternalUploadToCanonicalReference } = await importHelpers();
await expect(importExternalUploadToCanonicalReference({
originalName: 'proof.txt',
mimeType: 'text/plain',
bytes: new TextEncoder().encode('hello import'),
})).rejects.toThrow('Resolved attachment path is outside the workspace root.');
await expect(fs.stat(outsideStageRoot)).rejects.toMatchObject({ code: 'ENOENT' });
});
});

View file

@ -0,0 +1,162 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { resolveAgentWorkspace } from './agent-workspace.js';
import { getWorkspaceRoot, resolveWorkspacePath, resolveWorkspacePathForRoot } from './file-utils.js';
export type CanonicalUploadReferenceKind = 'direct_workspace_reference' | 'imported_workspace_reference';
export interface CanonicalUploadReference {
kind: CanonicalUploadReferenceKind;
canonicalPath: string;
absolutePath: string;
uri: string;
mimeType: string;
sizeBytes: number;
originalName: string;
}
function toFileUri(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
if (/^[A-Za-z]:\//.test(normalized)) return `file:///${encodeURI(normalized)}`;
return `file://${encodeURI(normalized)}`;
}
function isWithinDir(candidate: string, root: string): boolean {
const relative = path.relative(root, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function toCanonicalWorkspacePath(absolutePath: string, workspaceRoot: string): string {
const relative = path.relative(workspaceRoot, absolutePath);
return relative.split(path.sep).join('/');
}
function inferMimeTypeFromName(name: string): string {
const ext = path.extname(name).toLowerCase();
switch (ext) {
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.gif': return 'image/gif';
case '.webp': return 'image/webp';
case '.avif': return 'image/avif';
case '.svg': return 'image/svg+xml';
case '.ico': return 'image/x-icon';
case '.txt': return 'text/plain';
case '.md': return 'text/markdown';
case '.json': return 'application/json';
case '.pdf': return 'application/pdf';
case '.mov': return 'video/quicktime';
case '.mp4': return 'video/mp4';
default: return 'application/octet-stream';
}
}
function expandHomePath(input: string): string {
const home = process.env.HOME || os.homedir();
if (input === '~') return home;
if (input.startsWith('~/')) return path.join(home, input.slice(2));
return input;
}
function sanitizeFileName(name: string): string {
const trimmed = name.trim();
const base = path.basename(trimmed || 'upload.bin');
const safe = base.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return safe || 'upload.bin';
}
function buildStagedFileName(originalName: string): string {
const safeName = sanitizeFileName(originalName);
const ext = path.extname(safeName);
const stem = ext ? safeName.slice(0, -ext.length) : safeName;
const suffix = crypto.randomUUID().slice(0, 8);
return `${stem || 'upload'}-${suffix}${ext}`;
}
function buildStagedSubdir(now = new Date()): string {
const year = String(now.getUTCFullYear());
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const day = String(now.getUTCDate()).padStart(2, '0');
return path.join(year, month, day);
}
function getUploadStagingDir(): string {
const stagingRoot = process.env.NERVE_UPLOAD_STAGING_TEMP_DIR
|| path.join(getWorkspaceRoot(), '.temp', 'nerve-uploads');
return path.resolve(expandHomePath(stagingRoot));
}
async function buildCanonicalReference(params: {
kind: CanonicalUploadReferenceKind;
absolutePath: string;
originalName: string;
mimeType?: string;
workspaceRoot?: string;
}): Promise<CanonicalUploadReference> {
const workspaceRoot = path.resolve(getWorkspaceRoot(params.workspaceRoot));
const realAbsolutePath = await fs.realpath(params.absolutePath);
if (!isWithinDir(realAbsolutePath, workspaceRoot)) {
throw new Error('Resolved attachment path is outside the workspace root.');
}
const stat = await fs.stat(realAbsolutePath);
if (!stat.isFile()) {
throw new Error('Resolved attachment path is not a file.');
}
return {
kind: params.kind,
canonicalPath: toCanonicalWorkspacePath(realAbsolutePath, workspaceRoot),
absolutePath: realAbsolutePath,
uri: toFileUri(realAbsolutePath),
mimeType: params.mimeType?.trim() || inferMimeTypeFromName(params.originalName),
sizeBytes: stat.size,
originalName: params.originalName,
};
}
export async function resolveDirectWorkspaceReference(relativePath: string, agentId?: string): Promise<CanonicalUploadReference> {
const workspaceRoot = agentId ? resolveAgentWorkspace(agentId).workspaceRoot : undefined;
const resolved = workspaceRoot
? await resolveWorkspacePathForRoot(workspaceRoot, relativePath)
: await resolveWorkspacePath(relativePath);
if (!resolved) {
throw new Error('Invalid or excluded workspace path.');
}
return buildCanonicalReference({
kind: 'direct_workspace_reference',
absolutePath: resolved,
originalName: path.basename(resolved),
workspaceRoot,
});
}
export async function importExternalUploadToCanonicalReference(params: {
originalName: string;
mimeType?: string;
bytes: Uint8Array;
}): Promise<CanonicalUploadReference> {
const workspaceRoot = path.resolve(getWorkspaceRoot());
const rootDir = getUploadStagingDir();
const targetDir = path.join(rootDir, buildStagedSubdir());
const stagedPath = path.join(targetDir, buildStagedFileName(params.originalName));
if (!isWithinDir(stagedPath, workspaceRoot)) {
throw new Error('Resolved attachment path is outside the workspace root.');
}
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(stagedPath, params.bytes);
return buildCanonicalReference({
kind: 'imported_workspace_reference',
absolutePath: stagedPath,
originalName: params.originalName,
mimeType: params.mimeType,
});
}

View file

@ -30,13 +30,13 @@ describe('securityHeaders middleware', () => {
expect(csp).toBeTruthy();
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("script-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("frame-ancestors 'self'");
});
it('sets X-Frame-Options to DENY', async () => {
it('sets X-Frame-Options to SAMEORIGIN', async () => {
const app = await buildApp();
const res = await app.request('/test');
expect(res.headers.get('X-Frame-Options')).toBe('DENY');
expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
});
it('sets X-Content-Type-Options to nosniff', async () => {

View file

@ -56,8 +56,8 @@ function getCspDirectives(): string {
`connect-src ${connectSrc}`,
"img-src 'self' data: blob:",
"media-src 'self' blob:", // Allow blob: URLs for TTS audio playback
"frame-src https://s3.tradingview.com https://www.tradingview.com https://www.tradingview-widget.com https://s.tradingview.com",
"frame-ancestors 'none'",
"frame-src 'self' https://s3.tradingview.com https://www.tradingview.com https://www.tradingview-widget.com https://s.tradingview.com",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
@ -72,7 +72,7 @@ export const securityHeaders: MiddlewareHandler = async (c, next) => {
c.header('Content-Security-Policy', getCspDirectives());
// Prevent clickjacking
c.header('X-Frame-Options', 'DENY');
c.header('X-Frame-Options', 'SAMEORIGIN');
// Prevent MIME type sniffing
c.header('X-Content-Type-Options', 'nosniff');

142
server/routes/beads.test.ts Normal file
View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';
const getBeadDetailMock = vi.fn();
class MockBeadNotFoundError extends Error {}
class MockBeadAdapterError extends Error {}
class MockBeadValidationError extends Error {}
vi.mock('../lib/beads.js', () => ({
getBeadDetail: (...args: unknown[]) => getBeadDetailMock(...args),
BeadNotFoundError: MockBeadNotFoundError,
BeadAdapterError: MockBeadAdapterError,
BeadValidationError: MockBeadValidationError,
}));
describe('beads routes', () => {
beforeEach(() => {
getBeadDetailMock.mockReset();
});
async function buildApp() {
vi.resetModules();
const mod = await import('./beads.js');
const app = new Hono();
app.route('/', mod.default);
return app;
}
it('returns bead detail for a known bead id', async () => {
getBeadDetailMock.mockResolvedValue({
id: 'nerve-fms2',
title: 'Implement read-only bead viewer tab foundation',
notes: 'Open a bead viewer tab.',
status: 'in_progress',
priority: 1,
issueType: 'task',
owner: 'Derrick',
createdAt: '2026-04-06T13:23:33Z',
updatedAt: '2026-04-06T13:26:10Z',
closedAt: null,
closeReason: null,
dependencies: [{ id: 'nerve-qkdo', title: 'Create branch', status: 'closed', dependencyType: 'blocks' }],
dependents: [],
linkedPlan: {
path: '.plans/2026-04-06-bead-viewer-tab-foundation-execution.md',
title: 'Bead viewer tab foundation',
planId: 'plan-bead-viewer-tab-foundation-execution',
archived: false,
status: 'In Progress',
updatedAt: 123,
},
});
const app = await buildApp();
const res = await app.request('/api/beads/nerve-fms2');
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
ok: true,
bead: expect.objectContaining({
id: 'nerve-fms2',
dependencies: [expect.objectContaining({ id: 'nerve-qkdo' })],
linkedPlan: expect.objectContaining({
path: '.plans/2026-04-06-bead-viewer-tab-foundation-execution.md',
}),
}),
});
expect(getBeadDetailMock).toHaveBeenCalledWith('nerve-fms2', {
targetPath: undefined,
currentDocumentPath: undefined,
workspaceAgentId: undefined,
});
});
it('passes explicit lookup context through to the bead lookup', async () => {
getBeadDetailMock.mockResolvedValue({
id: 'virtra-apex-docs-id2',
title: 'Demo',
notes: null,
status: null,
priority: null,
issueType: null,
owner: null,
createdAt: null,
updatedAt: null,
closedAt: null,
closeReason: null,
dependencies: [],
dependents: [],
linkedPlan: null,
});
const app = await buildApp();
const res = await app.request('/api/beads/virtra-apex-docs-id2?targetPath=../projects/virtra-apex-docs/.beads&currentDocumentPath=bead-link-dogfood.md&workspaceAgentId=main');
expect(res.status).toBe(200);
expect(getBeadDetailMock).toHaveBeenCalledWith('virtra-apex-docs-id2', {
targetPath: '../projects/virtra-apex-docs/.beads',
currentDocumentPath: 'bead-link-dogfood.md',
workspaceAgentId: 'main',
});
});
it('returns 400 when the lookup request context is invalid', async () => {
getBeadDetailMock.mockRejectedValue(new MockBeadValidationError('Relative explicit bead URIs require a current document path'));
const app = await buildApp();
const res = await app.request('/api/beads/nerve-fms2');
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: 'invalid_request',
details: 'Relative explicit bead URIs require a current document path',
});
});
it('returns 404 when the bead is missing', async () => {
getBeadDetailMock.mockRejectedValue(new MockBeadNotFoundError('nerve-miss'));
const app = await buildApp();
const res = await app.request('/api/beads/nerve-miss');
expect(res.status).toBe(404);
await expect(res.json()).resolves.toEqual({
error: 'not_found',
details: 'nerve-miss',
});
});
it('returns 502 when the bd adapter fails', async () => {
getBeadDetailMock.mockRejectedValue(new MockBeadAdapterError('bd failed'));
const app = await buildApp();
const res = await app.request('/api/beads/nerve-fms2');
expect(res.status).toBe(502);
await expect(res.json()).resolves.toEqual({
error: 'beads_adapter_error',
details: 'bd failed',
});
});
});

38
server/routes/beads.ts Normal file
View file

@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
import { BeadAdapterError, BeadNotFoundError, BeadValidationError, getBeadDetail } from '../lib/beads.js';
const app = new Hono();
app.get('/api/beads/:id', rateLimitGeneral, async (c) => {
const beadId = c.req.param('id')?.trim();
if (!beadId) {
return c.json({ error: 'invalid_request', details: 'bead id is required' }, 400);
}
const targetPath = c.req.query('targetPath')?.trim() || undefined;
const currentDocumentPath = c.req.query('currentDocumentPath')?.trim() || undefined;
const workspaceAgentId = c.req.query('workspaceAgentId')?.trim() || undefined;
try {
const bead = await getBeadDetail(beadId, {
targetPath,
currentDocumentPath,
workspaceAgentId,
});
return c.json({ ok: true, bead });
} catch (error) {
if (error instanceof BeadValidationError) {
return c.json({ error: 'invalid_request', details: error.message }, 400);
}
if (error instanceof BeadNotFoundError) {
return c.json({ error: 'not_found', details: error.message }, 404);
}
if (error instanceof BeadAdapterError) {
return c.json({ error: 'beads_adapter_error', details: error.message }, 502);
}
throw error;
}
});
export default app;

View file

@ -9,12 +9,16 @@ describe('file-browser routes', () => {
let homeDir: string;
let tmpDir: string;
let researchWorkspace: string;
let remoteHomeDir: string;
let remoteWorkspace: string;
beforeEach(async () => {
vi.resetModules();
homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fbrowser-test-'));
tmpDir = path.join(homeDir, '.openclaw', 'workspace');
researchWorkspace = path.join(homeDir, '.openclaw', 'workspace-research');
remoteHomeDir = path.join(homeDir, 'remote-nonexistent');
remoteWorkspace = path.join(remoteHomeDir, '.openclaw', 'workspace');
await fs.mkdir(tmpDir, { recursive: true });
// Create a MEMORY.md in the tmpDir so getWorkspaceRoot returns tmpDir
await fs.writeFile(path.join(tmpDir, 'MEMORY.md'), '# Memories\n');
@ -25,22 +29,44 @@ describe('file-browser routes', () => {
await fs.rm(homeDir, { recursive: true, force: true });
});
async function buildApp(opts?: { fileBrowserRoot?: string }) {
async function buildApp(opts?: {
fileBrowserRoot?: string;
remote?: boolean;
gatewayFilesListResult?: Array<{ name: string; missing?: boolean; size?: number; updatedAtMs?: number }>;
}) {
vi.resetModules();
vi.doUnmock('../lib/gateway-rpc.js');
const useRemote = opts?.remote ?? false;
const configuredHomeDir = useRemote ? remoteHomeDir : homeDir;
const configuredWorkspace = useRemote ? remoteWorkspace : tmpDir;
vi.doMock('../lib/config.js', () => ({
config: {
auth: false,
port: 3000,
host: '127.0.0.1',
sslPort: 3443,
home: homeDir,
memoryPath: path.join(tmpDir, 'MEMORY.md'),
memoryDir: path.join(tmpDir, 'memory'),
home: configuredHomeDir,
memoryPath: path.join(configuredWorkspace, 'MEMORY.md'),
memoryDir: path.join(configuredWorkspace, 'memory'),
fileBrowserRoot: opts?.fileBrowserRoot ?? '',
workspaceRemote: false,
},
SESSION_COOKIE_NAME: 'nerve_session_3000',
}));
if (useRemote) {
vi.doMock('../lib/gateway-rpc.js', () => ({
gatewayFilesList: vi.fn().mockResolvedValue(opts?.gatewayFilesListResult ?? []),
gatewayFilesGet: vi.fn(),
gatewayFilesSet: vi.fn(),
}));
const detectMod = await import('../lib/workspace-detect.js');
detectMod.clearWorkspaceDetectCache();
}
const mod = await import('./file-browser.js');
const app = new Hono();
app.route('/', mod.default);
@ -89,6 +115,56 @@ describe('file-browser routes', () => {
expect(names).not.toContain('node_modules');
expect(names).not.toContain('.git');
});
it('hides hidden workspace entries by default', async () => {
await fs.writeFile(path.join(tmpDir, '.hidden.md'), 'secret');
await fs.writeFile(path.join(tmpDir, 'visible.md'), 'hello');
const app = await buildApp();
const res = await app.request('/api/files/tree');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; entries: Array<{ name: string }> };
const names = json.entries.map(e => e.name);
expect(names).toContain('visible.md');
expect(names).not.toContain('.hidden.md');
});
it('includes hidden workspace entries when showHidden=true', async () => {
await fs.writeFile(path.join(tmpDir, '.hidden.md'), 'secret');
await fs.mkdir(path.join(tmpDir, '.plans'));
const app = await buildApp();
const res = await app.request('/api/files/tree?showHidden=true');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; entries: Array<{ name: string }> };
const names = json.entries.map(e => e.name);
expect(names).toContain('.hidden.md');
expect(names).toContain('.plans');
});
it('includes hidden workspace entries when showHidden=true via remote gateway fallback', async () => {
const app = await buildApp({
remote: true,
gatewayFilesListResult: [
{ name: '.hidden.md', missing: false, size: 6, updatedAtMs: 1000 },
{ name: '.plans', missing: false, size: 0, updatedAtMs: 1001 },
{ name: 'visible.md', missing: false, size: 5, updatedAtMs: 1002 },
],
});
const res = await app.request('/api/files/tree?showHidden=true');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; entries: Array<{ name: string }>; remoteWorkspace?: boolean };
const names = json.entries.map((e) => e.name);
expect(json.ok).toBe(true);
expect(json.remoteWorkspace).toBe(true);
expect(names).toContain('.hidden.md');
expect(names).toContain('.plans');
expect(names).toContain('visible.md');
});
});
describe('GET /api/files/resolve', () => {
@ -112,17 +188,79 @@ describe('file-browser routes', () => {
expect(json).toEqual({ ok: true, path: 'docs', type: 'directory', binary: false });
});
it('resolves current-document-relative file links safely within the workspace', async () => {
await fs.mkdir(path.join(tmpDir, 'docs', 'guide'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'docs', 'guide', 'advanced.md'), '# Advanced');
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=advanced.md&relativeTo=docs/guide/index.md');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; path: string; type: string; binary: boolean };
expect(json).toEqual({ ok: true, path: 'docs/guide/advanced.md', type: 'file', binary: false });
});
it('supports workspace-root links from markdown docs via a leading slash', async () => {
await fs.mkdir(path.join(tmpDir, 'docs'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'docs', 'todo.md'), '# Todo');
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=/docs/todo.md&relativeTo=notes/index.md');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; path: string; type: string; binary: boolean };
expect(json).toEqual({ ok: true, path: 'docs/todo.md', type: 'file', binary: false });
});
it('resolves workspace-root-document relative links even when relativeTo is slash-prefixed', async () => {
await fs.mkdir(path.join(tmpDir, 'projects', 'demo'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'projects', 'demo', 'notes.md'), '# Notes');
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=./projects/demo/notes.md&relativeTo=/README.md');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; path: string; type: string; binary: boolean };
expect(json).toEqual({ ok: true, path: 'projects/demo/notes.md', type: 'file', binary: false });
});
it('returns 404 for safe missing targets inside the workspace root', async () => {
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=missing-note.md');
expect(res.status).toBe(404);
});
it('accepts /workspace-prefixed paths by normalizing to workspace-relative', async () => {
await fs.mkdir(path.join(tmpDir, 'src'));
await fs.writeFile(path.join(tmpDir, 'src', 'main.ts'), 'export {};');
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=%2Fworkspace%2Fsrc%2Fmain.ts');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; path: string; type: string; binary: boolean };
expect(json).toEqual({ ok: true, path: 'src/main.ts', type: 'file', binary: false });
});
it('keeps /workspace-prefixed links rooted even when relativeTo is provided', async () => {
await fs.mkdir(path.join(tmpDir, 'src'));
await fs.mkdir(path.join(tmpDir, 'notes'));
await fs.writeFile(path.join(tmpDir, 'src', 'main.ts'), 'export {};');
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=%2Fworkspace%2Fsrc%2Fmain.ts&relativeTo=notes/index.md');
expect(res.status).toBe(200);
const json = (await res.json()) as { ok: boolean; path: string; type: string; binary: boolean };
expect(json).toEqual({ ok: true, path: 'src/main.ts', type: 'file', binary: false });
});
it('returns 403 for invalid or excluded targets', async () => {
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=../../etc');
expect(res.status).toBe(403);
});
it('returns 403 when a current-document-relative link escapes the workspace', async () => {
const app = await buildApp();
const res = await app.request('/api/files/resolve?path=../../../etc/passwd&relativeTo=docs/guide/index.md');
expect(res.status).toBe(403);
});
});
describe('GET /api/files/read', () => {

View file

@ -17,6 +17,8 @@
import { Hono, type Context } from 'hono';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import { Readable } from 'node:stream';
import path from 'node:path';
import {
getWorkspaceRoot,
@ -89,6 +91,7 @@ async function listDirectory(
dirPath: string,
basePath: string,
depth: number,
showHidden: boolean,
): Promise<TreeEntry[]> {
const entries: TreeEntry[] = [];
@ -113,8 +116,8 @@ async function listDirectory(
if (inTrash) {
// Internal metadata file for restore bookkeeping.
if (item.name === '.index.json') continue;
// FILE_BROWSER_ROOT: Show all files when custom root is set, but always hide .trash folder
} else if (!config.fileBrowserRoot && item.name.startsWith('.') && item.name !== '.nerveignore' && item.name !== '.trash') {
// Hide dotfiles unless showHidden=true, except for .nerveignore and .trash; custom roots still hide .trash.
} else if (!showHidden && item.name.startsWith('.') && item.name !== '.nerveignore' && item.name !== '.trash') {
continue;
} else if (config.fileBrowserRoot && item.name === '.trash') {
continue;
@ -129,7 +132,7 @@ async function listDirectory(
path: relativePath,
type: 'directory',
children: depth > 1
? await listDirectory(fullPath, relativePath, depth - 1)
? await listDirectory(fullPath, relativePath, depth - 1, showHidden)
: null,
});
} else if (item.isFile()) {
@ -161,9 +164,13 @@ function handleFileOpError(c: Context, err: unknown) {
}
/** Convert gateway file list to TreeEntry format for the UI. */
function gatewayFilesToTree(files: Awaited<ReturnType<typeof gatewayFilesList>>): TreeEntry[] {
function gatewayFilesToTree(
files: Awaited<ReturnType<typeof gatewayFilesList>>,
showHidden: boolean,
): TreeEntry[] {
return files
.filter((f) => !f.missing)
.filter((f) => showHidden || !f.name.startsWith('.') || f.name === '.nerveignore' || f.name === '.trash')
.map((f) => ({
name: f.name,
path: f.name,
@ -173,6 +180,19 @@ function gatewayFilesToTree(files: Awaited<ReturnType<typeof gatewayFilesList>>)
}));
}
function normalizeWorkspaceLookupPath(input: string): string {
const trimmed = input.trim();
if (trimmed === '/workspace' || trimmed === '/workspace/') {
return '.';
}
if (trimmed.startsWith('/workspace/')) {
return trimmed.slice('/workspace/'.length);
}
return trimmed;
}
// ── GET /api/files/tree ──────────────────────────────────────────────
app.get('/api/files/tree', async (c) => {
@ -186,6 +206,7 @@ app.get('/api/files/tree', async (c) => {
const root = workspace.workspaceRoot;
const subPath = c.req.query('path') || '';
const depth = Math.min(Math.max(Number(c.req.query('depth')) || 1, 1), 5);
const showHidden = c.req.query('showHidden') === 'true';
// Check if workspace is local
const isLocal = await isWorkspaceLocal(root);
@ -213,7 +234,7 @@ app.get('/api/files/tree', async (c) => {
targetDir = root;
}
const entries = await listDirectory(targetDir, subPath, depth);
const entries = await listDirectory(targetDir, subPath, depth, showHidden);
return c.json({
ok: true,
@ -243,7 +264,7 @@ app.get('/api/files/tree', async (c) => {
try {
const remoteFiles = await gatewayFilesList(workspace.agentId);
const entries = gatewayFilesToTree(remoteFiles);
const entries = gatewayFilesToTree(remoteFiles, showHidden);
return c.json({
ok: true,
root: '.',
@ -273,6 +294,7 @@ app.get('/api/files/tree', async (c) => {
app.get('/api/files/resolve', async (c) => {
const targetPath = c.req.query('path');
const relativeTo = c.req.query('relativeTo');
if (!targetPath) {
return c.json({ ok: false, error: 'Missing path parameter' }, 400);
}
@ -288,9 +310,22 @@ app.get('/api/files/resolve', async (c) => {
return c.json({ ok: false, error: 'Not supported for remote workspaces', code: 'REMOTE_WORKSPACE' }, 501);
}
const rawTargetPath = targetPath.trim().replace(/\\/g, '/');
const normalizedTargetPath = normalizeWorkspaceLookupPath(rawTargetPath);
const workspaceRelativePath = (() => {
if (!relativeTo) return normalizedTargetPath;
if (rawTargetPath === '/workspace' || rawTargetPath === '/workspace/') return '.';
if (rawTargetPath.startsWith('/workspace/')) return rawTargetPath.slice('/workspace/'.length);
if (rawTargetPath.startsWith('/')) return rawTargetPath.replace(/^\/+/, '');
const normalizedRelativeTo = normalizeWorkspaceLookupPath(relativeTo.replace(/\\/g, '/')).replace(/^\/+/, '');
const relativeDir = path.posix.dirname(normalizedRelativeTo);
return path.posix.normalize(path.posix.join(relativeDir === '.' ? '' : relativeDir, normalizedTargetPath));
})();
const resolved = await resolveWorkspacePathForRoot(
workspace.workspaceRoot,
targetPath,
workspaceRelativePath,
{ allowNonExistent: true },
);
if (!resolved) {
@ -721,6 +756,7 @@ const MIME_TYPES: Record<string, string> = {
'.avif': 'image/avif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
};
/** Check if a file is a supported image. */
@ -762,18 +798,94 @@ app.get('/api/files/raw', async (c) => {
if (!stat.isFile()) {
return c.json({ ok: false, error: 'Not a file' }, 400);
}
// Cap at 10MB for images
if (stat.size > 10_485_760) {
return c.json({ ok: false, error: 'File too large (max 10MB)' }, 413);
// Cap at 10MB for images, 50 MB for PDFs (can adjust as needed)
const maxSize = ext === '.pdf' ? 52_428_800 : 10_485_760;
if (stat.size > maxSize) {
return c.json({ ok: false, error: `File too large (max ${ext === '.pdf' ? '50MB' : '10MB'})` }, 413);
}
const buffer = await fs.readFile(resolved);
return new Response(buffer, {
headers: {
'Content-Type': mime,
'Content-Length': String(stat.size),
'Cache-Control': 'no-cache',
},
// Parse Range header for PDFs to support partial content requests
let start = 0;
let end = stat.size - 1;
let statusCode = 200;
let rangeHeader: string | undefined;
const rangeHeaderValue = c.req.header('range');
if (rangeHeaderValue && ext === '.pdf') {
// Match both explicit ranges (bytes=100-200) and suffix ranges (bytes=-500)
const rangeMatch = rangeHeaderValue.match(/^bytes=(\d*)-(\d*)$/);
if (rangeMatch) {
const hasSuffix = rangeMatch[1] === '';
let rangeStart: number;
let rangeEnd: number;
let isValid = false;
if (hasSuffix) {
// Suffix range: bytes=-500 means last 500 bytes
const suffixLen = parseInt(rangeMatch[2], 10);
rangeStart = Math.max(0, stat.size - suffixLen);
rangeEnd = stat.size - 1;
isValid = suffixLen > 0;
} else {
rangeStart = parseInt(rangeMatch[1], 10);
rangeEnd = rangeMatch[2] ? parseInt(rangeMatch[2], 10) : stat.size - 1;
isValid = rangeStart >= 0 && rangeStart <= rangeEnd && rangeEnd < stat.size;
}
// Validate and apply range
if (isValid) {
start = rangeStart;
end = rangeEnd;
statusCode = 206;
rangeHeader = `bytes ${start}-${end}/${stat.size}`;
} else {
// Invalid range
return new Response('', {
status: 416,
headers: {
'Content-Range': `bytes */${stat.size}`,
},
});
}
}
}
// Stream file with optional range support
const fileStream = fsSync.createReadStream(resolved, { start, end });
fileStream.on('error', (err) => {
console.error(`[file-browser] Stream error for ${filePath}:`, err.message);
});
// Add error listener to surface stream failures for easier debugging
fileStream.on('error', (err) => {
console.error('[file-browser] fileStream error:', {
path: resolved,
rangeStart: start,
rangeEnd: end,
error: (err as Error).message,
});
});
// Convert Node.js stream to Web Stream using Node's built-in conversion
const webStream = Readable.toWeb(fileStream);
const responseHeaders: Record<string, string> = {
'Content-Type': mime,
'Content-Length': String(end - start + 1),
'Cache-Control': 'no-cache',
};
// Add Range headers for partial content responses
if (ext === '.pdf') {
responseHeaders['Accept-Ranges'] = 'bytes';
if (rangeHeader) {
responseHeaders['Content-Range'] = rangeHeader;
}
}
return new Response(webStream, {
status: statusCode,
headers: responseHeaders,
});
} catch {
return c.json({ ok: false, error: 'Failed to read file' }, 500);

View file

@ -37,6 +37,10 @@ vi.mock('../lib/config.js', () => ({
config: { agentName: 'Jen' },
}));
vi.mock('../lib/openclaw-config.js', () => ({
getDefaultAgentWorkspaceRoot: () => '/mock/workspaces',
}));
vi.mock('../middleware/rate-limit.js', () => ({
rateLimitGeneral: vi.fn(async (_c: unknown, next: () => Promise<void>) => next()),
}));
@ -85,6 +89,7 @@ describe('GET /api/server-info', () => {
expect(json.gatewayStartedAt).toBe(1700000012340);
expect(typeof json.serverTime).toBe('number');
expect(json.agentName).toBe('Jen');
expect(json.defaultAgentWorkspaceRoot).toBe('/mock/workspaces');
});
it('returns macOS gateway start time from ps output', async () => {
@ -113,6 +118,7 @@ describe('GET /api/server-info', () => {
const json = (await res.json()) as Record<string, unknown>;
expect(json.gatewayStartedAt).toBe(new Date('Tue Mar 31 20:14:31 2026').getTime());
expect(json.defaultAgentWorkspaceRoot).toBe('/mock/workspaces');
expect(execCalls).toEqual([
{ file: 'ps', args: ['-axo', 'pid=,comm='] },
{ file: 'ps', args: ['-p', '72246', '-o', 'lstart='] },

View file

@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import { config } from '../lib/config.js';
import { getDefaultAgentWorkspaceRoot } from '../lib/openclaw-config.js';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
const app = new Hono();
@ -126,6 +127,7 @@ app.get('/api/server-info', rateLimitGeneral, async (c) => {
gatewayStartedAt: await getGatewayStartedAt(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
agentName: config.agentName,
defaultAgentWorkspaceRoot: getDefaultAgentWorkspaceRoot(),
});
});

View file

@ -1,16 +1,18 @@
/** Tests for the sessions API route (GET /api/sessions/:id/model). */
/** Tests for the sessions API routes. */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
describe('GET /api/sessions/:id/model', () => {
describe('sessions routes', () => {
let tmpDir: string;
let spawnSubagentMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sessions-test-'));
spawnSubagentMock = vi.fn();
});
afterEach(async () => {
@ -22,6 +24,7 @@ describe('GET /api/sessions/:id/model', () => {
// Mock config to use our temp sessions dir
vi.doMock('../lib/config.js', () => ({
config: {
home: tmpDir,
sessionsDir: tmpDir,
auth: false,
port: 3000,
@ -33,6 +36,9 @@ describe('GET /api/sessions/:id/model', () => {
vi.doMock('../middleware/rate-limit.js', () => ({
rateLimitGeneral: vi.fn((_c: unknown, next: () => Promise<void>) => next()),
}));
vi.doMock('../lib/subagent-spawn.js', () => ({
spawnSubagent: spawnSubagentMock,
}));
const mod = await import('./sessions.js');
const app = new Hono();
@ -57,15 +63,17 @@ describe('GET /api/sessions/:id/model', () => {
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBeNull();
expect(json.thinking).toBeNull();
expect(json.missing).toBe(true);
});
it('returns model from transcript with model_change entry', async () => {
it('returns runtime defaults from transcript entries near the top', async () => {
const app = await buildApp();
const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const transcript = [
JSON.stringify({ type: 'session_start', ts: Date.now() }),
JSON.stringify({ type: 'model_change', modelId: 'anthropic/claude-opus-4', ts: Date.now() }),
JSON.stringify({ type: 'thinking_level_change', thinkingLevel: 'medium', ts: Date.now() }),
JSON.stringify({ type: 'message', role: 'user', content: 'hello' }),
].join('\n');
await fs.writeFile(path.join(tmpDir, `${uuid}.jsonl`), transcript);
@ -75,10 +83,11 @@ describe('GET /api/sessions/:id/model', () => {
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBe('anthropic/claude-opus-4');
expect(json.thinking).toBe('medium');
expect(json.missing).toBe(false);
});
it('returns model: null when transcript has no model_change', async () => {
it('returns model: null when transcript has no runtime defaults', async () => {
const app = await buildApp();
const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const transcript = [
@ -92,6 +101,7 @@ describe('GET /api/sessions/:id/model', () => {
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBeNull();
expect(json.thinking).toBeNull();
expect(json.missing).toBe(false);
});
@ -106,6 +116,243 @@ describe('GET /api/sessions/:id/model', () => {
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBe('openai/gpt-4o');
expect(json.thinking).toBeNull();
expect(json.missing).toBe(false);
});
it('reads non-main agent transcripts when agentId is provided', async () => {
const app = await buildApp();
const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const agentSessionsDir = path.join(tmpDir, '.openclaw', 'agents', 'smoke257', 'sessions');
await fs.mkdir(agentSessionsDir, { recursive: true });
await fs.writeFile(path.join(agentSessionsDir, `${uuid}.jsonl`), [
JSON.stringify({ type: 'model_change', modelId: 'openai-codex/gpt-5.4', ts: Date.now() }),
JSON.stringify({ type: 'thinking_level_change', thinkingLevel: 'medium', ts: Date.now() }),
].join('\n'));
const res = await app.request(`/api/sessions/${uuid}/model?agentId=smoke257`);
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBe('openai-codex/gpt-5.4');
expect(json.thinking).toBe('medium');
expect(json.missing).toBe(false);
});
it('resolves runtime defaults by sessionKey for non-main agents', async () => {
const app = await buildApp();
const uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const sessionKey = 'agent:smoke257:main';
const agentSessionsDir = path.join(tmpDir, '.openclaw', 'agents', 'smoke257', 'sessions');
await fs.mkdir(agentSessionsDir, { recursive: true });
await fs.writeFile(path.join(agentSessionsDir, 'sessions.json'), JSON.stringify({
[sessionKey]: { sessionId: uuid },
}));
await fs.writeFile(path.join(agentSessionsDir, `${uuid}.jsonl`), [
JSON.stringify({ type: 'model_change', modelId: 'openai-codex/gpt-5.4', ts: Date.now() }),
JSON.stringify({ type: 'thinking_level_change', thinkingLevel: 'medium', ts: Date.now() }),
].join('\n'));
const res = await app.request(`/api/sessions/runtime?sessionKey=${encodeURIComponent(sessionKey)}`);
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.model).toBe('openai-codex/gpt-5.4');
expect(json.thinking).toBe('medium');
expect(json.missing).toBe(false);
});
it('serves omitted image bytes from a session transcript', async () => {
const app = await buildApp();
const sessionKey = 'agent:main:main';
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
const timestamp = 1775131617235;
const base64 = Buffer.from('hello-image').toString('base64');
await fs.writeFile(path.join(tmpDir, 'sessions.json'), JSON.stringify({
[sessionKey]: { sessionId },
}));
await fs.writeFile(path.join(tmpDir, `${sessionId}.jsonl`), [
JSON.stringify({ type: 'session_start', ts: Date.now() }),
JSON.stringify({
type: 'message',
message: {
timestamp,
content: [
{ type: 'text', text: 'testing' },
{ type: 'image', mimeType: 'image/png', data: base64 },
],
},
}),
].join('\n'));
const res = await app.request(`/api/sessions/media?sessionKey=${encodeURIComponent(sessionKey)}&timestamp=${timestamp}&imageIndex=0`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('image/png');
expect(res.headers.get('content-disposition')).toContain(`message-${timestamp}-image-0.png`);
const body = Buffer.from(await res.arrayBuffer()).toString('utf-8');
expect(body).toBe('hello-image');
});
it('returns 404 when session transcript media cannot be resolved', async () => {
const app = await buildApp();
const sessionKey = 'agent:main:main';
await fs.writeFile(path.join(tmpDir, 'sessions.json'), JSON.stringify({
[sessionKey]: { sessionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' },
}));
const res = await app.request(`/api/sessions/media?sessionKey=${encodeURIComponent(sessionKey)}&timestamp=1775131617235&imageIndex=0`);
expect(res.status).toBe(404);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
});
// ── POST /api/sessions/spawn-subagent ────────────────────────────
it('rejects missing body with 400', async () => {
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not json',
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
expect(typeof json.error).toBe('string');
});
it('rejects body with missing required fields', async () => {
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task: 'do something' }), // missing parentSessionKey
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
expect(String(json.error)).toContain('parentSessionKey');
});
it('rejects parentSessionKey that is not a top-level root key', async () => {
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:subagent:child',
task: 'do something',
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
expect(String(json.error)).toContain('parentSessionKey');
});
it('rejects empty task string', async () => {
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:main',
task: '',
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
});
it('returns direct success payload when helper succeeds with direct mode', async () => {
spawnSubagentMock.mockResolvedValueOnce({
sessionKey: 'agent:reviewer:subagent:abc-123',
runId: 'run-xyz',
mode: 'direct',
});
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:main',
task: 'Reply with exactly: OK',
label: 'audit-auth-flow',
model: 'claude-sonnet-4-6',
thinking: 'medium',
cleanup: 'keep',
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.sessionKey).toBe('agent:reviewer:subagent:abc-123');
expect(json.runId).toBe('run-xyz');
expect(json.mode).toBe('direct');
});
it('returns marker success payload when helper falls back to marker mode', async () => {
spawnSubagentMock.mockResolvedValueOnce({
sessionKey: 'agent:reviewer:subagent:from-marker',
mode: 'marker',
});
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:main',
task: 'do something',
}),
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(true);
expect(json.sessionKey).toBe('agent:reviewer:subagent:from-marker');
expect(json.mode).toBe('marker');
expect(json.runId).toBeUndefined();
});
it('returns 500 with error message when helper throws', async () => {
spawnSubagentMock.mockRejectedValueOnce(new Error('Gateway connection failed'));
const app = await buildApp();
const res = await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:main',
task: 'do something',
}),
});
expect(res.status).toBe(500);
const json = (await res.json()) as Record<string, unknown>;
expect(json.ok).toBe(false);
expect(String(json.error)).toContain('Gateway connection failed');
});
it('defaults cleanup to keep when not specified', async () => {
spawnSubagentMock.mockResolvedValueOnce({
sessionKey: 'agent:reviewer:subagent:test',
mode: 'direct',
});
const app = await buildApp();
await app.request('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey: 'agent:reviewer:main',
task: 'do something',
}),
});
expect(spawnSubagentMock).toHaveBeenCalledWith(expect.objectContaining({
cleanup: 'keep',
}));
});
});

View file

@ -1,20 +1,24 @@
/**
* Sessions API Routes
*
* GET /api/sessions/:id/model Read the actual model used in a session from its transcript.
* GET /api/sessions/:id/model Read runtime defaults used in a session from its transcript.
* POST /api/sessions/spawn-subagent Server-side subagent spawn with lifecycle ownership.
*
* The gateway's sessions.list returns the agent default model, not the model
* actually used in a cron-run session (where payload.model overrides it).
* This endpoint reads the session transcript to find the real model.
* The gateway's sessions.list can omit the actual model/thinking bootstrapped into
* a session, especially after reloads. This endpoint reads the session transcript
* to recover the model and initial thinking level.
*/
import { Hono } from 'hono';
import { z } from 'zod';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { join } from 'node:path';
import { access, readdir, readFile } from 'node:fs/promises';
import { config } from '../lib/config.js';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
import { spawnSubagent } from '../lib/subagent-spawn.js';
import { normalizeAgentId } from '../lib/agent-workspace.js';
const app = new Hono();
const CRON_SESSION_RE = /^agent:[^:]+:cron:[^:]+(?::run:.+)?$/;
@ -31,6 +35,16 @@ interface StoredSessionSummary {
contextTokens?: number;
}
interface TranscriptImageContentBlock {
type?: string;
data?: string;
mimeType?: string;
source?: {
data?: string;
media_type?: string;
};
}
function isCronLikeSessionKey(sessionKey: string): boolean {
return CRON_SESSION_RE.test(sessionKey);
}
@ -45,9 +59,29 @@ function inferParentSessionKey(sessionKey: string): string | null {
return null;
}
async function loadSessionStoreFromDir(sessionsDir: string): Promise<Record<string, StoredSessionSummary | undefined>> {
const sessionsFile = join(sessionsDir, 'sessions.json');
const raw = await readFile(sessionsFile, 'utf-8');
return JSON.parse(raw) as Record<string, StoredSessionSummary | undefined>;
}
async function loadSessionStore(): Promise<Record<string, StoredSessionSummary | undefined>> {
return loadSessionStoreFromDir(config.sessionsDir);
}
function getAgentIdFromSessionKey(sessionKey: string): string {
const match = sessionKey.match(/^agent:([^:]+):/);
return match?.[1] || 'main';
}
function resolveSessionsDir(agentId?: string): string {
const normalized = normalizeAgentId(agentId);
if (normalized === 'main') return config.sessionsDir;
return join(config.home, '.openclaw', 'agents', normalized, 'sessions');
}
/** Resolve the transcript path for a session ID, checking both active and deleted files. */
async function findTranscript(sessionId: string): Promise<string | null> {
const sessionsDir = config.sessionsDir;
async function findTranscript(sessionId: string, sessionsDir = config.sessionsDir): Promise<string | null> {
const activePath = join(sessionsDir, `${sessionId}.jsonl`);
try {
@ -64,15 +98,64 @@ async function findTranscript(sessionId: string): Promise<string | null> {
}
}
/** Read the first N lines of a JSONL file to find a model_change entry. */
async function readModelFromTranscript(filePath: string): Promise<string | null> {
/** Read the first N lines of a JSONL file to recover runtime defaults near the top. */
async function readRuntimeFromTranscript(filePath: string): Promise<{ model: string | null; thinking: string | null }> {
return new Promise((resolve) => {
const stream = createReadStream(filePath, { encoding: 'utf-8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let lineCount = 0;
let resolved = false;
let model: string | null = null;
let thinking: string | null = null;
const done = (result: string | null) => {
const done = () => {
if (resolved) return;
resolved = true;
rl.close();
stream.destroy();
resolve({ model, thinking });
};
rl.on('line', (line) => {
if (resolved) return;
lineCount++;
try {
const entry = JSON.parse(line);
if (!model && entry.type === 'model_change' && entry.modelId) {
model = String(entry.modelId);
}
if (!thinking && entry.type === 'thinking_level_change' && entry.thinkingLevel) {
thinking = String(entry.thinkingLevel).toLowerCase();
}
if (model && thinking) {
done();
return;
}
} catch { /* skip malformed lines */ }
// Runtime defaults are emitted near the top when present.
if (lineCount >= 20) {
done();
}
});
rl.on('close', () => done());
rl.on('error', () => done());
stream.on('error', () => done());
});
}
async function readImageBlockFromTranscript(
filePath: string,
messageTimestamp: number,
imageIndex: number,
): Promise<{ buffer: Buffer; mimeType: string; filename: string } | null> {
return new Promise((resolve) => {
const stream = createReadStream(filePath, { encoding: 'utf-8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
let resolved = false;
const done = (result: { buffer: Buffer; mimeType: string; filename: string } | null) => {
if (resolved) return;
resolved = true;
rl.close();
@ -82,18 +165,34 @@ async function readModelFromTranscript(filePath: string): Promise<string | null>
rl.on('line', (line) => {
if (resolved) return;
lineCount++;
try {
const entry = JSON.parse(line);
if (entry.type === 'model_change' && entry.modelId) {
done(entry.modelId);
return;
}
} catch { /* skip malformed lines */ }
// Only check first 10 lines — model_change is always near the top
if (lineCount >= 10) {
done(null);
const entry = JSON.parse(line) as {
type?: string;
message?: { timestamp?: number; content?: TranscriptImageContentBlock[] | unknown };
};
if (entry.type !== 'message') return;
if ((entry.message?.timestamp ?? null) !== messageTimestamp) return;
const content = Array.isArray(entry.message?.content)
? entry.message.content as TranscriptImageContentBlock[]
: [];
const imageBlocks = content.filter((block) => block?.type === 'image');
const target = imageBlocks[imageIndex];
if (!target) return done(null);
const base64 = target.data || target.source?.data;
const mimeType = target.mimeType || target.source?.media_type || 'application/octet-stream';
if (!base64) return done(null);
const ext = mimeType === 'image/jpeg' ? '.jpg'
: mimeType === 'image/png' ? '.png'
: mimeType === 'image/gif' ? '.gif'
: mimeType === 'image/webp' ? '.webp'
: '';
return done({
buffer: Buffer.from(base64, 'base64'),
mimeType,
filename: `message-${messageTimestamp}-image-${imageIndex}${ext}`,
});
} catch {
// skip malformed lines
}
});
@ -102,6 +201,38 @@ async function readModelFromTranscript(filePath: string): Promise<string | null>
});
}
app.get('/api/sessions/media', rateLimitGeneral, async (c) => {
const sessionKey = c.req.query('sessionKey') || '';
const timestampRaw = c.req.query('timestamp') || '';
const imageIndexRaw = c.req.query('imageIndex') || '0';
const messageTimestamp = Number(timestampRaw);
const imageIndex = Number(imageIndexRaw);
if (!sessionKey || !Number.isFinite(messageTimestamp) || !Number.isInteger(imageIndex) || imageIndex < 0) {
return c.json({ ok: false, error: 'Invalid media lookup params' }, 400);
}
try {
const store = await loadSessionStore();
const sessionId = store[sessionKey]?.sessionId;
if (!sessionId) return c.json({ ok: false, error: 'Unknown session key' }, 404);
const transcriptPath = await findTranscript(sessionId);
if (!transcriptPath) return c.json({ ok: false, error: 'Transcript not found' }, 404);
const media = await readImageBlockFromTranscript(transcriptPath, messageTimestamp, imageIndex);
if (!media) return c.json({ ok: false, error: 'Image not found' }, 404);
return new Response(media.buffer, {
headers: {
'Content-Type': media.mimeType,
'Content-Disposition': `inline; filename="${media.filename}"`,
'Cache-Control': 'private, max-age=3600',
},
});
} catch (err) {
console.warn('[sessions] media lookup failed:', (err as Error).message);
return c.json({ ok: false, error: 'Failed to load media' }, 500);
}
});
app.get('/api/sessions/hidden', rateLimitGeneral, async (c) => {
const activeMinutesRaw = c.req.query('activeMinutes');
const limitRaw = c.req.query('limit');
@ -113,12 +244,10 @@ app.get('/api/sessions/hidden', rateLimitGeneral, async (c) => {
? Math.min(Number(limitRaw), 2000)
: 200;
const sessionsFile = join(config.sessionsDir, 'sessions.json');
const cutoffMs = Date.now() - activeMinutes * 60_000;
try {
const raw = await readFile(sessionsFile, 'utf-8');
const store = JSON.parse(raw) as Record<string, StoredSessionSummary | undefined>;
const store = await loadSessionStore();
const sessions = Object.entries(store)
.filter(([sessionKey, session]) => {
@ -156,23 +285,113 @@ app.get('/api/sessions/hidden', rateLimitGeneral, async (c) => {
}
});
app.get('/api/sessions/runtime', rateLimitGeneral, async (c) => {
const sessionKey = c.req.query('sessionKey')?.trim() || '';
if (!sessionKey) {
return c.json({ ok: false, error: 'sessionKey is required' }, 400);
}
const sessionsDir = resolveSessionsDir(getAgentIdFromSessionKey(sessionKey));
const store = await loadSessionStoreFromDir(sessionsDir).catch(() => ({} as Record<string, StoredSessionSummary | undefined>));
const session = store[sessionKey];
const storeThinking = session?.thinkingLevel || session?.thinking;
const info: { model: string | null; thinking: string | null; missing: boolean } = {
model: session?.model || null,
thinking: storeThinking ? String(storeThinking).toLowerCase() : null,
missing: false,
};
const sessionId = session?.sessionId;
if (!sessionId || !/^[0-9a-f-]{36}$/.test(sessionId)) {
return c.json({ ok: true, ...info, missing: true });
}
const transcriptPath = await findTranscript(sessionId, sessionsDir);
if (!transcriptPath) {
return c.json({ ok: true, ...info, missing: true });
}
const runtime = await readRuntimeFromTranscript(transcriptPath);
return c.json({
ok: true,
model: runtime.model ?? info.model,
thinking: runtime.thinking ?? info.thinking,
missing: false,
});
});
app.get('/api/sessions/:id/model', rateLimitGeneral, async (c) => {
const sessionId = c.req.param('id');
const agentId = c.req.query('agentId')?.trim() || 'main';
// Basic validation — session IDs are UUIDs
if (!/^[0-9a-f-]{36}$/.test(sessionId)) {
return c.json({ ok: false, error: 'Invalid session ID' }, 400);
}
const transcriptPath = await findTranscript(sessionId);
const transcriptPath = await findTranscript(sessionId, resolveSessionsDir(agentId));
if (!transcriptPath) {
// Avoid 404 noise in the UI when hovering sessions that no longer have transcripts
// (e.g. one-shot cron runs that were cleaned up).
return c.json({ ok: true, model: null, missing: true }, 200);
return c.json({ ok: true, model: null, thinking: null, missing: true }, 200);
}
const modelId = await readModelFromTranscript(transcriptPath);
return c.json({ ok: true, model: modelId, missing: false });
const runtime = await readRuntimeFromTranscript(transcriptPath);
return c.json({ ok: true, model: runtime.model, thinking: runtime.thinking, missing: false });
});
// ── POST /api/sessions/spawn-subagent ────────────────────────────────
const spawnSubagentSchema = z.object({
parentSessionKey: z
.string()
.min(1)
.max(500)
.regex(/^agent:[^:]+:main$/, 'parentSessionKey must be a top-level root session key (agent:<id>:main)'),
task: z.string().min(1).max(50_000),
label: z.string().max(500).optional(),
model: z.string().max(200).optional(),
thinking: z.string().max(20).optional(),
cleanup: z.enum(['keep', 'delete']).default('keep'),
});
app.post('/api/sessions/spawn-subagent', rateLimitGeneral, async (c) => {
let body: unknown;
try {
body = await c.req.json();
} catch {
return c.json({ ok: false, error: 'Invalid JSON body' }, 400);
}
const parsed = spawnSubagentSchema.safeParse(body);
if (!parsed.success) {
return c.json({
ok: false,
error: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
}, 400);
}
try {
const result = await spawnSubagent({
parentSessionKey: parsed.data.parentSessionKey,
task: parsed.data.task,
label: parsed.data.label,
model: parsed.data.model,
thinking: parsed.data.thinking,
cleanup: parsed.data.cleanup,
});
return c.json({
ok: true,
sessionKey: result.sessionKey,
...(result.runId ? { runId: result.runId } : {}),
mode: result.mode,
}, 200);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('[sessions] spawn-subagent failed:', message);
return c.json({ ok: false, error: message }, 500);
}
});
export default app;

View file

@ -0,0 +1,90 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest';
async function importRoute() {
vi.resetModules();
return import('./upload-config.js');
}
const ENV_KEYS = [
'NERVE_UPLOAD_TWO_MODE_ENABLED',
'NERVE_UPLOAD_INLINE_ENABLED',
'NERVE_UPLOAD_FILE_REFERENCE_ENABLED',
'NERVE_UPLOAD_MODE_CHOOSER_ENABLED',
'NERVE_UPLOAD_INLINE_ATTACHMENT_MAX_MB',
'NERVE_UPLOAD_INLINE_IMAGE_CONTEXT_MAX_BYTES',
'NERVE_UPLOAD_INLINE_IMAGE_AUTO_DOWNGRADE_TO_FILE_REFERENCE',
'NERVE_UPLOAD_INLINE_IMAGE_SHRINK_MIN_DIMENSION',
'NERVE_UPLOAD_IMAGE_OPTIMIZATION_MAX_DIMENSION',
'NERVE_UPLOAD_IMAGE_OPTIMIZATION_WEBP_QUALITY',
'NERVE_UPLOAD_EXPOSE_INLINE_BASE64_TO_AGENT',
] as const;
const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const key of ENV_KEYS) {
const value = originalEnv[key];
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe('GET /api/upload-config', () => {
it('returns upload capability defaults when env overrides are absent', async () => {
for (const key of ENV_KEYS) delete process.env[key];
const { default: app } = await importRoute();
const res = await app.request('/api/upload-config');
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
twoModeEnabled: false,
inlineEnabled: true,
fileReferenceEnabled: false,
modeChooserEnabled: false,
inlineAttachmentMaxMb: 4,
inlineImageContextMaxBytes: 32768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
});
});
it('returns env-backed capability flags for the existing client contract', async () => {
process.env.NERVE_UPLOAD_TWO_MODE_ENABLED = 'true';
process.env.NERVE_UPLOAD_INLINE_ENABLED = 'true';
process.env.NERVE_UPLOAD_FILE_REFERENCE_ENABLED = 'true';
process.env.NERVE_UPLOAD_MODE_CHOOSER_ENABLED = 'true';
process.env.NERVE_UPLOAD_INLINE_ATTACHMENT_MAX_MB = '8';
process.env.NERVE_UPLOAD_INLINE_IMAGE_CONTEXT_MAX_BYTES = '65536';
process.env.NERVE_UPLOAD_INLINE_IMAGE_AUTO_DOWNGRADE_TO_FILE_REFERENCE = 'false';
process.env.NERVE_UPLOAD_INLINE_IMAGE_SHRINK_MIN_DIMENSION = '640';
process.env.NERVE_UPLOAD_IMAGE_OPTIMIZATION_MAX_DIMENSION = '1536';
process.env.NERVE_UPLOAD_IMAGE_OPTIMIZATION_WEBP_QUALITY = '76';
process.env.NERVE_UPLOAD_EXPOSE_INLINE_BASE64_TO_AGENT = 'true';
const { default: app } = await importRoute();
const res = await app.request('/api/upload-config');
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: true,
inlineAttachmentMaxMb: 8,
inlineImageContextMaxBytes: 65536,
inlineImageAutoDowngradeToFileReference: false,
inlineImageShrinkMinDimension: 640,
inlineImageMaxDimension: 1536,
inlineImageWebpQuality: 76,
exposeInlineBase64ToAgent: true,
});
});
});

View file

@ -0,0 +1,9 @@
import { Hono } from 'hono';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
import { getUploadFeatureConfig } from '../lib/upload-config.js';
const app = new Hono();
app.get('/api/upload-config', rateLimitGeneral, (c) => c.json(getUploadFeatureConfig()));
export default app;

View file

@ -0,0 +1,183 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
async function importRoute() {
vi.resetModules();
return import('./upload-reference.js');
}
const originalHome = process.env.HOME;
const originalFileBrowserRoot = process.env.FILE_BROWSER_ROOT;
const originalUploadStagingTempDir = process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
const tempDirs = new Set<string>();
async function makeHomeWorkspace(): Promise<{ homeDir: string; workspaceRoot: string }> {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nerve-upload-reference-home-'));
tempDirs.add(homeDir);
const workspaceRoot = path.join(homeDir, '.openclaw', 'workspace');
await fs.mkdir(workspaceRoot, { recursive: true });
process.env.HOME = homeDir;
delete process.env.FILE_BROWSER_ROOT;
delete process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
return { homeDir, workspaceRoot };
}
afterEach(async () => {
if (originalHome == null) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalFileBrowserRoot == null) {
delete process.env.FILE_BROWSER_ROOT;
} else {
process.env.FILE_BROWSER_ROOT = originalFileBrowserRoot;
}
if (originalUploadStagingTempDir == null) {
delete process.env.NERVE_UPLOAD_STAGING_TEMP_DIR;
} else {
process.env.NERVE_UPLOAD_STAGING_TEMP_DIR = originalUploadStagingTempDir;
}
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
tempDirs.delete(dir);
}
});
describe('POST /api/upload-reference/resolve', () => {
it('returns a canonical direct workspace reference for a validated workspace file', async () => {
const { workspaceRoot } = await makeHomeWorkspace();
const targetPath = path.join(workspaceRoot, 'docs', 'note.md');
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, '# hi\n', 'utf8');
const { default: app } = await importRoute();
const res = await app.request('/api/upload-reference/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'docs/note.md' }),
});
expect(res.status).toBe(200);
const json = await res.json() as {
ok: boolean;
items: Array<{
kind: string;
canonicalPath: string;
absolutePath: string;
mimeType: string;
sizeBytes: number;
originalName: string;
}>;
};
expect(json.ok).toBe(true);
expect(json.items).toHaveLength(1);
expect(json.items[0]).toEqual(expect.objectContaining({
kind: 'direct_workspace_reference',
canonicalPath: 'docs/note.md',
absolutePath: targetPath,
mimeType: 'text/markdown',
sizeBytes: 5,
originalName: 'note.md',
}));
});
it('imports multipart uploads into canonical workspace references', async () => {
const { workspaceRoot } = await makeHomeWorkspace();
const { default: app } = await importRoute();
const form = new FormData();
form.append('files', new File(['hello upload'], 'proof.txt', { type: 'text/plain' }));
const res = await app.request('/api/upload-reference/resolve', {
method: 'POST',
body: form,
});
expect(res.status).toBe(200);
const json = await res.json() as {
ok: boolean;
items: Array<{
kind: string;
canonicalPath: string;
absolutePath: string;
mimeType: string;
sizeBytes: number;
originalName: string;
}>;
};
expect(json.ok).toBe(true);
expect(json.items).toHaveLength(1);
expect(json.items[0]).toEqual(expect.objectContaining({
kind: 'imported_workspace_reference',
mimeType: 'text/plain',
sizeBytes: 12,
originalName: 'proof.txt',
}));
expect(json.items[0].canonicalPath).toMatch(/^\.temp\/nerve-uploads\/\d{4}\/\d{2}\/\d{2}\/proof-[a-f0-9]{8}\.txt$/);
expect(json.items[0].absolutePath).toBe(path.join(workspaceRoot, json.items[0].canonicalPath));
await expect(fs.readFile(json.items[0].absolutePath, 'utf8')).resolves.toBe('hello upload');
});
it('resolves direct workspace references against the requested agent workspace', async () => {
const { homeDir } = await makeHomeWorkspace();
const agentWorkspaceRoot = path.join(homeDir, '.openclaw', 'workspace-research');
const targetPath = path.join(agentWorkspaceRoot, 'docs', 'note.md');
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, '# hi\n', 'utf8');
const { default: app } = await importRoute();
const res = await app.request('/api/upload-reference/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'docs/note.md', agentId: 'research' }),
});
expect(res.status).toBe(200);
const json = await res.json() as {
ok: boolean;
items: Array<{
canonicalPath: string;
absolutePath: string;
originalName: string;
}>;
};
expect(json.ok).toBe(true);
expect(json.items).toHaveLength(1);
expect(json.items[0]).toEqual(expect.objectContaining({
canonicalPath: 'docs/note.md',
absolutePath: targetPath,
originalName: 'note.md',
}));
});
it('rejects symlink escapes that resolve outside the workspace root', async () => {
const { homeDir, workspaceRoot } = await makeHomeWorkspace();
const outsidePath = path.join(homeDir, 'outside.txt');
const linkedPath = path.join(workspaceRoot, 'docs', 'linked.txt');
await fs.mkdir(path.dirname(linkedPath), { recursive: true });
await fs.writeFile(outsidePath, 'secret', 'utf8');
await fs.symlink(outsidePath, linkedPath);
const { default: app } = await importRoute();
const res = await app.request('/api/upload-reference/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'docs/linked.txt' }),
});
expect(res.status).toBe(403);
await expect(res.json()).resolves.toEqual({
ok: false,
error: 'Invalid or excluded workspace path.',
});
});
});

View file

@ -0,0 +1,65 @@
import { Hono } from 'hono';
import { InvalidAgentIdError } from '../lib/agent-workspace.js';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
import {
importExternalUploadToCanonicalReference,
resolveDirectWorkspaceReference,
} from '../lib/upload-reference.js';
const app = new Hono();
app.post('/api/upload-reference/resolve', rateLimitGeneral, async (c) => {
try {
const contentType = c.req.header('content-type') || '';
if (contentType.includes('application/json')) {
const body = await c.req.json().catch(() => null) as { path?: unknown; paths?: unknown; agentId?: unknown } | null;
const paths = Array.isArray(body?.paths)
? body.paths.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: typeof body?.path === 'string' && body.path.trim().length > 0
? [body.path]
: [];
const agentId = typeof body?.agentId === 'string' && body.agentId.trim().length > 0
? body.agentId
: undefined;
if (paths.length === 0) {
return c.json({ ok: false, error: 'At least one workspace path is required.' }, 400);
}
const items = await Promise.all(paths.map((targetPath) => resolveDirectWorkspaceReference(targetPath, agentId)));
return c.json({ ok: true, items });
}
const form = await c.req.formData();
const values = [...form.getAll('files'), ...form.getAll('file')];
const files = values.filter((value): value is File => value instanceof File);
if (files.length === 0) {
return c.json({ ok: false, error: 'At least one file is required.' }, 400);
}
const items = await Promise.all(files.map(async (file) => {
const bytes = new Uint8Array(await file.arrayBuffer());
return importExternalUploadToCanonicalReference({
originalName: file.name,
mimeType: file.type,
bytes,
});
}));
return c.json({ ok: true, items });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to resolve canonical upload reference';
const status = error instanceof InvalidAgentIdError
? 400
: message === 'Invalid or excluded workspace path.' || message === 'Resolved attachment path is outside the workspace root.'
? 403
: message === 'Resolved attachment path is not a file.'
? 400
: 500;
return c.json({ ok: false, error: message }, status);
}
});
export default app;

View file

@ -30,6 +30,7 @@ const FILE_MAP: Record<string, string> = {
user: 'USER.md',
agents: 'AGENTS.md',
heartbeat: 'HEARTBEAT.md',
chatPathLinks: 'CHAT_PATH_LINKS.json',
};
function getWorkspaceRoot(agentId?: string): { agentId: string; workspaceRoot: string } {

View file

@ -1,11 +1,13 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
type SaveResult = { ok: boolean; conflict?: boolean };
type SaveAllResult = { ok: boolean; failedPath?: string; conflict?: boolean };
const originalFetch = global.fetch;
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@ -17,15 +19,26 @@ function createDeferred<T>() {
}
const {
settingsContext,
uploadConfigState,
sessionContext,
saveFileByAgent,
saveAllDirtyFilesByAgent,
discardAllDirtyFilesByAgent,
dirtyStateByAgent,
reloadCalls,
topBarRenderSnapshots,
tabRenderSnapshots,
addWorkspacePathSpy,
useOpenFilesMock,
} = vi.hoisted(() => {
const settingsContext = {
kanbanVisible: true,
};
const uploadConfigState = {
fileReferenceEnabled: true,
};
const sessionContext = {
sessions: [
{ key: 'agent:alpha:main', label: 'Alpha' },
@ -65,11 +78,13 @@ const {
bravo: false,
};
const reloadCalls: Array<{ agentId: string; path: string }> = [];
const topBarRenderSnapshots: Array<{ showKanbanView?: boolean; viewMode?: string }> = [];
const tabRenderSnapshots: Array<{
workspaceAgentId: string;
hasSaveToast: boolean;
saveToastPath: string | null;
}> = [];
const addWorkspacePathSpy = vi.fn();
const useOpenFilesMock = vi.fn((agentId: string) => ({
openFiles: [{ path: 'shared.md', name: 'shared.md', content: 'draft', savedContent: 'draft', dirty: dirtyStateByAgent[agentId] ?? false }],
@ -92,13 +107,17 @@ const {
}));
return {
settingsContext,
uploadConfigState,
sessionContext,
saveFileByAgent,
saveAllDirtyFilesByAgent,
discardAllDirtyFilesByAgent,
dirtyStateByAgent,
reloadCalls,
topBarRenderSnapshots,
tabRenderSnapshots,
addWorkspacePathSpy,
useOpenFilesMock,
};
});
@ -165,6 +184,7 @@ vi.mock('@/contexts/SettingsContext', () => ({
toggleTelemetry: vi.fn(),
setTheme: vi.fn(),
setFont: vi.fn(),
kanbanVisible: settingsContext.kanbanVisible,
}),
}));
@ -213,12 +233,22 @@ vi.mock('@/features/command-palette/commands', () => ({
vi.mock('@/features/file-browser', () => ({
useOpenFiles: useOpenFilesMock,
FileTreePanel: () => <div data-testid="file-tree-panel" />,
TabbedContentArea: ({ workspaceAgentId, onSaveFile, onReloadFile, saveToast }: {
FileTreePanel: ({ onAddToChat, addToChatEnabled }: {
onAddToChat?: (path: string, kind: 'file' | 'directory', agentId?: string) => void | Promise<void>;
addToChatEnabled?: boolean;
}) => (addToChatEnabled ? (
<button type="button" data-testid="file-tree-panel" onClick={() => onAddToChat?.('docs/note.md', 'file')}>
Trigger add to chat
</button>
) : <div data-testid="file-tree-panel-disabled">Add to chat disabled</div>),
TabbedContentArea: ({ workspaceAgentId, onSaveFile, onReloadFile, saveToast, chatPanel, openBeads, onOpenBeadId }: {
workspaceAgentId: string;
onSaveFile: (path: string) => void;
onReloadFile?: (path: string) => void;
saveToast?: { path: string; type: 'conflict' } | null;
chatPanel?: ReactNode;
openBeads?: Array<{ id: string; beadId: string }>;
onOpenBeadId?: (target: { beadId: string }) => void;
}) => {
tabRenderSnapshots.push({
workspaceAgentId,
@ -228,8 +258,11 @@ vi.mock('@/features/file-browser', () => ({
return (
<div>
{chatPanel}
<div data-testid="workspace-agent">{workspaceAgentId}</div>
<button type="button" onClick={() => onSaveFile('shared.md')}>Save shared.md</button>
<button type="button" onClick={() => onOpenBeadId?.({ beadId: 'nerve-fms2' })}>Open bead viewer</button>
<div data-testid="open-beads">{(openBeads ?? []).map((bead) => bead.beadId).join(',')}</div>
{saveToast && (
<div>
<span>File changed externally.</span>
@ -248,7 +281,15 @@ vi.mock('@/features/connect/ConnectDialog', () => ({
}));
vi.mock('@/components/TopBar', () => ({
TopBar: () => null,
TopBar: ({ showKanbanView, viewMode }: { showKanbanView?: boolean; viewMode?: string }) => {
topBarRenderSnapshots.push({ showKanbanView, viewMode });
return (
<div>
<div data-testid="topbar-show-kanban">{String(showKanbanView ?? true)}</div>
<div data-testid="topbar-view-mode">{viewMode ?? 'chat'}</div>
</div>
);
},
}));
vi.mock('@/components/StatusBar', () => ({
@ -260,7 +301,14 @@ vi.mock('@/components/ConfirmDialog', () => ({
}));
vi.mock('@/features/chat/ChatPanel', () => ({
ChatPanel: () => null,
ChatPanel: forwardRef((_props: { onOpenBeadId?: (target: { beadId: string; workspaceAgentId?: string }) => void }, ref) => {
useImperativeHandle(ref, () => ({
focusInput: vi.fn(),
addWorkspacePath: addWorkspacePathSpy,
}));
return null;
}),
}));
vi.mock('@/components/ResizablePanels', () => ({
@ -337,6 +385,48 @@ vi.mock('@/features/kanban/KanbanPanel', () => ({
KanbanPanel: () => null,
}));
beforeEach(() => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/upload-config')) {
return {
ok: true,
json: async () => ({
twoModeEnabled: false,
inlineEnabled: true,
fileReferenceEnabled: uploadConfigState.fileReferenceEnabled,
modeChooserEnabled: false,
inlineAttachmentMaxMb: 4,
inlineImageContextMaxBytes: 32768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
}),
} as Response;
}
if (url.includes('/api/workspace/chatPathLinks')) {
return {
ok: false,
status: 404,
json: async () => ({ ok: false }),
} as Response;
}
return {
ok: true,
json: async () => ({}),
} as Response;
}) as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
describe('App save toast workspace scoping', () => {
beforeEach(() => {
localStorage.clear();
@ -346,9 +436,13 @@ describe('App save toast workspace scoping', () => {
Object.values(saveFileByAgent).forEach((mockFn) => mockFn.mockReset());
Object.values(saveAllDirtyFilesByAgent).forEach((mockFn) => mockFn.mockReset());
Object.values(discardAllDirtyFilesByAgent).forEach((mockFn) => mockFn.mockReset());
addWorkspacePathSpy.mockReset();
uploadConfigState.fileReferenceEnabled = true;
dirtyStateByAgent.alpha = false;
dirtyStateByAgent.bravo = false;
settingsContext.kanbanVisible = true;
reloadCalls.length = 0;
topBarRenderSnapshots.length = 0;
tabRenderSnapshots.length = 0;
useOpenFilesMock.mockClear();
@ -367,6 +461,88 @@ describe('App save toast workspace scoping', () => {
});
});
it('passes the active workspace agent through add-to-chat requests from the file tree', async () => {
sessionContext.currentSession = 'agent:bravo:main';
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Trigger add to chat' }));
expect(addWorkspacePathSpy).toHaveBeenCalledWith('docs/note.md', 'file', 'bravo');
});
it('does not expose add-to-chat from the file tree when file references are disabled', async () => {
uploadConfigState.fileReferenceEnabled = false;
render(<App />);
await screen.findByTestId('file-tree-panel-disabled');
expect(screen.queryByRole('button', { name: 'Trigger add to chat' })).not.toBeInTheDocument();
expect(addWorkspacePathSpy).not.toHaveBeenCalled();
});
it('retries upload-config after a transient failure before hiding add-to-chat', async () => {
vi.useFakeTimers();
let uploadConfigAttempts = 0;
try {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/upload-config')) {
uploadConfigAttempts += 1;
if (uploadConfigAttempts === 1) {
throw new Error('temporary upload-config failure');
}
return {
ok: true,
json: async () => ({
twoModeEnabled: false,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: false,
inlineAttachmentMaxMb: 4,
inlineImageContextMaxBytes: 32768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
}),
} as Response;
}
if (url.includes('/api/workspace/chatPathLinks')) {
return {
ok: false,
status: 404,
json: async () => ({ ok: false }),
} as Response;
}
return {
ok: true,
json: async () => ({}),
} as Response;
}) as typeof fetch;
render(<App />);
expect(screen.getByTestId('file-tree-panel-disabled')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Trigger add to chat' })).not.toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByRole('button', { name: 'Trigger add to chat' })).toBeInTheDocument();
expect(uploadConfigAttempts).toBe(2);
} finally {
vi.useRealTimers();
}
});
it('drops a late save conflict toast after switching workspaces before the save resolves', async () => {
const alphaSave = createDeferred<SaveResult>();
saveFileByAgent.alpha.mockReturnValue(alphaSave.promise);
@ -456,10 +632,77 @@ describe('App save toast workspace scoping', () => {
});
});
describe('App bead tab workspace scoping', () => {
beforeEach(() => {
localStorage.clear();
sessionContext.currentSession = 'agent:alpha:main';
sessionContext.setCurrentSession.mockReset();
dirtyStateByAgent.alpha = false;
dirtyStateByAgent.bravo = false;
tabRenderSnapshots.length = 0;
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
it('shows bead tabs only for the active workspace and drops them immediately on workspace switch', () => {
const { rerender } = render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Open bead viewer' }));
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
sessionContext.currentSession = 'agent:bravo:main';
rerender(<App />);
expect(screen.getByTestId('workspace-agent')).toHaveTextContent('bravo');
expect(screen.getByTestId('open-beads')).toHaveTextContent('');
sessionContext.currentSession = 'agent:alpha:main';
rerender(<App />);
expect(screen.getByTestId('workspace-agent')).toHaveTextContent('alpha');
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
});
it('creates distinct shorthand bead tabs per workspace instead of deduping across hidden tabs', () => {
const { rerender } = render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'Open bead viewer' }));
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
sessionContext.currentSession = 'agent:bravo:main';
rerender(<App />);
expect(screen.getByTestId('open-beads')).toHaveTextContent('');
fireEvent.click(screen.getByRole('button', { name: 'Open bead viewer' }));
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
sessionContext.currentSession = 'agent:alpha:main';
rerender(<App />);
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
sessionContext.currentSession = 'agent:bravo:main';
rerender(<App />);
expect(screen.getByTestId('open-beads')).toHaveTextContent('nerve-fms2');
});
});
describe('App workspace switch guard', () => {
beforeEach(() => {
localStorage.clear();
sessionContext.currentSession = 'agent:alpha:main';
uploadConfigState.fileReferenceEnabled = true;
sessionContext.setCurrentSession.mockReset();
sessionContext.spawnSession.mockReset();
Object.values(saveAllDirtyFilesByAgent).forEach((mockFn) => mockFn.mockReset());
@ -579,3 +822,29 @@ describe('App workspace switch guard', () => {
});
});
});
describe('App kanban visibility gating', () => {
beforeEach(() => {
localStorage.clear();
settingsContext.kanbanVisible = true;
topBarRenderSnapshots.length = 0;
});
it('passes the kanban visibility flag through to the top bar', () => {
settingsContext.kanbanVisible = false;
render(<App />);
expect(screen.getByTestId('topbar-show-kanban')).toHaveTextContent('false');
expect(topBarRenderSnapshots.at(-1)).toMatchObject({ showKanbanView: false });
});
it('falls back to chat when kanban is persisted but hidden', () => {
localStorage.setItem('nerve:viewMode', 'kanban');
settingsContext.kanbanVisible = false;
render(<App />);
expect(screen.getByTestId('topbar-view-mode')).toHaveTextContent('chat');
});
});

View file

@ -38,7 +38,9 @@ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { createCommands } from '@/features/command-palette/commands';
import { PanelErrorBoundary } from '@/components/PanelErrorBoundary';
import { SpawnAgentDialog } from '@/features/sessions/SpawnAgentDialog';
import { DEFAULT_CHAT_PATH_LINKS_CONFIG, parseChatPathLinksConfig } from '@/features/chat/chatPathLinks';
import { FileTreePanel, TabbedContentArea, useOpenFiles, type FileTreeChangeEvent } from '@/features/file-browser';
import { type BeadLinkTarget, type OpenBeadTab, buildBeadTabId } from '@/features/beads';
import { isImageFile } from '@/features/file-browser/utils/fileTypes';
import { buildAgentRootSessionKey, getSessionDisplayLabel } from '@/features/sessions/sessionKeys';
import { shouldGuardWorkspaceSwitch } from '@/features/workspace/workspaceSwitchGuard';
@ -77,6 +79,17 @@ function buildWorkspaceSwitchErrorMessage(result: {
return `Could not save ${fileLabel}. Resolve it before switching agents.`;
}
function getInitialViewMode(canShowKanban: boolean): ViewMode {
try {
const saved = localStorage.getItem('nerve:viewMode');
if (saved === 'kanban' && canShowKanban) return 'kanban';
} catch {
// ignore storage errors
}
return 'chat';
}
export default function App({ onLogout }: AppProps) {
// Gateway state
const {
@ -111,6 +124,7 @@ export default function App({ onLogout }: AppProps) {
eventsVisible, logVisible,
toggleEvents, toggleLog, toggleTelemetry,
setTheme, setFont,
kanbanVisible,
} = useSettings();
// Connection management (extracted hook)
@ -306,30 +320,162 @@ export default function App({ onLogout }: AppProps) {
const [spawnDialogOpen, setSpawnDialogOpen] = useState(false);
// View mode state (chat | kanban), persisted to localStorage
const [viewMode, setViewModeRaw] = useState<ViewMode>(() => {
try {
const saved = localStorage.getItem('nerve:viewMode');
if (saved === 'kanban') return 'kanban';
} catch { /* ignore */ }
return 'chat';
});
const [viewMode, setViewModeRaw] = useState<ViewMode>(() => getInitialViewMode(kanbanVisible));
const [pendingTaskId, setPendingTaskId] = useState<string | null>(null);
const [openBeads, setOpenBeads] = useState<OpenBeadTab[]>([]);
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeRaw(mode);
const nextMode = mode === 'kanban' && !kanbanVisible ? 'chat' : mode;
setViewModeRaw(nextMode);
if (mode === 'kanban' && isCompactLayout) {
if (nextMode === 'kanban' && isCompactLayout) {
setFileBrowserCollapsed(true);
}
try { localStorage.setItem('nerve:viewMode', mode); } catch { /* ignore */ }
}, [isCompactLayout, setFileBrowserCollapsed]);
try { localStorage.setItem('nerve:viewMode', nextMode); } catch { /* ignore */ }
}, [isCompactLayout, kanbanVisible, setFileBrowserCollapsed]);
const openTaskInBoard = useCallback((taskId: string) => {
setPendingTaskId(taskId);
setViewMode('kanban');
}, [setViewMode]);
const [chatPathLinkPrefixes, setChatPathLinkPrefixes] = useState<string[]>(
DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes,
);
const [addToChatEnabled, setAddToChatEnabled] = useState(false);
const openWorkspacePath = useCallback(async (targetPath: string) => {
useEffect(() => {
const params = new URLSearchParams({ agentId: workspaceAgentId });
const controller = new AbortController();
void fetch(`/api/workspace/chatPathLinks?${params.toString()}`, { signal: controller.signal })
.then(async (res) => {
if (res.status === 404) {
setChatPathLinkPrefixes(DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes);
return;
}
const data = await res.json() as { ok: boolean; content?: string };
if (!data.ok || !data.content) {
setChatPathLinkPrefixes(DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes);
return;
}
const parsed = parseChatPathLinksConfig(data.content);
setChatPathLinkPrefixes(parsed.prefixes);
})
.catch(() => {
if (!controller.signal.aborted) {
setChatPathLinkPrefixes(DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes);
}
});
return () => controller.abort();
}, [workspaceAgentId]);
useEffect(() => {
const controller = new AbortController();
let retryTimer: number | null = null;
let attempts = 0;
const maxAttempts = 3;
const loadUploadConfig = () => {
attempts += 1;
void fetch('/api/upload-config', { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (controller.signal.aborted) return;
if (data) {
setAddToChatEnabled(Boolean(data.fileReferenceEnabled));
return;
}
if (attempts >= maxAttempts) {
setAddToChatEnabled(false);
return;
}
retryTimer = window.setTimeout(loadUploadConfig, 1000);
})
.catch(() => {
if (controller.signal.aborted) return;
if (attempts >= maxAttempts) {
setAddToChatEnabled(false);
return;
}
retryTimer = window.setTimeout(loadUploadConfig, 1000);
});
};
loadUploadConfig();
return () => {
controller.abort();
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
};
}, []);
useEffect(() => {
if (kanbanVisible || viewMode !== 'kanban') return;
setViewMode('chat');
}, [kanbanVisible, setViewMode, viewMode]);
const openBeadId = useCallback((target: BeadLinkTarget) => {
const normalizedBeadId = target.beadId.trim();
if (!normalizedBeadId) return;
const normalizedTarget: BeadLinkTarget = {
beadId: normalizedBeadId,
explicitTargetPath: target.explicitTargetPath?.trim() || undefined,
currentDocumentPath: target.currentDocumentPath?.trim() || undefined,
workspaceAgentId: target.workspaceAgentId?.trim() || workspaceAgentId,
};
const tabId = buildBeadTabId(normalizedTarget);
setOpenBeads((prev) => {
if (prev.some((bead) => bead.id === tabId)) return prev;
return [...prev, {
id: tabId,
beadId: normalizedBeadId,
name: normalizedBeadId,
explicitTargetPath: normalizedTarget.explicitTargetPath,
currentDocumentPath: normalizedTarget.currentDocumentPath,
workspaceAgentId: normalizedTarget.workspaceAgentId,
}];
});
setActiveTab(tabId);
}, [setActiveTab, workspaceAgentId]);
const visibleOpenBeads = useMemo(() => openBeads.filter((bead) => {
const beadWorkspaceAgentId = bead.workspaceAgentId?.trim() || workspaceAgentId;
return beadWorkspaceAgentId === workspaceAgentId;
}), [openBeads, workspaceAgentId]);
useEffect(() => {
if (!activeTab.startsWith('bead:')) return;
if (visibleOpenBeads.some((bead) => bead.id === activeTab)) return;
setActiveTab('chat');
}, [activeTab, setActiveTab, visibleOpenBeads]);
const closeWorkspaceTab = useCallback((tabId: string) => {
if (tabId.startsWith('bead:')) {
setOpenBeads((prev) => prev.filter((bead) => bead.id !== tabId));
if (activeTab === tabId) {
setActiveTab('chat');
}
return;
}
closeFile(tabId);
}, [activeTab, closeFile, setActiveTab]);
const openWorkspacePath = useCallback(async (targetPath: string, basePath?: string) => {
const params = new URLSearchParams({ path: targetPath, agentId: workspaceAgentId });
if (basePath) {
params.set('relativeTo', basePath);
}
const res = await fetch(`/api/files/resolve?${params.toString()}`);
const data = await res.json().catch(() => null) as {
ok?: boolean;
@ -340,14 +486,12 @@ export default function App({ onLogout }: AppProps) {
if (!res.ok || !data?.ok || !data.path || !data.type) return;
if (data.type === 'file' && (!data.binary || isImageFile(data.path))) {
setRevealRequest(null);
await openFile(data.path);
return;
}
setFileBrowserCollapsed(false);
setRevealRequest({ id: Date.now(), path: data.path, kind: data.type, agentId: workspaceAgentId });
if (data.type === 'file' && (!data.binary || isImageFile(data.path))) {
await openFile(data.path);
}
}, [openFile, setFileBrowserCollapsed, workspaceAgentId]);
const toggleMobileTopBar = useCallback(() => {
@ -381,9 +525,10 @@ export default function App({ onLogout }: AppProps) {
onRefreshSessions: refreshSessions,
onRefreshMemory: refreshMemories,
onSetViewMode: setViewMode,
canShowKanban: kanbanVisible,
}), [openSpawnDialog, handleReset, toggleSound, handleAbort, openSettings, openSearch,
setTheme, setFont, setTtsProvider, handleToggleWakeWord, toggleEvents, toggleLog, toggleTelemetry,
refreshSessions, refreshMemories, setViewMode]);
refreshSessions, refreshMemories, setViewMode, kanbanVisible]);
// Keyboard shortcut handlers with useCallback
const handleOpenPalette = useCallback(() => setPaletteOpen(true), []);
@ -622,15 +767,19 @@ export default function App({ onLogout }: AppProps) {
<TabbedContentArea
activeTab={activeTab}
openFiles={openFiles}
openBeads={visibleOpenBeads}
workspaceAgentId={workspaceAgentId}
onSelectTab={setActiveTab}
onCloseTab={closeFile}
onCloseTab={closeWorkspaceTab}
onContentChange={updateContent}
onSaveFile={handleSaveFile}
saveToast={visibleSaveToast}
onDismissToast={dismissSaveToast}
onReloadFile={reloadFile}
onRetryFile={reloadFile}
onOpenWorkspacePath={openWorkspacePath}
onOpenBeadId={openBeadId}
pathLinkPrefixes={chatPathLinkPrefixes}
chatPanel={
<PanelErrorBoundary name="Chat">
<ChatPanel
@ -657,6 +806,8 @@ export default function App({ onLogout }: AppProps) {
onToggleMobileTopBar={isCompactLayout ? toggleMobileTopBar : undefined}
isMobileTopBarHidden={isMobileTopBarHidden}
onOpenWorkspacePath={openWorkspacePath}
pathLinkPrefixes={chatPathLinkPrefixes}
onOpenBeadId={openBeadId}
/>
</PanelErrorBoundary>
}
@ -822,6 +973,7 @@ export default function App({ onLogout }: AppProps) {
workspacePanel={compactWorkspacePanel}
viewMode={viewMode}
onViewModeChange={setViewMode}
showKanbanView={kanbanVisible}
/>
)}
@ -868,6 +1020,8 @@ export default function App({ onLogout }: AppProps) {
<FileTreePanel
workspaceAgentId={workspaceAgentId}
onOpenFile={openFile}
onAddToChat={(path, kind, agentId) => chatPanelRef.current?.addWorkspacePath(path, kind, agentId ?? workspaceAgentId)}
addToChatEnabled={addToChatEnabled}
lastChangedEvent={lastChangedEvent}
revealRequest={revealRequest}
onRemapOpenPaths={remapOpenPaths}
@ -894,6 +1048,8 @@ export default function App({ onLogout }: AppProps) {
<FileTreePanel
workspaceAgentId={workspaceAgentId}
onOpenFile={openFile}
onAddToChat={(path, kind, agentId) => chatPanelRef.current?.addWorkspacePath(path, kind, agentId ?? workspaceAgentId)}
addToChatEnabled={addToChatEnabled}
lastChangedEvent={lastChangedEvent}
revealRequest={revealRequest}
onRemapOpenPaths={remapOpenPaths}

View file

@ -0,0 +1,40 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TopBar } from './TopBar';
vi.mock('./NerveLogo', () => ({
default: () => <div data-testid="nerve-logo" />,
}));
function renderTopBar(props: Partial<React.ComponentProps<typeof TopBar>> = {}) {
return render(
<TopBar
onSettings={vi.fn()}
agentLogEntries={[]}
tokenData={null}
logGlow={false}
eventEntries={[]}
eventsVisible={false}
logVisible={false}
viewMode="chat"
onViewModeChange={vi.fn()}
{...props}
/>,
);
}
describe('TopBar', () => {
it('shows the tasks view toggle by default', () => {
renderTopBar();
expect(screen.getByRole('button', { name: /switch to tasks view/i })).toBeInTheDocument();
});
it('hides the tasks view toggle when kanban visibility is disabled', () => {
renderTopBar({ showKanbanView: false });
expect(screen.queryByRole('button', { name: /switch to tasks view/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /switch to chat view/i })).toBeInTheDocument();
});
});

View file

@ -108,6 +108,8 @@ interface TopBarProps {
viewMode?: ViewMode;
/** Callback to change the view mode. */
onViewModeChange?: (mode: ViewMode) => void;
/** Whether the Tasks/Kanban view toggle should be shown. */
showKanbanView?: boolean;
}
/**
@ -130,6 +132,7 @@ export function TopBar({
workspacePanel,
viewMode = "chat",
onViewModeChange,
showKanbanView = true,
}: TopBarProps) {
const [activePanel, setActivePanel] = useState<PanelId>(null);
const panelRef = useRef<HTMLDivElement>(null);
@ -260,17 +263,19 @@ export function TopBar({
<MessageSquare size={13} aria-hidden="true" />
<span>Chat</span>
</button>
<button
onClick={() => onViewModeChange("kanban")}
title="Tasks View"
aria-label="Switch to tasks view"
aria-pressed={viewMode === "kanban"}
data-active={viewMode === "kanban"}
className="shell-chip min-h-11 flex-1 justify-center text-[0.733rem] uppercase tracking-[0.14em] max-[371px]:min-h-[38px] max-[371px]:gap-1 max-[371px]:px-2 max-[371px]:text-[0.667rem] max-[371px]:tracking-[0.08em] max-[371px]:[&_svg]:size-3 sm:min-h-10 sm:flex-none"
>
<LayoutGrid size={13} aria-hidden="true" />
<span>Tasks</span>
</button>
{showKanbanView && (
<button
onClick={() => onViewModeChange("kanban")}
title="Tasks View"
aria-label="Switch to tasks view"
aria-pressed={viewMode === "kanban"}
data-active={viewMode === "kanban"}
className="shell-chip min-h-11 flex-1 justify-center text-[0.733rem] uppercase tracking-[0.14em] max-[371px]:min-h-[38px] max-[371px]:gap-1 max-[371px]:px-2 max-[371px]:text-[0.667rem] max-[371px]:tracking-[0.08em] max-[371px]:[&_svg]:size-3 sm:min-h-10 sm:flex-none"
>
<LayoutGrid size={13} aria-hidden="true" />
<span>Tasks</span>
</button>
)}
</div>
)}
<div ref={buttonsRef} className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-1.5 overflow-x-auto pb-1 max-[371px]:gap-0.5 sm:max-w-none sm:gap-2 sm:overflow-visible sm:pb-0">

View file

@ -19,6 +19,8 @@ interface InlineSelectProps {
triggerClassName?: string;
menuClassName?: string;
dropUp?: boolean;
/** Optional label override for the closed trigger button. */
displayLabel?: string;
/** When true, render dropdown inline (absolute) instead of via portal.
* Use inside Radix Dialog or other portal-based overlays where
* a sibling portal's clicks get intercepted. */
@ -42,6 +44,7 @@ export function InlineSelect({
triggerClassName,
menuClassName,
dropUp = false,
displayLabel,
inline = false,
}: InlineSelectProps) {
const [open, setOpen] = useState(false);
@ -262,7 +265,7 @@ export function InlineSelect({
onKeyDown={handleKeyDown}
className={cn('font-mono text-[0.8rem] bg-background/40 text-foreground/80 border border-border/60 px-2 py-1.5 outline-none disabled:opacity-50 disabled:cursor-not-allowed inline-flex min-h-11 items-center gap-1 min-w-0 sm:min-h-8 sm:px-1.5 sm:py-0.5 sm:text-[0.667rem]', triggerClassName)}
>
<span className="truncate">{selected?.label ?? value}</span>
<span className="truncate">{displayLabel ?? selected?.label ?? value}</span>
<span className="text-muted-foreground"></span>
</button>

View file

@ -45,7 +45,7 @@ import {
updateHighestSeq,
} from '@/features/chat/operations';
import { generateMsgId } from '@/features/chat/types';
import type { ImageAttachment, ChatMsg } from '@/features/chat/types';
import type { ImageAttachment, ChatMsg, OutgoingUploadPayload } from '@/features/chat/types';
import type { RecoveryReason, RunState } from '@/features/chat/operations';
import { useChatMessages, mergeFinalMessages, patchThinkingDuration } from '@/hooks/useChatMessages';
@ -144,6 +144,41 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setStream: streamHook.setStream,
});
const {
loadHistory,
getAllMessages,
applyMessageWindow,
} = msgHook;
const {
triggerRecovery,
clearDisconnectState,
captureDisconnectState,
wasGeneratingOnDisconnect,
isRecoveryInFlight,
isRecoveryPending,
incrementGeneration,
getGeneration,
} = recoveryHook;
const {
lastEventTimestamp,
setProcessingStage,
setLastEventTimestamp,
setActivityLog,
addActivityEntry,
completeActivityEntry,
startThinking,
captureThinkingDuration,
scheduleStreamingUpdate,
clearStreamBuffer,
getThinkingDuration,
resetThinking,
} = streamHook;
const {
playCompletionPing,
resetPlayedSounds,
handleFinalTTS,
} = ttsHook;
// ─── Reset transient state on session switch ──────────────────────────────
useEffect(() => {
setIsGenerating(false);
@ -167,31 +202,29 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const prevConnection = previousConnectionStateRef.current;
if (connectionState === 'connected') {
if (prevConnection === 'reconnecting' && recoveryHook.wasGeneratingOnDisconnect()) {
recoveryHook.triggerRecovery('reconnect');
if (prevConnection === 'reconnecting' && wasGeneratingOnDisconnect()) {
triggerRecovery('reconnect');
}
recoveryHook.clearDisconnectState();
clearDisconnectState();
}
if (connectionState === 'reconnecting' && prevConnection === 'connected') {
recoveryHook.captureDisconnectState();
captureDisconnectState();
}
previousConnectionStateRef.current = connectionState;
}, [
connectionState,
currentSession,
msgHook.loadHistory,
recoveryHook.wasGeneratingOnDisconnect,
recoveryHook.triggerRecovery,
recoveryHook.clearDisconnectState,
recoveryHook.captureDisconnectState,
wasGeneratingOnDisconnect,
triggerRecovery,
clearDisconnectState,
captureDisconnectState,
]);
useEffect(() => {
if (connectionState !== 'connected' || !currentSession) return;
msgHook.loadHistory(currentSession);
}, [connectionState, currentSession, msgHook.loadHistory]);
loadHistory(currentSession);
}, [connectionState, currentSession, loadHistory]);
// ─── Periodic history poll for sub-agent sessions ─────────────────────────
const isSubagentSession = currentSession ? isSubagentSessionKey(currentSession) : false;
@ -212,14 +245,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const sk = currentSessionRef.current;
const result = await loadChatHistory({ rpc, sessionKey: sk, limit: 500 });
if (sk !== currentSessionRef.current) return;
const prev = msgHook.getAllMessages();
const prev = getAllMessages();
if (
result.length === prev.length &&
result.length > 0 &&
result[result.length - 1]?.rawText === prev[prev.length - 1]?.rawText &&
result[result.length - 1]?.role === prev[prev.length - 1]?.role
) return;
msgHook.applyMessageWindow(result, false);
applyMessageWindow(result, false);
} catch { /* best-effort */ } finally {
subagentPollInFlightRef.current = false;
}
@ -229,26 +262,26 @@ export function ChatProvider({ children }: { children: ReactNode }) {
clearInterval(pollInterval);
subagentPollInFlightRef.current = false;
};
}, [isSubagentActive, connectionState, currentSession, rpc, msgHook.applyMessageWindow, msgHook.getAllMessages]);
}, [isSubagentActive, connectionState, currentSession, rpc, applyMessageWindow, getAllMessages]);
// ─── Watchdog: if stream stalls, recover once ─────────────────────────────
useEffect(() => {
if (!isGenerating || !streamHook.lastEventTimestamp) return;
if (!isGenerating || !lastEventTimestamp) return;
const timer = setTimeout(() => {
const elapsed = Date.now() - streamHook.lastEventTimestamp;
if (elapsed >= 12_000 && !recoveryHook.isRecoveryInFlight() && !recoveryHook.isRecoveryPending()) {
recoveryHook.triggerRecovery('chat-gap');
const elapsed = Date.now() - lastEventTimestamp;
if (elapsed >= 12_000 && !isRecoveryInFlight() && !isRecoveryPending()) {
triggerRecovery('chat-gap');
}
}, 12_000);
return () => clearTimeout(timer);
}, [
isGenerating,
streamHook.lastEventTimestamp,
recoveryHook.isRecoveryInFlight,
recoveryHook.isRecoveryPending,
recoveryHook.triggerRecovery,
lastEventTimestamp,
isRecoveryInFlight,
isRecoveryPending,
triggerRecovery,
]);
// ─── Subscribe to streaming events ────────────────────────────────────────
@ -258,7 +291,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const triggerRecoveryOnce = (reason: RecoveryReason) => {
if (recoveryTriggeredThisEvent) return;
recoveryTriggeredThisEvent = true;
recoveryHook.triggerRecovery(reason);
triggerRecovery(reason);
};
const classified = classifyStreamEvent(msg);
@ -273,7 +306,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isRootChildSession(classified.sessionKey, getRootAgentSessionKey(currentSk) || currentSk) &&
(classified.type === 'chat_final' || classified.type === 'lifecycle_end')
) {
recoveryHook.triggerRecovery('subagent-complete');
triggerRecovery('subagent-complete');
}
return;
}
@ -294,32 +327,32 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (type === 'lifecycle_start') {
setIsGenerating(true);
streamHook.setProcessingStage('thinking');
streamHook.setLastEventTimestamp(Date.now());
setProcessingStage('thinking');
setLastEventTimestamp(Date.now());
return;
}
if (type === 'lifecycle_end') {
setIsGenerating(false);
streamHook.setProcessingStage(null);
streamHook.setActivityLog([]);
streamHook.setLastEventTimestamp(0);
ttsHook.playCompletionPing();
setProcessingStage(null);
setActivityLog([]);
setLastEventTimestamp(0);
playCompletionPing();
recoveryHook.incrementGeneration();
incrementGeneration();
const activeRun = activeRunIdRef.current;
const runFinalized = activeRun ? runsRef.current.get(activeRun)?.finalized : false;
if (!runFinalized) {
recoveryHook.triggerRecovery('reconnect');
triggerRecovery('reconnect');
}
activeRunIdRef.current = null;
return;
}
if (type === 'assistant_stream') {
streamHook.setProcessingStage('streaming');
streamHook.setLastEventTimestamp(Date.now());
setProcessingStage('streaming');
setLastEventTimestamp(Date.now());
return;
}
@ -328,30 +361,30 @@ export function ChatProvider({ children }: { children: ReactNode }) {
setIsGenerating(true);
}
streamHook.setLastEventTimestamp(Date.now());
setLastEventTimestamp(Date.now());
if (type === 'agent_tool_start') {
streamHook.setProcessingStage('tool_use');
streamHook.addActivityEntry(ap);
setProcessingStage('tool_use');
addActivityEntry(ap);
return;
}
if (type === 'agent_tool_result') {
const completedId = ap.data?.toolCallId;
if (completedId) streamHook.completeActivityEntry(completedId);
if (completedId) completeActivityEntry(completedId);
if (toolResultRefreshRef.current) clearTimeout(toolResultRefreshRef.current);
const capturedSession = currentSessionRef.current;
const capturedGeneration = recoveryHook.getGeneration();
const capturedGeneration = getGeneration();
toolResultRefreshRef.current = setTimeout(async () => {
toolResultRefreshRef.current = null;
try {
const recovered = await loadChatHistory({ rpc, sessionKey: capturedSession, limit: 100 });
if (capturedSession !== currentSessionRef.current) return;
if (capturedGeneration !== recoveryHook.getGeneration()) return;
if (capturedGeneration !== getGeneration()) return;
if (recovered.length > 0) {
const merged = mergeRecoveredTail(msgHook.getAllMessages(), recovered);
msgHook.applyMessageWindow(merged, false);
const merged = mergeRecoveredTail(getAllMessages(), recovered);
applyMessageWindow(merged, false);
}
} catch { /* best-effort */ }
}, 300);
@ -360,7 +393,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (type === 'agent_state' && agentState) {
const stage = deriveProcessingStage(agentState);
if (stage) streamHook.setProcessingStage(stage);
if (stage) setProcessingStage(stage);
}
return;
}
@ -385,7 +418,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
const prevRunSeq = run.lastChatSeq;
run.lastChatSeq = updateHighestSeq(run.lastChatSeq, classified.chatSeq);
streamHook.setLastEventTimestamp(Date.now());
setLastEventTimestamp(Date.now());
if (type === 'chat_started') {
activeRunIdRef.current = runId;
@ -397,10 +430,10 @@ export function ChatProvider({ children }: { children: ReactNode }) {
run.bufferText = '';
setIsGenerating(true);
ttsHook.resetPlayedSounds();
streamHook.setProcessingStage('thinking');
streamHook.setActivityLog([]);
streamHook.startThinking(runId);
resetPlayedSounds();
setProcessingStage('thinking');
setActivityLog([]);
startThinking(runId);
return;
}
@ -411,14 +444,14 @@ export function ChatProvider({ children }: { children: ReactNode }) {
if (!isGeneratingRef.current) setIsGenerating(true);
if (!activeRunIdRef.current) activeRunIdRef.current = runId;
streamHook.captureThinkingDuration();
captureThinkingDuration();
const delta = extractStreamDelta(cp);
if (delta) {
run.bufferRaw = delta.text;
run.bufferText = delta.cleaned;
streamHook.scheduleStreamingUpdate(runId, run.bufferText);
streamHook.setProcessingStage('streaming');
scheduleStreamingUpdate(runId, run.bufferText);
setProcessingStage('streaming');
}
return;
}
@ -435,32 +468,32 @@ export function ChatProvider({ children }: { children: ReactNode }) {
run.bufferText = '';
if (activeRunIdRef.current === runId) activeRunIdRef.current = null;
recoveryHook.incrementGeneration();
incrementGeneration();
if (isActiveRun) {
setIsGenerating(false);
streamHook.setProcessingStage(null);
streamHook.setActivityLog([]);
streamHook.setLastEventTimestamp(0);
streamHook.clearStreamBuffer();
setProcessingStage(null);
setActivityLog([]);
setLastEventTimestamp(0);
clearStreamBuffer();
}
const finalData = extractFinalMessage(cp);
const finalMessages = processChatMessages(extractFinalMessages(cp));
if (finalMessages.length > 0) {
const merged = mergeFinalMessages(msgHook.getAllMessages(), finalMessages);
const thinkingDuration = streamHook.getThinkingDuration(runId);
const merged = mergeFinalMessages(getAllMessages(), finalMessages);
const thinkingDuration = getThinkingDuration(runId);
const withDuration = thinkingDuration
? patchThinkingDuration(merged, thinkingDuration)
: merged;
msgHook.applyMessageWindow(withDuration, false);
applyMessageWindow(withDuration, false);
} else {
recoveryHook.triggerRecovery('unrenderable-final');
triggerRecovery('unrenderable-final');
}
ttsHook.handleFinalTTS(finalData, isActiveRun);
streamHook.resetThinking();
handleFinalTTS(finalData, isActiveRun);
resetThinking();
pruneRunRegistry(runsRef.current, activeRunIdRef.current);
return;
}
@ -477,27 +510,27 @@ export function ChatProvider({ children }: { children: ReactNode }) {
run.bufferText = '';
if (activeRunIdRef.current === runId) activeRunIdRef.current = null;
recoveryHook.incrementGeneration();
incrementGeneration();
const partialMessagesRaw = extractFinalMessages(cp);
if (partialMessagesRaw.length > 0) {
const partialMessages = processChatMessages(partialMessagesRaw);
if (partialMessages.length > 0) {
const merged = mergeFinalMessages(msgHook.getAllMessages(), partialMessages);
msgHook.applyMessageWindow(merged, false);
const merged = mergeFinalMessages(getAllMessages(), partialMessages);
applyMessageWindow(merged, false);
}
}
if (isActiveRun) {
setIsGenerating(false);
streamHook.setProcessingStage(null);
streamHook.setActivityLog([]);
streamHook.setLastEventTimestamp(0);
streamHook.clearStreamBuffer();
ttsHook.playCompletionPing();
setProcessingStage(null);
setActivityLog([]);
setLastEventTimestamp(0);
clearStreamBuffer();
playCompletionPing();
}
streamHook.resetThinking();
resetThinking();
pruneRunRegistry(runsRef.current, activeRunIdRef.current);
return;
}
@ -514,62 +547,62 @@ export function ChatProvider({ children }: { children: ReactNode }) {
run.bufferText = '';
if (activeRunIdRef.current === runId) activeRunIdRef.current = null;
recoveryHook.incrementGeneration();
incrementGeneration();
if (isActiveRun) {
setIsGenerating(false);
streamHook.setProcessingStage(null);
streamHook.setActivityLog([]);
streamHook.setLastEventTimestamp(0);
streamHook.clearStreamBuffer();
setProcessingStage(null);
setActivityLog([]);
setLastEventTimestamp(0);
clearStreamBuffer();
}
if (isActiveRun) {
recoveryHook.triggerRecovery('unrenderable-final');
triggerRecovery('unrenderable-final');
}
streamHook.resetThinking();
resetThinking();
pruneRunRegistry(runsRef.current, activeRunIdRef.current);
}
});
}, [
msgHook.getAllMessages,
msgHook.applyMessageWindow,
streamHook.setProcessingStage,
streamHook.setLastEventTimestamp,
streamHook.setActivityLog,
streamHook.addActivityEntry,
streamHook.completeActivityEntry,
streamHook.startThinking,
streamHook.captureThinkingDuration,
streamHook.scheduleStreamingUpdate,
streamHook.clearStreamBuffer,
streamHook.getThinkingDuration,
streamHook.resetThinking,
recoveryHook.triggerRecovery,
recoveryHook.incrementGeneration,
recoveryHook.getGeneration,
ttsHook.playCompletionPing,
ttsHook.resetPlayedSounds,
ttsHook.handleFinalTTS,
getAllMessages,
applyMessageWindow,
setProcessingStage,
setLastEventTimestamp,
setActivityLog,
addActivityEntry,
completeActivityEntry,
startThinking,
captureThinkingDuration,
scheduleStreamingUpdate,
clearStreamBuffer,
getThinkingDuration,
resetThinking,
triggerRecovery,
incrementGeneration,
getGeneration,
playCompletionPing,
resetPlayedSounds,
handleFinalTTS,
subscribe,
rpc,
]);
// ─── Send message ─────────────────────────────────────────────────────────
const handleSend = useCallback(async (text: string, images?: ImageAttachment[]) => {
const handleSend = useCallback(async (text: string, images?: ImageAttachment[], uploadPayload?: OutgoingUploadPayload) => {
ttsHook.trackVoiceMessage(text);
const { msg: userMsg, tempId } = buildUserMessage({ text, images });
const { msg: userMsg, tempId } = buildUserMessage({ text, images, uploadPayload });
recoveryHook.incrementGeneration();
incrementGeneration();
// Optimistic insert (functional updater to avoid read-then-write race)
msgHook.setAllMessages(prev => [...prev, userMsg]);
msgHook.setMessages((prev: ChatMsg[]) => [...prev, userMsg]);
setIsGenerating(true);
streamHook.setStream((prev: ChatStreamState) => ({ ...prev, html: '', runId: undefined }));
streamHook.setProcessingStage('thinking');
setProcessingStage('thinking');
const idempotencyKey = crypto.randomUUID ? crypto.randomUUID() : 'ik-' + Date.now();
try {
@ -578,6 +611,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
sessionKey: currentSessionRef.current,
text,
images,
uploadPayload,
idempotencyKey,
});
@ -586,7 +620,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
run.status = ack.status;
run.finalized = false;
activeRunIdRef.current = ack.runId;
streamHook.startThinking(ack.runId);
startThinking(ack.runId);
}
// Confirm the message (functional updater to avoid race after await)
@ -611,7 +645,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
msgHook.setMessages((prev: ChatMsg[]) => [...prev, errMsgBubble]);
setIsGenerating(false);
}
}, [rpc, msgHook, streamHook, ttsHook, recoveryHook]);
}, [rpc, msgHook, streamHook, ttsHook, incrementGeneration, setProcessingStage, startThinking]);
// ─── Abort / Reset ────────────────────────────────────────────────────────
const handleAbort = useCallback(async () => {
@ -663,13 +697,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isGenerating,
stream: streamHook.stream,
processingStage: streamHook.processingStage,
lastEventTimestamp: streamHook.lastEventTimestamp,
lastEventTimestamp: lastEventTimestamp,
activityLog: streamHook.activityLog,
currentToolDescription: streamHook.currentToolDescription,
handleSend,
handleAbort,
handleReset,
loadHistory: msgHook.loadHistory,
loadHistory: loadHistory,
loadMore: msgHook.loadMore,
hasMore: msgHook.hasMore,
showResetConfirm,
@ -680,13 +714,13 @@ export function ChatProvider({ children }: { children: ReactNode }) {
isGenerating,
streamHook.stream,
streamHook.processingStage,
streamHook.lastEventTimestamp,
lastEventTimestamp,
streamHook.activityLog,
streamHook.currentToolDescription,
handleSend,
handleAbort,
handleReset,
msgHook.loadHistory,
loadHistory,
msgHook.loadMore,
msgHook.hasMore,
showResetConfirm,

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import { SessionProvider, useSessionContext } from './SessionContext';
import { getSessionKey, type GatewayEvent } from '@/types';
import { getSessionDisplayLabel } from '@/features/sessions/sessionKeys';
const mockUseGateway = vi.fn();
const mockUseSettings = vi.fn();
@ -44,6 +45,28 @@ function SessionLabels() {
);
}
function SessionDisplayLabels() {
const { sessions, agentName } = useSessionContext();
return (
<div>
{sessions.map((session) => (
<div key={getSessionKey(session)}>{getSessionDisplayLabel(session, agentName)}</div>
))}
</div>
);
}
function SessionRefreshProbe() {
const { refreshSessions } = useSessionContext();
return (
<button data-testid="refresh-sessions" onClick={() => void refreshSessions()}>
Refresh sessions
</button>
);
}
function SessionUnreadProbe() {
const { currentSession, unreadSessions, setCurrentSession } = useSessionContext();
@ -138,6 +161,212 @@ describe('SessionContext', () => {
});
});
it('subagent spawn calls /api/sessions/spawn-subagent, refreshes sessions, and switches to the returned child', async () => {
let sessionsListCalls = 0;
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
sessionsListCalls += 1;
return sessionsListCalls >= 2
? {
sessions: [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:reviewer:subagent:new-child-uuid', label: 'Reviewer child' },
],
}
: {
sessions: [
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
],
};
}
return {};
});
const spawnedChildKey = 'agent:reviewer:subagent:new-child-uuid';
const fetchSpy = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/sessions/spawn-subagent')) {
return Promise.resolve({
ok: true,
json: async () => ({ ok: true, sessionKey: spawnedChildKey, mode: 'direct' }),
} as Response);
}
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
globalThis.fetch = fetchSpy;
function SpawnSubagent() {
const { spawnSession, currentSession } = useSessionContext();
return (
<div>
<div data-testid="current-session">{currentSession}</div>
<button
data-testid="spawn-subagent"
onClick={() => spawnSession({
kind: 'subagent',
task: 'do something',
label: 'my-task',
cleanup: 'keep',
parentSessionKey: 'agent:reviewer:main',
})}
/>
</div>
);
}
render(<SessionProvider><SpawnSubagent /></SessionProvider>);
await waitFor(() => {
expect(screen.getByTestId('current-session').textContent).toBe('agent:reviewer:main');
});
await act(async () => {
screen.getByTestId('spawn-subagent').click();
});
await waitFor(() => {
expect(screen.getByTestId('current-session').textContent).toBe(spawnedChildKey);
});
const spawnCall = fetchSpy.mock.calls.find(([input]) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.includes('/api/sessions/spawn-subagent');
});
expect(spawnCall).toBeDefined();
expect(spawnCall?.[1]).toMatchObject({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(JSON.parse(String((spawnCall?.[1] as RequestInit).body))).toEqual({
parentSessionKey: 'agent:reviewer:main',
task: 'do something',
label: 'my-task',
cleanup: 'keep',
});
expect(sessionsListCalls).toBeGreaterThanOrEqual(2);
});
it('surfaces route error when subagent spawn fails', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return { sessions: [{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' }] };
}
return {};
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/sessions/spawn-subagent')) {
return Promise.resolve({
ok: false,
json: async () => ({ ok: false, error: 'Gateway connection failed' }),
} as Response);
}
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
let caughtError: Error | null = null;
function SpawnSubagentError() {
const { spawnSession } = useSessionContext();
return (
<button
data-testid="spawn-error"
onClick={async () => {
try {
await spawnSession({
kind: 'subagent',
task: 'do something',
parentSessionKey: 'agent:reviewer:main',
});
} catch (err) {
caughtError = err as Error;
}
}}
/>
);
}
render(<SessionProvider><SpawnSubagentError /></SessionProvider>);
await waitFor(() => expect(rpcMock).toHaveBeenCalled());
await act(async () => {
screen.getByTestId('spawn-error').click();
});
await waitFor(() => {
expect(caughtError).not.toBeNull();
});
expect(caughtError!.message).toContain('Gateway connection failed');
});
it('root spawn still uses agents.create + chat.send and does not call spawn-subagent route', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return { sessions: [{ sessionKey: 'agent:main:main', label: 'Main' }] };
}
return {};
});
const fetchSpy = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
globalThis.fetch = fetchSpy;
function SpawnRoot() {
const { spawnSession } = useSessionContext();
return (
<button
data-testid="spawn-root"
onClick={() => spawnSession({ kind: 'root', agentName: 'NewAgent', task: 'hi' })}
/>
);
}
render(<SessionProvider><SpawnRoot /></SessionProvider>);
await waitFor(() => expect(rpcMock).toHaveBeenCalled());
await act(async () => {
screen.getByTestId('spawn-root').click();
});
await waitFor(() => {
expect(rpcMock).toHaveBeenCalledWith('agents.create', expect.objectContaining({ name: 'NewAgent' }));
expect(rpcMock).toHaveBeenCalledWith('chat.send', expect.objectContaining({ message: 'hi' }));
});
const spawnRouteCalled = fetchSpy.mock.calls.some(([input]) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return url.includes('/api/sessions/spawn-subagent');
});
expect(spawnRouteCalled).toBe(false);
});
it('uses a unique config name when spawning a duplicate root agent', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
@ -173,6 +402,43 @@ describe('SessionContext', () => {
});
});
it('uses the server-provided default workspace root when spawning a root agent', async () => {
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/server-info')) {
return Promise.resolve(jsonResponse({
agentName: 'Jen',
defaultAgentWorkspaceRoot: '/managed/workspaces',
}));
}
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
function Spawn() {
const { spawnSession } = useSessionContext();
return <button data-testid="spawn-managed" onClick={() => spawnSession({
kind: 'root', agentName: 'Managed', task: 'hi', model: 'anthropic/claude-sonnet-4-5',
})} />;
}
render(<SessionProvider><Spawn /></SessionProvider>);
await waitFor(() => expect(rpcMock).toHaveBeenCalledWith('sessions.list', { limit: 1000 }));
screen.getByTestId('spawn-managed').click();
await waitFor(() => {
expect(rpcMock).toHaveBeenCalledWith('agents.create', expect.objectContaining({
name: 'Managed',
workspace: '/managed/workspaces/managed',
}));
});
});
it('uses the full gateway session list for sidebar refreshes so older agent chats stay visible', async () => {
render(
<SessionProvider>
@ -188,6 +454,163 @@ describe('SessionContext', () => {
expect(rpcMock).not.toHaveBeenCalledWith('sessions.list', expect.objectContaining({ activeMinutes: expect.any(Number) }));
});
it('hydrates root session labels from IDENTITY.md names', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', displayName: 'stale reviewer label' },
],
};
}
return {};
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/workspace/identity?agentId=reviewer')) {
return Promise.resolve(jsonResponse({ ok: true, content: '# IDENTITY.md\n- Name: Reviewer Prime\n- Role: Review agent\n' }));
}
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
render(
<SessionProvider>
<SessionDisplayLabels />
</SessionProvider>,
);
await waitFor(() => {
expect(screen.getByText('Jen (main)')).toBeInTheDocument();
expect(screen.getByText('Reviewer Prime (reviewer)')).toBeInTheDocument();
});
});
it('clears stale identity labels when a non-main root has no parseable identity name', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', identityName: 'Reviewer Prime' },
],
};
}
return {};
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/workspace/identity?agentId=reviewer')) {
return Promise.resolve(jsonResponse({ ok: true, content: '# IDENTITY.md\n- Role: Review agent\n' }));
}
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
render(
<SessionProvider>
<SessionDisplayLabels />
</SessionProvider>,
);
await waitFor(() => {
expect(screen.getByText('Jen (main)')).toBeInTheDocument();
expect(screen.getByText('reviewer')).toBeInTheDocument();
});
expect(screen.queryByText('Reviewer Prime (reviewer)')).not.toBeInTheDocument();
});
it('does not refetch identity content for roots whose identity files have no parseable name', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {
return {
sessions: [
{ sessionKey: 'agent:main:main', label: 'Main' },
{ sessionKey: 'agent:reviewer:main', displayName: 'stale reviewer label' },
],
};
}
return {};
});
const fetchSpy = vi.fn((input: string | URL | Request) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes('/api/server-info')) return Promise.resolve(jsonResponse({ agentName: 'Jen' }));
if (url.includes('/api/workspace/identity?agentId=reviewer')) {
return Promise.resolve(jsonResponse({ ok: true, content: '# IDENTITY.md\n- Role: Review agent\n' }));
}
if (url.includes('/api/agentlog')) return Promise.resolve(jsonResponse([]));
if (url.includes('/api/sessions/hidden')) return Promise.resolve(jsonResponse({ ok: true, sessions: [] }));
return Promise.resolve(jsonResponse({}));
}) as typeof fetch;
globalThis.fetch = fetchSpy;
const { getByTestId } = render(
<SessionProvider>
<SessionRefreshProbe />
</SessionProvider>,
);
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
'/api/workspace/identity?agentId=reviewer',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
const identityCallsBeforeRefresh = fetchSpy.mock.calls.filter(([input]) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
return url.includes('/api/workspace/identity?agentId=reviewer');
}).length;
expect(identityCallsBeforeRefresh).toBe(1);
await act(async () => {
getByTestId('refresh-sessions').click();
});
await waitFor(() => {
expect(rpcMock).toHaveBeenCalledWith('sessions.list', { limit: 1000 });
});
const identityCallsAfterRefresh = fetchSpy.mock.calls.filter(([input]) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
return url.includes('/api/workspace/identity?agentId=reviewer');
}).length;
expect(identityCallsAfterRefresh).toBe(1);
});
it('marks background top-level roots unread on start and pings when chat reaches a terminal event', async () => {
rpcMock.mockImplementation(async (method: string) => {
if (method === 'sessions.list') {

View file

@ -8,6 +8,7 @@ import { describeToolUse } from '@/utils/helpers';
import { buildSessionTree } from '@/features/sessions/sessionTree';
import {
buildAgentRootSessionKey,
extractIdentityName,
getAgentRegistrationName,
getRootAgentSessionKey,
getSessionDisplayLabel,
@ -16,21 +17,17 @@ import {
isTopLevelAgentSessionKey,
pickDefaultSessionKey,
getRootAgentId,
isRootChildSession,
} from '@/features/sessions/sessionKeys';
import { buildSpawnSubagentMessage, type SubagentCleanupMode } from '@/features/sessions/buildSpawnSubagentMessage';
const BUSY_STATES = new Set(['running', 'thinking', 'tool_use', 'delta', 'started']);
const IDLE_STATES = new Set(['idle', 'done', 'error', 'final', 'aborted', 'completed']);
// sessions.list query defaults.
// Keep spawn/discovery polling on a recent active-window query, but use the
// full session list for the sidebar so older root chats stay visible.
const SESSIONS_ACTIVE_MINUTES = 24 * 60; // 24h
const SESSIONS_LIMIT = 200;
// Use the full session list for the sidebar so older root chats stay visible.
const FULL_SESSIONS_LIMIT = 1000;
const SUBAGENT_DISCOVERY_TIMEOUT_MS = 60_000;
const SUBAGENT_DISCOVERY_POLL_MS = 1_000;
const MAIN_SESSION_KEY = 'agent:main:main';
const SESSIONS_SPAWNED_LIMIT = 500;
export type SubagentCleanupMode = 'keep' | 'delete';
export interface SpawnSessionOpts {
kind: 'root' | 'subagent';
@ -75,6 +72,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const [eventEntries, setEventEntries] = useState<EventEntry[]>([]);
const [agentStatus, setAgentStatus] = useState<Record<string, GranularAgentState>>({});
const [agentName, setAgentName] = useState('Agent');
const [defaultAgentWorkspaceRoot, setDefaultAgentWorkspaceRoot] = useState<string | null>(null);
const [rootIdentityNames, setRootIdentityNames] = useState<Record<string, string>>({});
const [rootIdentityMisses, setRootIdentityMisses] = useState<Record<string, true>>({});
const [unreadSessionKeys, setUnreadSessionKeys] = useState<Set<string>>(new Set());
const unreadSessionKeysRef = useRef(unreadSessionKeys);
const soundEnabledRef = useRef(soundEnabled);
@ -158,10 +158,15 @@ export function SessionProvider({ children }: { children: ReactNode }) {
try {
const res = await fetch('/api/server-info', { signal: controller.signal });
if (!res.ok) return;
const data = await res.json();
const data = await res.json() as { agentName?: string; defaultAgentWorkspaceRoot?: string | null };
if (data.agentName) {
setAgentName(data.agentName);
}
setDefaultAgentWorkspaceRoot(
typeof data.defaultAgentWorkspaceRoot === 'string' && data.defaultAgentWorkspaceRoot.trim()
? data.defaultAgentWorkspaceRoot
: null,
);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
// silent fail - use default
@ -174,8 +179,90 @@ export function SessionProvider({ children }: { children: ReactNode }) {
// Update refs in effect to avoid render-time mutations
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
const rootAgentIds = Array.from(new Set(
sessions
.map((session) => getSessionKey(session))
.filter(isTopLevelAgentSessionKey)
.map((sessionKey) => getRootAgentId(sessionKey))
.filter((rootId): rootId is string => Boolean(rootId) && rootId !== 'main'),
)).filter((rootId) => !rootIdentityNames[rootId] && !rootIdentityMisses[rootId]);
if (rootAgentIds.length === 0) return;
const controller = new AbortController();
void Promise.all(rootAgentIds.map(async (rootId) => {
try {
const params = new URLSearchParams({ agentId: rootId });
const res = await fetch(`/api/workspace/identity?${params.toString()}`, { signal: controller.signal });
if (!res.ok) return null;
const data = await res.json() as { ok?: boolean; content?: string };
const identityName = typeof data.content === 'string' ? extractIdentityName(data.content) : null;
if (identityName) return { rootId, identityName, kind: 'hit' as const };
return { rootId, kind: 'miss' as const };
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return null;
return null;
}
})).then((results) => {
if (controller.signal.aborted) return;
const resolved = results.filter((result): result is NonNullable<typeof result> => Boolean(result));
if (resolved.length === 0) return;
setRootIdentityMisses((prev) => {
let changed = false;
const next = { ...prev };
for (const result of resolved) {
if (result.kind === 'hit') {
if (!next[result.rootId]) continue;
delete next[result.rootId];
changed = true;
continue;
}
if (next[result.rootId]) continue;
next[result.rootId] = true;
changed = true;
}
return changed ? next : prev;
});
setRootIdentityNames((prev) => {
const next = { ...prev };
let changed = false;
for (const result of resolved) {
if (result.kind !== 'hit') continue;
const { rootId, identityName } = result;
if (next[rootId] === identityName) continue;
next[rootId] = identityName;
changed = true;
}
return changed ? next : prev;
});
});
return () => controller.abort();
}, [rootIdentityMisses, rootIdentityNames, sessions]);
const displaySessions = useMemo(() => sessions.map((session) => {
const sessionKey = getSessionKey(session);
if (!isTopLevelAgentSessionKey(sessionKey)) return session;
const rootId = getRootAgentId(sessionKey);
if (!rootId) return session;
const identityName = rootId === 'main' ? agentName : rootIdentityNames[rootId];
if (rootId !== 'main' && !identityName) {
if (!session.identityName) return session;
const rest = { ...session };
delete rest.identityName;
return rest;
}
if (!identityName || session.identityName === identityName) return session;
return { ...session, identityName };
}), [agentName, rootIdentityNames, sessions]);
useEffect(() => {
sessionsRef.current = displaySessions;
}, [displaySessions]);
const currentSessionRef = useRef(currentSession);
useEffect(() => {
@ -228,7 +315,31 @@ export function SessionProvider({ children }: { children: ReactNode }) {
rpc('sessions.list', { limit: FULL_SESSIONS_LIMIT }) as Promise<SessionsListResponse>,
fetchHiddenCronSessions(24 * 60, FULL_SESSIONS_LIMIT),
]);
return mergeSessionLists(res?.sessions ?? [], hiddenCronSessions);
const baseSessions = mergeSessionLists(res?.sessions ?? [], hiddenCronSessions);
const spawnedByRoots = new Set<string>([MAIN_SESSION_KEY]);
for (const rootSession of getTopLevelAgentSessions(baseSessions)) {
spawnedByRoots.add(getSessionKey(rootSession));
}
// Keep active child sessions visible even when the full sessions.list
// result lags behind the recent spawn/discovery flow.
const spawnedSessionLists = await Promise.all(
[...spawnedByRoots].map(async (rootSessionKey) => {
try {
const spawnedRes = await rpc('sessions.list', { spawnedBy: rootSessionKey, limit: SESSIONS_SPAWNED_LIMIT }) as SessionsListResponse;
return spawnedRes?.sessions ?? [];
} catch (err) {
console.debug('[SessionContext] Failed to fetch spawned sessions for root:', rootSessionKey, err);
return [];
}
}),
);
return spawnedSessionLists.reduce(
(acc, spawnedSessions) => mergeSessionLists(acc, spawnedSessions),
baseSessions,
);
} catch (err) {
console.debug('[SessionContext] Failed to fetch authoritative session list:', err);
return sessionsRef.current;
@ -741,7 +852,6 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const spawnSession = useCallback(async (opts: SpawnSessionOpts) => {
const authoritativeSessions = await listAuthoritativeSessions();
const before = new Set(authoritativeSessions.map(getSessionKey));
if (opts.kind === 'root') {
const rootName = opts.agentName?.trim();
@ -754,8 +864,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
// Register agent in config (ignore if already registered)
const agentId = getRootAgentId(sessionKey);
const registrationName = getAgentRegistrationName(rootName, sessionKey);
const workspacePath = defaultAgentWorkspaceRoot
? `${defaultAgentWorkspaceRoot.replace(/\/+$/, '')}/${agentId}`
: `~/.openclaw/workspace-${agentId}`;
try {
await rpc('agents.create', { name: registrationName, workspace: `~/.openclaw/workspace-${agentId}` });
await rpc('agents.create', { name: registrationName, workspace: workspacePath });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('already exists')) throw err;
@ -789,38 +902,28 @@ export function SessionProvider({ children }: { children: ReactNode }) {
if (!parentSessionKey) {
throw new Error('Create a top-level agent before launching a subagent');
}
const message = buildSpawnSubagentMessage({
task: opts.task,
label: opts.label,
model: opts.model,
thinking: opts.thinking,
cleanup: opts.cleanup,
});
const idempotencyKey = `spawn-subagent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await rpc('chat.send', { sessionKey: parentSessionKey, message, idempotencyKey });
// A spawned child can take a while to appear in sessions.list for non-main
// roots, even after the parent agent accepts the request.
const deadline = Date.now() + SUBAGENT_DISCOVERY_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const res = await rpc('sessions.list', { activeMinutes: SESSIONS_ACTIVE_MINUTES, limit: SESSIONS_LIMIT }) as SessionsListResponse;
const fresh = res?.sessions ?? [];
const newSession = fresh.find((session) => {
const sessionKey = getSessionKey(session);
return isSubagentSessionKey(sessionKey) && isRootChildSession(sessionKey, parentSessionKey) && !before.has(sessionKey);
});
if (newSession) {
await refreshSessions();
setCurrentSession(getSessionKey(newSession));
return;
}
} catch { /* keep polling */ }
await new Promise(r => setTimeout(r, SUBAGENT_DISCOVERY_POLL_MS));
const res = await fetch('/api/sessions/spawn-subagent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parentSessionKey,
task: opts.task,
label: opts.label,
model: opts.model,
thinking: opts.thinking,
cleanup: opts.cleanup ?? 'keep',
}),
});
const data = await res.json() as { ok: boolean; sessionKey?: string; error?: string };
if (!data.ok || !data.sessionKey) {
throw new Error(data.error ?? 'Failed to spawn subagent');
}
await refreshSessions();
throw new Error('Timed out waiting for the new subagent session to appear');
}, [listAuthoritativeSessions, rpc, refreshSessions, setCurrentSession]);
setCurrentSession(data.sessionKey);
}, [defaultAgentWorkspaceRoot, listAuthoritativeSessions, rpc, refreshSessions, setCurrentSession]);
const renameSession = useCallback(async (sessionKey: string, label: string) => {
await rpc('sessions.patch', { key: sessionKey, label });
@ -836,7 +939,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
}, [rpc]);
const value = useMemo<SessionContextValue>(() => ({
sessions,
sessions: displaySessions,
sessionsLoading,
currentSession,
setCurrentSession,
@ -854,7 +957,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
eventEntries,
agentName,
}), [
sessions, sessionsLoading, currentSession, setCurrentSession, busyState, agentStatus,
displaySessions, sessionsLoading, currentSession, setCurrentSession, busyState, agentStatus,
unreadSessions, markSessionRead,
abortSession, refreshSessions, deleteSession, spawnSession, renameSession,
updateSessionFromEvent, agentLogEntries, eventEntries, agentName,

View file

@ -36,6 +36,8 @@ interface SettingsContextValue {
toggleEvents: () => void;
logVisible: boolean;
toggleLog: () => void;
showHiddenWorkspaceEntries: boolean;
toggleShowHiddenWorkspaceEntries: () => void;
theme: ThemeName;
setTheme: (theme: ThemeName) => void;
font: FontName;
@ -44,10 +46,13 @@ interface SettingsContextValue {
setFontSize: (size: number) => void;
editorFontSize: number;
setEditorFontSize: (size: number) => void;
kanbanVisible: boolean;
toggleKanbanVisible: () => void;
}
const SettingsContext = createContext<SettingsContextValue | null>(null);
const FONT_REFRESH_STORAGE_KEY = 'nerve:font-refresh-20260312';
const KANBAN_VISIBILITY_STORAGE_KEY = 'nerve:workspace:kanban-visible';
const ALLOWED_FONT_SIZES = new Set([10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24]);
const ALLOWED_EDITOR_FONT_SIZES = new Set([10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 22, 24]);
@ -118,6 +123,9 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const [logVisible, setLogVisible] = useState(() => {
return localStorage.getItem('nerve:showLog') === 'true'; // Default to false (hidden)
});
const [showHiddenWorkspaceEntries, setShowHiddenWorkspaceEntries] = useState(() => {
return localStorage.getItem('nerve:showHiddenWorkspaceEntries') === 'true';
});
const [theme, setThemeState] = useState<ThemeName>(() => {
const saved = localStorage.getItem('oc-theme') as ThemeName | null;
return saved && themeNames.includes(saved) ? saved : 'ayu-dark';
@ -133,6 +141,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
const parsed = saved ? parseInt(saved, 10) : NaN;
return normalizeEditorFontSize(parsed);
});
const [kanbanVisible, setKanbanVisible] = useState(() => {
const saved = localStorage.getItem(KANBAN_VISIBILITY_STORAGE_KEY);
return saved !== 'false';
});
const { speak } = useTTS(soundEnabled, ttsProvider, ttsModel || undefined);
const wakeWordToggleRef = useRef<(() => void) | null>(null);
@ -288,6 +300,14 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
});
}, []);
const toggleShowHiddenWorkspaceEntries = useCallback(() => {
setShowHiddenWorkspaceEntries(prev => {
const next = !prev;
localStorage.setItem('nerve:showHiddenWorkspaceEntries', String(next));
return next;
});
}, []);
const setTheme = useCallback((newTheme: ThemeName) => {
setThemeState(newTheme);
localStorage.setItem('oc-theme', newTheme);
@ -310,6 +330,14 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
localStorage.setItem('nerve:editor-font-size', String(normalized));
}, []);
const toggleKanbanVisible = useCallback(() => {
setKanbanVisible(prev => {
const next = !prev;
localStorage.setItem(KANBAN_VISIBILITY_STORAGE_KEY, String(next));
return next;
});
}, []);
const value = useMemo<SettingsContextValue>(() => ({
soundEnabled,
toggleSound,
@ -339,6 +367,8 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
toggleEvents,
logVisible,
toggleLog,
showHiddenWorkspaceEntries,
toggleShowHiddenWorkspaceEntries,
theme,
setTheme,
font,
@ -347,14 +377,16 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
setFontSize,
editorFontSize,
setEditorFontSize,
kanbanVisible,
toggleKanbanVisible,
}), [
soundEnabled, toggleSound, ttsProvider, ttsModel, changeTtsProvider, changeTtsModel, toggleTtsProvider,
sttProvider, changeSttProvider, sttInputMode, changeSttInputMode, sttModel, changeSttModel,
wakeWordEnabled, handleToggleWakeWord, handleWakeWordState,
liveTranscriptionPreview, toggleLiveTranscriptionPreview,
speak, panelRatio, setPanelRatio, telemetryVisible, toggleTelemetry,
eventsVisible, toggleEvents, logVisible, toggleLog, theme, setTheme, font, setFont,
fontSize, setFontSize, editorFontSize, setEditorFontSize,
eventsVisible, toggleEvents, logVisible, toggleLog, showHiddenWorkspaceEntries, toggleShowHiddenWorkspaceEntries, theme, setTheme, font, setFont,
fontSize, setFontSize, editorFontSize, setEditorFontSize, kanbanVisible, toggleKanbanVisible,
]);
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;

View file

@ -0,0 +1,122 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { BeadViewerTab } from './BeadViewerTab';
const beadDetailState = {
bead: {
id: 'nerve-4gpd',
title: 'Second CodeRabbit fixes',
notes: 'Open /workspace/src/plain.tsx and `/workspace/src/code.tsx` plus [related](bead:nerve-related)',
status: 'open',
priority: 1,
issueType: 'task',
owner: 'chip',
createdAt: null,
updatedAt: null,
closedAt: null,
closeReason: null,
dependencies: [{ id: 'nerve-dep', title: 'Dependency', status: 'open', dependencyType: 'blocks' }],
dependents: [],
linkedPlan: {
path: '.plans/demo.md',
workspacePath: 'projects/demo/.plans/demo.md',
title: 'Demo plan',
planId: 'plan-demo',
archived: false,
status: 'In Progress',
updatedAt: 123,
},
},
loading: false,
error: null as string | null,
};
vi.mock('./useBeadDetail', () => ({
useBeadDetail: () => beadDetailState,
}));
describe('BeadViewerTab', () => {
beforeEach(() => {
beadDetailState.loading = false;
beadDetailState.error = null;
});
it('preserves the current bead context when opening related beads and markdown bead links', async () => {
const onOpenBeadId = vi.fn();
render(
<BeadViewerTab
beadTarget={{
beadId: 'nerve-4gpd',
explicitTargetPath: '../projects/demo/.beads',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
}}
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={vi.fn()}
pathLinkPrefixes={['/workspace/']}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /Dependency/ }));
fireEvent.click(screen.getByRole('link', { name: 'related' }));
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenNthCalledWith(1, {
beadId: 'nerve-dep',
explicitTargetPath: '../projects/demo/.beads',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
});
expect(onOpenBeadId).toHaveBeenNthCalledWith(2, {
beadId: 'nerve-related',
explicitTargetPath: '../projects/demo/.beads',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
});
});
});
it('linkifies bead note plain-text workspace paths with the same chat path prefixes', async () => {
const onOpenWorkspacePath = vi.fn();
render(
<BeadViewerTab
beadTarget={{ beadId: 'nerve-4gpd', currentDocumentPath: 'notes/beads.md', workspaceAgentId: 'research' }}
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={['/workspace/']}
/>,
);
fireEvent.click(screen.getByRole('link', { name: '/workspace/src/plain.tsx' }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalled();
expect(onOpenWorkspacePath.mock.calls.at(-1)?.[0]).toBe('/workspace/src/plain.tsx');
});
expect(screen.queryByRole('link', { name: '/workspace/src/code.tsx' })).toBeNull();
});
it('opens linked plans via their resolved workspace path and logs async failures', async () => {
const error = new Error('nope');
const onOpenWorkspacePath = vi.fn().mockRejectedValueOnce(error);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<BeadViewerTab
beadTarget={{ beadId: 'nerve-4gpd', workspaceAgentId: 'research' }}
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /Demo plan/i }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('projects/demo/.plans/demo.md');
expect(consoleError).toHaveBeenCalledWith('Failed to open linked plan:', error);
});
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,192 @@
import { useCallback } from 'react';
import { AlertTriangle, ArrowUpRight, CircleDot, FileText, GitBranch, Loader2 } from 'lucide-react';
import { MarkdownRenderer } from '@/features/markdown/MarkdownRenderer';
import { useBeadDetail } from './useBeadDetail';
import type { BeadLinkTarget } from './links';
interface BeadViewerTabProps {
beadTarget: BeadLinkTarget;
onOpenBeadId?: (target: BeadLinkTarget) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void | Promise<void>;
pathLinkPrefixes?: string[];
}
function formatTimestamp(value: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function RelationList({
title,
items,
onOpenBeadId,
}: {
title: string;
items: Array<{ id: string; title: string | null; status: string | null; dependencyType: string | null }>;
onOpenBeadId?: (target: BeadLinkTarget) => void;
}) {
if (items.length === 0) return null;
return (
<section className="space-y-3">
<div className="flex items-center gap-2 text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
<GitBranch size={13} />
<span>{title}</span>
</div>
<div className="space-y-2">
{items.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-start justify-between gap-3 rounded-2xl border border-border/60 bg-card/60 px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-card disabled:cursor-default disabled:opacity-75"
onClick={() => onOpenBeadId?.({ beadId: item.id })}
disabled={!onOpenBeadId}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className="truncate">{item.title || item.id}</span>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-mono">{item.id}</span>
{item.status ? <span> · {item.status}</span> : null}
{item.dependencyType ? <span> · {item.dependencyType}</span> : null}
</div>
</div>
<ArrowUpRight size={14} className="mt-0.5 shrink-0 text-muted-foreground" />
</button>
))}
</div>
</section>
);
}
export function BeadViewerTab({ beadTarget, onOpenBeadId, onOpenWorkspacePath, pathLinkPrefixes }: BeadViewerTabProps) {
const { bead, loading, error } = useBeadDetail(beadTarget);
const linkedPlan = bead?.linkedPlan ?? null;
const openBeadWithContext = useCallback((target: BeadLinkTarget) => {
if (!onOpenBeadId) return;
return onOpenBeadId({
beadId: target.beadId,
explicitTargetPath: target.explicitTargetPath ?? beadTarget.explicitTargetPath,
currentDocumentPath: target.currentDocumentPath ?? beadTarget.currentDocumentPath,
workspaceAgentId: target.workspaceAgentId ?? beadTarget.workspaceAgentId,
});
}, [beadTarget.currentDocumentPath, beadTarget.explicitTargetPath, beadTarget.workspaceAgentId, onOpenBeadId]);
const openLinkedPlan = useCallback(async () => {
if (!onOpenWorkspacePath || !linkedPlan) return;
const planPath = linkedPlan.workspacePath ?? linkedPlan.path;
await onOpenWorkspacePath(planPath);
}, [linkedPlan, onOpenWorkspacePath]);
if (loading) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
<span>Loading bead</span>
</div>
);
}
if (error || !bead) {
return (
<div className="flex h-full items-center justify-center px-6">
<div className="max-w-md rounded-3xl border border-destructive/30 bg-destructive/5 p-5 text-sm text-destructive">
<div className="mb-2 flex items-center gap-2 font-medium">
<AlertTriangle size={15} />
<span>Could not load bead {beadTarget.beadId}</span>
</div>
<p className="text-destructive/80">{error || 'Unknown error'}</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-background">
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-5 py-5 sm:px-6 sm:py-6">
<section className="shell-panel rounded-[28px] border border-border/60 p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="cockpit-badge">Bead Viewer</span>
<span className="font-mono">{bead.id}</span>
{bead.status ? <span className="cockpit-badge">{bead.status}</span> : null}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{bead.title}</h1>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{bead.issueType ? <span>Type: {bead.issueType}</span> : null}
{bead.priority !== null ? <span>Priority: {bead.priority}</span> : null}
{bead.owner ? <span>Owner: {bead.owner}</span> : null}
{bead.updatedAt ? <span>Updated: {formatTimestamp(bead.updatedAt)}</span> : null}
{bead.closedAt ? <span>Closed: {formatTimestamp(bead.closedAt)}</span> : null}
</div>
</div>
</div>
</div>
{bead.notes ? (
<div className="mt-5 border-t border-border/50 pt-5">
<div className="mb-3 flex items-center gap-2 text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
<CircleDot size={13} />
<span>Notes</span>
</div>
<MarkdownRenderer
content={bead.notes}
currentDocumentPath={beadTarget.currentDocumentPath}
workspaceAgentId={beadTarget.workspaceAgentId}
onOpenBeadId={openBeadWithContext}
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={pathLinkPrefixes}
/>
</div>
) : null}
{bead.closeReason ? (
<div className="mt-5 rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
<span className="font-medium text-foreground">Close reason:</span> {bead.closeReason}
</div>
) : null}
</section>
{bead.linkedPlan ? (
<section className="shell-panel rounded-[28px] border border-border/60 p-5 sm:p-6">
<div className="mb-3 flex items-center gap-2 text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
<FileText size={13} />
<span>Linked plan</span>
</div>
<button
type="button"
className="flex w-full items-start justify-between gap-3 rounded-2xl border border-border/60 bg-card/60 px-4 py-4 text-left transition-colors hover:border-primary/40 hover:bg-card disabled:cursor-default disabled:opacity-75"
onClick={() => {
void openLinkedPlan().catch((error) => {
console.error('Failed to open linked plan:', error);
});
}}
disabled={!onOpenWorkspacePath}
>
<div className="min-w-0 space-y-1">
<div className="truncate text-sm font-medium text-foreground">{bead.linkedPlan.title}</div>
<div className="text-xs text-muted-foreground">
<span className="font-mono">{bead.linkedPlan.path}</span>
{bead.linkedPlan.status ? <span> · {bead.linkedPlan.status}</span> : null}
{bead.linkedPlan.archived ? <span> · archived</span> : null}
</div>
</div>
<ArrowUpRight size={14} className="mt-0.5 shrink-0 text-muted-foreground" />
</button>
</section>
) : null}
<RelationList title="Dependencies" items={bead.dependencies} onOpenBeadId={openBeadWithContext} />
<RelationList title="Dependents" items={bead.dependents} onOpenBeadId={openBeadWithContext} />
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
export {
buildBeadTabId,
decodeBeadLinkHref,
isBeadId,
isBeadLinkHref,
isSyntacticallyValidExplicitBeadHref,
parseBeadLinkHref,
} from './links';
export { BeadViewerTab } from './BeadViewerTab';
export type { BeadDetail, BeadRelationSummary, BeadLinkedPlanSummary, OpenBeadTab } from './types';
export type { BeadLinkTarget } from './links';

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { buildBeadTabId, decodeBeadLinkHref, isBeadId, isBeadLinkHref, isSyntacticallyValidExplicitBeadHref, parseBeadLinkHref } from './links';
describe('bead link helpers', () => {
it('recognizes legacy bead shorthand and explicit bead URIs', () => {
expect(isBeadId('nerve-fms2')).toBe(true);
expect(isBeadLinkHref('bead:nerve-fms2')).toBe(true);
expect(isBeadLinkHref('bead:///repos/demo#nerve-fms2')).toBe(true);
});
it('rejects bare bead ids and normal file paths as markdown bead links', () => {
expect(isBeadLinkHref('nerve-fms2')).toBe(false);
expect(isBeadId('.plans/demo.md')).toBe(false);
expect(isBeadLinkHref('docs/todo.md')).toBe(false);
});
it('parses legacy same-context bead links', () => {
expect(parseBeadLinkHref('bead:nerve-fms2')).toEqual({ beadId: 'nerve-fms2' });
expect(decodeBeadLinkHref('bead:nerve-fms2')).toBe('nerve-fms2');
expect(buildBeadTabId('nerve-fms2')).toBe('bead:nerve-fms2');
});
it('preserves current document context for legacy same-context bead links', () => {
expect(parseBeadLinkHref('bead:nerve-fms2', {
currentDocumentPath: 'repos/demo/docs/beads.md',
workspaceAgentId: 'research',
})).toEqual({
beadId: 'nerve-fms2',
currentDocumentPath: 'repos/demo/docs/beads.md',
workspaceAgentId: 'research',
});
});
it('builds workspace-aware tab ids for shorthand bead tabs', () => {
expect(buildBeadTabId({ beadId: 'nerve-fms2', workspaceAgentId: 'research' })).toBe('bead:research:nerve-fms2');
expect(buildBeadTabId({ beadId: 'nerve-fms2' })).toBe('bead:main:nerve-fms2');
});
it('builds distinct shorthand tab ids when legacy bead links include document context', () => {
expect(buildBeadTabId({
beadId: 'nerve-fms2',
currentDocumentPath: 'repos/demo/docs/beads.md',
workspaceAgentId: 'research',
})).toBe('bead://research:repos/demo/docs/beads.md:#nerve-fms2');
});
it('parses explicit absolute bead URIs with custom payload parsing', () => {
expect(parseBeadLinkHref('bead:///home/alice/work/repos/demo/.beads#nerve-fms2')).toEqual({
beadId: 'nerve-fms2',
explicitTargetPath: '/home/alice/work/repos/demo/.beads',
currentDocumentPath: undefined,
workspaceAgentId: undefined,
});
});
it('preserves relative explicit bead targets and the current document context', () => {
expect(parseBeadLinkHref('bead://../projects/demo#nerve-fms2', {
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
})).toEqual({
beadId: 'nerve-fms2',
explicitTargetPath: '../projects/demo',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
});
});
it('keeps context-aware parsing strict for relative explicit bead URIs without a current document path', () => {
expect(parseBeadLinkHref('bead://../projects/demo#nerve-fms2')).toBeNull();
});
it('recognizes syntactically valid explicit-relative bead URIs in detection-only flows', () => {
expect(isSyntacticallyValidExplicitBeadHref('bead://../projects/demo#nerve-fms2')).toBe(true);
expect(isBeadLinkHref('bead://../projects/demo#nerve-fms2')).toBe(true);
});
it('builds distinct tab ids for relative explicit bead targets', () => {
expect(buildBeadTabId({
beadId: 'nerve-fms2',
explicitTargetPath: '../projects/demo',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
})).toBe('bead://research:notes/beads.md:../projects/demo#nerve-fms2');
});
it('canonicalizes absolute explicit target paths in tab ids without keying by current document path', () => {
expect(buildBeadTabId({
beadId: 'nerve-fms2',
explicitTargetPath: '/home/alice/work/repos/demo/.beads',
currentDocumentPath: 'notes/beads.md',
workspaceAgentId: 'research',
})).toBe('bead://research::/home/alice/work/repos/demo#nerve-fms2');
expect(buildBeadTabId({
beadId: 'nerve-fms2',
explicitTargetPath: '/home/alice/work/repos/demo/',
currentDocumentPath: 'other/path.md',
workspaceAgentId: 'research',
})).toBe('bead://research::/home/alice/work/repos/demo#nerve-fms2');
});
});

142
src/features/beads/links.ts Normal file
View file

@ -0,0 +1,142 @@
const BEAD_ID_PATTERN = /^[A-Za-z0-9]+-[A-Za-z0-9][A-Za-z0-9_-]*$/;
const LEGACY_BEAD_SCHEME = 'bead:';
const EXPLICIT_BEAD_SCHEME = 'bead://';
export interface BeadLinkTarget {
beadId: string;
explicitTargetPath?: string;
currentDocumentPath?: string;
workspaceAgentId?: string;
}
function decodeUriComponentOrRaw(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isAbsoluteFilesystemPath(value: string): boolean {
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
}
function normalizeBeadRepoRoot(targetPath: string): string {
const trimmed = targetPath.trim();
if (!trimmed) return trimmed;
return trimmed.endsWith('/.beads') || trimmed.endsWith('\\.beads')
? trimmed.slice(0, -'.beads'.length)
: trimmed;
}
function canonicalizeAbsoluteExplicitTargetPath(targetPath: string): string {
return normalizeBeadRepoRoot(targetPath).replace(/\\+/g, '/').replace(/\/$/, '');
}
export function isBeadId(value: string): boolean {
const trimmed = value.trim();
if (!trimmed || trimmed.includes('/') || trimmed.includes('.') || trimmed.includes('#') || trimmed.includes('?')) {
return false;
}
return BEAD_ID_PATTERN.test(trimmed);
}
export function isSyntacticallyValidExplicitBeadHref(href: string): boolean {
if (!href) return false;
const trimmed = href.trim();
if (!trimmed.toLowerCase().startsWith(EXPLICIT_BEAD_SCHEME)) return false;
const rawPayload = trimmed.slice(EXPLICIT_BEAD_SCHEME.length);
const hashIndex = rawPayload.indexOf('#');
if (hashIndex <= 0 || hashIndex === rawPayload.length - 1) return false;
const rawTargetPath = decodeUriComponentOrRaw(rawPayload.slice(0, hashIndex)).trim();
const beadId = decodeUriComponentOrRaw(rawPayload.slice(hashIndex + 1)).trim();
return Boolean(rawTargetPath) && isBeadId(beadId);
}
export function isBeadLinkHref(href: string): boolean {
if (isSyntacticallyValidExplicitBeadHref(href)) {
return true;
}
return parseBeadLinkHref(href) !== null;
}
export function decodeBeadLinkHref(href: string): string {
return parseBeadLinkHref(href)?.beadId ?? href.trim();
}
export function parseBeadLinkHref(
href: string,
options: {
currentDocumentPath?: string;
workspaceAgentId?: string;
} = {},
): BeadLinkTarget | null {
if (!href) return null;
const trimmed = href.trim();
if (!trimmed) return null;
if (trimmed.toLowerCase().startsWith(EXPLICIT_BEAD_SCHEME)) {
const rawPayload = trimmed.slice(EXPLICIT_BEAD_SCHEME.length);
const hashIndex = rawPayload.indexOf('#');
if (hashIndex <= 0 || hashIndex === rawPayload.length - 1) return null;
const rawTargetPath = decodeUriComponentOrRaw(rawPayload.slice(0, hashIndex)).trim();
const beadId = decodeUriComponentOrRaw(rawPayload.slice(hashIndex + 1)).trim();
if (!rawTargetPath || !isBeadId(beadId)) return null;
const currentDocumentPath = options.currentDocumentPath?.trim();
if (!isAbsoluteFilesystemPath(rawTargetPath) && !currentDocumentPath) {
return null;
}
return {
beadId,
explicitTargetPath: rawTargetPath,
currentDocumentPath,
workspaceAgentId: options.workspaceAgentId?.trim() || undefined,
};
}
if (!trimmed.toLowerCase().startsWith(LEGACY_BEAD_SCHEME)) return null;
const beadId = decodeUriComponentOrRaw(trimmed.slice(LEGACY_BEAD_SCHEME.length)).trim();
if (!isBeadId(beadId)) return null;
const currentDocumentPath = options.currentDocumentPath?.trim();
const workspaceAgentId = options.workspaceAgentId?.trim();
return {
beadId,
...(currentDocumentPath ? { currentDocumentPath } : {}),
...(workspaceAgentId ? { workspaceAgentId } : {}),
};
}
export function buildBeadTabId(target: BeadLinkTarget | string): string {
if (typeof target === 'string') {
return `bead:${target}`;
}
const workspaceAgentId = target.workspaceAgentId?.trim() || 'main';
const currentDocumentPath = target.currentDocumentPath?.trim() || '';
if (!target.explicitTargetPath) {
if (!currentDocumentPath) {
return `bead:${workspaceAgentId}:${target.beadId}`;
}
return `bead://${workspaceAgentId}:${currentDocumentPath}:#${target.beadId}`;
}
const explicitTargetPath = isAbsoluteFilesystemPath(target.explicitTargetPath)
? canonicalizeAbsoluteExplicitTargetPath(target.explicitTargetPath)
: target.explicitTargetPath;
const tabSourceDocumentPath = isAbsoluteFilesystemPath(target.explicitTargetPath)
? ''
: currentDocumentPath;
return `bead://${workspaceAgentId}:${tabSourceDocumentPath}:${explicitTargetPath}#${target.beadId}`;
}

View file

@ -0,0 +1,42 @@
export interface BeadRelationSummary {
id: string;
title: string | null;
status: string | null;
dependencyType: string | null;
}
export interface BeadLinkedPlanSummary {
path: string;
workspacePath: string | null;
title: string;
planId: string | null;
archived: boolean;
status: string | null;
updatedAt: number;
}
export interface BeadDetail {
id: string;
title: string;
notes: string | null;
status: string | null;
priority: number | null;
issueType: string | null;
owner: string | null;
createdAt: string | null;
updatedAt: string | null;
closedAt: string | null;
closeReason: string | null;
dependencies: BeadRelationSummary[];
dependents: BeadRelationSummary[];
linkedPlan: BeadLinkedPlanSummary | null;
}
export interface OpenBeadTab {
id: string;
beadId: string;
name: string;
explicitTargetPath?: string;
currentDocumentPath?: string;
workspaceAgentId?: string;
}

View file

@ -0,0 +1,72 @@
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useBeadDetail } from './useBeadDetail';
import type { BeadLinkTarget } from './links';
type PendingRequest = {
signal: AbortSignal;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
};
describe('useBeadDetail', () => {
const fetchMock = vi.fn<typeof fetch>();
const pendingRequests: PendingRequest[] = [];
beforeEach(() => {
pendingRequests.length = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(((_input: RequestInfo | URL, init?: RequestInit) => new Promise((resolve, reject) => {
pendingRequests.push({
signal: init?.signal as AbortSignal,
resolve: resolve as (value: Response) => void,
reject,
});
})) as typeof fetch);
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('aborts stale bead-detail fetches when the target changes', async () => {
const initialTarget: BeadLinkTarget = { beadId: 'nerve-old', workspaceAgentId: 'main' };
const nextTarget: BeadLinkTarget = { beadId: 'nerve-new', workspaceAgentId: 'main' };
const { result, rerender } = renderHook(({ target }) => useBeadDetail(target), {
initialProps: { target: initialTarget },
});
expect(result.current.loading).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pendingRequests[0]?.signal.aborted).toBe(false);
rerender({ target: nextTarget });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(pendingRequests[0]?.signal.aborted).toBe(true);
expect(pendingRequests[1]?.signal.aborted).toBe(false);
pendingRequests[0]?.reject(new DOMException('Aborted', 'AbortError'));
pendingRequests[1]?.resolve({
ok: true,
json: async () => ({
ok: true,
bead: {
id: 'nerve-new',
title: 'New bead',
},
}),
} as Response);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.bead).toEqual({
id: 'nerve-new',
title: 'New bead',
});
});
});
});

View file

@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import type { BeadDetail } from './types';
import type { BeadLinkTarget } from './links';
interface UseBeadDetailState {
bead: BeadDetail | null;
loading: boolean;
error: string | null;
}
interface UseBeadDetailFetchState {
bead: BeadDetail | null;
error: string | null;
requestKey: string | null;
}
export function useBeadDetail(target: BeadLinkTarget): UseBeadDetailState {
const [state, setState] = useState<UseBeadDetailFetchState>({ bead: null, error: null, requestKey: null });
const params = new URLSearchParams();
if (target.explicitTargetPath) {
params.set('targetPath', target.explicitTargetPath);
}
if (target.currentDocumentPath) {
params.set('currentDocumentPath', target.currentDocumentPath);
}
if (target.workspaceAgentId) {
params.set('workspaceAgentId', target.workspaceAgentId);
}
const suffix = params.toString() ? `?${params.toString()}` : '';
const requestKey = `${target.beadId}${suffix}`;
useEffect(() => {
const controller = new AbortController();
void fetch(`/api/beads/${encodeURIComponent(target.beadId)}${suffix}`, {
signal: controller.signal,
})
.then(async (res) => {
const data = await res.json().catch(() => null) as {
ok?: boolean;
bead?: BeadDetail;
details?: string;
error?: string;
} | null;
if (controller.signal.aborted) return;
if (!res.ok || !data?.ok || !data.bead) {
setState({
bead: null,
error: data?.details || data?.error || 'Failed to load bead',
requestKey,
});
return;
}
setState({ bead: data.bead, error: null, requestKey });
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
if (error instanceof DOMException && error.name === 'AbortError') return;
setState({ bead: null, error: 'Network error', requestKey });
});
return () => {
controller.abort();
};
}, [requestKey, suffix, target.beadId]);
const loading = state.requestKey !== requestKey;
return {
bead: loading ? null : state.bead,
loading,
error: loading ? null : state.error,
};
}

View file

@ -7,11 +7,12 @@ import { SearchBar } from './SearchBar';
import { useMessageSearch } from './useMessageSearch';
import { ActivityLog, ChatHeader, ProcessingIndicator, ScrollToBottomButton, StreamingMessage, ToolGroupBlock } from './components';
import { isMessageCollapsible } from './types';
import type { ChatMsg, ImageAttachment } from './types';
import type { ChatMsg, ImageAttachment, OutgoingUploadPayload } from './types';
import type { BeadLinkTarget } from '@/features/beads';
interface ChatPanelProps {
messages: ChatMsg[];
onSend: (text: string, attachments?: ImageAttachment[]) => void;
onSend: (text: string, attachments?: ImageAttachment[], uploadPayload?: OutgoingUploadPayload) => void | Promise<void>;
onAbort: () => void;
isGenerating: boolean;
stream: ChatStreamState;
@ -43,10 +44,15 @@ interface ChatPanelProps {
isMobileTopBarHidden?: boolean;
/** Open or reveal a safe workspace path in the file explorer/editor. */
onOpenWorkspacePath?: (path: string) => void | Promise<void>;
/** Configured path prefixes that should render as clickable inline path links. */
pathLinkPrefixes?: string[];
/** Open a dedicated bead viewer tab. */
onOpenBeadId?: (target: BeadLinkTarget) => void | Promise<void>;
}
export interface ChatPanelHandle {
focusInput: () => void;
addWorkspacePath: (path: string, kind: 'file' | 'directory', agentId?: string) => Promise<void>;
}
/** Main chat panel with message list, infinite scroll, search, and input bar. */
@ -59,6 +65,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(function Ch
loadMore, hasMore = false, onToggleFileBrowser, isFileBrowserCollapsed = true,
onToggleMobileTopBar, isMobileTopBarHidden = false,
onOpenWorkspacePath,
pathLinkPrefixes,
onOpenBeadId,
}, ref) {
const scrollRef = useRef<HTMLDivElement>(null);
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
@ -116,7 +124,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(function Ch
// Expose focusInput to parent
useImperativeHandle(ref, () => ({
focusInput: () => inputBarRef.current?.focus()
focusInput: () => inputBarRef.current?.focus(),
addWorkspacePath: async (path: string, kind: 'file' | 'directory', agentId?: string) => {
await inputBarRef.current?.addWorkspacePath(path, kind, agentId);
},
}), []);
// Clean up stale messageRefs when messages change
@ -333,6 +344,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(function Ch
isCurrentMatch={isCurrentMatch}
agentName={agentName}
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={pathLinkPrefixes}
onOpenBeadId={onOpenBeadId}
/>
</div>
);

View file

@ -0,0 +1,803 @@
import { createRef } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InputBar, type InputBarHandle, resetInputBarComposerSnapshotForTests } from './InputBar';
import { compressImage } from './image-compress';
vi.mock('./image-compress', () => ({
compressImage: vi.fn(async (file: File) => ({
base64: `mock-${file.name}`,
mimeType: file.type || 'application/octet-stream',
preview: `data:${file.type};base64,mock-${file.name}`,
width: 1024,
height: 768,
bytes: `mock-${file.name}`.length,
iterations: 1,
attempts: [],
targetBytes: 29_491,
maxBytes: 32_768,
minDimension: 512,
})),
}));
vi.mock('@/features/voice/useVoiceInput', () => ({
useVoiceInput: () => ({
voiceState: 'idle',
interimTranscript: '',
wakeWordEnabled: false,
toggleWakeWord: vi.fn(),
error: null,
clearError: vi.fn(),
}),
}));
vi.mock('@/hooks/useTabCompletion', () => ({
useTabCompletion: () => ({
handleKeyDown: vi.fn(() => false),
reset: vi.fn(),
}),
}));
const { mockUseInputHistory } = vi.hoisted(() => ({
mockUseInputHistory: vi.fn(() => ({
addToHistory: vi.fn(),
isNavigating: vi.fn(() => false),
reset: vi.fn(),
navigateUp: vi.fn(() => null),
navigateDown: vi.fn(() => null),
})),
}));
vi.mock('@/hooks/useInputHistory', () => ({
useInputHistory: mockUseInputHistory,
}));
vi.mock('@/contexts/SessionContext', () => ({
useSessionContext: () => ({
sessions: [],
agentName: 'Agent',
}),
}));
vi.mock('@/contexts/SettingsContext', () => ({
useSettings: () => ({
liveTranscriptionPreview: false,
sttInputMode: 'browser',
sttProvider: 'browser',
}),
}));
describe('InputBar', () => {
const originalFetch = global.fetch;
const originalRequestAnimationFrame = global.requestAnimationFrame;
const originalCancelAnimationFrame = global.cancelAnimationFrame;
const originalCreateObjectUrl = global.URL.createObjectURL;
const originalRevokeObjectUrl = global.URL.revokeObjectURL;
let uploadConfigResponse: {
twoModeEnabled: boolean;
inlineEnabled: boolean;
fileReferenceEnabled: boolean;
modeChooserEnabled: boolean;
inlineAttachmentMaxMb: number;
inlineImageContextMaxBytes: number;
inlineImageAutoDowngradeToFileReference: boolean;
inlineImageShrinkMinDimension: number;
inlineImageMaxDimension: number;
inlineImageWebpQuality: number;
exposeInlineBase64ToAgent: boolean;
};
beforeEach(() => {
resetInputBarComposerSnapshotForTests();
uploadConfigResponse = {
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: true,
inlineAttachmentMaxMb: 1,
inlineImageContextMaxBytes: 32_768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
};
vi.mocked(compressImage).mockImplementation(async (file: File) => ({
base64: `mock-${file.name}`,
mimeType: file.type || 'application/octet-stream',
preview: `data:${file.type};base64,mock-${file.name}`,
width: 1024,
height: 768,
bytes: `mock-${file.name}`.length,
iterations: 1,
attempts: [],
targetBytes: 29_491,
maxBytes: 32_768,
minDimension: 512,
}));
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/api/upload-config')) {
return {
ok: true,
json: async () => uploadConfigResponse,
} as Response;
}
if (url.includes('/api/upload-reference/resolve')) {
const headers = init?.headers;
const contentType = headers instanceof Headers
? (headers.get('Content-Type') || headers.get('content-type') || '')
: Array.isArray(headers)
? (headers.find(([key]) => key.toLowerCase() === 'content-type')?.[1] || '')
: (headers as Record<string, string> | undefined)?.['Content-Type']
?? (headers as Record<string, string> | undefined)?.['content-type']
?? '';
if (contentType.includes('application/json')) {
const payload = typeof init?.body === 'string'
? JSON.parse(init.body) as { path?: string }
: {};
const targetPath = payload.path || '';
return {
ok: true,
json: async () => ({
ok: true,
items: [{
kind: 'direct_workspace_reference',
canonicalPath: targetPath,
absolutePath: `/workspace/${targetPath}`,
uri: `file:///workspace/${targetPath}`,
mimeType: targetPath.endsWith('.png') ? 'image/png' : 'text/plain',
sizeBytes: targetPath.endsWith('.png') ? 2048 : 1234,
originalName: targetPath.split('/').pop() || targetPath,
}],
}),
} as Response;
}
const formData = init?.body as FormData | undefined;
const files = formData ? formData.getAll('files').filter((value): value is File => value instanceof File) : [];
return {
ok: true,
json: async () => ({
ok: true,
items: files.map((file, index) => ({
kind: 'imported_workspace_reference',
canonicalPath: `.temp/nerve-uploads/2026/03/21/${index + 1}-${file.name}`,
absolutePath: `/workspace/.temp/nerve-uploads/2026/03/21/${index + 1}-${file.name}`,
uri: `file:///workspace/.temp/nerve-uploads/2026/03/21/${index + 1}-${file.name}`,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
originalName: file.name,
})),
}),
} as Response;
}
if (url.includes('/api/files/tree')) {
const parsed = new URL(url, 'http://localhost');
const dirPath = parsed.searchParams.get('path') || '';
const entries = dirPath === 'docs'
? [{ name: 'nested.txt', path: 'docs/nested.txt', type: 'file', size: 1234, binary: false }]
: [
{ name: 'docs', path: 'docs', type: 'directory', children: null },
{ name: 'attach-me.png', path: 'attach-me.png', type: 'file', size: 2048, binary: false },
];
return {
ok: true,
json: async () => ({
ok: true,
root: dirPath || '.',
entries,
workspaceInfo: {
isCustomWorkspace: false,
rootPath: '/workspace',
},
}),
} as Response;
}
return {
ok: true,
json: async () => ({ language: 'en' }),
} as Response;
}) as typeof fetch;
global.URL.createObjectURL = vi.fn(() => 'blob:preview');
global.URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
global.requestAnimationFrame = originalRequestAnimationFrame;
global.cancelAnimationFrame = originalCancelAnimationFrame;
global.URL.createObjectURL = originalCreateObjectUrl;
global.URL.revokeObjectURL = originalRevokeObjectUrl;
vi.restoreAllMocks();
});
it('re-runs textarea resize after injected text when layout settles', async () => {
const rafQueue: FrameRequestCallback[] = [];
global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
rafQueue.push(callback);
return rafQueue.length;
}) as typeof requestAnimationFrame;
global.cancelAnimationFrame = vi.fn((id: number) => {
if (id > 0 && id <= rafQueue.length) {
rafQueue[id - 1] = () => {};
}
}) as typeof cancelAnimationFrame;
const ref = createRef<InputBarHandle>();
render(<InputBar ref={ref} onSend={vi.fn()} isGenerating={false} />);
const textarea = screen.getByLabelText('Message input') as HTMLTextAreaElement;
let scrollHeightValue = 42;
Object.defineProperty(textarea, 'scrollHeight', {
configurable: true,
get: () => scrollHeightValue,
});
ref.current?.injectText('Plan context:\n- Title: Mobile composer polish', 'append');
expect(textarea.style.height).toBe('42px');
scrollHeightValue = 96;
const firstFrame = rafQueue.shift();
expect(firstFrame).toBeDefined();
firstFrame?.(16);
const secondFrame = rafQueue.shift();
expect(secondFrame).toBeDefined();
secondFrame?.(32);
await waitFor(() => {
expect(textarea.style.height).toBe('96px');
});
});
it('uses the paperclip as the single primary attachment affordance', async () => {
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click');
fireEvent.click(await screen.findByLabelText('Attach files'));
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('menu', { name: 'Attachment actions' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Browse by path/i })).not.toBeInTheDocument();
});
it('stages workspace file add-to-chat requests as server_path file references for the active workspace agent', async () => {
const onSend = vi.fn();
const ref = createRef<InputBarHandle>();
render(<InputBar ref={ref} onSend={onSend} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
await (ref.current as InputBarHandle & {
addWorkspacePath: (path: string, kind: 'file' | 'directory', agentId?: string) => Promise<void>;
} | null)?.addWorkspacePath('attach-me.png', 'file', 'agent-research');
await waitFor(() => {
expect(screen.getAllByText('attach-me.png')).toHaveLength(1);
expect(screen.getByText('Local File')).toBeInTheDocument();
expect(screen.queryByText('Path Ref')).not.toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
const [, attachments, uploadPayload] = onSend.mock.calls[0] as [
string,
Array<{ mimeType: string; content: string; name: string }>?,
{
descriptors: Array<{
origin: string;
mode: string;
reference?: { kind: string; path: string; uri: string };
}>;
}?,
];
expect(attachments).toBeUndefined();
expect(uploadPayload?.descriptors[0]).toMatchObject({
origin: 'server_path',
mode: 'file_reference',
name: 'attach-me.png',
mimeType: 'image/png',
reference: {
kind: 'local_path',
path: '/workspace/attach-me.png',
uri: 'file:///workspace/attach-me.png',
},
});
const resolveCall = vi.mocked(global.fetch).mock.calls.find(([input]) => String(input).includes('/api/upload-reference/resolve'));
expect(resolveCall).toBeDefined();
expect(JSON.parse(String((resolveCall?.[1] as RequestInit | undefined)?.body ?? '{}'))).toMatchObject({
path: 'attach-me.png',
agentId: 'agent-research',
});
const fetchUrls = vi.mocked(global.fetch).mock.calls.map(([input]) => String(input));
expect(fetchUrls.some((url) => url.includes('/api/upload-reference/resolve'))).toBe(true);
expect(fetchUrls.some((url) => url.includes('/api/files/resolve'))).toBe(false);
});
it('adds workspace directories to chat as path context', async () => {
const ref = createRef<InputBarHandle>();
render(<InputBar ref={ref} onSend={vi.fn()} isGenerating={false} />);
await ref.current?.addWorkspacePath('src/features/chat', 'directory');
expect(screen.getByDisplayValue(/Workspace context:/i)).toBeInTheDocument();
expect(screen.getByDisplayValue(/Path: src\/features\/chat/i)).toBeInTheDocument();
});
it('stages browser uploads as uploads without exposing a file-reference chooser', async () => {
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const smallImage = new File([new Uint8Array(100_000)], 'small.png', { type: 'image/png' });
const pdf = new File([new Uint8Array(400_000)], 'notes.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, {
target: { files: [smallImage, pdf] },
});
await waitFor(() => {
expect(screen.getAllByText('Upload').length).toBeGreaterThan(0);
expect(screen.getByText('small.png')).toBeInTheDocument();
expect(screen.getByText('notes.pdf')).toBeInTheDocument();
});
expect(screen.queryByLabelText('Upload mode for small.png')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Upload mode for notes.pdf')).not.toBeInTheDocument();
expect(screen.queryByText('File Reference')).not.toBeInTheDocument();
});
it('rejects oversized non-image browser uploads with browse-by-path guidance', async () => {
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const archive = new File([new Uint8Array(2 * 1024 * 1024)], 'too-big.zip', { type: 'application/zip' });
fireEvent.change(fileInput, {
target: { files: [archive] },
});
await waitFor(() => {
expect(screen.getByText(/too large to send as a browser upload/i)).toBeInTheDocument();
expect(screen.getByText(/choose a smaller file or browse by path/i)).toBeInTheDocument();
});
expect(screen.queryByText('too-big.zip')).not.toBeInTheDocument();
});
it('stages browser uploads into file-reference descriptors before send', async () => {
const onSend = vi.fn();
render(<InputBar onSend={onSend} isGenerating={false} />);
const textarea = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'hello' } });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const smallImage = new File([new Uint8Array(80_000)], 'shot.png', { type: 'image/png' });
fireEvent.change(fileInput, {
target: { files: [smallImage] },
});
await waitFor(() => {
expect(screen.getByText('shot.png')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
expect(compressImage).not.toHaveBeenCalled();
const [text, attachments, uploadPayload] = onSend.mock.calls[0] as [
string,
Array<{ mimeType: string; content: string; name: string }>?,
{
descriptors: Array<{
origin: string;
mode: string;
reference?: { kind: string; path: string; uri: string };
preparation?: {
outcome: string;
};
}>;
}?,
];
expect(text).toBe('hello');
expect(attachments).toBeUndefined();
expect(uploadPayload?.descriptors).toHaveLength(1);
expect(uploadPayload?.descriptors[0]).toMatchObject({
origin: 'upload',
mode: 'file_reference',
reference: {
kind: 'local_path',
path: '/workspace/.temp/nerve-uploads/2026/03/21/1-shot.png',
uri: 'file:///workspace/.temp/nerve-uploads/2026/03/21/1-shot.png',
},
preparation: {
outcome: 'file_reference_ready',
},
});
const fetchUrls = vi.mocked(global.fetch).mock.calls.map(([input]) => String(input));
expect(fetchUrls.some((url) => url.includes('/api/upload-reference/resolve'))).toBe(true);
expect(fetchUrls.some((url) => url.includes('/api/upload-stage'))).toBe(false);
});
it('keeps large browser-uploaded images on the staged file-reference path', async () => {
const onSend = vi.fn();
render(<InputBar onSend={onSend} isGenerating={false} />);
const textarea = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'handle safely' } });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const image = new File([new Uint8Array(2 * 1024 * 1024)], 'oversized-inline.png', { type: 'image/png' });
fireEvent.change(fileInput, {
target: { files: [image] },
});
await waitFor(() => {
expect(screen.getByText('oversized-inline.png')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
expect(onSend.mock.calls[0][1]).toBeUndefined();
expect(onSend.mock.calls[0][2].descriptors[0]).toMatchObject({
origin: 'upload',
mode: 'file_reference',
reference: {
path: '/workspace/.temp/nerve-uploads/2026/03/21/1-oversized-inline.png',
},
});
});
it('does not re-inline staged browser uploads during send preparation', async () => {
const onSend = vi.fn();
vi.mocked(compressImage).mockImplementation(async (file: File) => ({
base64: 'x'.repeat(200_000),
mimeType: file.type || 'application/octet-stream',
preview: `data:${file.type};base64,oversized-${file.name}`,
width: 512,
height: 512,
bytes: 150_000,
iterations: 8,
attempts: [],
targetBytes: 29_491,
maxBytes: 32_768,
minDimension: 512,
}));
render(<InputBar onSend={onSend} isGenerating={false} />);
const textarea = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'ship staged upload' } });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const image = new File([new Uint8Array(80_000)], 'oversized-inline.png', { type: 'image/png' });
fireEvent.change(fileInput, {
target: { files: [image] },
});
await waitFor(() => {
expect(screen.getByText('oversized-inline.png')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
expect(compressImage).not.toHaveBeenCalled();
expect(onSend.mock.calls[0][2].descriptors[0].mode).toBe('file_reference');
});
it('keeps browser uploads on the staged file-reference transport path', async () => {
const onSend = vi.fn();
render(<InputBar onSend={onSend} isGenerating={false} />);
const textarea = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'ship this file' } });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const doc = new File([new Uint8Array(40_000)], 'notes.txt', { type: 'text/plain' });
fireEvent.change(fileInput, {
target: { files: [doc] },
});
await waitFor(() => {
expect(screen.getByText('notes.txt')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
const [text, attachments, uploadPayload] = onSend.mock.calls[0] as [
string,
Array<{ mimeType: string; content: string; name: string }>?,
{
descriptors: Array<{
origin: string;
mode: string;
inline?: { base64: string };
reference?: { kind: string; path: string; uri: string };
}>;
}?,
];
expect(text).toBe('ship this file');
expect(attachments).toBeUndefined();
expect(uploadPayload?.descriptors).toHaveLength(1);
expect(uploadPayload?.descriptors[0]).toMatchObject({
origin: 'upload',
mode: 'file_reference',
reference: {
kind: 'local_path',
path: '/workspace/.temp/nerve-uploads/2026/03/21/1-notes.txt',
uri: 'file:///workspace/.temp/nerve-uploads/2026/03/21/1-notes.txt',
},
});
});
it('hides manual forwarding controls and forwards workspace path attachments by default', async () => {
const onSend = vi.fn();
const ref = createRef<InputBarHandle>();
render(<InputBar ref={ref} onSend={onSend} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
await ref.current?.addWorkspacePath('attach-me.png', 'file');
await waitFor(() => {
expect(screen.getAllByText('attach-me.png').length).toBeGreaterThan(0);
});
expect(screen.queryByLabelText(/Allow forwarding .* to subagents/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][2].descriptors[0].policy.forwardToSubagents).toBe(true);
expect(onSend.mock.calls[0][2].manifest.allowSubagentForwarding).toBe(true);
});
it('forwards inline uploads by default without a forwarding toggle', async () => {
const onSend = vi.fn();
render(<InputBar onSend={onSend} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const smallImage = new File([new Uint8Array(120_000)], 'inline-forwardable.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [smallImage] } });
await waitFor(() => {
expect(screen.getByText('inline-forwardable.png')).toBeInTheDocument();
});
expect(screen.queryByLabelText(/Allow forwarding .* to subagents/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Send message'));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][2].descriptors[0].origin).toBe('upload');
expect(onSend.mock.calls[0][2].descriptors[0].mode).toBe('file_reference');
expect(onSend.mock.calls[0][2].descriptors[0].policy.forwardToSubagents).toBe(true);
expect(onSend.mock.calls[0][2].manifest.allowSubagentForwarding).toBe(true);
});
it('disables the attachment menu when both upload modes are disabled', async () => {
uploadConfigResponse.inlineEnabled = false;
uploadConfigResponse.fileReferenceEnabled = false;
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const attachButton = await screen.findByLabelText('Uploads disabled by configuration');
expect(attachButton).toBeDisabled();
});
it('rejects browser uploads when inline uploads are disabled and directs the user to browse by path', async () => {
uploadConfigResponse.inlineEnabled = false;
uploadConfigResponse.fileReferenceEnabled = true;
uploadConfigResponse.modeChooserEnabled = false;
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const image = new File([new Uint8Array(100_000)], 'vision-off.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [image] } });
await waitFor(() => {
expect(screen.getByText(/browser uploads are disabled by configuration/i)).toBeInTheDocument();
expect(screen.getByText(/enable uploads or browse by path/i)).toBeInTheDocument();
});
expect(screen.queryByText('vision-off.png')).not.toBeInTheDocument();
});
it('allows large images onto the upload path when only inline mode is enabled', async () => {
uploadConfigResponse.inlineEnabled = true;
uploadConfigResponse.fileReferenceEnabled = false;
uploadConfigResponse.modeChooserEnabled = false;
uploadConfigResponse.inlineAttachmentMaxMb = 1;
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const largeImage = new File([new Uint8Array(2 * 1024 * 1024)], 'too-large-inline.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [largeImage] } });
await waitFor(() => {
expect(screen.getByText('too-large-inline.png')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
expect(screen.queryByText(/too large to send as a browser upload/i)).not.toBeInTheDocument();
});
it('restores in-progress text and staged uploads after remount', async () => {
const onSend = vi.fn();
const firstRender = render(<InputBar onSend={onSend} isGenerating={false} />);
const textarea = firstRender.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: 'keep this draft alive' } });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await waitFor(() => {
expect(fileInput.accept).toBe('*/*');
});
const image = new File([new Uint8Array(50_000)], 'persist-me.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [image] } });
await waitFor(() => {
expect(firstRender.getByText('persist-me.png')).toBeInTheDocument();
expect(firstRender.getByDisplayValue('keep this draft alive')).toBeInTheDocument();
});
firstRender.unmount();
render(<InputBar onSend={onSend} isGenerating={false} />);
expect(screen.getByDisplayValue('keep this draft alive')).toBeInTheDocument();
expect(screen.getByText('persist-me.png')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
});
describe('InputBar ArrowUp behavior', () => {
it('moves caret to start on single-line input before recalling history', async () => {
const navigateUp = vi.fn(() => 'previous command');
mockUseInputHistory.mockReturnValue({
addToHistory: vi.fn(),
isNavigating: vi.fn(() => false),
reset: vi.fn(),
navigateUp,
navigateDown: vi.fn(() => null),
} as ReturnType<typeof useInputHistory>);
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'current draft' } });
input.setSelectionRange(input.value.length, input.value.length);
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(navigateUp).not.toHaveBeenCalled();
expect(input.value).toBe('current draft');
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(0);
});
it('recalls history when caret is already at start on single-line input', async () => {
const navigateUp = vi.fn(() => 'previous command');
mockUseInputHistory.mockReturnValue({
addToHistory: vi.fn(),
isNavigating: vi.fn(() => false),
reset: vi.fn(),
navigateUp,
navigateDown: vi.fn(() => null),
} as ReturnType<typeof useInputHistory>);
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'current draft' } });
input.setSelectionRange(0, 0);
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(navigateUp).toHaveBeenCalledWith('current draft');
expect(input.value).toBe('previous command');
});
it('keeps native behavior for multi-line input', async () => {
const navigateUp = vi.fn(() => 'previous command');
mockUseInputHistory.mockReturnValue({
addToHistory: vi.fn(),
isNavigating: vi.fn(() => false),
reset: vi.fn(),
navigateUp,
navigateDown: vi.fn(() => null),
} as ReturnType<typeof useInputHistory>);
render(<InputBar onSend={vi.fn()} isGenerating={false} />);
const input = screen.getByLabelText('Message input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'line 1\nline 2' } });
input.setSelectionRange(input.value.length, input.value.length);
const beforeCaret = input.selectionStart;
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(navigateUp).not.toHaveBeenCalled();
expect(input.value).toBe('line 1\nline 2');
expect(input.selectionStart).toBe(beforeCaret);
});
});

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
vi.mock('@/features/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content, onOpenWorkspacePath }: { content: string; onOpenWorkspacePath?: ((path: string) => void) & { handlerId?: string } }) => (
MarkdownRenderer: ({ content, onOpenWorkspacePath }: { content: string; onOpenWorkspacePath?: ((path: string) => void) & { handlerId?: string }; onOpenBeadId?: ((beadId: string) => void) }) => (
<div data-handler-id={onOpenWorkspacePath?.handlerId ?? ''}>{content}</div>
),
}));
@ -83,4 +83,164 @@ describe('MessageBubble', () => {
expect(container.querySelector('[data-handler-id="two"]')).toBeTruthy();
});
});
it('renders upload attachment metadata for user messages loaded from history', async () => {
const { getByText } = render(
<MessageBubble
msg={makeMessage({
rawText: 'Please review these.',
uploadAttachments: [
{
id: 'att-path',
origin: 'server_path',
mode: 'file_reference',
name: 'capture.mov',
mimeType: 'video/quicktime',
sizeBytes: 8_000_000,
reference: {
kind: 'local_path',
path: '/workspace/capture.mov',
uri: 'file:///workspace/capture.mov',
},
preparation: {
sourceMode: 'file_reference',
finalMode: 'file_reference',
outcome: 'file_reference_ready',
originalMimeType: 'video/quicktime',
originalSizeBytes: 8_000_000,
},
policy: { forwardToSubagents: true },
},
],
})}
index={0}
isCollapsed={false}
isMemoryCollapsed={false}
onToggleCollapse={() => {}}
onToggleMemory={() => {}}
/>,
);
await waitFor(() => {
expect(getByText('capture.mov')).toBeTruthy();
expect(getByText('Local File')).toBeTruthy();
expect(getByText('/workspace/capture.mov')).toBeTruthy();
});
});
it('re-renders when upload attachment mimeType changes', async () => {
const attachment = {
id: 'att-path',
origin: 'server_path' as const,
mode: 'file_reference' as const,
name: 'capture.mov',
mimeType: 'video/quicktime',
sizeBytes: 8_000_000,
reference: {
kind: 'local_path' as const,
path: '/workspace/capture.mov',
uri: 'file:///workspace/capture.mov',
},
preparation: {
sourceMode: 'file_reference' as const,
finalMode: 'file_reference' as const,
outcome: 'file_reference_ready' as const,
originalMimeType: 'video/quicktime',
originalSizeBytes: 8_000_000,
},
policy: { forwardToSubagents: true },
};
const { getByText, queryByText, rerender } = render(
<MessageBubble
msg={makeMessage({ rawText: 'Please review these.', uploadAttachments: [attachment] })}
index={0}
isCollapsed={false}
isMemoryCollapsed={false}
onToggleCollapse={() => {}}
onToggleMemory={() => {}}
/>,
);
await waitFor(() => {
expect(getByText('video/quicktime')).toBeTruthy();
});
rerender(
<MessageBubble
msg={makeMessage({
rawText: 'Please review these.',
uploadAttachments: [{ ...attachment, mimeType: 'video/mp4' }],
})}
index={0}
isCollapsed={false}
isMemoryCollapsed={false}
onToggleCollapse={() => {}}
onToggleMemory={() => {}}
/>,
);
await waitFor(() => {
expect(getByText('video/mp4')).toBeTruthy();
expect(queryByText('video/quicktime')).toBeNull();
});
});
it('re-renders when upload attachment size changes', async () => {
const attachment = {
id: 'att-path',
origin: 'server_path' as const,
mode: 'file_reference' as const,
name: 'capture.mov',
mimeType: 'video/quicktime',
sizeBytes: 1024,
reference: {
kind: 'local_path' as const,
path: '/workspace/capture.mov',
uri: 'file:///workspace/capture.mov',
},
preparation: {
sourceMode: 'file_reference' as const,
finalMode: 'file_reference' as const,
outcome: 'file_reference_ready' as const,
originalMimeType: 'video/quicktime',
originalSizeBytes: 1024,
},
policy: { forwardToSubagents: true },
};
const { getByText, queryByText, rerender } = render(
<MessageBubble
msg={makeMessage({ rawText: 'Please review these.', uploadAttachments: [attachment] })}
index={0}
isCollapsed={false}
isMemoryCollapsed={false}
onToggleCollapse={() => {}}
onToggleMemory={() => {}}
/>,
);
await waitFor(() => {
expect(getByText('1 KB')).toBeTruthy();
});
rerender(
<MessageBubble
msg={makeMessage({
rawText: 'Please review these.',
uploadAttachments: [{ ...attachment, sizeBytes: 2048, preparation: { ...attachment.preparation, originalSizeBytes: 2048 } }],
})}
index={0}
isCollapsed={false}
isMemoryCollapsed={false}
onToggleCollapse={() => {}}
onToggleMemory={() => {}}
/>,
);
await waitFor(() => {
expect(getByText('2 KB')).toBeTruthy();
expect(queryByText('1 KB')).toBeNull();
});
});
});

View file

@ -5,6 +5,7 @@ import { isMessageCollapsible } from './types';
import { decodeHtmlEntities } from '@/lib/formatting';
import { isStructuredMarkdown } from '@/lib/text/isStructuredMarkdown';
import type { ChatMsg } from './types';
import type { BeadLinkTarget } from '@/features/beads';
// Lazy-load markdown renderer (includes highlight.js)
const MarkdownRenderer = lazy(() => import('@/features/markdown/MarkdownRenderer').then(m => ({ default: m.MarkdownRenderer })));
@ -31,6 +32,13 @@ function formatMissionTime(msgTime: Date, firstTime: Date | null): string {
return `T+${h}:${m}:${s}`;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(/\.0$/, '')} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB`;
}
interface MessageBubbleProps {
msg: ChatMsg;
index: number;
@ -44,6 +52,8 @@ interface MessageBubbleProps {
isCurrentMatch?: boolean;
agentName?: string;
onOpenWorkspacePath?: (path: string) => void | Promise<void>;
pathLinkPrefixes?: string[];
onOpenBeadId?: (target: BeadLinkTarget) => void | Promise<void>;
}
const borderClass = (role: string) => {
@ -72,7 +82,7 @@ function RoleBadge({ role, agentName = 'Agent' }: { role: string; agentName?: st
return <span className="cockpit-badge">System</span>;
}
function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memoryKey, onToggleCollapse, onToggleMemory, firstMessageTime, searchQuery, isCurrentMatch, agentName, onOpenWorkspacePath }: MessageBubbleProps) {
function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memoryKey, onToggleCollapse, onToggleMemory, firstMessageTime, searchQuery, isCurrentMatch, agentName, onOpenWorkspacePath, pathLinkPrefixes, onOpenBeadId }: MessageBubbleProps) {
const isUser = msg.role === 'user';
const isAssistant = msg.role === 'assistant';
const isSystem = msg.role === 'system' || msg.role === 'event';
@ -189,7 +199,7 @@ function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memory
{!isCollapsed && (
<div className="ml-3 border-l border-primary/12 px-3 pb-2 pt-1 text-[0.8rem] text-foreground/70 msg-body-intermediate">
<Suspense fallback={<span className="text-muted-foreground text-xs"></span>}>
<MarkdownRenderer content={msg.rawText} searchQuery={searchQuery} onOpenWorkspacePath={onOpenWorkspacePath} />
<MarkdownRenderer content={msg.rawText} searchQuery={searchQuery} onOpenWorkspacePath={onOpenWorkspacePath} pathLinkPrefixes={pathLinkPrefixes} onOpenBeadId={onOpenBeadId} />
</Suspense>
</div>
)}
@ -219,7 +229,7 @@ function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memory
) : (
<div className="text-muted-foreground/70 text-[0.8rem] flex-1 min-w-0 msg-body-intermediate">
<Suspense fallback={<span className="text-muted-foreground text-xs"></span>}>
<MarkdownRenderer content={displayContent} searchQuery={searchQuery} suppressImages={isAssistant} onOpenWorkspacePath={onOpenWorkspacePath} />
<MarkdownRenderer content={displayContent} searchQuery={searchQuery} suppressImages={isAssistant} onOpenWorkspacePath={onOpenWorkspacePath} pathLinkPrefixes={pathLinkPrefixes} onOpenBeadId={onOpenBeadId} />
</Suspense>
</div>
)}
@ -285,7 +295,7 @@ function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memory
)}
{displayContent && (
<Suspense fallback={<div className="text-muted-foreground text-xs">Loading</div>}>
<MarkdownRenderer content={displayContent} searchQuery={searchQuery} suppressImages={isAssistant} onOpenWorkspacePath={onOpenWorkspacePath} />
<MarkdownRenderer content={displayContent} searchQuery={searchQuery} suppressImages={isAssistant} onOpenWorkspacePath={onOpenWorkspacePath} pathLinkPrefixes={pathLinkPrefixes} onOpenBeadId={onOpenBeadId} />
</Suspense>
)}
</div>
@ -298,6 +308,25 @@ function MessageBubbleInner({ msg, index, isCollapsed, isMemoryCollapsed, memory
</Suspense>
</div>
)}
{msg.uploadAttachments && msg.uploadAttachments.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
{msg.uploadAttachments.map((attachment) => (
<div key={attachment.id} className="rounded-xl border border-border/60 bg-secondary/30 px-3 py-2 text-[0.733rem] text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{attachment.name}</span>
<span className="cockpit-badge" data-tone={attachment.mode === 'file_reference' ? 'warning' : 'primary'}>
{attachment.mode === 'file_reference' ? 'Local File' : 'Inline'}
</span>
<span>{formatBytes(attachment.sizeBytes)}</span>
<span>{attachment.mimeType}</span>
</div>
{attachment.reference?.path && (
<div className="mt-1 font-mono text-[0.667rem] text-muted-foreground/90">{attachment.reference.path}</div>
)}
</div>
))}
</div>
)}
{msg.extractedImages && msg.extractedImages.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{msg.extractedImages.map((img, idx) => (
@ -372,13 +401,26 @@ export const MessageBubble = memo(MessageBubbleInner, (prev, next) => {
// Charts
if (prev.msg.charts?.length !== next.msg.charts?.length) return false;
// Images
// Images and attachment metadata
if (prev.msg.images?.length !== next.msg.images?.length) return false;
if (prev.msg.extractedImages?.length !== next.msg.extractedImages?.length) return false;
if ((prev.msg.uploadAttachments?.length || 0) !== (next.msg.uploadAttachments?.length || 0)) return false;
if ((prev.msg.uploadAttachments || []).some((attachment, idx) => {
const nextAttachment = next.msg.uploadAttachments?.[idx];
return !nextAttachment
|| attachment.id !== nextAttachment.id
|| attachment.name !== nextAttachment.name
|| attachment.mode !== nextAttachment.mode
|| attachment.sizeBytes !== nextAttachment.sizeBytes
|| attachment.mimeType !== nextAttachment.mimeType
|| attachment.reference?.path !== nextAttachment.reference?.path;
})) return false;
// Agent name (rare change but must re-render when it does)
if (prev.agentName !== next.agentName) return false;
if (prev.onOpenWorkspacePath !== next.onOpenWorkspacePath) return false;
if (prev.pathLinkPrefixes !== next.pathLinkPrefixes) return false;
if (prev.onOpenBeadId !== next.onOpenBeadId) return false;
// All relevant props are equal, skip re-render
return true;

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { formatBeadAddToChat, formatPlanAddToChat, formatWorkspacePathAddToChat, mergeAddToChatText } from './addToChat';
describe('addToChat helpers', () => {
it('formats plan context with source, title, and path', () => {
expect(formatPlanAddToChat({
source: 'Gambit OpenClaw Nerve',
title: 'Nerve mobile polish',
path: '.plans/2026-03-15-mobile-plans-back-button-and-add-to-chat.md',
})).toBe('Plan context:\n- Source: Gambit OpenClaw Nerve\n- Title: Nerve mobile polish\n- Path: .plans/2026-03-15-mobile-plans-back-button-and-add-to-chat.md');
});
it('formats bead context with source, title, and id', () => {
expect(formatBeadAddToChat({
source: '~/.openclaw/workspace/projects/gambit-openclaw-nerve',
title: 'Implement Add to Chat',
id: 'nerve-qn2',
})).toBe('Bead context:\n- Source: ~/.openclaw/workspace/projects/gambit-openclaw-nerve\n- Title: Implement Add to Chat\n- ID: nerve-qn2');
});
it('omits blank source lines when no source is available', () => {
expect(formatBeadAddToChat({
source: ' ',
title: 'Implement Add to Chat',
id: 'nerve-qn2',
})).toBe('Bead context:\n- Title: Implement Add to Chat\n- ID: nerve-qn2');
});
it('formats workspace path context for directories', () => {
expect(formatWorkspacePathAddToChat({
source: 'Workspace',
kind: 'directory',
path: 'src/features/chat',
})).toBe('Workspace context:\n- Source: Workspace\n- Kind: directory\n- Path: src/features/chat');
});
it('appends add-to-chat context after an existing draft', () => {
expect(mergeAddToChatText('Please help with this next.', 'Plan context:\n- Title: Test\n- Path: .plans/test.md'))
.toBe('Please help with this next.\n\nPlan context:\n- Title: Test\n- Path: .plans/test.md');
});
});

View file

@ -0,0 +1,58 @@
interface BaseChatArtifact {
source?: string | null;
}
export interface PlanChatArtifact extends BaseChatArtifact {
title: string;
path: string;
}
export interface BeadChatArtifact extends BaseChatArtifact {
title: string;
id: string;
}
export interface WorkspacePathChatArtifact extends BaseChatArtifact {
path: string;
kind: 'file' | 'directory';
}
function formatArtifactBlock(kind: string, lines: Array<[label: string, value: string | null | undefined]>): string {
return [
`${kind} context:`,
...lines
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0)
.map(([label, value]) => `- ${label}: ${value!.trim()}`),
].join('\n');
}
export function formatPlanAddToChat(plan: PlanChatArtifact): string {
return formatArtifactBlock('Plan', [
['Source', plan.source],
['Title', plan.title],
['Path', plan.path],
]);
}
export function formatBeadAddToChat(bead: BeadChatArtifact): string {
return formatArtifactBlock('Bead', [
['Source', bead.source],
['Title', bead.title],
['ID', bead.id],
]);
}
export function formatWorkspacePathAddToChat(item: WorkspacePathChatArtifact): string {
return formatArtifactBlock('Workspace', [
['Source', item.source],
['Kind', item.kind],
['Path', item.path],
]);
}
export function mergeAddToChatText(currentDraft: string, nextBlock: string): string {
const trimmedDraft = currentDraft.trim();
if (!trimmedDraft) return nextBlock;
return `${trimmedDraft}\n\n${nextBlock}`;
}

View file

@ -0,0 +1,25 @@
export interface ChatPathLinksConfig {
prefixes: string[];
}
export const DEFAULT_CHAT_PATH_LINKS_CONFIG: ChatPathLinksConfig = {
prefixes: ['/workspace/'],
};
function normalizePrefixes(rawPrefixes: unknown): string[] {
if (!Array.isArray(rawPrefixes)) return [...DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes];
const normalized = rawPrefixes
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0);
return normalized.length > 0 ? normalized : [...DEFAULT_CHAT_PATH_LINKS_CONFIG.prefixes];
}
export function parseChatPathLinksConfig(content: string): ChatPathLinksConfig {
const parsed = JSON.parse(content) as { prefixes?: unknown };
return {
prefixes: normalizePrefixes(parsed?.prefixes),
};
}

View file

@ -25,6 +25,7 @@ const defaultMockHook = {
],
selectedModel: 'gpt-4',
selectedEffort: 'balanced',
selectedEffortLabel: 'Balanced',
handleModelChange: vi.fn(),
handleEffortChange: vi.fn(),
controlsDisabled: false,
@ -171,6 +172,30 @@ describe('ChatHeader', () => {
expect(screen.getByRole('button', { name: 'Model' })).toHaveTextContent('No configured models');
});
it('uses the effort display label when provided by the hook', () => {
const mockUseModelEffort = vi.mocked(useModelEffort);
mockUseModelEffort.mockReturnValue({
...defaultMockHook,
selectedEffort: 'thinkingDefault',
selectedEffortLabel: 'medium',
effortOptions: [
{ value: 'thinkingDefault', label: 'medium (default)' },
{ value: 'medium', label: 'medium' },
],
});
render(
<ChatHeader
onReset={mockOnReset}
onAbort={mockOnAbort}
isGenerating={false}
/>
);
expect(screen.getByRole('button', { name: 'Effort' })).toHaveTextContent('medium');
expect(screen.getByRole('button', { name: 'Effort' })).not.toHaveTextContent('medium (default)');
});
it('shows abort button when generating', () => {
const mockUseModelEffort = vi.mocked(useModelEffort);
mockUseModelEffort.mockReturnValue(defaultMockHook);

View file

@ -36,6 +36,7 @@ export function ChatHeader({
effortOptions,
selectedModel,
selectedEffort,
selectedEffortLabel,
handleModelChange,
handleEffortChange,
controlsDisabled,
@ -126,6 +127,7 @@ export function ChatHeader({
title={controlsDisabled ? 'Connect to gateway to change effort' : undefined}
triggerClassName="max-w-[82px] rounded-xl border-border/75 bg-background/65 px-2.5 py-1.5 text-[0.733rem] font-sans text-foreground sm:max-w-none sm:min-h-8 sm:px-2.5 sm:py-1"
menuClassName="rounded-2xl border-border/80 bg-card/98 p-1 shadow-[0_20px_50px_rgba(0,0,0,0.28)]"
displayLabel={selectedEffortLabel}
options={effortOptions}
/>
</div>

View file

@ -17,8 +17,8 @@ vi.mock('@/contexts/SessionContext', () => ({
import { buildModelCatalogUiError, buildSelectableModelList, type GatewayModelInfo, useModelEffort } from './useModelEffort';
const CONFIGURED_MODELS: GatewayModelInfo[] = [
{ id: 'zai/glm-4.7', label: 'glm-4.7', provider: 'zai' },
{ id: 'ollama/qwen2.5:7b-instruct-q5_K_M', label: 'qwen-local', provider: 'ollama' },
{ id: 'zai/glm-4.7', label: 'glm-4.7', provider: 'zai', role: 'primary' },
{ id: 'ollama/qwen2.5:7b-instruct-q5_K_M', label: 'qwen-local', provider: 'ollama', role: 'fallback' },
];
function jsonResponse(data: unknown, init: { ok?: boolean; status?: number } = {}) {
@ -86,6 +86,9 @@ describe('useModelEffort', () => {
if (url.startsWith('/api/gateway/session-info?sessionKey=')) {
return Promise.resolve(jsonResponse({}));
}
if (url.startsWith('/api/sessions/runtime?sessionKey=')) {
return Promise.resolve(jsonResponse({ ok: true, model: null, thinking: null, missing: true }));
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
});
@ -106,11 +109,113 @@ describe('useModelEffort', () => {
});
expect(result.current.modelOptions).toEqual([
{ value: 'primary', label: 'primary' },
{ value: 'zai/glm-4.7', label: 'glm-4.7' },
{ value: 'ollama/qwen2.5:7b-instruct-q5_K_M', label: 'qwen-local' },
{ value: 'openrouter/xiaomi/mimo-v2-pro', label: 'xiaomi/mimo-v2-pro' },
]);
});
it('keeps inherited effort selected while displaying the effective default value', async () => {
mockUseGateway.mockReturnValue({
rpc: vi.fn(),
connectionState: 'connected',
model: 'zai/glm-4.7',
thinking: 'medium',
});
mockUseSessionContext.mockReturnValue({
currentSession: 'agent:main:main',
sessions: [
{ key: 'agent:main:main', model: 'openai-codex/gpt-5.4', thinking: 'medium' },
],
updateSession: vi.fn(),
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = String(input);
if (url === '/api/gateway/models') {
return Promise.resolve(jsonResponse({
models: [
{ id: 'openai/gpt-5.4', label: 'gpt-5.4', provider: 'openai', role: 'primary' },
{ id: 'zai/glm-4.7', label: 'glm-4.7', provider: 'zai', role: 'fallback' },
],
error: null,
}));
}
if (url.startsWith('/api/gateway/session-info?sessionKey=')) {
return Promise.resolve(jsonResponse({}));
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
const { result } = renderHook(() => useModelEffort());
await waitFor(() => {
expect(result.current.selectedModel).toBe('primary');
expect(result.current.selectedEffort).toBe('thinkingDefault');
expect(result.current.selectedEffortLabel).toBe('medium');
expect(result.current.effortOptions[0]).toEqual({ value: 'thinkingDefault', label: 'medium (default)' });
});
});
it('uses transcript runtime defaults to label inherited effort when session summaries omit thinking', async () => {
mockUseGateway.mockReturnValue({
rpc: vi.fn(),
connectionState: 'connected',
model: 'zai/glm-4.7',
thinking: '--',
});
mockUseSessionContext.mockReturnValue({
currentSession: 'agent:smoke257:main',
sessions: [
{ key: 'agent:smoke257:main', id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', model: 'zai/glm-4.7' },
],
updateSession: vi.fn(),
});
globalThis.fetch = vi.fn((input: string | URL | Request) => {
const url = String(input);
if (url === '/api/gateway/models') {
return Promise.resolve(jsonResponse({ models: CONFIGURED_MODELS, error: null }));
}
if (url === '/api/gateway/session-info?sessionKey=agent%3Asmoke257%3Amain') {
return Promise.resolve(jsonResponse({}));
}
if (url === '/api/sessions/runtime?sessionKey=agent%3Asmoke257%3Amain') {
return Promise.resolve(jsonResponse({ ok: true, model: 'openai-codex/gpt-5.4', thinking: 'medium', missing: false }));
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
const { result } = renderHook(() => useModelEffort());
await waitFor(() => {
expect(result.current.selectedEffort).toBe('thinkingDefault');
expect(result.current.selectedEffortLabel).toBe('medium');
expect(result.current.effortOptions[0]).toEqual({ value: 'thinkingDefault', label: 'medium (default)' });
});
});
it('preserves explicit effort overrides when present', async () => {
mockUseSessionContext.mockReturnValue({
currentSession: 'agent:main:subagent:explicit',
sessions: [
{ key: 'agent:main:main', model: 'zai/glm-4.7' },
{ key: 'agent:main:subagent:explicit', model: 'zai/glm-4.7', thinking: 'medium', thinkingLevel: 'high' },
],
updateSession: vi.fn(),
});
const { result } = renderHook(() => useModelEffort());
await waitFor(() => {
expect(result.current.selectedModel).toBe('primary');
expect(result.current.selectedEffort).toBe('high');
expect(result.current.selectedEffortLabel).toBe('high');
});
});
});
describe('buildModelCatalogUiError', () => {

View file

@ -32,10 +32,25 @@ function getEffortKey(sessionKey?: string | null) {
return sessionKey ? `oc-effort-${sessionKey}` : 'oc-effort-default';
}
const INHERITED_MODEL_VALUE = 'primary';
const INHERITED_EFFORT_VALUE = 'thinkingDefault';
type EffortLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
type EffortSelection = typeof INHERITED_EFFORT_VALUE | EffortLevel;
const EFFORT_OPTIONS: EffortLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
export type GatewayModelInfo = { id: string; label: string; provider: string };
function normalizeEffortLevel(raw: string | null | undefined): EffortLevel | null {
const normalized = raw?.toLowerCase();
return normalized && EFFORT_OPTIONS.includes(normalized as EffortLevel)
? normalized as EffortLevel
: null;
}
export type GatewayModelInfo = {
id: string;
label: string;
provider: string;
role?: 'primary' | 'fallback' | 'allowed';
};
type GatewayModelsResponse = {
models: GatewayModelInfo[];
@ -62,6 +77,15 @@ function resolveModelId(raw: string, options: GatewayModelInfo[]): string {
return raw;
}
function modelRefsMatch(a: string | null | undefined, b: string | null | undefined, options: GatewayModelInfo[]): boolean {
if (!a || !b) return false;
if (a === b) return true;
const resolvedA = resolveModelId(a, options);
const resolvedB = resolveModelId(b, options);
if (resolvedA === resolvedB) return true;
return baseModelName(resolvedA) === baseModelName(resolvedB);
}
export function buildSelectableModelList(
gatewayModels: GatewayModelInfo[] | null,
currentModel: string | null | undefined,
@ -120,6 +144,7 @@ export interface UseModelEffortReturn {
effortOptions: { value: string; label: string }[];
selectedModel: string;
selectedEffort: string;
selectedEffortLabel: string;
handleModelChange: (next: string) => Promise<void>;
handleEffortChange: (next: string) => Promise<void>;
controlsDisabled: boolean;
@ -147,6 +172,7 @@ export function useModelEffort(): UseModelEffortReturn {
// Keyed by session key → resolved model ID. Survives session switches so
// we don't re-fetch when switching back to a previously visited session.
const [resolvedSessionModels, setResolvedSessionModels] = useState<Record<string, string>>({});
const [resolvedSessionThinking, setResolvedSessionThinking] = useState<Record<string, EffortLevel>>({});
const rawCurrentSessionModel = useMemo(() => {
const cached = resolvedSessionModels[currentSession];
@ -160,12 +186,18 @@ export function useModelEffort(): UseModelEffortReturn {
() => buildSelectableModelList(gatewayModels, rawCurrentSessionModel || model),
[gatewayModels, rawCurrentSessionModel, model],
);
const [selectedEffort, setSelectedEffort] = useState<EffortLevel>(() => {
const primaryModelId = useMemo(
() => gatewayModels?.find((entry) => entry.role === 'primary')?.id || null,
[gatewayModels],
);
const [selectedEffort, setSelectedEffort] = useState<EffortSelection>(() => {
try {
const saved = localStorage.getItem(getEffortKey(currentSession)) as EffortLevel | null;
return saved && EFFORT_OPTIONS.includes(saved) ? saved : 'low';
const saved = localStorage.getItem(getEffortKey(currentSession)) as EffortSelection | null;
return saved && (saved === INHERITED_EFFORT_VALUE || EFFORT_OPTIONS.includes(saved as EffortLevel))
? saved
: INHERITED_EFFORT_VALUE;
} catch {
return 'low';
return INHERITED_EFFORT_VALUE;
}
});
const [prevEffortSource, setPrevEffortSource] = useState<string | null>(null);
@ -173,24 +205,50 @@ export function useModelEffort(): UseModelEffortReturn {
// Resolve current session's model.
// Priority: resolved cache (from transcript/cron) → session.model from gateway
const currentSessionModel = useMemo(() => {
const sessionRecord = sessions.find(sess => getSessionKey(sess) === currentSession);
// Check cached resolved model first (accurate for cron/subagent sessions)
const cached = resolvedSessionModels[currentSession];
if (cached) return resolveModelId(cached, modelOptionsList);
// Fall back to session.model from sessions.list (correct for main, default for others)
const s = sessions.find(sess => getSessionKey(sess) === currentSession);
const raw = s?.model;
const raw = resolvedSessionModels[currentSession] || sessionRecord?.model;
if (!raw) return null;
return resolveModelId(raw, modelOptionsList);
}, [sessions, currentSession, modelOptionsList, resolvedSessionModels]);
// Resolve current session's thinking level
if (modelRefsMatch(raw, primaryModelId, modelOptionsList)) {
return INHERITED_MODEL_VALUE;
}
return resolveModelId(raw, modelOptionsList);
}, [sessions, currentSession, modelOptionsList, resolvedSessionModels, primaryModelId]);
// Resolve current session's thinking level.
// Prefer explicit overrides. If no explicit override exists, surface
// the inherited default selector state (thinkingDefault).
const currentSessionThinking = useMemo(() => {
const s = sessions.find(sess => getSessionKey(sess) === currentSession);
const raw = (s?.thinkingLevel || s?.thinking)?.toLowerCase();
if (raw && EFFORT_OPTIONS.includes(raw as EffortLevel)) return raw as EffortLevel;
const explicit = normalizeEffortLevel(s?.thinkingLevel);
if (explicit) {
return explicit;
}
const effective = normalizeEffortLevel(s?.thinking);
const inheritedDefault = normalizeEffortLevel(thinking);
if (effective && inheritedDefault && effective !== inheritedDefault) {
return effective;
}
return null;
}, [sessions, currentSession]);
}, [sessions, currentSession, thinking]);
const inheritedEffortLabel = useMemo<EffortSelection>(() => {
const s = sessions.find(sess => getSessionKey(sess) === currentSession);
return resolvedSessionThinking[currentSession]
|| normalizeEffortLevel(s?.thinking)
|| normalizeEffortLevel(thinking)
|| INHERITED_EFFORT_VALUE;
}, [sessions, currentSession, thinking, resolvedSessionThinking]);
const selectedEffortLabel = selectedEffort === INHERITED_EFFORT_VALUE
? inheritedEffortLabel
: selectedEffort;
// Sync model dropdown when switching sessions (setState-during-render pattern)
//
@ -200,7 +258,10 @@ export function useModelEffort(): UseModelEffortReturn {
// is available).
const rawModelSource = currentSessionModel || model || '--';
let modelSource = rawModelSource;
if (modelSource !== '--' && !modelOptionsList.some(m => m.id === modelSource)) {
if (modelSource !== '--' && modelRefsMatch(modelSource, primaryModelId, modelOptionsList)) {
modelSource = INHERITED_MODEL_VALUE;
} else if (modelSource !== '--' && modelSource !== INHERITED_MODEL_VALUE && !modelOptionsList.some(m => m.id === modelSource)) {
const byLabel = modelOptionsList.find(m => m.label === modelSource);
const srcBase = baseModelName(modelSource);
const byBaseName = modelOptionsList.find(m => baseModelName(m.id) === srcBase);
@ -224,18 +285,16 @@ export function useModelEffort(): UseModelEffortReturn {
}
// Sync effort dropdown from gateway thinking level (setState-during-render pattern)
const effortSource = `${currentSession}:${currentSessionThinking ?? thinking ?? ''}`;
const effortSource = `${currentSession}:${currentSessionThinking ?? INHERITED_EFFORT_VALUE}`;
if (effortSource !== prevEffortSource) {
setPrevEffortSource(effortSource);
// Fix 1: Respect optimistic lock for effort changes too
if (Date.now() <= effortLockUntilRef.current) {
// Skip — we're in the grace period after a manual effort change
} else if (currentSessionThinking) {
setSelectedEffort(currentSessionThinking);
try { localStorage.setItem(getEffortKey(currentSession), currentSessionThinking); } catch { /* ignore */ }
} else if (thinking && thinking !== '--' && EFFORT_OPTIONS.includes(thinking as EffortLevel)) {
setSelectedEffort(thinking as EffortLevel);
try { localStorage.setItem(getEffortKey(currentSession), thinking); } catch { /* ignore */ }
} else {
const nextEffortSelection = currentSessionThinking || INHERITED_EFFORT_VALUE;
setSelectedEffort(nextEffortSelection);
try { localStorage.setItem(getEffortKey(currentSession), nextEffortSelection); } catch { /* ignore */ }
}
}
@ -282,24 +341,37 @@ export function useModelEffort(): UseModelEffortReturn {
useEffect(() => {
const signal = { cancelled: false };
(async () => {
const sessionInfo = await fetchGatewaySessionInfo(currentSession || undefined);
if (signal.cancelled) return;
if (signal.cancelled || !currentSession) return;
if (sessionInfo?.thinking && !currentSessionThinking) {
const level = sessionInfo.thinking.toLowerCase() as EffortLevel;
if (EFFORT_OPTIONS.includes(level)) {
setSelectedEffort(level);
try { localStorage.setItem(getEffortKey(currentSession), level); } catch { /* ignore */ }
let resolvedModel: string | null = null;
let resolvedThinking: EffortLevel | null = null;
try {
const info = await fetchGatewaySessionInfo(currentSession);
if (signal.cancelled) return;
resolvedThinking = normalizeEffortLevel(info?.thinking);
} catch (err) {
console.warn('[useModelEffort] Failed to fetch gateway session info:', err);
}
try {
const res = await fetch(`/api/sessions/runtime?sessionKey=${encodeURIComponent(currentSession)}`);
if (signal.cancelled) return;
const data = await res.json() as { ok: boolean; model?: string | null; thinking?: string | null; missing?: boolean };
if (data.ok) {
if (data.model != null) resolvedModel = data.model;
if (!resolvedThinking) resolvedThinking = normalizeEffortLevel(data.thinking);
}
} catch { /* ignore */ }
if (resolvedThinking && !signal.cancelled) {
setResolvedSessionThinking(prev => prev[currentSession] === resolvedThinking
? prev
: { ...prev, [currentSession]: resolvedThinking });
}
// For child sessions, resolve the actual model from cron payload or transcript
if (!currentSession) return;
const sessionType = getSessionType(currentSession);
if (sessionType === 'main') return;
let resolvedModel: string | null = null;
if (sessionType === 'cron') {
// Cron parent: look up the job's payload.model
const jobIdMatch = currentSession.match(/:cron:([^:]+)$/);
@ -315,18 +387,6 @@ export function useModelEffort(): UseModelEffortReturn {
}
} catch { /* ignore */ }
}
} else {
// Cron-run or subagent: read model from session transcript
const parts = currentSession.split(':');
const sessionId = parts[parts.length - 1];
if (sessionId && /^[0-9a-f-]{36}$/.test(sessionId)) {
try {
const res = await fetch(`/api/sessions/${sessionId}/model`);
if (signal.cancelled) return;
const data = await res.json() as { ok: boolean; model?: string | null; missing?: boolean };
if (data.ok && data.model != null) resolvedModel = data.model;
} catch { /* ignore */ }
}
}
if (resolvedModel && !signal.cancelled) {
@ -339,7 +399,7 @@ export function useModelEffort(): UseModelEffortReturn {
console.warn('[useModelEffort] Failed to fetch session info:', err);
});
return () => { signal.cancelled = true; };
}, [currentSession, currentSessionThinking, modelOptionsList]);
}, [currentSession, modelOptionsList]);
const controlsDisabled = connectionState !== 'connected' || !currentSession;
@ -366,26 +426,44 @@ export function useModelEffort(): UseModelEffortReturn {
confirmTimerRef.current = null;
}
const selectingInheritedPrimary = nextInput === INHERITED_MODEL_VALUE;
let patchModel: string | null = selectingInheritedPrimary ? null : next;
try {
let wsSucceeded = false;
// Attempt 1: WS RPC (fast path)
try {
await rpc('sessions.patch', { key: currentSession, model: next });
await rpc('sessions.patch', { key: currentSession, model: patchModel });
wsSucceeded = true;
} catch (patchErr) {
// Attempt 2: Cross-provider fallback via WS
const nextBase = baseModelName(next);
const alt = modelOptionsList.find(m => m.id !== next && baseModelName(m.id) === nextBase);
if (alt) {
// For inherited-default model selection, some gateways may reject null model.
// In that case, fall back to explicitly setting the configured primary model ID.
if (selectingInheritedPrimary && primaryModelId) {
try {
await rpc('sessions.patch', { key: currentSession, model: alt.id });
next = alt.id;
setSelectedModel(next);
try { localStorage.setItem(MODEL_KEY, next); } catch { /* ignore */ }
await rpc('sessions.patch', { key: currentSession, model: primaryModelId });
patchModel = primaryModelId;
wsSucceeded = true;
} catch {
// WS completely broken — fall through to HTTP
// Keep failing through to HTTP fallback.
}
}
// Attempt 2: Cross-provider fallback via WS
if (!wsSucceeded && !selectingInheritedPrimary) {
const nextBase = baseModelName(next);
const alt = modelOptionsList.find(m => m.id !== next && baseModelName(m.id) === nextBase);
if (alt) {
try {
await rpc('sessions.patch', { key: currentSession, model: alt.id });
next = alt.id;
patchModel = alt.id;
setSelectedModel(next);
try { localStorage.setItem(MODEL_KEY, next); } catch { /* ignore */ }
wsSucceeded = true;
} catch {
// WS completely broken — fall through to HTTP
}
}
}
@ -396,10 +474,14 @@ export function useModelEffort(): UseModelEffortReturn {
// Attempt 3: HTTP fallback (reliable path via session_status tool)
if (!wsSucceeded) {
const fallbackModel = selectingInheritedPrimary ? (primaryModelId || model || null) : patchModel;
if (!fallbackModel) {
throw new Error('No primary model is available to apply inherited default model state');
}
const res = await fetch('/api/gateway/session-patch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionKey: currentSession, model: next }),
body: JSON.stringify({ sessionKey: currentSession, model: fallbackModel }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { error?: string };
@ -410,7 +492,10 @@ export function useModelEffort(): UseModelEffortReturn {
// Optimistically update the session object so that
// SessionContext.refreshSessions() doesn't overwrite with stale data
if (currentSession) {
updateSession(currentSession, { model: next });
const optimisticModel = selectingInheritedPrimary
? (primaryModelId || patchModel || model || undefined)
: (patchModel || next);
updateSession(currentSession, { model: optimisticModel });
}
// Schedule a confirmation poll to verify the change took effect
@ -419,13 +504,17 @@ export function useModelEffort(): UseModelEffortReturn {
try {
const info = await fetchGatewaySessionInfo(currentSession || undefined);
if (info?.model) {
const infoBase = baseModelName(info.model);
const confirmed = modelOptionsList.find(m =>
m.id === info.model || m.label === info.model ||
baseModelName(m.id) === infoBase || m.id.endsWith('/' + info.model)
);
if (confirmed) {
setSelectedModel(confirmed.id);
if (modelRefsMatch(info.model, primaryModelId, modelOptionsList)) {
setSelectedModel(INHERITED_MODEL_VALUE);
} else {
const infoBase = baseModelName(info.model);
const confirmed = modelOptionsList.find(m =>
m.id === info.model || m.label === info.model ||
baseModelName(m.id) === infoBase || m.id.endsWith('/' + info.model)
);
if (confirmed) {
setSelectedModel(confirmed.id);
}
}
}
} catch {
@ -442,20 +531,21 @@ export function useModelEffort(): UseModelEffortReturn {
try { localStorage.setItem(MODEL_KEY, prev); } catch { /* ignore */ }
setUiError(`Model: ${errMsg}`);
}
}, [controlsDisabled, selectedModel, rpc, currentSession, updateSession, modelOptionsList]);
}, [controlsDisabled, selectedModel, rpc, currentSession, updateSession, modelOptionsList, primaryModelId, model]);
const handleEffortChange = useCallback(async (next: string) => {
if (controlsDisabled) return;
setUiError(null);
const prev = selectedEffort;
const nextEffort = next as EffortLevel;
const nextEffort = next as EffortSelection;
setSelectedEffort(nextEffort);
effortLockUntilRef.current = Date.now() + OPTIMISTIC_LOCK_MS;
try { localStorage.setItem(getEffortKey(currentSession), nextEffort); } catch { /* ignore */ }
try {
const thinkingValue = nextEffort === 'off' ? null : nextEffort;
const isInheritedDefault = nextEffort === INHERITED_EFFORT_VALUE;
const thinkingValue = isInheritedDefault || nextEffort === 'off' ? null : nextEffort;
try {
await rpc('sessions.patch', { key: currentSession, thinkingLevel: thinkingValue });
} catch (wsErr) {
@ -466,7 +556,9 @@ export function useModelEffort(): UseModelEffortReturn {
await rpc('sessions.patch', { key: currentSession, thinkingLevel: thinkingValue });
}
if (currentSession) {
updateSession(currentSession, { thinkingLevel: nextEffort });
updateSession(currentSession, {
thinkingLevel: isInheritedDefault ? undefined : nextEffort,
});
}
setTimeout(() => { effortLockUntilRef.current = 0; }, CONFIRM_POLL_DELAY_MS);
} catch (err) {
@ -479,14 +571,22 @@ export function useModelEffort(): UseModelEffortReturn {
}
}, [controlsDisabled, selectedEffort, rpc, currentSession, updateSession]);
const modelOptions = useMemo(
() => modelOptionsList.map((m) => ({ value: m.id, label: m.label })),
[modelOptionsList],
);
const modelOptions = useMemo(() => {
const configured = modelOptionsList.map((m) => ({ value: m.id, label: m.label }));
return [{ value: INHERITED_MODEL_VALUE, label: INHERITED_MODEL_VALUE }, ...configured];
}, [modelOptionsList]);
const effortOptions = useMemo(
() => EFFORT_OPTIONS.map((lvl) => ({ value: lvl, label: lvl })),
[],
() => [
{
value: INHERITED_EFFORT_VALUE,
label: inheritedEffortLabel === INHERITED_EFFORT_VALUE
? 'default'
: `${inheritedEffortLabel} (default)`,
},
...EFFORT_OPTIONS.map((lvl) => ({ value: lvl, label: lvl })),
],
[inheritedEffortLabel],
);
return {
@ -494,6 +594,7 @@ export function useModelEffort(): UseModelEffortReturn {
effortOptions,
selectedModel,
selectedEffort,
selectedEffortLabel,
handleModelChange,
handleEffortChange,
controlsDisabled,

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { buildQualityLadder, computeDimensionRungs, computeScaledSize } from './image-compress';
describe('image-compress policy helpers', () => {
it('builds the expected quality ladder without duplicates', () => {
expect(buildQualityLadder(82)).toEqual([82, 74, 66]);
expect(buildQualityLadder(74)).toEqual([74, 66]);
expect(buildQualityLadder(120)).toEqual([100, 74, 66]);
});
it('reduces dimensions by about 15% per rung until the configured minimum', () => {
expect(computeDimensionRungs(2048, 512)).toEqual([2048, 1741, 1480, 1258, 1069, 909, 773, 657, 558, 512]);
});
it('preserves aspect ratio while clamping to a max dimension', () => {
expect(computeScaledSize(4000, 3000, 2048)).toEqual({ width: 2048, height: 1536 });
expect(computeScaledSize(1000, 500, 2048)).toEqual({ width: 1000, height: 500 });
});
});

View file

@ -1,15 +1,14 @@
/**
* image-compress.ts Client-side image compression for chat attachments.
* image-compress.ts Client-side adaptive image compression for chat attachments.
*
* Resizes and compresses images to stay within the WebSocket 512KB payload limit.
* Preserves PNG transparency when detected, falls back to JPEG otherwise.
* Iteratively rescales and recompresses images so inline uploads stay within the
* configured model-context-safe byte budget before falling back to file references.
*/
/** Max width/height in pixels */
const MAX_DIMENSION = 1024;
const JPEG_QUALITY = 0.7;
/** ~350KB base64 budget per image (under 512KB WS limit with overhead) */
const MAX_COMPRESSED_BYTES = 350_000;
function getBase64ByteLength(base64: string): number {
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
return Math.floor((base64.length * 3) / 4) - padding;
}
/** Check whether a canvas has any non-opaque pixels (samples every 4th pixel for speed) */
function hasAlpha(ctx: CanvasRenderingContext2D, w: number, h: number): boolean {
@ -20,80 +19,215 @@ function hasAlpha(ctx: CanvasRenderingContext2D, w: number, h: number): boolean
return false;
}
function clampDimension(value: number): number {
return Math.max(1, Math.round(value));
}
export function buildQualityLadder(baseQualityPercent: number): number[] {
const normalizedBase = Math.max(1, Math.min(100, Math.round(baseQualityPercent)));
return Array.from(new Set([normalizedBase, 74, 66]));
}
export function computeScaledSize(sourceWidth: number, sourceHeight: number, maxDimension: number): { width: number; height: number } {
if (sourceWidth <= maxDimension && sourceHeight <= maxDimension) {
return { width: sourceWidth, height: sourceHeight };
}
const ratio = Math.min(maxDimension / sourceWidth, maxDimension / sourceHeight);
return {
width: clampDimension(sourceWidth * ratio),
height: clampDimension(sourceHeight * ratio),
};
}
export function computeDimensionRungs(startDimension: number, minDimension: number): number[] {
const normalizedStart = clampDimension(startDimension);
const normalizedMin = Math.min(normalizedStart, clampDimension(minDimension));
const rungs: number[] = [normalizedStart];
let current = normalizedStart;
while (current > normalizedMin) {
const next = Math.max(normalizedMin, clampDimension(current * 0.85));
if (next === current) break;
rungs.push(next);
current = next;
}
if (rungs[rungs.length - 1] !== normalizedMin) {
rungs.push(normalizedMin);
}
return rungs;
}
export interface CompressImagePolicy {
contextMaxBytes: number;
contextTargetBytes?: number;
maxDimension: number;
minDimension: number;
webpQuality?: number;
}
export interface CompressionAttempt {
iteration: number;
maxDimension: number;
width: number;
height: number;
quality: number;
mimeType: string;
bytes: number;
metTarget: boolean;
fitWithinLimit: boolean;
}
export interface CompressedImage {
base64: string;
mimeType: string;
preview: string;
width: number;
height: number;
bytes: number;
iterations: number;
attempts: CompressionAttempt[];
targetBytes: number;
maxBytes: number;
minDimension: number;
fallbackReason?: string;
}
/** Compress an image file to JPEG/WebP within size and dimension limits. */
export function compressImage(file: File): Promise<CompressedImage> {
/** Compress an image file to an adaptive inline-safe payload. */
export function compressImage(file: File, policy: CompressImagePolicy): Promise<CompressedImage> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let { width, height } = img;
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
const ratio = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (!sourceWidth || !sourceHeight) {
reject(new Error('Failed to read image dimensions'));
return;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('Canvas not supported'));
ctx.drawImage(img, 0, 0, width, height);
const startDimension = Math.min(
Math.max(1, Math.round(policy.maxDimension)),
Math.max(sourceWidth, sourceHeight),
);
const minDimension = Math.min(
Math.max(1, Math.round(policy.minDimension)),
startDimension,
);
const maxBytes = Math.max(1, Math.round(policy.contextMaxBytes));
const targetBytes = Math.min(
maxBytes,
Math.max(1, Math.round(policy.contextTargetBytes ?? Math.floor(maxBytes * 0.9))),
);
const qualityLadder = buildQualityLadder(policy.webpQuality ?? 82);
const dimensionRungs = computeDimensionRungs(startDimension, minDimension);
const attempts: CompressionAttempt[] = [];
const isPng = file.type === 'image/png' || file.type === 'image/webp';
const useAlpha = isPng && hasAlpha(ctx, width, height);
const mimeType = useAlpha ? 'image/png' : 'image/jpeg';
const quality = JPEG_QUALITY;
const dataUrl = canvas.toDataURL(mimeType, quality);
const base64 = dataUrl.split(',')[1];
if (base64.length > MAX_COMPRESSED_BYTES) {
// Retry at lower quality
const retryMime = useAlpha ? 'image/jpeg' : mimeType;
const retryUrl = canvas.toDataURL(retryMime, 0.4);
const retryBase64 = retryUrl.split(',')[1];
if (retryBase64.length <= MAX_COMPRESSED_BYTES) {
resolve({ base64: retryBase64, mimeType: retryMime, preview: retryUrl });
return;
}
// Still too large: scale dimensions to 50% and try once more
const halfW = Math.round(width / 2);
const halfH = Math.round(height / 2);
const smallCanvas = document.createElement('canvas');
smallCanvas.width = halfW;
smallCanvas.height = halfH;
const smallCtx = smallCanvas.getContext('2d');
if (!smallCtx) return reject(new Error('Canvas not supported'));
smallCtx.drawImage(canvas, 0, 0, halfW, halfH);
const smallUrl = smallCanvas.toDataURL(retryMime, 0.4);
const smallBase64 = smallUrl.split(',')[1];
if (smallBase64.length > MAX_COMPRESSED_BYTES) {
reject(new Error('Image is too large to send. Please use a smaller image.'));
return;
}
resolve({ base64: smallBase64, mimeType: retryMime, preview: smallUrl });
} else {
resolve({ base64, mimeType, preview: dataUrl });
const alphaProbeCanvas = document.createElement('canvas');
alphaProbeCanvas.width = sourceWidth;
alphaProbeCanvas.height = sourceHeight;
const alphaProbeCtx = alphaProbeCanvas.getContext('2d');
if (!alphaProbeCtx) {
reject(new Error('Canvas not supported'));
return;
}
alphaProbeCtx.drawImage(img, 0, 0, sourceWidth, sourceHeight);
const isPngLike = file.type === 'image/png' || file.type === 'image/webp';
const preserveAlpha = isPngLike && hasAlpha(alphaProbeCtx, sourceWidth, sourceHeight);
const mimeType = preserveAlpha ? 'image/png' : 'image/webp';
const encodeQualities = preserveAlpha ? [100] : qualityLadder;
let firstAcceptable: CompressedImage | null = null;
let bestEffort: CompressedImage | null = null;
for (const maxDimension of dimensionRungs) {
const { width, height } = computeScaledSize(sourceWidth, sourceHeight, maxDimension);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas not supported'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
for (const quality of encodeQualities) {
const dataUrl = preserveAlpha
? canvas.toDataURL(mimeType)
: canvas.toDataURL(mimeType, quality / 100);
const base64 = dataUrl.split(',')[1] || '';
const bytes = getBase64ByteLength(base64);
const fitWithinLimit = bytes <= maxBytes;
const metTarget = bytes <= targetBytes;
const iteration = attempts.length + 1;
attempts.push({
iteration,
maxDimension,
width,
height,
quality,
mimeType,
bytes,
metTarget,
fitWithinLimit,
});
const candidate: CompressedImage = {
base64,
mimeType,
preview: dataUrl,
width,
height,
bytes,
iterations: iteration,
attempts: [...attempts],
targetBytes,
maxBytes,
minDimension,
};
bestEffort = candidate;
if (fitWithinLimit && !firstAcceptable) {
firstAcceptable = candidate;
}
if (metTarget) {
resolve(candidate);
return;
}
}
if (firstAcceptable) {
resolve(firstAcceptable);
return;
}
}
if (bestEffort) {
resolve({
...bestEffort,
fallbackReason: `Unable to shrink image inline below ${maxBytes} bytes.`,
});
return;
}
reject(new Error(`Unable to shrink image inline below ${maxBytes} bytes.`));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
};
img.src = url;
});
}

View file

@ -20,6 +20,18 @@ describe('filterMessage', () => {
expect(filterMessage({ role: 'assistant', content: 'Hi there' })).toBe(true);
});
it('hides exact internal assistant control replies', () => {
expect(filterMessage({ role: 'assistant', content: 'NO_REPLY' })).toBe(false);
expect(filterMessage({ role: 'assistant', content: ' HEARTBEAT_OK\n' })).toBe(false);
});
it('hides pure internal wake bundles', () => {
expect(filterMessage({
role: 'user',
content: 'System (untrusted): [2026-04-16 18:27:55 GMT+3] Exec completed (wild-orb, code 0) :: done\n\nAn async command you ran earlier has completed. The result is shown in the system messages above. Handle the result internally. Do not relay it to the user unless explicitly requested.\nCurrent time: Thursday, April 16th, 2026 - 6:29 PM (Europe/Istanbul) / 2026-04-16 15:29 UTC',
})).toBe(false);
});
it('passes through sub-agent completions (tagged)', () => {
expect(filterMessage({
role: 'user',
@ -108,6 +120,20 @@ describe('splitToolCallMessage', () => {
expect(result[0].isVoice).toBe(true);
});
it('extracts upload manifest attachments from user transcript messages', () => {
const msg: ChatMessage = {
role: 'user',
content: 'Please use these files.\n\n<nerve-upload-manifest>{"version":1,"attachments":[{"id":"att-upload","origin":"upload","mode":"inline","name":"small.png","mimeType":"image/png","sizeBytes":120000,"inline":{"encoding":"base64","base64":"","base64Bytes":98000,"compressed":true},"preparation":{"sourceMode":"inline","finalMode":"inline","outcome":"optimized_inline","reason":"Inline image stayed within context-safe budget.","originalMimeType":"image/png","originalSizeBytes":120000},"policy":{"forwardToSubagents":false}},{"id":"att-path","origin":"server_path","mode":"file_reference","name":"capture.mov","mimeType":"video/quicktime","sizeBytes":8000000,"reference":{"kind":"local_path","path":"/workspace/capture.mov","uri":"file:///workspace/capture.mov"},"preparation":{"sourceMode":"file_reference","finalMode":"file_reference","outcome":"file_reference_ready","reason":"Sent as a validated workspace path.","originalMimeType":"video/quicktime","originalSizeBytes":8000000},"policy":{"forwardToSubagents":true}}]}</nerve-upload-manifest>',
};
const result = splitToolCallMessage(msg);
expect(result).toHaveLength(1);
expect(result[0].rawText).toBe('Please use these files.');
expect(result[0].uploadAttachments).toHaveLength(2);
expect(result[0].uploadAttachments?.[0].origin).toBe('upload');
expect(result[0].uploadAttachments?.[1].origin).toBe('server_path');
expect(result[0].uploadAttachments?.[1].reference?.path).toBe('/workspace/capture.mov');
});
it('returns empty array for voice-only messages with no text', () => {
const msg: ChatMessage = { role: 'user', content: '[voice] ' };
const result = splitToolCallMessage(msg);
@ -126,7 +152,7 @@ describe('splitToolCallMessage', () => {
expect(result.some(m => m.isThinking)).toBe(true);
});
it('handles user messages with system events', () => {
it('handles user messages with timestamped system events', () => {
const msg: ChatMessage = {
role: 'user',
content: 'System: [2026-02-17 20:30:23 GMT+1] Agent started\nHello!',
@ -135,6 +161,52 @@ describe('splitToolCallMessage', () => {
expect(result.some(m => m.role === 'event')).toBe(true);
expect(result.some(m => m.role === 'user')).toBe(true);
});
it('handles untrusted system-event prefixes from newer OpenClaw builds', () => {
const msg: ChatMessage = {
role: 'user',
content: 'System (untrusted): [2026-04-16 18:11:51 GMT+3] Exec completed (gentle-l, code 0) :: done',
};
const result = splitToolCallMessage(msg);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('event');
expect(result[0].rawText).toContain('System (untrusted): [2026-04-16 18:11:51 GMT+3] Exec completed');
});
it('strips async exec follow-up instructions from injected system-event bundles', () => {
const msg: ChatMessage = {
role: 'user',
content: 'System (untrusted): [2026-04-16 18:11:51 GMT+3] Exec completed (gentle-l, code 0) :: done\n\nAn async command you ran earlier has completed. The result is shown in the system messages above. Handle the result internally. Do not relay it to the user unless explicitly requested.\nCurrent time: Thursday, April 16th, 2026 - 6:12 PM (Europe/Istanbul) / 2026-04-16 15:12 UTC',
};
const result = splitToolCallMessage(msg);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('event');
expect(result[0].rawText).not.toContain('An async command you ran earlier has completed.');
expect(result[0].rawText).not.toContain('Current time:');
});
it('strips standalone follow-up lines like "Handle the result internally."', () => {
const msg: ChatMessage = {
role: 'user',
content: 'System (untrusted): [2026-04-16 18:11:51 GMT+3] Exec completed (gentle-l, code 0) :: done\nHandle the result internally.\nDo not relay it to the user unless explicitly requested.',
};
const result = splitToolCallMessage(msg);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('event');
expect(result[0].rawText).not.toContain('Handle the result internally.');
});
it('preserves later user text and paragraph breaks after a system event bundle', () => {
const msg: ChatMessage = {
role: 'user',
content: 'System (untrusted): [2026-04-16 18:11:51 GMT+3] Exec completed (gentle-l, code 0) :: done\n\nAn async command you ran earlier has completed.\nCurrent time: Thursday, April 16th, 2026 - 6:12 PM (Europe/Istanbul) / 2026-04-16 15:12 UTC\nHello there\n\nSecond paragraph',
};
const result = splitToolCallMessage(msg);
expect(result).toHaveLength(2);
expect(result[0].role).toBe('event');
expect(result[1].role).toBe('user');
expect(result[1].rawText).toBe('Hello there\n\nSecond paragraph');
});
});
describe('groupToolMessages', () => {
@ -240,9 +312,74 @@ describe('processChatMessages', () => {
expect(sysMsg!.isSystemNotification).toBe(true);
});
it('drops internal wake bundles and silent control replies from rendered history', () => {
const msgs: ChatMessage[] = [
{ role: 'assistant', content: 'Already did it ⚡' },
{
role: 'user',
content: 'System (untrusted): [2026-04-16 18:27:55 GMT+3] Exec completed (wild-orb, code 0) :: done\nSystem (untrusted): [2026-04-16 18:28:54 GMT+3] Exec completed (rapid-sa, code 0) :: https://github.com/daggerhashimoto/openclaw-nerve/pull/278\n\nAn async command you ran earlier has completed. The result is shown in the system messages above. Handle the result internally. Do not relay it to the user unless explicitly requested.\nCurrent time: Thursday, April 16th, 2026 - 6:29 PM (Europe/Istanbul) / 2026-04-16 15:29 UTC',
},
{ role: 'assistant', content: 'NO_REPLY' },
];
const result = processChatMessages(msgs);
expect(result).toHaveLength(1);
expect(result[0].role).toBe('assistant');
expect(result[0].rawText).toBe('Already did it ⚡');
});
it('handles empty input', () => {
expect(processChatMessages([])).toHaveLength(0);
});
it('preserves legacy MediaPath and MediaUrl images as extracted images', () => {
const msgs: ChatMessage[] = [
{
role: 'assistant',
content: 'legacy image payload',
MediaPath: '/root/.openclaw/workspace/legacy/screenshot.png',
MediaUrls: ['https://example.com/generated.png'],
},
];
const result = processChatMessages(msgs);
expect(result).toHaveLength(1);
expect(result[0].extractedImages).toEqual([
{ url: '/api/files?path=%2Froot%2F.openclaw%2Fworkspace%2Flegacy%2Fscreenshot.png', alt: 'screenshot.png' },
{ url: 'https://example.com/generated.png', alt: 'generated.png' },
]);
});
it('does not synthesize omitted transcript image URLs without a persisted timestamp', () => {
const result = processChatMessages([
{
role: 'assistant',
content: [
{ type: 'text', text: 'generated image' },
{ type: 'image', omitted: true, mimeType: 'image/png' },
],
},
], { sessionKey: 'agent:main:main' });
expect(result).toHaveLength(1);
expect(result[0].extractedImages).toBeUndefined();
});
it('deduplicates merged image references while preserving order', () => {
const result = processChatMessages([
{
role: 'assistant',
content: '![generated](https://example.com/generated.png)\n![other](https://example.com/other.png)',
MediaUrls: ['https://example.com/generated.png'],
},
]);
expect(result).toHaveLength(1);
expect(result[0].extractedImages?.map((img) => img.url)).toEqual([
'https://example.com/generated.png',
'https://example.com/other.png',
]);
});
});
describe('loadChatHistory', () => {
@ -281,4 +418,29 @@ describe('loadChatHistory', () => {
const rpc = vi.fn().mockRejectedValue(new Error('network error'));
await expect(loadChatHistory({ rpc, sessionKey: 'sk' })).rejects.toThrow('network error');
});
it('hydrates omitted transcript images through the session media route', async () => {
const rpc = vi.fn().mockResolvedValue({
messages: [
{
role: 'assistant',
timestamp: 1775131617235,
content: [
{ type: 'text', text: 'generated image' },
{ type: 'image', omitted: true, mimeType: 'image/png' },
],
},
],
});
const result = await loadChatHistory({ rpc, sessionKey: 'agent:main:main' });
expect(result).toHaveLength(1);
expect(result[0].extractedImages).toEqual([
{
url: '/api/sessions/media?sessionKey=agent%3Amain%3Amain&timestamp=1775131617235&imageIndex=0',
alt: 'message-1775131617235-image-0.png',
},
]);
});
});

View file

@ -5,7 +5,7 @@
* All functions here are pure (no React hooks, setState, or refs).
*/
import { generateMsgId } from '@/features/chat/types';
import type { ChatMsg, ChatMsgRole, ToolGroupEntry } from '@/features/chat/types';
import type { ChatMsg, ChatMsgRole, ToolGroupEntry, UploadAttachmentDescriptor } from '@/features/chat/types';
import type { ChatMessage, ContentBlock, ChatHistoryResponse } from '@/types';
import { extractText, describeToolUse, renderMarkdown, renderToolResults } from '@/utils/helpers';
import { decodeHtmlEntities } from '@/lib/formatting';
@ -15,6 +15,77 @@ import { extractEditBlocks, extractWriteBlocks } from '@/features/chat/edit-bloc
import { extractImages } from '@/features/chat/extractImages';
import type { MessageImage } from '@/features/chat/types';
function toArray<T>(value: T | T[] | undefined): T[] {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}
function getFilenameFromPathish(value: string, fallback: string): string {
const trimmed = value.trim();
if (!trimmed) return fallback;
const segments = trimmed.split('/').filter(Boolean);
return segments[segments.length - 1] || fallback;
}
function imageExtensionFromMimeType(mimeType?: string): string {
if (!mimeType?.startsWith('image/')) return 'png';
const subtype = mimeType.slice('image/'.length).toLowerCase();
if (subtype === 'jpeg') return 'jpg';
return subtype || 'png';
}
function dedupeExtractedImages(images: Array<{ url: string; alt?: string }>): Array<{ url: string; alt?: string }> {
const seen = new Set<string>();
return images.filter((image) => {
if (seen.has(image.url)) return false;
seen.add(image.url);
return true;
});
}
function extractLegacyMessageImages(
message: ChatMessage,
options: { sessionKey?: string; messageTimestampMs?: number },
): Array<{ url: string; alt?: string }> {
const extracted: Array<{ url: string; alt?: string }> = [];
for (const mediaPath of [...toArray(message.MediaPath), ...toArray(message.MediaPaths)]) {
const trimmedPath = mediaPath.trim();
if (!trimmedPath) continue;
extracted.push({
url: `/api/files?path=${encodeURIComponent(trimmedPath)}`,
alt: getFilenameFromPathish(trimmedPath, 'image'),
});
}
for (const mediaUrl of [...toArray(message.MediaUrl), ...toArray(message.MediaUrls)]) {
const trimmedUrl = mediaUrl.trim();
if (!trimmedUrl) continue;
extracted.push({
url: trimmedUrl,
alt: getFilenameFromPathish(trimmedUrl.split('?')[0] || trimmedUrl, 'image'),
});
}
if (options.sessionKey && Number.isFinite(options.messageTimestampMs) && Array.isArray(message.content)) {
const timestampMs = options.messageTimestampMs as number;
let imageIndex = 0;
for (const block of message.content) {
if (block.type !== 'image') continue;
if (block.omitted) {
const extension = imageExtensionFromMimeType(block.mimeType || block.source?.media_type);
extracted.push({
url: `/api/sessions/media?sessionKey=${encodeURIComponent(options.sessionKey)}&timestamp=${timestampMs}&imageIndex=${imageIndex}`,
alt: `message-${timestampMs}-image-${imageIndex}.${extension}`,
});
}
imageIndex += 1;
}
}
return extracted;
}
/** Convert an image content block (from gateway) into a MessageImage for rendering. */
function imageBlockToMessageImage(block: ContentBlock): MessageImage | null {
// Format 1: { type: "image", data: "base64...", mimeType: "image/jpeg" }
@ -52,6 +123,32 @@ const SYSTEM_NOTIFICATION_PATTERNS = [
/^\[System Message\].*?(?:subagent|task|cron).*?(?:completed|finished|failed)/is,
];
/** Matches timestamped gateway-injected system lines, including untrusted variants. */
const SYSTEM_EVENT_LINE = /^System(?: \(untrusted\))?: \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})? [^\]]*\]/;
/** Internal follow-up lines appended after async exec/cron system events. */
const SYSTEM_EVENT_FOLLOWUP_LINE = /^(?:An async command you ran earlier has completed\.|A scheduled reminder has been triggered\.|A scheduled cron event was triggered(?:, but no event content was found)?\.|Handle this reminder internally\.|Handle this internally\.|Handle the result internally\.?|Do not relay it to the user unless explicitly requested\.|Please relay the command output to the user in a helpful way\.|Please relay this reminder to the user in a helpful and friendly way\.|Current time:)/i;
/** Internal assistant control replies that should never render as chat bubbles. */
const INTERNAL_CONTROL_REPLY_RE = /^(?:NO_REPLY|HEARTBEAT_OK)$/;
function isInternalWakeBundle(text: string): boolean {
let sawSystemEvent = false;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (SYSTEM_EVENT_LINE.test(line)) {
sawSystemEvent = true;
continue;
}
if (SYSTEM_EVENT_FOLLOWUP_LINE.test(line)) continue;
return false;
}
return sawSystemEvent;
}
/** Check if text matches a system notification and extract label. */
export function detectSystemNotification(text: string): { match: boolean; label: string } {
// Extract task/job name from quotes if present
@ -84,6 +181,15 @@ export function detectSystemNotification(text: string): { match: boolean; label:
/** Determine whether a history message should be shown in the chat UI. */
export function filterMessage(m: ChatMessage): boolean {
const text = extractText(m);
const trimmedText = text.trim();
if (m.role === 'assistant' && INTERNAL_CONTROL_REPLY_RE.test(trimmedText)) {
return false;
}
if (m.role === 'user' && isInternalWakeBundle(trimmedText)) {
return false;
}
// System notifications are now rendered as collapsible strips, not hidden.
// They pass through the filter and get tagged during message processing.
@ -91,7 +197,6 @@ export function filterMessage(m: ChatMessage): boolean {
// Hide redundant tool results for Edit/Write operations
// (diff view already shows the changes — only hide exact success patterns)
if (m.role === 'tool' || m.role === 'toolResult') {
const trimmedText = text.trim();
if (/^Successfully replaced text in .+\.$/.test(trimmedText)) return false;
if (/^Successfully wrote \d+ bytes to .+\.$/.test(trimmedText)) return false;
}
@ -112,9 +217,6 @@ export function filterMessage(m: ChatMessage): boolean {
*/
// ─── System event splitting ────────────────────────────────────────────────────
/** Matches "System: [2026-02-17 20:30:23 GMT+1] ..." lines injected by the gateway. */
const SYSTEM_EVENT_LINE = /^System: \[\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})? [^\]]*\]/;
/** Strip the TTS system prompt hint appended to voice messages by sendMessage. */
const TTS_SYSTEM_HINT_RE = /\s*\[system: User sent a voice message\.[\s\S]*$/;
@ -130,6 +232,31 @@ const WEBCHAT_ENVELOPE_RE = /Conversation info \(untrusted metadata\):[\s\S]*?"s
// eslint-disable-next-line no-control-regex
const stripAnsi = (s: string) => s.replace(/\x1b\[\d*(?:;\d+)*m/g, '');
const UPLOAD_MANIFEST_RE = /\s*<nerve-upload-manifest>([\s\S]*?)<\/nerve-upload-manifest>\s*$/;
function extractUploadAttachments(rawText: string): {
cleanedText: string;
uploadAttachments?: UploadAttachmentDescriptor[];
} {
const match = rawText.match(UPLOAD_MANIFEST_RE);
if (!match) return { cleanedText: rawText };
const cleanedText = rawText.replace(UPLOAD_MANIFEST_RE, '').trimEnd();
try {
const parsed = JSON.parse(match[1]) as { attachments?: UploadAttachmentDescriptor[] };
if (!Array.isArray(parsed.attachments) || parsed.attachments.length === 0) {
return { cleanedText };
}
return {
cleanedText,
uploadAttachments: parsed.attachments,
};
} catch {
return { cleanedText: rawText };
}
}
/**
* Split system event lines out of a user message text.
* Consecutive non-system lines are joined back into a single user segment.
@ -137,6 +264,7 @@ const stripAnsi = (s: string) => s.replace(/\x1b\[\d*(?:;\d+)*m/g, '');
function splitSystemEvents(text: string): Array<{ role: 'event' | 'user'; text: string }> {
const segments: Array<{ role: 'event' | 'user'; text: string }> = [];
let userBuffer: string[] = [];
let sawSystemEvent = false;
const flushUser = () => {
const joined = userBuffer.join('\n').trim();
@ -148,7 +276,15 @@ function splitSystemEvents(text: string): Array<{ role: 'event' | 'user'; text:
if (SYSTEM_EVENT_LINE.test(line)) {
flushUser();
segments.push({ role: 'event', text: stripAnsi(line) });
sawSystemEvent = true;
} else {
const trimmed = line.trim();
if (sawSystemEvent) {
if (!trimmed || SYSTEM_EVENT_FOLLOWUP_LINE.test(trimmed)) {
continue;
}
sawSystemEvent = false;
}
userBuffer.push(line);
}
}
@ -156,9 +292,11 @@ function splitSystemEvents(text: string): Array<{ role: 'event' | 'user'; text:
return segments;
}
export function splitToolCallMessage(m: ChatMessage): ChatMsg[] {
export function splitToolCallMessage(m: ChatMessage, options: { sessionKey?: string } = {}): ChatMsg[] {
const ts = m.timestamp || m.createdAt || m.ts || null;
const timestamp = ts ? new Date(ts as string | number) : new Date();
const parsedTimestamp = ts ? new Date(ts as string | number) : null;
const hasPersistedTimestamp = Boolean(parsedTimestamp && Number.isFinite(parsedTimestamp.getTime()));
const timestamp = hasPersistedTimestamp ? parsedTimestamp as Date : new Date();
// Only interleave for assistant messages with array content containing tool_use
if (m.role === 'assistant' && Array.isArray(m.content)) {
@ -264,6 +402,12 @@ export function splitToolCallMessage(m: ChatMessage): ChatMsg[] {
if (!rawText.trim()) return [];
}
const { cleanedText: uploadManifestStripped, uploadAttachments } = m.role === 'user'
? extractUploadAttachments(rawText)
: { cleanedText: rawText, uploadAttachments: undefined };
rawText = uploadManifestStripped;
// Split system events out of user messages into separate event bubbles
if (m.role === 'user' && SYSTEM_EVENT_LINE.test(rawText)) {
const segments = splitSystemEvents(rawText);
@ -279,6 +423,7 @@ export function splitToolCallMessage(m: ChatMessage): ChatMsg[] {
streaming: false,
...(charts.length > 0 ? { charts } : {}),
...(isVoice && seg.role === 'user' ? { isVoice: true } : {}),
...(uploadAttachments && seg.role === 'user' ? { uploadAttachments } : {}),
};
});
}
@ -290,6 +435,11 @@ export function splitToolCallMessage(m: ChatMessage): ChatMsg[] {
const { cleaned: text, images: extractedImages } = isAssistant
? extractImages(chartCleaned)
: { cleaned: chartCleaned, images: [] };
const legacyExtractedImages = extractLegacyMessageImages(m, {
sessionKey: options.sessionKey,
messageTimestampMs: hasPersistedTimestamp ? timestamp.getTime() : undefined,
});
const combinedExtractedImages = dedupeExtractedImages([...extractedImages, ...legacyExtractedImages]);
// Extract image content blocks (base64 images from gateway)
const contentImages = Array.isArray(m.content) ? extractImageBlocks(m.content as ContentBlock[]) : [];
@ -304,8 +454,9 @@ export function splitToolCallMessage(m: ChatMessage): ChatMsg[] {
timestamp,
streaming: false,
...(charts.length > 0 ? { charts } : {}),
...(extractedImages.length > 0 ? { extractedImages } : {}),
...(combinedExtractedImages.length > 0 ? { extractedImages: combinedExtractedImages } : {}),
...(contentImages.length > 0 ? { images: contentImages } : {}),
...(uploadAttachments ? { uploadAttachments } : {}),
...(isVoice ? { isVoice: true } : {}),
...(sysNotif.match ? { isSystemNotification: true, systemLabel: sysNotif.label } : {}),
}];
@ -435,10 +586,10 @@ export function tagIntermediateMessages(msgs: ChatMsg[]): ChatMsg[] {
*
* filter split group tag
*/
export function processChatMessages(messages: ChatMessage[]): ChatMsg[] {
export function processChatMessages(messages: ChatMessage[], options: { sessionKey?: string } = {}): ChatMsg[] {
const chatMsgs: ChatMsg[] = messages
.filter(filterMessage)
.flatMap(splitToolCallMessage);
.flatMap((message) => splitToolCallMessage(message, options));
const grouped = groupToolMessages(chatMsgs);
const tagged = tagIntermediateMessages(grouped);
@ -467,5 +618,5 @@ export async function loadChatHistory(params: {
const res = await rpc('chat.history', { sessionKey, limit }) as ChatHistoryResponse;
const msgs = res?.messages || [];
return processChatMessages(msgs);
return processChatMessages(msgs, { sessionKey });
}

View file

@ -1,6 +1,71 @@
/** Tests for sendMessage — message building and RPC sending. */
import { describe, it, expect, vi } from 'vitest';
import { applyVoiceTTSHint, buildUserMessage, sendChatMessage } from './sendMessage';
import { appendUploadManifest, applyVoiceTTSHint, buildUserMessage, sendChatMessage } from './sendMessage';
import type { OutgoingUploadPayload, UploadAttachmentDescriptor } from '../types';
function makeUploadPayload(overrides: Partial<OutgoingUploadPayload> = {}): OutgoingUploadPayload {
return {
descriptors: [
{
id: 'att-inline',
origin: 'upload',
mode: 'inline',
name: 'small.png',
mimeType: 'image/png',
sizeBytes: 120_000,
inline: {
encoding: 'base64',
base64: 'YmFzZTY0LWJ5dGVz',
base64Bytes: 12,
previewUrl: 'data:image/png;base64,abc',
compressed: true,
},
preparation: {
sourceMode: 'inline',
finalMode: 'inline',
outcome: 'optimized_inline',
originalMimeType: 'image/png',
originalSizeBytes: 120_000,
inlineBase64Bytes: 12,
inlineChosenWidth: 1024,
inlineChosenHeight: 768,
},
policy: {
forwardToSubagents: false,
},
},
{
id: 'att-ref',
origin: 'server_path',
mode: 'file_reference',
name: 'capture.mov',
mimeType: 'video/quicktime',
sizeBytes: 8_000_000,
reference: {
kind: 'local_path',
path: '/workspace/capture.mov',
uri: 'file:///workspace/capture.mov',
},
policy: {
forwardToSubagents: false,
},
},
],
manifest: {
enabled: true,
exposeInlineBase64ToAgent: false,
allowSubagentForwarding: false,
},
...overrides,
};
}
function extractManifestAttachments(message: string): UploadAttachmentDescriptor[] {
const manifestMatch = message.match(/<nerve-upload-manifest>(.*?)<\/nerve-upload-manifest>/);
expect(manifestMatch?.[1]).toBeTruthy();
const manifest = JSON.parse(manifestMatch![1]) as { attachments: UploadAttachmentDescriptor[] };
return manifest.attachments;
}
describe('applyVoiceTTSHint', () => {
it('appends TTS hint to voice messages', () => {
@ -22,6 +87,59 @@ describe('applyVoiceTTSHint', () => {
});
});
describe('appendUploadManifest', () => {
it('injects the manifest wrapper when enabled', () => {
const message = appendUploadManifest('hello', makeUploadPayload());
expect(message).toContain('<nerve-upload-manifest>');
expect(message).toContain('</nerve-upload-manifest>');
expect(message).toContain('capture.mov');
});
it('hides inline base64 and strips preview data URLs by default while preserving metadata', () => {
const message = appendUploadManifest('hello', makeUploadPayload());
const attachments = extractManifestAttachments(message);
const inlineAttachment = attachments[0];
expect(message).not.toContain('data:image/');
expect(inlineAttachment.inline?.base64).toBe('');
expect(inlineAttachment.inline?.previewUrl).toBeUndefined();
expect(inlineAttachment.inline?.base64Bytes).toBe(12);
expect(inlineAttachment.inline?.compressed).toBe(true);
expect(inlineAttachment.origin).toBe('upload');
expect(inlineAttachment.preparation?.outcome).toBe('optimized_inline');
expect(inlineAttachment.preparation?.inlineChosenWidth).toBe(1024);
expect(inlineAttachment.preparation?.inlineChosenHeight).toBe(768);
});
it('includes inline base64 in explicit debug mode but still strips preview URLs', () => {
const message = appendUploadManifest('hello', makeUploadPayload({
manifest: {
enabled: true,
exposeInlineBase64ToAgent: true,
allowSubagentForwarding: false,
},
}));
const attachments = extractManifestAttachments(message);
const inlineAttachment = attachments[0];
expect(inlineAttachment.inline?.base64).toBe('YmFzZTY0LWJ5dGVz');
expect(inlineAttachment.inline?.previewUrl).toBeUndefined();
expect(message).not.toContain('data:image/');
});
it('keeps message unchanged when manifest is disabled', () => {
const message = appendUploadManifest('hello', makeUploadPayload({
manifest: {
enabled: false,
exposeInlineBase64ToAgent: false,
allowSubagentForwarding: false,
},
}));
expect(message).toBe('hello');
});
});
describe('buildUserMessage', () => {
it('creates a message with the correct role and text', () => {
const { msg, tempId } = buildUserMessage({ text: 'Hello world' });
@ -59,6 +177,14 @@ describe('buildUserMessage', () => {
expect(msg.images![0].name).toBe('test.png');
});
it('stores upload descriptors for local rendering', () => {
const uploadPayload = makeUploadPayload();
const { msg } = buildUserMessage({ text: 'with upload', uploadPayload });
expect(msg.uploadAttachments).toHaveLength(2);
expect(msg.uploadAttachments?.[1].mode).toBe('file_reference');
expect(msg.uploadAttachments?.[1].origin).toBe('server_path');
});
it('omits images field when none provided', () => {
const { msg } = buildUserMessage({ text: 'no images' });
expect(msg.images).toBeUndefined();
@ -111,6 +237,27 @@ describe('sendChatMessage', () => {
expect(callParams.attachments[0].content).toBe('b64');
});
it('injects sanitized upload manifest data into outgoing message body', async () => {
const rpc = vi.fn().mockResolvedValue({});
await sendChatMessage({
rpc,
sessionKey: 's1',
text: 'with attachment metadata',
uploadPayload: makeUploadPayload(),
idempotencyKey: 'k1',
});
const sentMessage = rpc.mock.calls[0][1].message as string;
const attachments = extractManifestAttachments(sentMessage);
expect(sentMessage).toContain('<nerve-upload-manifest>');
expect(sentMessage).toContain('capture.mov');
expect(attachments[0].inline?.base64).toBe('');
expect(attachments[0].inline?.previewUrl).toBeUndefined();
expect(attachments[0].inline?.base64Bytes).toBe(12);
expect(attachments[1].origin).toBe('server_path');
});
it('applies voice TTS hint to voice messages', async () => {
const rpc = vi.fn().mockResolvedValue({});
await sendChatMessage({

View file

@ -4,12 +4,14 @@
* Extracted from ChatContext.handleSend. No React hooks, setState, or refs.
*/
import { generateMsgId } from '@/features/chat/types';
import type { ChatMsg, ImageAttachment } from '@/features/chat/types';
import type { ChatMsg, ImageAttachment, OutgoingUploadPayload, UploadAttachmentDescriptor } from '@/features/chat/types';
import { renderMarkdown, renderToolResults } from '@/utils/helpers';
// ─── Voice → TTS prompt hint ───────────────────────────────────────────────────
const VOICE_PREFIX = '[voice] ';
const TTS_HINT = '\n\n[system: User sent a voice message. Always include your full text reply AND a [tts:...] marker so it plays back as audio. Never send only TTS markers — the response must be readable in chat too. TTS marker format: [tts: your spoken text here] — place it at the end of your reply. Example reply:\n\nHere is my text response.\n\n[tts: Here is my text response.]]';
const UPLOAD_MANIFEST_OPEN = '<nerve-upload-manifest>';
const UPLOAD_MANIFEST_CLOSE = '</nerve-upload-manifest>';
/** Detect voice messages and append a TTS prompt hint for the agent. */
export function applyVoiceTTSHint(text: string): string {
@ -17,6 +19,43 @@ export function applyVoiceTTSHint(text: string): string {
return text + TTS_HINT;
}
function sanitizeUploadDescriptor(
descriptor: UploadAttachmentDescriptor,
exposeInlineBase64ToAgent: boolean,
): UploadAttachmentDescriptor {
if (descriptor.mode !== 'inline' || !descriptor.inline) {
return descriptor;
}
const inline = {
...descriptor.inline,
previewUrl: undefined,
base64: exposeInlineBase64ToAgent ? descriptor.inline.base64 : '',
};
return {
...descriptor,
inline,
};
}
export function appendUploadManifest(
text: string,
uploadPayload?: OutgoingUploadPayload,
): string {
if (!uploadPayload?.manifest.enabled) return text;
if (uploadPayload.descriptors.length === 0) return text;
const manifest = {
version: 1,
attachments: uploadPayload.descriptors.map((descriptor) =>
sanitizeUploadDescriptor(descriptor, uploadPayload.manifest.exposeInlineBase64ToAgent),
),
};
return `${text}\n\n${UPLOAD_MANIFEST_OPEN}${JSON.stringify(manifest)}${UPLOAD_MANIFEST_CLOSE}`;
}
// ─── RPC type alias ────────────────────────────────────────────────────────────
type RpcFn = (method: string, params: Record<string, unknown>) => Promise<unknown>;
@ -36,8 +75,9 @@ export interface ChatSendAck {
export function buildUserMessage(params: {
text: string;
images?: ImageAttachment[];
uploadPayload?: OutgoingUploadPayload;
}): { msg: ChatMsg; tempId: string } {
const { text, images } = params;
const { text, images, uploadPayload } = params;
const tempId = crypto.randomUUID ? crypto.randomUUID() : 'temp-' + Date.now();
const msg: ChatMsg = {
@ -52,6 +92,7 @@ export function buildUserMessage(params: {
preview: i.preview,
name: i.name,
})),
uploadAttachments: uploadPayload?.descriptors,
pending: true,
tempId,
};
@ -69,13 +110,16 @@ export async function sendChatMessage(params: {
sessionKey: string;
text: string;
images?: ImageAttachment[];
uploadPayload?: OutgoingUploadPayload;
idempotencyKey: string;
}): Promise<ChatSendAck> {
const { rpc, sessionKey, text, images, idempotencyKey } = params;
const { rpc, sessionKey, text, images, uploadPayload, idempotencyKey } = params;
const messageWithManifest = appendUploadManifest(text, uploadPayload);
const rpcParams: Record<string, unknown> = {
sessionKey,
message: applyVoiceTTSHint(text),
message: applyVoiceTTSHint(messageWithManifest),
deliver: false,
idempotencyKey,
};

View file

@ -9,6 +9,76 @@ export interface ImageAttachment {
/** Image data as stored on messages (no id needed — not user-removable) */
export type MessageImage = Omit<ImageAttachment, 'id'>;
export type UploadMode = 'inline' | 'file_reference';
export type UploadAttachmentOrigin = 'upload' | 'server_path';
export interface UploadAttachmentPolicy {
forwardToSubagents: boolean;
}
export interface InlineUploadReference {
encoding: 'base64';
base64: string;
base64Bytes: number;
previewUrl?: string;
compressed: boolean;
}
export type UploadPreparationOutcome =
| 'inline_ready'
| 'optimized_inline'
| 'file_reference_ready'
| 'downgraded_to_file_reference'
| 'blocked_inline';
export interface UploadPreparationMetadata {
sourceMode: UploadMode;
finalMode: UploadMode;
outcome: UploadPreparationOutcome;
reason?: string;
originalMimeType: string;
originalSizeBytes: number;
inlineBase64Bytes?: number;
contextSafetyMaxBytes?: number;
inlineTargetBytes?: number;
inlineChosenWidth?: number;
inlineChosenHeight?: number;
inlineIterations?: number;
inlineMinDimension?: number;
inlineFallbackReason?: string;
localPathAvailable?: boolean;
}
export interface FileUploadReference {
kind: 'local_path';
path: string;
uri: string;
}
export interface UploadAttachmentDescriptor {
id: string;
origin: UploadAttachmentOrigin;
mode: UploadMode;
name: string;
mimeType: string;
sizeBytes: number;
inline?: InlineUploadReference;
reference?: FileUploadReference;
preparation?: UploadPreparationMetadata;
policy: UploadAttachmentPolicy;
}
export interface UploadManifestOptions {
enabled: boolean;
exposeInlineBase64ToAgent: boolean;
allowSubagentForwarding: boolean;
}
export interface OutgoingUploadPayload {
descriptors: UploadAttachmentDescriptor[];
manifest: UploadManifestOptions;
}
export type ChatMsgRole = 'user' | 'assistant' | 'tool' | 'toolResult' | 'system' | 'event';
/** Whether a message should default to collapsed state */
@ -44,6 +114,8 @@ export interface ChatMsg {
streaming?: boolean;
collapsed?: boolean;
images?: MessageImage[];
/** Local attachment metadata for upload mode summaries/debug rendering. */
uploadAttachments?: UploadAttachmentDescriptor[];
/** Optimistic: message is being sent, not yet confirmed */
pending?: boolean;
/** Optimistic: message send failed */

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_UPLOAD_FEATURE_CONFIG,
getDefaultUploadMode,
getInlineAttachmentMaxBytes,
getInlineModeGuardrailError,
isUploadsEnabled,
shouldShowUploadChooser,
type UploadFeatureConfig,
} from './uploadPolicy';
function makeConfig(overrides: Partial<UploadFeatureConfig> = {}): UploadFeatureConfig {
return {
...DEFAULT_UPLOAD_FEATURE_CONFIG,
...overrides,
};
}
function makeFile(name: string, type: string, sizeBytes: number): File {
return new File([new Uint8Array(sizeBytes)], name, { type });
}
describe('uploadPolicy', () => {
it('defaults small images to inline when both modes are enabled', () => {
const config = makeConfig({
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: true,
inlineAttachmentMaxMb: 4,
});
const file = makeFile('small.png', 'image/png', 100_000);
expect(getDefaultUploadMode(file, config)).toBe('inline');
});
it('defaults images to inline and non-images to file_reference when both modes are enabled', () => {
const config = makeConfig({
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: true,
inlineAttachmentMaxMb: 1,
});
const image = makeFile('big.png', 'image/png', 2 * 1024 * 1024);
const pdf = makeFile('notes.pdf', 'application/pdf', 400_000);
expect(getDefaultUploadMode(image, config)).toBe('inline');
expect(getDefaultUploadMode(pdf, config)).toBe('file_reference');
});
it('uses inline mode when only inline uploads are enabled', () => {
const config = makeConfig({ inlineEnabled: true, fileReferenceEnabled: false });
const file = makeFile('clip.mov', 'video/quicktime', 128_000);
expect(getDefaultUploadMode(file, config)).toBe('inline');
});
it('uses file_reference mode when inline mode is disabled', () => {
const config = makeConfig({ inlineEnabled: false, fileReferenceEnabled: true });
const file = makeFile('clip.mov', 'video/quicktime', 128_000);
expect(getDefaultUploadMode(file, config)).toBe('file_reference');
});
it('reports uploads disabled when both modes are off', () => {
const config = makeConfig({ inlineEnabled: false, fileReferenceEnabled: false });
const file = makeFile('small.png', 'image/png', 1_000);
expect(isUploadsEnabled(config)).toBe(false);
expect(getDefaultUploadMode(file, config)).toBeNull();
});
it('returns hard inline guardrail errors for oversized non-image files only', () => {
const config = makeConfig({ inlineAttachmentMaxMb: 1.5 });
const archive = makeFile('archive.zip', 'application/zip', 2 * 1024 * 1024);
const image = makeFile('huge.png', 'image/png', 8 * 1024 * 1024);
expect(getInlineModeGuardrailError(archive, config)).toContain('exceeds inline cap (1.5MB)');
expect(getInlineModeGuardrailError(image, config)).toBeNull();
});
it('shows chooser only when all chooser gates are true', () => {
const on = makeConfig({
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: true,
});
const off = makeConfig({
twoModeEnabled: true,
inlineEnabled: true,
fileReferenceEnabled: true,
modeChooserEnabled: false,
});
expect(shouldShowUploadChooser(on)).toBe(true);
expect(shouldShowUploadChooser(off)).toBe(false);
});
it('converts inline MB caps to bytes', () => {
const config = makeConfig({ inlineAttachmentMaxMb: 2 });
expect(getInlineAttachmentMaxBytes(config)).toBe(2 * 1024 * 1024);
});
});

View file

@ -0,0 +1,74 @@
export type UploadMode = 'inline' | 'file_reference';
export interface UploadFeatureConfig {
twoModeEnabled: boolean;
inlineEnabled: boolean;
fileReferenceEnabled: boolean;
modeChooserEnabled: boolean;
inlineAttachmentMaxMb: number;
inlineImageContextMaxBytes: number;
inlineImageAutoDowngradeToFileReference: boolean;
inlineImageShrinkMinDimension: number;
inlineImageMaxDimension: number;
inlineImageWebpQuality: number;
exposeInlineBase64ToAgent: boolean;
}
export const DEFAULT_UPLOAD_FEATURE_CONFIG: UploadFeatureConfig = {
twoModeEnabled: false,
inlineEnabled: true,
fileReferenceEnabled: false,
modeChooserEnabled: false,
inlineAttachmentMaxMb: 4,
inlineImageContextMaxBytes: 32_768,
inlineImageAutoDowngradeToFileReference: true,
inlineImageShrinkMinDimension: 512,
inlineImageMaxDimension: 2048,
inlineImageWebpQuality: 82,
exposeInlineBase64ToAgent: false,
};
export function getInlineAttachmentMaxBytes(config: UploadFeatureConfig): number {
const mb = Number.isFinite(config.inlineAttachmentMaxMb) && config.inlineAttachmentMaxMb > 0
? config.inlineAttachmentMaxMb
: DEFAULT_UPLOAD_FEATURE_CONFIG.inlineAttachmentMaxMb;
return Math.round(mb * 1024 * 1024);
}
export function shouldShowUploadChooser(config: UploadFeatureConfig): boolean {
return Boolean(
config.twoModeEnabled
&& config.inlineEnabled
&& config.fileReferenceEnabled
&& config.modeChooserEnabled,
);
}
export function isUploadsEnabled(config: UploadFeatureConfig): boolean {
return config.inlineEnabled || config.fileReferenceEnabled;
}
export function getDefaultUploadMode(file: File, config: UploadFeatureConfig): UploadMode | null {
if (!isUploadsEnabled(config)) return null;
if (config.inlineEnabled && config.fileReferenceEnabled) {
if (file.type.startsWith('image/')) return 'inline';
return 'file_reference';
}
if (config.inlineEnabled) return 'inline';
return 'file_reference';
}
export function getInlineModeGuardrailError(file: File, config: UploadFeatureConfig): string | null {
if (file.type.startsWith('image/')) return null;
const maxBytes = getInlineAttachmentMaxBytes(config);
if (file.size <= maxBytes) return null;
const maxMbLabel = Number.isInteger(config.inlineAttachmentMaxMb)
? String(config.inlineAttachmentMaxMb)
: config.inlineAttachmentMaxMb.toFixed(1);
return `"${file.name}" exceeds inline cap (${maxMbLabel}MB).`;
}

View file

@ -23,6 +23,7 @@ export interface CommandActions {
onRefreshSessions: () => void;
onRefreshMemory: () => void;
onSetViewMode?: (mode: ViewMode) => void;
canShowKanban?: boolean;
}
const THEME_LABELS: Record<ThemeName, string> = {
@ -190,7 +191,7 @@ export function createCommands(actions: CommandActions): Command[] {
keywords: ['wake', 'voice', 'microphone', 'hey'],
},
// Kanban commands
...(actions.onSetViewMode ? [
...(actions.onSetViewMode && actions.canShowKanban !== false ? [
{
id: 'open-kanban',
label: 'Open Tasks View',

View file

@ -1,21 +1,24 @@
import { EditorTab } from './EditorTab';
import type { OpenFile } from './types';
import type { OpenBeadTab } from '@/features/beads';
interface EditorTabBarProps {
activeTab: string;
openFiles: OpenFile[];
openBeads?: OpenBeadTab[];
onSelectTab: (id: string) => void;
onCloseTab: (path: string) => void;
onCloseTab: (id: string) => void;
}
export function EditorTabBar({
activeTab,
openFiles,
openBeads = [],
onSelectTab,
onCloseTab,
}: EditorTabBarProps) {
// Don't render tab bar if no files are open (chat-only mode)
if (openFiles.length === 0) return null;
// Don't render tab bar if no file/bead tabs are open (chat-only mode)
if (openFiles.length === 0 && openBeads.length === 0) return null;
return (
<div
@ -47,6 +50,20 @@ export function EditorTabBar({
onMiddleClick={() => onCloseTab(file.path)}
/>
))}
{/* Bead viewer tabs */}
{openBeads.map((bead) => (
<EditorTab
key={bead.id}
id={bead.id}
label={bead.name}
active={activeTab === bead.id}
tooltip={bead.beadId}
onSelect={() => onSelectTab(bead.id)}
onClose={() => onCloseTab(bead.id)}
onMiddleClick={() => onCloseTab(bead.id)}
/>
))}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { FileIcon, FolderIcon } from './utils/fileIcons';
import { isImageFile } from './utils/fileTypes';
import { isImageFile, isPdfFile } from './utils/fileTypes';
import type { TreeEntry } from './types';
interface FileTreeNodeProps {
@ -67,7 +67,7 @@ export function FileTreeNode({
}
};
const canOpen = !isDir && (!entry.binary || isImageFile(entry.name));
const canOpen = !isDir && (!entry.binary || isImageFile(entry.name) || isPdfFile(entry.name));
const handleDoubleClick = () => {
if (canOpen && !isRenaming) {

View file

@ -9,6 +9,13 @@ vi.mock('./hooks/useFileTree', () => ({
useFileTree: vi.fn(),
}));
// Mock settings context
vi.mock('@/contexts/SettingsContext', () => ({
useSettings: () => ({
showHiddenWorkspaceEntries: false,
}),
}));
// Mock the ConfirmDialog component
vi.mock('../../components/ConfirmDialog', () => ({
ConfirmDialog: ({ open, title, message, onConfirm, onCancel }: {
@ -53,6 +60,7 @@ function createDeferred<T>() {
}
const mockOnOpenFile = vi.fn();
const mockOnAddToChat = vi.fn();
const mockOnRemapOpenPaths = vi.fn();
const mockOnCloseOpenPaths = vi.fn();
@ -193,6 +201,109 @@ describe('FileTreePanel', () => {
});
});
describe('context menu add to chat', () => {
it('shows "Add to chat" for files when file references are enabled, and calls the callback with the workspace agent', async () => {
render(
<FileTreePanel
workspaceAgentId="agent-a"
onOpenFile={mockOnOpenFile}
onAddToChat={mockOnAddToChat}
addToChatEnabled={true}
onRemapOpenPaths={mockOnRemapOpenPaths}
onCloseOpenPaths={mockOnCloseOpenPaths}
collapsed={false}
onCollapseChange={vi.fn()}
/>
);
fireEvent.contextMenu(screen.getByText('package.json'), new MouseEvent('contextmenu', { bubbles: true }));
await waitFor(() => {
expect(screen.getByText('Add to chat')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to chat'));
await waitFor(() => {
expect(mockOnAddToChat).toHaveBeenCalledWith('package.json', 'file', 'agent-a');
});
});
it('shows an error toast when file add to chat fails', async () => {
mockOnAddToChat.mockRejectedValueOnce(new Error('Failed to add file to chat'));
vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<FileTreePanel
workspaceAgentId="agent-a"
onOpenFile={mockOnOpenFile}
onAddToChat={mockOnAddToChat}
addToChatEnabled={true}
onRemapOpenPaths={mockOnRemapOpenPaths}
onCloseOpenPaths={mockOnCloseOpenPaths}
collapsed={false}
onCollapseChange={vi.fn()}
/>
);
fireEvent.contextMenu(screen.getByText('package.json'), new MouseEvent('contextmenu', { bubbles: true }));
const addToChatButton = await screen.findByText('Add to chat');
fireEvent.click(addToChatButton);
expect(await screen.findByText('Failed to add file to chat')).toBeInTheDocument();
expect(mockOnAddToChat).toHaveBeenCalledWith('package.json', 'file', 'agent-a');
});
it('shows "Add to chat" for directories even when file references are disabled, and calls the callback with directory kind', async () => {
render(
<FileTreePanel
workspaceAgentId="agent-a"
onOpenFile={mockOnOpenFile}
onAddToChat={mockOnAddToChat}
addToChatEnabled={false}
onRemapOpenPaths={mockOnRemapOpenPaths}
onCloseOpenPaths={mockOnCloseOpenPaths}
collapsed={false}
onCollapseChange={vi.fn()}
/>
);
fireEvent.contextMenu(screen.getByText('src'), new MouseEvent('contextmenu', { bubbles: true }));
await waitFor(() => {
expect(screen.getByText('Add to chat')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Add to chat'));
await waitFor(() => {
expect(mockOnAddToChat).toHaveBeenCalledWith('src', 'directory', 'agent-a');
});
});
it('does not show "Add to chat" when workspace path attachments are disabled', async () => {
render(
<FileTreePanel
workspaceAgentId="agent-a"
onOpenFile={mockOnOpenFile}
onAddToChat={mockOnAddToChat}
addToChatEnabled={false}
onRemapOpenPaths={mockOnRemapOpenPaths}
onCloseOpenPaths={mockOnCloseOpenPaths}
collapsed={false}
onCollapseChange={vi.fn()}
/>
);
fireEvent.contextMenu(screen.getByText('package.json'), new MouseEvent('contextmenu', { bubbles: true }));
await waitFor(() => {
expect(screen.queryByText('Add to chat')).not.toBeInTheDocument();
});
});
});
describe('context menu for deletion', () => {
it('shows "Move to Trash" for default workspace', async () => {
render(

View file

@ -6,10 +6,11 @@
*/
import { useRef, useState, useCallback, useEffect } from 'react';
import { PanelLeftClose, RefreshCw, Pencil, Trash2, RotateCcw, X } from 'lucide-react';
import { PanelLeftClose, RefreshCw, Pencil, Trash2, RotateCcw, X, Paperclip } from 'lucide-react';
import { FileTreeNode } from './FileTreeNode';
import { useFileTree } from './hooks/useFileTree';
import { ConfirmDialog } from '../../components/ConfirmDialog';
import { useSettings } from '@/contexts/SettingsContext';
import type { TreeEntry } from './types';
const MIN_WIDTH = 160;
@ -58,6 +59,8 @@ export interface FileTreeChangeEvent {
interface FileTreePanelProps {
workspaceAgentId: string;
onOpenFile: (path: string) => void;
onAddToChat?: (path: string, kind: 'file' | 'directory', agentId?: string) => Promise<void> | void;
addToChatEnabled?: boolean;
onRemapOpenPaths?: (fromPath: string, toPath: string, targetAgentId?: string) => void;
onCloseOpenPaths?: (pathPrefix: string, targetAgentId?: string) => void;
/** Called externally when a file changes (SSE) — refreshes affected directory. */
@ -99,6 +102,8 @@ function isSameScopedSession<T extends ScopedSessionState>(current: T | null, ta
export function FileTreePanel({
workspaceAgentId = 'main',
onOpenFile,
onAddToChat,
addToChatEnabled = false,
onRemapOpenPaths,
onCloseOpenPaths,
lastChangedEvent,
@ -107,10 +112,11 @@ export function FileTreePanel({
onCollapseChange,
collapsed,
}: FileTreePanelProps) {
const { showHiddenWorkspaceEntries } = useSettings();
const {
entries, loading, error, expandedPaths, selectedPath,
loadingPaths, workspaceInfo, toggleDirectory, selectFile, refresh, handleFileChange, revealPath,
} = useFileTree(workspaceAgentId);
} = useFileTree(workspaceAgentId, showHiddenWorkspaceEntries);
// React to external file changes. Sequence keeps repeated same-path events distinct,
// and agentId prevents a stale event from one workspace from replaying in another.
@ -667,6 +673,16 @@ export function FileTreePanel({
const menuPath = menuEntry?.path || '';
const menuInTrash = isTrashItemPath(menuPath);
const showRestore = menuInTrash;
const showAddToChat = Boolean(
onAddToChat
&& menuEntry
&& !menuPath.startsWith('.trash')
&& menuPath !== '.trash'
&& (
menuEntry.type === 'directory'
|| (menuEntry.type === 'file' && addToChatEnabled)
),
);
const showRename = Boolean(menuEntry && menuPath !== '.trash');
const showTrashAction = Boolean(menuEntry && !menuPath.startsWith('.trash') && menuPath !== '.trash');
@ -791,6 +807,29 @@ export function FileTreePanel({
</button>
)}
{showAddToChat && (
<button
className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-muted/60 flex items-center gap-2"
onClick={() => {
setContextMenu(null);
const itemKind = menuEntry.type === 'directory' ? 'directory' : 'file';
void Promise
.resolve(onAddToChat?.(menuEntry.path, itemKind, workspaceAgentId))
.catch((error: unknown) => {
const fallbackMessage = itemKind === 'directory'
? 'Failed to add directory to chat'
: 'Failed to add file to chat';
const message = error instanceof Error ? error.message : fallbackMessage;
console.error('[FileTreePanel] add-to-chat failed:', error);
showToastForAgent(workspaceAgentId, { type: 'error', message }, 4500);
});
}}
>
<Paperclip size={12} />
Add to chat
</button>
)}
{showRename && (
<button
className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-muted/60 flex items-center gap-2"
@ -811,7 +850,7 @@ export function FileTreePanel({
</button>
)}
{!showRestore && !showRename && !showTrashAction && (
{!showRestore && !showAddToChat && !showRename && !showTrashAction && (
<div className="px-3 py-1.5 text-xs text-muted-foreground">
No actions
</div>

View file

@ -33,6 +33,7 @@ export function ImageViewer({ file, agentId }: ImageViewerProps) {
return (
<div className="h-full flex items-center justify-center p-6 overflow-auto bg-[#0a0a0a]">
<img
key={`image-${file.path}-v${file.viewerVersion ?? 0}`}
src={`/api/files/raw?path=${encodeURIComponent(file.path)}&agentId=${encodeURIComponent(agentId)}`}
alt={file.name}
className="max-w-full max-h-full object-contain rounded"

View file

@ -0,0 +1,150 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MarkdownDocumentView } from './MarkdownDocumentView';
import type { OpenFile } from './types';
const markdownRendererSpy = vi.fn();
vi.mock('@/features/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({
content,
className,
currentDocumentPath,
onOpenBeadId,
onOpenWorkspacePath,
workspaceAgentId,
}: {
content: string;
className?: string;
currentDocumentPath?: string;
onOpenBeadId?: (target: { beadId: string }) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void;
workspaceAgentId?: string;
}) => {
markdownRendererSpy({ content, className, currentDocumentPath, onOpenBeadId, onOpenWorkspacePath, workspaceAgentId });
return <div data-testid="markdown-renderer" className={className}>{content}</div>;
},
}));
vi.mock('./FileEditor', () => ({
FileEditor: () => <div data-testid="file-editor" />,
}));
const file: OpenFile = {
path: 'docs/guide.md',
name: 'guide.md',
content: '# Guide',
savedContent: '# Guide',
dirty: false,
locked: false,
mtime: 0,
loading: false,
};
describe('MarkdownDocumentView', () => {
beforeEach(() => {
markdownRendererSpy.mockClear();
});
it('renders preview mode with light full-width gutters and no nested card', () => {
render(
<MarkdownDocumentView
file={file}
onContentChange={vi.fn()}
onSave={vi.fn()}
onRetry={vi.fn()}
/>,
);
const renderer = screen.getByTestId('markdown-renderer');
expect(renderer.closest('article')).toBeNull();
expect(renderer.parentElement).toHaveClass('px-4');
expect(renderer.parentElement).toHaveClass('md:px-6');
});
it('uses a segmented button switcher to toggle between preview and edit', () => {
render(
<MarkdownDocumentView
file={file}
onContentChange={vi.fn()}
onSave={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(screen.queryByRole('tablist', { name: 'Document mode' })).toBeNull();
const previewButton = screen.getByRole('button', { name: 'Preview' });
const editButton = screen.getByRole('button', { name: 'Edit' });
expect(previewButton).toHaveAttribute('aria-pressed', 'true');
expect(editButton).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByTestId('markdown-renderer')).toBeInTheDocument();
fireEvent.click(editButton);
expect(editButton).toHaveAttribute('aria-pressed', 'true');
expect(previewButton).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByTestId('file-editor')).toBeInTheDocument();
});
it('passes bead and workspace handlers through to the markdown renderer while preserving document path fallback', () => {
const onOpenBeadId = vi.fn();
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownDocumentView
file={file}
onContentChange={vi.fn()}
onSave={vi.fn()}
onRetry={vi.fn()}
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={onOpenWorkspacePath}
workspaceAgentId="research"
/>,
);
expect(markdownRendererSpy).toHaveBeenCalled();
const props = markdownRendererSpy.mock.calls.at(-1)?.[0];
expect(props.currentDocumentPath).toBe('docs/guide.md');
expect(props.onOpenBeadId).toBe(onOpenBeadId);
expect(props.workspaceAgentId).toBe('research');
props.onOpenWorkspacePath?.('docs/todo.md');
expect(onOpenWorkspacePath).toHaveBeenCalledWith('docs/todo.md', 'docs/guide.md');
props.onOpenWorkspacePath?.('docs/child.md', 'docs');
expect(onOpenWorkspacePath).toHaveBeenCalledWith('docs/child.md', 'docs');
});
it('shows the loading state in preview mode instead of a blank markdown pane', () => {
render(
<MarkdownDocumentView
file={{ ...file, loading: true, content: '' }}
onContentChange={vi.fn()}
onSave={vi.fn()}
onRetry={vi.fn()}
/>,
);
expect(screen.getByText('Loading guide.md...')).toBeInTheDocument();
expect(screen.queryByTestId('markdown-renderer')).toBeNull();
});
it('shows the error state in preview mode and allows retry', () => {
const onRetry = vi.fn();
render(
<MarkdownDocumentView
file={{ ...file, error: 'boom' }}
onContentChange={vi.fn()}
onSave={vi.fn()}
onRetry={onRetry}
/>,
);
expect(screen.getByText(/Failed to load/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledWith('docs/guide.md');
});
});

View file

@ -0,0 +1,115 @@
import { useState } from 'react';
import { AlertTriangle, Eye, Loader2, PencilLine, RotateCw } from 'lucide-react';
import { MarkdownRenderer } from '@/features/markdown/MarkdownRenderer';
import type { OpenFile } from './types';
import { FileEditor } from './FileEditor';
interface MarkdownDocumentViewProps {
file: OpenFile;
onContentChange: (path: string, content: string) => void;
onSave: (path: string) => void;
onRetry: (path: string) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void | Promise<void>;
onOpenBeadId?: (target: import('@/features/beads').BeadLinkTarget) => void | Promise<void>;
workspaceAgentId?: string;
}
export function MarkdownDocumentView({
file,
onContentChange,
onSave,
onRetry,
onOpenWorkspacePath,
onOpenBeadId,
workspaceAgentId,
}: MarkdownDocumentViewProps) {
const [mode, setMode] = useState<'preview' | 'edit'>('preview');
return (
<div className="h-full flex flex-col min-h-0 bg-background/20">
<div className="flex items-center justify-between gap-3 border-b border-border/60 px-3 py-2 shrink-0 bg-card/55">
<div className="min-w-0">
<div className="text-[0.733rem] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Markdown document
</div>
<div className="truncate text-[0.8rem] text-foreground/90">{file.path}</div>
</div>
<div className="inline-flex items-center rounded-xl border border-border/70 bg-background/55 p-1" role="group" aria-label="Document mode">
<button
type="button"
aria-pressed={mode === 'preview'}
className={`inline-flex min-h-8 items-center gap-2 rounded-[10px] px-3 text-[0.733rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
mode === 'preview'
? 'bg-card text-foreground shadow-[0_10px_30px_rgba(0,0,0,0.12)]'
: 'text-muted-foreground hover:text-foreground'
}`}
data-active={mode === 'preview'}
onClick={() => setMode('preview')}
>
<Eye size={14} />
Preview
</button>
<button
type="button"
aria-pressed={mode === 'edit'}
className={`inline-flex min-h-8 items-center gap-2 rounded-[10px] px-3 text-[0.733rem] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
mode === 'edit'
? 'bg-card text-foreground shadow-[0_10px_30px_rgba(0,0,0,0.12)]'
: 'text-muted-foreground hover:text-foreground'
}`}
data-active={mode === 'edit'}
onClick={() => setMode('edit')}
>
<PencilLine size={14} />
Edit
</button>
</div>
</div>
{mode === 'preview' ? (
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 md:px-6">
{file.loading ? (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="animate-spin" size={14} />
Loading {file.name}...
</div>
) : file.error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
<AlertTriangle size={24} className="text-destructive" />
<div className="text-sm">
Failed to load <span className="font-mono text-foreground">{file.name}</span>
</div>
<div className="text-xs">{file.error}</div>
<button
type="button"
onClick={() => onRetry(file.path)}
className="mt-1 flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<RotateCw size={12} />
Retry
</button>
</div>
) : (
<MarkdownRenderer
content={file.content}
className="markdown-document-content"
currentDocumentPath={file.path}
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={(targetPath, basePath) => onOpenWorkspacePath?.(targetPath, basePath ?? file.path)}
workspaceAgentId={workspaceAgentId}
/>
)}
</div>
) : (
<div className="flex-1 min-h-0">
<FileEditor
file={file}
onContentChange={onContentChange}
onSave={onSave}
onRetry={onRetry}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,45 @@
/**
* PdfViewer Renders PDF files using the browser's built-in PDF viewer via iframe.
*/
import { Loader2, AlertTriangle } from 'lucide-react';
import type { OpenFile } from './types';
interface PdfViewerProps {
file: OpenFile;
agentId: string;
}
export function PdfViewer({ file, agentId }: PdfViewerProps) {
if (file.loading) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs gap-2">
<Loader2 className="animate-spin" size={14} />
Loading {file.name}...
</div>
);
}
if (file.error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<AlertTriangle size={24} className="text-destructive" />
<div className="text-sm">Failed to load PDF</div>
<div className="text-xs">{file.error}</div>
</div>
);
}
const src = `/api/files/raw?path=${encodeURIComponent(file.path)}&agentId=${encodeURIComponent(agentId)}`;
return (
<div className="h-full w-full bg-[#0a0a0a]">
<iframe
key={`pdf-${file.path}-v${file.viewerVersion ?? 0}`}
src={src}
title={file.name}
className="w-full h-full border-0"
/>
</div>
);
}

View file

@ -0,0 +1,116 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TabbedContentArea } from './TabbedContentArea';
import type { OpenFile } from './types';
const markdownDocumentViewSpy = vi.fn();
const beadViewerTabSpy = vi.fn();
vi.mock('./MarkdownDocumentView', () => ({
MarkdownDocumentView: (props: {
file: OpenFile;
onOpenBeadId?: (target: { beadId: string }) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void;
workspaceAgentId?: string;
}) => {
markdownDocumentViewSpy(props);
return <div data-testid="markdown-document-view">{props.file.path}</div>;
},
}));
vi.mock('./ImageViewer', () => ({
ImageViewer: () => <div data-testid="image-viewer" />,
}));
vi.mock('./FileEditor', () => ({
default: () => <div data-testid="file-editor" />,
}));
vi.mock('@/features/beads', () => ({
BeadViewerTab: (props: {
beadTarget: { beadId: string; workspaceAgentId?: string };
onOpenBeadId?: (target: { beadId: string }) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void;
pathLinkPrefixes?: string[];
}) => {
beadViewerTabSpy(props);
return <div data-testid="bead-viewer-tab">{props.beadTarget.beadId}</div>;
},
}));
const file: OpenFile = {
path: 'docs/guide.md',
name: 'guide.md',
content: '# Guide',
savedContent: '# Guide',
dirty: false,
locked: false,
mtime: 0,
loading: false,
};
describe('TabbedContentArea', () => {
it('passes the bead-open handler into markdown document preview tabs', () => {
const onOpenBeadId = vi.fn();
const onOpenWorkspacePath = vi.fn();
render(
<TabbedContentArea
activeTab="docs/guide.md"
openFiles={[file]}
openBeads={[]}
workspaceAgentId="agent-1"
onSelectTab={vi.fn()}
onCloseTab={vi.fn()}
onContentChange={vi.fn()}
onSaveFile={vi.fn()}
onRetryFile={vi.fn()}
onOpenWorkspacePath={onOpenWorkspacePath}
onOpenBeadId={onOpenBeadId}
chatPanel={<div>chat</div>}
/>,
);
expect(markdownDocumentViewSpy).toHaveBeenCalled();
const props = markdownDocumentViewSpy.mock.calls.at(-1)?.[0];
expect(props.file.path).toBe('docs/guide.md');
expect(props.onOpenBeadId).toBe(onOpenBeadId);
expect(props.onOpenWorkspacePath).toBe(onOpenWorkspacePath);
expect(props.workspaceAgentId).toBe('agent-1');
});
it('passes chat path link prefixes into bead viewer tabs', () => {
const onOpenBeadId = vi.fn();
const onOpenWorkspacePath = vi.fn();
render(
<TabbedContentArea
activeTab="bead:nerve-4gpd"
openFiles={[]}
openBeads={[{ id: 'bead:nerve-4gpd', beadId: 'nerve-4gpd', name: 'nerve-4gpd', workspaceAgentId: 'agent-1' }]}
workspaceAgentId="agent-1"
onSelectTab={vi.fn()}
onCloseTab={vi.fn()}
onContentChange={vi.fn()}
onSaveFile={vi.fn()}
onRetryFile={vi.fn()}
onOpenWorkspacePath={onOpenWorkspacePath}
onOpenBeadId={onOpenBeadId}
pathLinkPrefixes={['/workspace/', '~/workspace/']}
chatPanel={<div>chat</div>}
/>,
);
expect(beadViewerTabSpy).toHaveBeenCalled();
const props = beadViewerTabSpy.mock.calls.at(-1)?.[0];
expect(props.beadTarget).toEqual({
beadId: 'nerve-4gpd',
explicitTargetPath: undefined,
currentDocumentPath: undefined,
workspaceAgentId: 'agent-1',
});
expect(props.onOpenBeadId).toBe(onOpenBeadId);
expect(props.onOpenWorkspacePath).toBe(onOpenWorkspacePath);
expect(props.pathLinkPrefixes).toEqual(['/workspace/', '~/workspace/']);
});
});

View file

@ -10,8 +10,11 @@ import { type ReactNode, lazy, Suspense } from 'react';
import { Loader2, AlertTriangle, X } from 'lucide-react';
import { EditorTabBar } from './EditorTabBar';
import { ImageViewer } from './ImageViewer';
import { isImageFile } from './utils/fileTypes';
import { MarkdownDocumentView } from './MarkdownDocumentView';
import { PdfViewer } from './PdfViewer';
import { isImageFile, isMarkdownFile, isPdfFile } from './utils/fileTypes';
import type { OpenFile } from './types';
import { BeadViewerTab, type BeadLinkTarget, type OpenBeadTab } from '@/features/beads';
// Lazy-load CodeMirror editor — keeps it out of the initial bundle
const FileEditor = lazy(() => import('./FileEditor'));
@ -34,13 +37,17 @@ interface SaveToast {
interface TabbedContentAreaProps {
activeTab: string;
openFiles: OpenFile[];
openBeads?: OpenBeadTab[];
workspaceAgentId: string;
onSelectTab: (id: string) => void;
onCloseTab: (path: string) => void;
onCloseTab: (id: string) => void;
onContentChange: (path: string, content: string) => void;
onSaveFile: (path: string) => void;
onRetryFile: (path: string) => void;
onReloadFile?: (path: string) => void;
onOpenWorkspacePath?: (path: string, basePath?: string) => void | Promise<void>;
onOpenBeadId?: (target: BeadLinkTarget) => void;
pathLinkPrefixes?: string[];
saveToast?: SaveToast | null;
onDismissToast?: () => void;
/** The chat panel rendered as-is (never unmounted). */
@ -50,6 +57,7 @@ interface TabbedContentAreaProps {
export function TabbedContentArea({
activeTab,
openFiles,
openBeads = [],
workspaceAgentId,
onSelectTab,
onCloseTab,
@ -57,11 +65,14 @@ export function TabbedContentArea({
onSaveFile,
onRetryFile,
onReloadFile,
onOpenWorkspacePath,
onOpenBeadId,
pathLinkPrefixes,
saveToast,
onDismissToast,
chatPanel,
}: TabbedContentAreaProps) {
const hasOpenFiles = openFiles.length > 0;
const hasOpenTabs = openFiles.length > 0 || openBeads.length > 0;
const visibleSaveToast = saveToast && (!saveToast.agentId || saveToast.agentId === workspaceAgentId)
? saveToast
: null;
@ -69,10 +80,11 @@ export function TabbedContentArea({
return (
<div className="h-full flex flex-col min-h-0 min-w-0">
{/* Tab bar — only shown when files are open */}
{hasOpenFiles && (
{hasOpenTabs && (
<EditorTabBar
activeTab={activeTab}
openFiles={openFiles}
openBeads={openBeads}
onSelectTab={onSelectTab}
onCloseTab={onCloseTab}
/>
@ -82,7 +94,7 @@ export function TabbedContentArea({
<div className="flex-1 min-h-0 relative">
{/* Chat panel — always mounted, hidden when file tab is active */}
<div
className={activeTab === 'chat' || !hasOpenFiles ? 'h-full' : 'hidden'}
className={activeTab === 'chat' || !hasOpenTabs ? 'h-full' : 'hidden'}
role="tabpanel"
id="tabpanel-chat"
aria-labelledby="tab-chat"
@ -101,6 +113,18 @@ export function TabbedContentArea({
>
{isImageFile(file.name) ? (
<ImageViewer file={file} agentId={workspaceAgentId} />
) : isPdfFile(file.name) ? (
<PdfViewer file={file} agentId={workspaceAgentId} />
) : isMarkdownFile(file.name) ? (
<MarkdownDocumentView
file={file}
onContentChange={onContentChange}
onSave={onSaveFile}
onRetry={onRetryFile}
onOpenWorkspacePath={onOpenWorkspacePath}
onOpenBeadId={onOpenBeadId}
workspaceAgentId={workspaceAgentId}
/>
) : (
<Suspense fallback={<EditorFallback />}>
<FileEditor
@ -114,6 +138,29 @@ export function TabbedContentArea({
</div>
))}
{/* Bead viewer tabs */}
{openBeads.map((bead) => (
<div
key={bead.id}
className={activeTab === bead.id ? 'h-full' : 'hidden'}
role="tabpanel"
id={`tabpanel-${bead.id}`}
aria-labelledby={`tab-${bead.id}`}
>
<BeadViewerTab
beadTarget={{
beadId: bead.beadId,
explicitTargetPath: bead.explicitTargetPath,
currentDocumentPath: bead.currentDocumentPath,
workspaceAgentId: bead.workspaceAgentId,
}}
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={pathLinkPrefixes}
/>
</div>
))}
{/* Save conflict toast */}
{visibleSaveToast && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 px-4 py-2.5 rounded-lg bg-destructive/10 border border-destructive/30 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 duration-200">

View file

@ -419,6 +419,93 @@ describe('useFileTree', () => {
expect(mockLocalStorage.getItem).toHaveBeenCalledWith(getWorkspaceStorageKey('file-tree-expanded', 'main'));
});
it('includes showHidden in tree requests when enabled', async () => {
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
ok: true,
entries: [],
workspaceInfo: { isCustomWorkspace: false, rootPath: '/workspace' },
}),
} as Response);
renderHook(() => useFileTree('main', true));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled();
});
const requestUrl = getRequestUrl(mockFetch.mock.calls[0]![0]);
expect(requestUrl.searchParams.get('showHidden')).toBe('true');
});
it('reloads the tree when hidden entry visibility changes', async () => {
const mockFetch = vi.mocked(fetch);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({
ok: true,
entries: [
{ name: 'src', path: 'src', type: 'directory' as const, children: null },
],
workspaceInfo: { isCustomWorkspace: false, rootPath: '/workspace' },
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
ok: true,
entries: [
{ name: '.plans', path: '.plans', type: 'directory' as const, children: null },
{ name: 'src', path: 'src', type: 'directory' as const, children: null },
],
workspaceInfo: { isCustomWorkspace: false, rootPath: '/workspace' },
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
ok: true,
entries: [
{ name: 'demo.md', path: '.plans/demo.md', type: 'file' as const, children: null },
],
}),
} as Response);
const { result, rerender } = renderHook(
({ showHidden }: { showHidden: boolean }) => useFileTree('main', showHidden),
{ initialProps: { showHidden: false } },
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.entries.map((entry) => entry.path)).toEqual(['src']);
});
rerender({ showHidden: true });
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.path)).toEqual(['.plans', 'src']);
});
await act(async () => {
await result.current.revealPath('.plans/demo.md', 'file');
});
await waitFor(() => {
expect(result.current.expandedPaths.has('.plans')).toBe(true);
expect(result.current.selectedPath).toBe('.plans/demo.md');
});
const reloadedRootUrl = getRequestUrl(mockFetch.mock.calls[1]![0]);
const revealUrl = getRequestUrl(mockFetch.mock.calls[2]![0]);
expect(reloadedRootUrl.searchParams.get('showHidden')).toBe('true');
expect(revealUrl.searchParams.get('showHidden')).toBe('true');
expect(revealUrl.searchParams.get('path')).toBe('.plans');
});
});
describe('return object includes workspaceInfo', () => {

View file

@ -98,14 +98,15 @@ function findEntry(entries: TreeEntry[], targetPath: string): TreeEntry | null {
return null;
}
function buildTreeUrl(dirPath: string, agentId: string): string {
function buildTreeUrl(dirPath: string, agentId: string, showHiddenEntries: boolean): string {
const params = new URLSearchParams({ depth: '1', agentId });
if (showHiddenEntries) params.set('showHidden', 'true');
if (dirPath) params.set('path', dirPath);
return `/api/files/tree?${params.toString()}`;
}
/** Hook for managing file tree state with workspace info and persistence. */
export function useFileTree(agentId = DEFAULT_AGENT_ID) {
export function useFileTree(agentId = DEFAULT_AGENT_ID, showHiddenEntries = false) {
const scopedAgentId = normalizeAgentId(agentId);
const [entries, setEntries] = useState<TreeEntry[]>([]);
const entriesRef = useRef<TreeEntry[]>([]);
@ -158,7 +159,7 @@ export function useFileTree(agentId = DEFAULT_AGENT_ID) {
requestAgentId = agentIdRef.current,
): Promise<TreeEntry[] | null> => {
try {
const res = await fetch(buildTreeUrl(dirPath, requestAgentId));
const res = await fetch(buildTreeUrl(dirPath, requestAgentId, showHiddenEntries));
if (!res.ok) {
if (dirPath && (res.status === 400 || res.status === 404) && agentIdRef.current === requestAgentId) {
setExpandedPaths((prev) => {
@ -184,7 +185,7 @@ export function useFileTree(agentId = DEFAULT_AGENT_ID) {
} catch {
return null;
}
}, []);
}, [showHiddenEntries]);
// Initial load and agent changes
const loadRoot = useCallback(async (targetAgentId = scopedAgentId) => {

View file

@ -7,7 +7,7 @@ import {
useMemo,
} from 'react';
import { getWorkspaceStorageKey } from '@/features/workspace/workspaceScope';
import { isImageFile } from '../utils/fileTypes';
import { isImageFile, isPdfFile } from '../utils/fileTypes';
import type { OpenFile } from '../types';
const DEFAULT_AGENT_ID = 'main';
@ -364,6 +364,27 @@ export function useOpenFiles(agentId = DEFAULT_AGENT_ID) {
files.push(createSnapshotBackedOpenFile(path, dirtySnapshot));
};
// Skip /api/files/read for image and PDF files; restore them directly
const fileName = basename(path);
if (isImageFile(fileName) || isPdfFile(fileName)) {
const dirtySnapshot = getDirtySnapshot();
if (dirtySnapshot) {
files.push(createSnapshotBackedOpenFile(path, dirtySnapshot));
} else {
files.push({
path,
name: fileName,
content: '',
savedContent: '',
dirty: false,
locked: false,
mtime: 0,
loading: false,
});
}
continue;
}
try {
const res = await fetch(buildReadUrl(path, targetAgentId));
if (!isLatestReadRequest(scopedPathKey, token)) {
@ -515,7 +536,7 @@ export function useOpenFiles(agentId = DEFAULT_AGENT_ID) {
});
setActiveTab(filePath);
if (isImageFile(basename(filePath))) {
if (isImageFile(basename(filePath)) || isPdfFile(basename(filePath))) {
setOpenFiles((prev) => prev.map((file) => (
file.path === filePath ? { ...file, loading: false } : file
)));
@ -734,10 +755,24 @@ export function useOpenFiles(agentId = DEFAULT_AGENT_ID) {
const isOpen = openFilesRef.current.some((file) => file.path === changedPath);
if (!isOpen) return;
const fileName = basename(changedPath);
const isRawAsset = isImageFile(fileName) || isPdfFile(fileName);
setOpenFiles((prev) => prev.map((file) => (
file.path === changedPath ? { ...file, locked: true } : file
)));
// For raw assets (images, PDFs), bump viewerVersion to trigger iframe remount
if (isRawAsset) {
setOpenFiles((prev) => prev.map((file) => (
file.path === changedPath
? { ...file, viewerVersion: (file.viewerVersion ?? 0) + 1, locked: false }
: file
)));
return;
}
// For text files, reload content from disk
void reloadFile(changedPath).then(() => {
if (agentIdRef.current !== requestAgentId) return;

View file

@ -29,4 +29,6 @@ export interface OpenFile {
loading: boolean;
/** Error message if fetch failed. */
error?: string;
/** Version counter for raw asset viewers (images, PDFs) to trigger remount. */
viewerVersion?: number;
}

View file

@ -2,8 +2,25 @@ const IMAGE_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico',
]);
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
const PDF_EXTENSIONS = new Set(['.pdf']);
function fileExtension(name: string): string {
return name.includes('.') ? '.' + name.split('.').pop()!.toLowerCase() : '';
}
/** Check if a filename is a supported image type. */
export function isImageFile(name: string): boolean {
const ext = name.includes('.') ? '.' + name.split('.').pop()!.toLowerCase() : '';
return IMAGE_EXTENSIONS.has(ext);
return IMAGE_EXTENSIONS.has(fileExtension(name));
}
/** Check if a filename should open in the markdown document view. */
export function isMarkdownFile(name: string): boolean {
return MARKDOWN_EXTENSIONS.has(fileExtension(name));
}
/** Check if a filename is a PDF file. */
export function isPdfFile(name: string): boolean {
return PDF_EXTENSIONS.has(fileExtension(name));
}

View file

@ -47,8 +47,8 @@ describe('CreateTaskDialog', () => {
mockUseSessionContext.mockReturnValue({
sessions: [
{ sessionKey: 'agent:main:main', label: 'Kim (main)' },
{ sessionKey: 'agent:designer:main', label: 'Designer' },
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:designer:main', identityName: 'Designer' },
{ sessionKey: 'agent:reviewer:main', identityName: 'Reviewer' },
{ sessionKey: 'agent:designer:subagent:abc', label: 'Designer helper' },
],
agentName: 'Kim',
@ -63,8 +63,8 @@ describe('CreateTaskDialog', () => {
expect(await screen.findByRole('option', { name: 'Unassigned' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Operator' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Designer' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Reviewer' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Designer (designer)' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Reviewer (reviewer)' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Kim (main)' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Designer helper' })).not.toBeInTheDocument();
});
@ -76,7 +76,7 @@ describe('CreateTaskDialog', () => {
await user.type(screen.getByLabelText(/title/i), 'Test task');
await user.click(screen.getByRole('combobox', { name: 'Assignee' }));
await user.click(await screen.findByRole('option', { name: 'Designer' }));
await user.click(await screen.findByRole('option', { name: 'Designer (designer)' }));
await user.click(screen.getByRole('button', { name: /create task/i }));
await waitFor(() => {

View file

@ -52,8 +52,8 @@ describe('TaskDetailDrawer', () => {
});
mockUseSessionContext.mockReturnValue({
sessions: [
{ sessionKey: 'agent:designer:main', label: 'Designer' },
{ sessionKey: 'agent:reviewer:main', label: 'Reviewer' },
{ sessionKey: 'agent:designer:main', identityName: 'Designer' },
{ sessionKey: 'agent:reviewer:main', identityName: 'Reviewer' },
],
agentName: 'Kim',
});
@ -62,7 +62,7 @@ describe('TaskDetailDrawer', () => {
it('shows the friendly current assignee label when the task assignee is active', () => {
renderDrawer(makeTask({ assignee: 'agent:designer' }));
expect(screen.getByRole('combobox', { name: 'Assignee' })).toHaveValue('Designer');
expect(screen.getByRole('combobox', { name: 'Assignee' })).toHaveValue('Designer (designer)');
});
it('does not render the assignee combobox inside an extra input-styled shell', () => {
@ -102,7 +102,7 @@ describe('TaskDetailDrawer', () => {
renderDrawer(makeTask({ assignee: 'agent:ghost-reviewer' }), onUpdate);
await user.click(screen.getByRole('combobox', { name: 'Assignee' }));
await user.click(await screen.findByRole('option', { name: 'Reviewer' }));
await user.click(await screen.findByRole('option', { name: 'Reviewer (reviewer)' }));
await user.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {

View file

@ -20,8 +20,8 @@ describe('assigneeOptions', () => {
expect(buildAssigneeOptions(sessions, 'Nerve')).toEqual([
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:designer', label: 'Alpha Agent' },
{ value: 'agent:reviewer', label: 'Reviewer' },
{ value: 'agent:designer', label: 'designer' },
{ value: 'agent:reviewer', label: 'reviewer' },
]);
});
@ -38,6 +38,19 @@ describe('assigneeOptions', () => {
]);
});
it('uses identity-backed labels for hydrated root agents', () => {
const sessions = [
session('agent:main:main'),
session('agent:designer:main', { identityName: 'Designer Prime' }),
];
expect(buildAssigneeOptions(sessions, 'Nerve')).toEqual([
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:designer', label: 'Designer Prime (designer)' },
]);
});
it('ignores non-top-level sessions', () => {
const sessions = [
session('agent:main:main'),
@ -50,7 +63,7 @@ describe('assigneeOptions', () => {
expect(buildAssigneeOptions(sessions, 'Nerve')).toEqual([
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:builder', label: 'Builder' },
{ value: 'agent:builder', label: 'builder' },
]);
});
@ -63,7 +76,7 @@ describe('assigneeOptions', () => {
expect(buildAssigneeOptionsForEdit(sessions, 'agent:design-reviewer-2', 'Nerve')).toEqual([
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:reviewer', label: 'Reviewer' },
{ value: 'agent:reviewer', label: 'reviewer' },
{ value: 'agent:design-reviewer-2', label: 'Agent design reviewer 2 (inactive)', disabled: true },
]);
});
@ -77,7 +90,7 @@ describe('assigneeOptions', () => {
const expected = [
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:reviewer', label: 'Reviewer' },
{ value: 'agent:reviewer', label: 'reviewer' },
];
expect(buildAssigneeOptionsForEdit(sessions, '')).toEqual(expected);
@ -95,7 +108,7 @@ describe('assigneeOptions', () => {
expect(buildAssigneeOptionsForEdit(sessions, ' agent:reviewer ')).toEqual([
{ value: '', label: 'Unassigned' },
{ value: 'operator', label: 'Operator' },
{ value: 'agent:reviewer', label: 'Reviewer' },
{ value: 'agent:reviewer', label: 'reviewer' },
]);
});

View file

@ -1,6 +1,6 @@
/** Tests for the MarkdownRenderer component. */
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
// Mock highlight.js to avoid complex setup
vi.mock('@/lib/highlight', () => ({
@ -69,13 +69,430 @@ describe('MarkdownRenderer', () => {
expect(link?.getAttribute('href')).toBe('https://example.com');
});
it('opens workspace links in-app when a handler is provided', () => {
it('linkifies configured inline /workspace paths', () => {
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="Open /workspace/src/App.tsx now"
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={['/workspace/']}
/>,
);
fireEvent.click(screen.getByRole('link', { name: '/workspace/src/App.tsx' }));
expect(onOpenWorkspacePath).toHaveBeenCalledWith('/workspace/src/App.tsx', undefined);
});
it('passes current document context to inline path references too', () => {
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="Open /workspace/src/App.tsx now"
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={['/workspace/']}
currentDocumentPath="notes/index.md"
/>,
);
fireEvent.click(screen.getByRole('link', { name: '/workspace/src/App.tsx' }));
expect(onOpenWorkspacePath).toHaveBeenCalledWith('/workspace/src/App.tsx', 'notes/index.md');
});
it('logs and swallows rejected inline workspace path opens', async () => {
const error = new Error('nope');
const onOpenWorkspacePath = vi.fn().mockRejectedValueOnce(error);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<MarkdownRenderer
content="Open /workspace/src/App.tsx now"
onOpenWorkspacePath={onOpenWorkspacePath}
pathLinkPrefixes={['/workspace/']}
/>,
);
fireEvent.click(screen.getByRole('link', { name: '/workspace/src/App.tsx' }));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith('Failed to open workspace path link:', error);
});
consoleError.mockRestore();
});
it('does not linkify relative paths when only /workspace is configured', () => {
render(<MarkdownRenderer content="src/App.tsx" pathLinkPrefixes={['/workspace/']} onOpenWorkspacePath={vi.fn()} />);
expect(screen.queryByRole('link', { name: 'src/App.tsx' })).toBeNull();
});
it('does not linkify configured path text inside inline code', () => {
render(<MarkdownRenderer content="Use `/workspace/src/App.tsx` later" pathLinkPrefixes={['/workspace/']} onOpenWorkspacePath={vi.fn()} />);
expect(screen.queryByRole('link', { name: '/workspace/src/App.tsx' })).toBeNull();
});
it('opens workspace links in-app when a handler is provided', async () => {
const onOpenWorkspacePath = vi.fn();
render(<MarkdownRenderer content="[notes](docs/todo.md)" onOpenWorkspacePath={onOpenWorkspacePath} />);
fireEvent.click(screen.getByRole('link', { name: 'notes' }));
expect(onOpenWorkspacePath).toHaveBeenCalledWith('docs/todo.md');
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('docs/todo.md', undefined);
});
});
it('passes the current document path for markdown-document-relative links', async () => {
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="[advanced](../advanced.md)"
currentDocumentPath="docs/guide/index.md"
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'advanced' }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('../advanced.md', 'docs/guide/index.md');
});
});
it('preserves leading-slash workspace links for markdown documents', async () => {
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="[todo](/docs/todo.md)"
currentDocumentPath="notes/index.md"
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'todo' }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('/docs/todo.md', 'notes/index.md');
});
});
it('splits fragments from workspace link paths before opening files', async () => {
const onOpenWorkspacePath = vi.fn().mockResolvedValue(undefined);
const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => undefined);
render(
<MarkdownRenderer
content="[guide](docs/guide.md#intro)"
currentDocumentPath="notes/index.md"
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'guide' }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('docs/guide.md', 'notes/index.md');
expect(replaceState).toHaveBeenCalledWith(null, '', '#intro');
});
replaceState.mockRestore();
});
it('does not split encoded hash characters in workspace link paths', async () => {
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="[guide](foo%23bar.md)"
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'guide' }));
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('foo#bar.md', undefined);
});
});
it('adds stable ids to headings for same-document anchor navigation', () => {
render(<MarkdownRenderer content={'## External Links'} />);
expect(document.querySelector('h2#external-links')).toBeTruthy();
});
it('keeps heading ids stable across rerenders', () => {
const { rerender } = render(<MarkdownRenderer content={'## Intro\n\n## Intro'} />);
expect(document.getElementById('intro')).toBeTruthy();
expect(document.getElementById('intro-1')).toBeTruthy();
rerender(<MarkdownRenderer content={'## Intro\n\n## Intro'} />);
expect(document.getElementById('intro')).toBeTruthy();
expect(document.getElementById('intro-1')).toBeTruthy();
expect(document.getElementById('intro-2')).toBeNull();
});
it('keeps non-ascii headings addressable', () => {
render(<MarkdownRenderer content={'## 日本語'} />);
expect(document.getElementById('日本語')).toBeTruthy();
});
it('handles same-document anchor links in-app instead of opening a new tab', () => {
const onOpenWorkspacePath = vi.fn();
const scrollIntoView = vi.fn();
const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => undefined);
const originalScrollIntoView = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, 'scrollIntoView');
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
try {
render(
<MarkdownRenderer
content={'[Jump](#external-links)\n\n## External Links'}
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
const link = screen.getByRole('link', { name: 'Jump' });
expect(link).not.toHaveAttribute('target', '_blank');
fireEvent.click(link);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(replaceState).toHaveBeenCalledWith(null, '', '#external-links');
expect(onOpenWorkspacePath).not.toHaveBeenCalled();
} finally {
if (originalScrollIntoView) {
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView);
} else {
delete (window.HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
replaceState.mockRestore();
}
});
it('logs and swallows rejected markdown workspace link opens', async () => {
const error = new Error('nope');
const onOpenWorkspacePath = vi.fn().mockRejectedValueOnce(error);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<MarkdownRenderer content="[notes](docs/todo.md)" onOpenWorkspacePath={onOpenWorkspacePath} />);
fireEvent.click(screen.getByRole('link', { name: 'notes' }));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith('Failed to open workspace path link:', error);
});
consoleError.mockRestore();
});
it('logs and swallows synchronous throws from markdown workspace link opens', async () => {
const error = new Error('boom');
const onOpenWorkspacePath = vi.fn(() => {
throw error;
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<MarkdownRenderer content="[notes](docs/todo.md)" onOpenWorkspacePath={onOpenWorkspacePath} />);
fireEvent.click(screen.getByRole('link', { name: 'notes' }));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith('Failed to open workspace path link:', error);
});
consoleError.mockRestore();
});
it('opens explicit bead-scheme links in-app when a bead handler is provided', async () => {
const onOpenBeadId = vi.fn();
render(<MarkdownRenderer content="[viewer](bead:nerve-fms2)" onOpenBeadId={onOpenBeadId} />);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenCalledWith({ beadId: 'nerve-fms2' });
});
});
it('passes same-context metadata through for legacy bead links when document context is available', async () => {
const onOpenBeadId = vi.fn();
render(
<MarkdownRenderer
content="[viewer](bead:nerve-fms2)"
currentDocumentPath="repos/demo/docs/beads.md"
workspaceAgentId="research"
onOpenBeadId={onOpenBeadId}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenCalledWith({
beadId: 'nerve-fms2',
currentDocumentPath: 'repos/demo/docs/beads.md',
workspaceAgentId: 'research',
});
});
});
it('logs and swallows rejected bead link opens', async () => {
const error = new Error('nope');
const onOpenBeadId = vi.fn().mockRejectedValueOnce(error);
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<MarkdownRenderer content="[viewer](bead:nerve-fms2)" onOpenBeadId={onOpenBeadId} />);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith('Failed to open bead link:', error);
});
consoleError.mockRestore();
});
it('logs and swallows synchronous throws from bead link opens', async () => {
const error = new Error('boom');
const onOpenBeadId = vi.fn(() => {
throw error;
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(<MarkdownRenderer content="[viewer](bead:nerve-fms2)" onOpenBeadId={onOpenBeadId} />);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith('Failed to open bead link:', error);
});
consoleError.mockRestore();
});
it('routes explicit bead-scheme links to bead tabs before workspace resolution or browser fallback', async () => {
const onOpenBeadId = vi.fn();
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="[viewer](bead:nerve-fms2)"
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
const link = screen.getByRole('link', { name: 'viewer' });
expect(link).toHaveAttribute('href', 'bead:nerve-fms2');
expect(link).not.toHaveAttribute('target', '_blank');
fireEvent.click(link);
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenCalledWith({ beadId: 'nerve-fms2' });
});
expect(onOpenWorkspacePath).not.toHaveBeenCalled();
});
it('does not treat bare bead ids as bead links when a workspace handler is also present', async () => {
const onOpenBeadId = vi.fn();
const onOpenWorkspacePath = vi.fn();
render(
<MarkdownRenderer
content="[viewer](nerve-fms2)"
onOpenBeadId={onOpenBeadId}
onOpenWorkspacePath={onOpenWorkspacePath}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
expect(onOpenBeadId).not.toHaveBeenCalled();
await waitFor(() => {
expect(onOpenWorkspacePath).toHaveBeenCalledWith('nerve-fms2', undefined);
});
});
it('passes explicit bead lookup context through for cross-context links', async () => {
const onOpenBeadId = vi.fn();
render(
<MarkdownRenderer
content="[viewer](bead:///home/derrick/.openclaw/workspace/projects/virtra-apex-docs/.beads#virtra-apex-docs-id2)"
currentDocumentPath="bead-link-dogfood.md"
workspaceAgentId="main"
onOpenBeadId={onOpenBeadId}
/>,
);
fireEvent.click(screen.getByRole('link', { name: 'viewer' }));
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenCalledWith({
beadId: 'virtra-apex-docs-id2',
explicitTargetPath: '/home/derrick/.openclaw/workspace/projects/virtra-apex-docs/.beads',
currentDocumentPath: 'bead-link-dogfood.md',
workspaceAgentId: 'main',
});
});
});
it('does not preserve relative explicit bead links when this renderer lacks the context to open them', () => {
const onOpenBeadId = vi.fn();
render(
<MarkdownRenderer
content="[viewer](bead://../projects/virtra-apex-docs/.beads#virtra-apex-docs-id2)"
onOpenBeadId={onOpenBeadId}
/>,
);
expect(screen.queryByRole('link', { name: 'viewer' })).toBeNull();
expect(screen.getByText('viewer').tagName).toBe('SPAN');
expect(onOpenBeadId).not.toHaveBeenCalled();
});
it('preserves explicit bead links when this renderer instance can open them', () => {
render(
<MarkdownRenderer
content="[viewer](bead:///home/derrick/.openclaw/workspace/projects/virtra-apex-docs/.beads#virtra-apex-docs-id2)"
onOpenBeadId={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'viewer' });
expect(link).toHaveAttribute('href', 'bead:///home/derrick/.openclaw/workspace/projects/virtra-apex-docs/.beads#virtra-apex-docs-id2');
expect(link).not.toHaveAttribute('target', '_blank');
});
it('routes relative explicit bead links in-app once current document context is available', async () => {
const onOpenBeadId = vi.fn();
render(
<MarkdownRenderer
content="[viewer](bead://../projects/virtra-apex-docs/.beads#virtra-apex-docs-id2)"
currentDocumentPath="notes/bead-link-dogfood.md"
workspaceAgentId="main"
onOpenBeadId={onOpenBeadId}
/>,
);
const link = screen.getByRole('link', { name: 'viewer' });
expect(link).toHaveAttribute('href', 'bead://../projects/virtra-apex-docs/.beads#virtra-apex-docs-id2');
expect(link).not.toHaveAttribute('target', '_blank');
fireEvent.click(link);
await waitFor(() => {
expect(onOpenBeadId).toHaveBeenCalledWith({
beadId: 'virtra-apex-docs-id2',
explicitTargetPath: '../projects/virtra-apex-docs/.beads',
currentDocumentPath: 'notes/bead-link-dogfood.md',
workspaceAgentId: 'main',
});
});
});
it('keeps external links as normal browser links when a handler is provided', () => {
@ -89,6 +506,12 @@ describe('MarkdownRenderer', () => {
expect(onOpenWorkspacePath).not.toHaveBeenCalled();
});
it('preserves markdown-provided link attributes', () => {
render(<MarkdownRenderer content={'[example](https://example.com "Read more")'} />);
expect(screen.getByRole('link', { name: 'example' })).toHaveAttribute('title', 'Read more');
});
it('renders code blocks', () => {
render(<MarkdownRenderer content={'```js\nconst x = 1;\n```'} />);
const code = document.querySelector('code');

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