) => {
+ if (method === 'task.create') {
+ return {
+ id: 'task-1',
+ space_id: 'space-1',
+ title: params?.title,
+ description: params?.description,
+ status: 'todo',
+ priority: params?.priority,
+ queue_status: 'none',
+ sort_order: 0,
+ created_at: 1,
+ };
+ }
+ if (method === 'ai.summarize') return null;
+ throw new Error(`Unexpected method: ${method}`);
+ });
+
+ render();
+
+ await fireEvent.input(screen.getByPlaceholderText('e.g., Fix login bug, Add dark mode...'), {
+ target: { value: 'Cover startup dispatch' },
+ });
+ await fireEvent.click(screen.getByLabelText('High'));
+ await fireEvent.click(screen.getByText('Create'));
+
+ await waitFor(() => {
+ expect(ipcCall).toHaveBeenCalledWith('ai.summarize', {
+ prompt: 'Cover startup dispatch',
+ pane_id: 'task-1',
+ });
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(store.tasks.value.get('task-1')).toMatchObject({
+ title: 'Cover startup dispatch',
+ priority: 'high',
+ });
+ expect(store.panes.value.find((pane) => pane.id === 'task-1')).toMatchObject({
+ taskTitle: 'Cover startup dispatch',
+ taskPriority: 'high',
+ });
+ });
+
+ it('closes without creating a task when cancel is clicked', async () => {
+ const onClose = vi.fn();
+ const ipcCall = vi.spyOn(store.ipc, 'call');
+
+ render();
+
+ await fireEvent.click(screen.getByText('Cancel'));
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(ipcCall).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/TaskDialog.tsx b/frontend/src/components/TaskDialog.tsx
index 0b05f5b..707b6e3 100644
--- a/frontend/src/components/TaskDialog.tsx
+++ b/frontend/src/components/TaskDialog.tsx
@@ -11,11 +11,11 @@ export function TaskDialog({ onClose }: Props) {
useEffect(() => { titleRef.current?.focus(); }, []);
- const submit = () => {
+ const submit = async () => {
const title = titleRef.current?.value.trim();
if (!title) return;
- const pane = createTask(title, '...', priority);
+ const pane = await createTask(title, '...', priority);
onClose();
if (pane) {
@@ -36,7 +36,7 @@ export function TaskDialog({ onClose }: Props) {
ref={titleRef}
class="dialog-input"
placeholder="e.g., Fix login bug, Add dark mode..."
- onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
+ onKeyDown={(e) => { if (e.key === 'Enter') void submit(); }}
/>
@@ -51,7 +51,7 @@ export function TaskDialog({ onClose }: Props) {
-
+
AI will fill description in background
diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts
new file mode 100644
index 0000000..2687d47
--- /dev/null
+++ b/frontend/src/store.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it, vi } from 'vitest';
+import * as store from './store';
+
+describe('store startup and dispatch flows', () => {
+ it('hydrates persisted state and only respawns shell panes on startup', async () => {
+ const ipcCall = vi.spyOn(store.ipc, 'call').mockImplementation(async (method: string) => {
+ if (method === 'state.hydrate') {
+ return {
+ workspaces: [
+ {
+ id: 'ws-1',
+ name: 'Workspace',
+ path: '/repo',
+ spaces: [
+ {
+ id: 'space-1',
+ workspace_id: 'ws-1',
+ name: 'Default',
+ tasks: [
+ {
+ id: 'task-1',
+ space_id: 'space-1',
+ title: 'Persisted task',
+ description: 'Verify hydration',
+ status: 'todo',
+ priority: 'medium',
+ queue_status: 'none',
+ created_at: 1,
+ },
+ ],
+ agents: [
+ {
+ id: 'slot-1',
+ space_id: 'space-1',
+ provider_id: 'claude',
+ provider_name: 'Claude Code',
+ status: 'idle',
+ created_at: 1,
+ },
+ ],
+ nodes: [
+ {
+ id: 'shell-1',
+ space_id: 'space-1',
+ kind: 'shell',
+ title: 'Shell',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ settings: {},
+ };
+ }
+
+ if (method === 'pty.spawn') {
+ return { session_id: 'session-1' };
+ }
+
+ throw new Error(`Unexpected method: ${method}`);
+ });
+
+ await store.hydrateState();
+
+ expect(store.currentWorkspaceId.value).toBe('ws-1');
+ expect(store.workspacePath.value).toBe('/repo');
+ expect(store.activeSpaceId.value).toBe('space-1');
+ expect(store.tasks.value.get('task-1')?.title).toBe('Persisted task');
+ expect(store.agents.value.get('slot-1')?.providerName).toBe('Claude Code');
+ expect(store.panes.value.find((pane) => pane.id === 'shell-1')).toMatchObject({
+ sessionId: 'session-1',
+ sessionStatus: 'running',
+ });
+ expect(ipcCall).toHaveBeenCalledWith('pty.spawn', {
+ cwd: '/repo',
+ kind: 'shell',
+ space_id: 'space-1',
+ node_id: 'shell-1',
+ });
+ expect(ipcCall).toHaveBeenCalledTimes(2);
+ });
+
+ it('auto-dispatches queued tasks into idle slots during startup scheduling', async () => {
+ vi.useFakeTimers();
+
+ store.spaces.value = [{ id: 'space-1', name: 'Default' }];
+ store.activeSpaceId.value = 'space-1';
+ store.workspacePath.value = '/repo';
+ store.schedulerSettings.value = {
+ concurrency: 1,
+ autoDispatch: true,
+ defaultAgentId: 'claude',
+ };
+ store.panes.value = [
+ {
+ id: 'task-1',
+ kind: 'task',
+ spaceId: 'space-1',
+ taskTitle: 'Queued task',
+ taskDescription: 'Dispatch me',
+ taskStatus: 'todo',
+ taskPriority: 'high',
+ },
+ ];
+ store.agents.value = new Map([
+ ['slot-1', {
+ id: 'slot-1',
+ spaceId: 'space-1',
+ providerId: 'claude',
+ providerName: 'Claude Code',
+ status: 'idle',
+ sortOrder: 0,
+ createdAt: 1,
+ }],
+ ]);
+
+ const ipcCall = vi.spyOn(store.ipc, 'call').mockImplementation(async (method: string, params?: Record) => {
+ if (method === 'task.assign') return null;
+ if (method === 'pty.spawn') return { session_id: 'session-42' };
+ if (method === 'agent.update') return null;
+ if (method === 'pty.write') return null;
+ throw new Error(`Unexpected method: ${method} ${JSON.stringify(params)}`);
+ });
+
+ store.tasks.value = new Map([
+ ['task-1', {
+ id: 'task-1',
+ spaceId: 'space-1',
+ title: 'Queued task',
+ description: 'Dispatch me',
+ status: 'todo',
+ priority: 'high',
+ queueStatus: 'queued',
+ queuedAt: 10,
+ sortOrder: 0,
+ createdAt: 1,
+ }],
+ ]);
+
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ await vi.advanceTimersByTimeAsync(2500);
+
+ expect(ipcCall).toHaveBeenCalledWith('task.assign', { task_id: 'task-1', agent_id: 'slot-1' });
+ expect(ipcCall).toHaveBeenCalledWith('pty.spawn', { kind: 'agent', space_id: 'space-1', cwd: '/repo', command: 'claude' });
+ expect(ipcCall).toHaveBeenCalledWith('agent.update', { id: 'slot-1', session_id: 'session-42' });
+ expect(ipcCall).toHaveBeenCalledWith('pty.write', { session_id: 'session-42', data: 'Dispatch me\r' });
+ expect(store.tasks.value.get('task-1')).toMatchObject({
+ assignedAgentId: 'slot-1',
+ queueStatus: 'dispatched',
+ status: 'doing',
+ });
+ expect(store.agents.value.get('slot-1')).toMatchObject({
+ assignedTaskId: 'task-1',
+ status: 'running',
+ sessionId: 'session-42',
+ });
+ expect(store.panes.value.find((pane) => pane.kind === 'agent')).toMatchObject({
+ embedded: true,
+ sessionId: 'session-42',
+ });
+ expect(store.panes.value.find((pane) => pane.id === 'task-1')?.linkedPaneId).toBeTruthy();
+ });
+});
diff --git a/frontend/src/test/helpers.ts b/frontend/src/test/helpers.ts
new file mode 100644
index 0000000..2b18e34
--- /dev/null
+++ b/frontend/src/test/helpers.ts
@@ -0,0 +1,29 @@
+import * as store from '../store';
+
+export function resetStoreState() {
+ store.tasks.value = new Map();
+ store.agents.value = new Map();
+ store.schedulerSettingsEntity.value = null;
+ store.schedulerSettings.value = {
+ concurrency: 4,
+ autoDispatch: true,
+ defaultAgentId: 'claude',
+ };
+ store.executionHistory.value = [];
+ store.customAgents.value = [];
+ store.activeAgentId.value = 'claude';
+ store.panes.value = [];
+ store.focusedPaneId.value = null;
+ store.expandedPaneId.value = null;
+ store.popoutPanes.value = new Map();
+ store.planModeAlert.value = null;
+ store.notes.value = [];
+ store.editingNoteId.value = null;
+ store.spaces.value = [];
+ store.activeSpaceId.value = null;
+ store.currentWorkspaceId.value = null;
+ store.workspacePath.value = '';
+ localStorage.clear();
+ document.body.innerHTML = '';
+ document.documentElement.removeAttribute('data-theme');
+}
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..9fe4e3f
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,26 @@
+import { afterEach, beforeEach, vi } from 'vitest';
+import { resetStoreState } from './helpers';
+
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+beforeEach(() => {
+ resetStoreState();
+ vi.useRealTimers();
+});
+
+afterEach(() => {
+ resetStoreState();
+ vi.restoreAllMocks();
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index ce2a218..50ea7dd 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -15,4 +15,9 @@ export default defineConfig({
server: {
port: 5173,
},
+ test: {
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.ts',
+ restoreMocks: true,
+ },
});
diff --git a/src/db.zig b/src/db.zig
index 645e684..b834f22 100644
--- a/src/db.zig
+++ b/src/db.zig
@@ -13,6 +13,10 @@ pub const Db = struct {
const db_path = try resolveDbPath(allocator);
defer allocator.free(db_path);
+ return openPath(allocator, db_path);
+ }
+
+ fn openPath(allocator: Allocator, db_path: [:0]const u8) !Db {
var handle: ?*c.sqlite3 = null;
if (c.sqlite3_open(db_path.ptr, &handle) != c.SQLITE_OK) {
if (handle) |h| _ = c.sqlite3_close(h);
@@ -858,3 +862,138 @@ fn resolveDbPath(allocator: Allocator) ![:0]u8 {
// Return null-terminated copy
return try allocator.dupeZ(u8, path);
}
+
+fn uniqueTestDbPath(allocator: Allocator) ![:0]u8 {
+ return std.fmt.allocPrintZ(
+ allocator,
+ "/tmp/nexus-db-test-{d}.sqlite",
+ .{std.time.nanoTimestamp()},
+ );
+}
+
+fn freeWorkspaceRows(allocator: Allocator, rows: []WorkspaceRow) void {
+ for (rows) |row| {
+ allocator.free(row.id);
+ allocator.free(row.name);
+ allocator.free(row.path);
+ if (row.active_space_id) |value| allocator.free(value);
+ }
+ allocator.free(rows);
+}
+
+fn freeSpaceRows(allocator: Allocator, rows: []SpaceRow) void {
+ for (rows) |row| {
+ allocator.free(row.id);
+ allocator.free(row.workspace_id);
+ allocator.free(row.name);
+ allocator.free(row.directory_path);
+ if (row.label_color) |value| allocator.free(value);
+ }
+ allocator.free(rows);
+}
+
+fn freeTaskRows(allocator: Allocator, rows: []TaskRow) void {
+ for (rows) |row| {
+ allocator.free(row.id);
+ allocator.free(row.space_id);
+ if (row.parent_task_id) |value| allocator.free(value);
+ allocator.free(row.title);
+ allocator.free(row.description);
+ allocator.free(row.status);
+ allocator.free(row.priority);
+ allocator.free(row.queue_status);
+ if (row.assigned_agent_id) |value| allocator.free(value);
+ if (row.node_id) |value| allocator.free(value);
+ }
+ allocator.free(rows);
+}
+
+fn freeAgentRows(allocator: Allocator, rows: []AgentRow) void {
+ for (rows) |row| {
+ allocator.free(row.id);
+ allocator.free(row.space_id);
+ allocator.free(row.provider_id);
+ allocator.free(row.provider_name);
+ allocator.free(row.status);
+ if (row.session_id) |value| allocator.free(value);
+ if (row.assigned_task_id) |value| allocator.free(value);
+ if (row.prompt) |value| allocator.free(value);
+ if (row.node_id) |value| allocator.free(value);
+ }
+ allocator.free(rows);
+}
+
+test "Db persists workspaces, spaces, and scheduler settings across reopen" {
+ const allocator = std.testing.allocator;
+ const db_path = try uniqueTestDbPath(allocator);
+ defer allocator.free(db_path);
+ defer std.fs.cwd().deleteFile(std.mem.sliceTo(db_path, 0)) catch {};
+
+ {
+ var db = try Db.openPath(allocator, db_path);
+ defer db.close();
+
+ try db.createWorkspace("ws-1", "Workspace", "/repo");
+ try db.createSpace("space-1", "ws-1", "Default", "/repo");
+ try db.setSchedulerSettings("ws-1", 3, true, "claude");
+ }
+
+ {
+ var reopened = try Db.openPath(allocator, db_path);
+ defer reopened.close();
+
+ const workspaces = try reopened.listWorkspaces(allocator);
+ defer freeWorkspaceRows(allocator, workspaces);
+ try std.testing.expectEqual(@as(usize, 1), workspaces.len);
+ try std.testing.expectEqualStrings("ws-1", workspaces[0].id);
+ try std.testing.expectEqualStrings("/repo", workspaces[0].path);
+
+ const spaces = try reopened.listSpaces(allocator, "ws-1");
+ defer freeSpaceRows(allocator, spaces);
+ try std.testing.expectEqual(@as(usize, 1), spaces.len);
+ try std.testing.expectEqualStrings("space-1", spaces[0].id);
+ try std.testing.expectEqualStrings("Default", spaces[0].name);
+
+ const settings = (try reopened.getSchedulerSettings(allocator, "ws-1")).?;
+ defer {
+ allocator.free(settings.workspace_id);
+ allocator.free(settings.default_agent_id);
+ }
+ try std.testing.expectEqual(@as(i32, 3), settings.concurrency);
+ try std.testing.expect(settings.auto_dispatch);
+ try std.testing.expectEqualStrings("claude", settings.default_agent_id);
+ }
+}
+
+test "Db persists task queue assignment and agent linkage" {
+ const allocator = std.testing.allocator;
+ const db_path = try uniqueTestDbPath(allocator);
+ defer allocator.free(db_path);
+ defer std.fs.cwd().deleteFile(std.mem.sliceTo(db_path, 0)) catch {};
+
+ var db = try Db.openPath(allocator, db_path);
+ defer db.close();
+
+ try db.createWorkspace("ws-1", "Workspace", "/repo");
+ try db.createSpace("space-1", "ws-1", "Default", "/repo");
+ try db.createTask("task-1", "space-1", "Test persistence", "Verify dispatch", "high", null);
+ try db.createAgent("slot-1", "space-1", "claude", "Claude Code");
+ try db.enqueueTask("task-1");
+ try db.assignTaskToAgent("task-1", "slot-1");
+
+ const tasks_list = try db.listTasks(allocator, "space-1", null);
+ defer freeTaskRows(allocator, tasks_list);
+ try std.testing.expectEqual(@as(usize, 1), tasks_list.len);
+ try std.testing.expectEqualStrings("dispatched", tasks_list[0].queue_status);
+ try std.testing.expectEqualStrings("doing", tasks_list[0].status);
+ try std.testing.expect(tasks_list[0].queued_at != null);
+ try std.testing.expect(tasks_list[0].dispatched_at != null);
+ try std.testing.expectEqualStrings("slot-1", tasks_list[0].assigned_agent_id.?);
+
+ const agents_list = try db.listAgents(allocator, "space-1", null);
+ defer freeAgentRows(allocator, agents_list);
+ try std.testing.expectEqual(@as(usize, 1), agents_list.len);
+ try std.testing.expectEqualStrings("running", agents_list[0].status);
+ try std.testing.expectEqualStrings("task-1", agents_list[0].assigned_task_id.?);
+ try std.testing.expect(agents_list[0].started_at != null);
+}
diff --git a/src/pty.zig b/src/pty.zig
index c3b495d..44be48c 100644
--- a/src/pty.zig
+++ b/src/pty.zig
@@ -69,9 +69,8 @@ pub fn spawn(
// Ensure common user bin paths are in PATH for macOS app bundles
const current_path = std.posix.getenv("PATH") orelse "/usr/bin:/bin";
const home = std.posix.getenv("HOME") orelse "";
- const extended_path = std.fmt.allocPrintSentinel(allocator,
- "{s}/.local/bin:{s}/.npm-global/bin:{s}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:{s}",
- .{ home, home, home, current_path }, 0) catch current_path;
+ const extended_path = buildExtendedPath(allocator, home, current_path) catch std.posix.exit(1);
+ defer allocator.free(extended_path);
_ = setenv("PATH", extended_path, 1);
// Source user profile to get additional env vars (ANTHROPIC_API_KEY, etc.)
@@ -79,11 +78,8 @@ pub fn spawn(
if (command) |cmd| {
// Wrap command in login shell to get full environment
const shell = std.posix.getenv("SHELL") orelse "/bin/zsh";
- const wrapped_cmd = std.fmt.allocPrintSentinel(allocator,
- "source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null; {s}",
- .{cmd}, 0) catch {
- std.posix.exit(1);
- };
+ const wrapped_cmd = buildWrappedCommand(allocator, cmd) catch std.posix.exit(1);
+ defer allocator.free(wrapped_cmd);
const argv = [_:null]?[*:0]const u8{
@ptrCast(shell),
"-l".ptr,
@@ -96,10 +92,8 @@ pub fn spawn(
std.posix.execvpeZ(@ptrCast(shell), &argv, envp) catch {};
} else {
const shell = std.posix.getenv("SHELL") orelse "/bin/zsh";
- const shell_basename = std.fs.path.basename(shell);
- const login_name = std.fmt.allocPrintSentinel(allocator, "-{s}", .{shell_basename}, 0) catch {
- std.posix.exit(1);
- };
+ const login_name = buildLoginArg0(allocator, shell) catch std.posix.exit(1);
+ defer allocator.free(login_name);
const argv = [_:null]?[*:0]const u8{
login_name.ptr,
@@ -125,6 +119,24 @@ pub fn spawn(
};
}
+fn buildExtendedPath(allocator: std.mem.Allocator, home: []const u8, current_path: []const u8) ![:0]u8 {
+ return std.fmt.allocPrintSentinel(allocator,
+ "{s}/.local/bin:{s}/.npm-global/bin:{s}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:{s}",
+ .{ home, home, home, current_path }, 0);
+}
+
+fn buildWrappedCommand(allocator: std.mem.Allocator, command: []const u8) ![:0]u8 {
+ return std.fmt.allocPrintSentinel(allocator,
+ "source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null; {s}",
+ .{command},
+ 0,
+ );
+}
+
+fn buildLoginArg0(allocator: std.mem.Allocator, shell: []const u8) ![:0]u8 {
+ return std.fmt.allocPrintSentinel(allocator, "-{s}", .{std.fs.path.basename(shell)}, 0);
+}
+
extern "c" fn forkpty(
master: *posix.fd_t,
name: ?[*:0]u8,
@@ -141,3 +153,33 @@ extern "c" fn setenv(
comptime {
_ = @import("std").c;
}
+
+test "buildExtendedPath keeps inherited PATH while prepending user tool directories" {
+ const allocator = std.testing.allocator;
+ const path = try buildExtendedPath(allocator, "/Users/edward", "/usr/bin:/bin");
+ defer allocator.free(path);
+
+ try std.testing.expectEqualStrings(
+ "/Users/edward/.local/bin:/Users/edward/.npm-global/bin:/Users/edward/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin",
+ std.mem.sliceTo(path, 0),
+ );
+}
+
+test "buildWrappedCommand sources shell profiles before running the command" {
+ const allocator = std.testing.allocator;
+ const wrapped = try buildWrappedCommand(allocator, "echo $FOO");
+ defer allocator.free(wrapped);
+
+ try std.testing.expectEqualStrings(
+ "source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null; echo $FOO",
+ std.mem.sliceTo(wrapped, 0),
+ );
+}
+
+test "buildLoginArg0 converts the shell basename into a login-shell argv0" {
+ const allocator = std.testing.allocator;
+ const login_arg0 = try buildLoginArg0(allocator, "/bin/zsh");
+ defer allocator.free(login_arg0);
+
+ try std.testing.expectEqualStrings("-zsh", std.mem.sliceTo(login_arg0, 0));
+}
diff --git a/src/test_main.zig b/src/test_main.zig
new file mode 100644
index 0000000..a42e787
--- /dev/null
+++ b/src/test_main.zig
@@ -0,0 +1,5 @@
+comptime {
+ _ = @import("db.zig");
+ _ = @import("grid.zig");
+ _ = @import("pty.zig");
+}
diff --git a/tests/run-functional-tests.sh b/tests/run-functional-tests.sh
new file mode 100755
index 0000000..a02320e
--- /dev/null
+++ b/tests/run-functional-tests.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+npm --prefix "$repo_root/frontend" run test:run
+cd "$repo_root"
+zig build test