From a9bdb8358f7e16e3bd6f35a7b0695dae138e5165 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Mon, 19 Oct 2026 00:06:34 +0000 Subject: [PATCH 01/15] feat(servers): run Manager automation on linked servers --- .../202610181300-session-server-scope.ts | 61 ++ .../202610181310-multi-run-targets.ts | 84 +++ .../202610181320-remote-schedule-targets.ts | 254 +++++++++ backend/src/db/migrations/index.ts | 6 + backend/src/db/multi-runs.ts | 52 +- backend/src/db/schedules.ts | 144 +++-- backend/src/db/session-goals.ts | 40 +- backend/src/db/session-permission-modes.ts | 52 +- backend/src/index.ts | 75 ++- backend/src/routes/internal/index.ts | 2 +- backend/src/routes/internal/sessions.ts | 2 +- backend/src/routes/multi-runs.ts | 21 +- .../src/routes/opencode-server-terminals.ts | 189 +++++++ backend/src/routes/repo-terminal-socket.ts | 11 +- backend/src/routes/schedules.ts | 136 +++++ backend/src/routes/session-goals.ts | 15 +- .../src/routes/session-permission-modes.ts | 12 +- backend/src/routes/sse.ts | 9 +- backend/src/services/multi-runs.ts | 159 ++++-- backend/src/services/notification.ts | 94 +++- backend/src/services/remote-server-events.ts | 18 + backend/src/services/schedule-config.ts | 5 + backend/src/services/schedules.ts | 519 ++++++++++++++---- backend/src/services/session-ancestry.ts | 42 +- backend/src/services/session-goals.ts | 235 +++++--- backend/src/services/session-launcher.ts | 179 ++++-- .../src/services/session-permission-modes.ts | 159 ++++-- backend/src/services/session-reply.ts | 13 +- backend/src/services/sse-aggregator.ts | 58 +- backend/src/services/terminal.ts | 36 +- backend/src/utils/terminal-cursor.ts | 7 + backend/test/db/multi-run-fusions.test.ts | 6 +- .../db/multi-run-targets-migration.test.ts | 144 +++++ .../test/db/schedule-remote-targets.test.ts | 89 +++ backend/test/db/schedules.test.ts | 27 +- .../db/session-server-scope-migration.test.ts | 123 +++++ .../test/helpers/raw-websocket-handshake.ts | 42 ++ backend/test/index.test.ts | 5 + .../test/routes/internal-multi-runs.test.ts | 4 +- backend/test/routes/multi-runs.test.ts | 79 ++- .../opencode-server-terminal-socket.test.ts | 177 ++++++ .../routes/opencode-server-terminals.test.ts | 215 ++++++++ .../test/routes/repo-terminal-socket.test.ts | 53 +- backend/test/routes/server-schedules.test.ts | 149 +++++ backend/test/routes/session-goals.test.ts | 19 + .../routes/session-permission-modes.test.ts | 17 + backend/test/routes/sse.test.ts | 17 +- .../test/services/multi-runs.remote.test.ts | 326 +++++++++++ backend/test/services/multi-runs.test.ts | 27 +- .../test/services/notification-remote.test.ts | 122 ++++ .../services/notification-service.test.ts | 33 +- .../services/remote-server-events.test.ts | 45 ++ .../test/services/schedules-remote.test.ts | 159 ++++++ backend/test/services/session-goals.test.ts | 153 ++++++ .../services/session-permission-modes.test.ts | 127 +++++ backend/test/services/session-reply.test.ts | 27 +- backend/test/services/sse-aggregator.test.ts | 90 +++ backend/test/services/terminal.test.ts | 84 ++- frontend/src/api/multiRuns.ts | 17 +- frontend/src/api/schedules.ts | 62 +++ frontend/src/api/sessionGoals.ts | 13 +- .../src/api/sessionPermissionModes.test.ts | 28 +- frontend/src/api/sessionPermissionModes.ts | 15 +- frontend/src/api/terminals.test.tsx | 122 ++++ frontend/src/api/terminals.ts | 47 +- .../message/ComposerToolsMenu.test.tsx | 32 ++ .../components/message/ComposerToolsMenu.tsx | 54 +- .../src/components/message/PromptInput.tsx | 11 +- .../components/navigation/DesktopSidebar.tsx | 27 +- .../navigation/LinkedServersNav.test.tsx | 44 +- .../navigation/LinkedServersNav.tsx | 136 ++++- .../components/navigation/MoreDrawer.test.tsx | 4 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 94 ++++ .../navigation/RepoQuickSwitchSheet.tsx | 7 + .../components/navigation/ToolSidePanel.tsx | 22 +- .../navigation/moreDrawerItems.test.ts | 13 + .../components/navigation/moreDrawerItems.ts | 12 +- .../src/components/repo/FusionComposer.tsx | 35 +- frontend/src/components/repo/MultiRunCard.tsx | 10 +- .../components/repo/MultiRunSheet.test.tsx | 100 +++- .../src/components/repo/MultiRunSheet.tsx | 67 ++- .../components/repo/ProjectActionsMenu.tsx | 4 +- .../repo/RemoteMultiRunButton.test.tsx | 43 ++ .../components/repo/RemoteMultiRunButton.tsx | 36 ++ .../src/components/schedules/GeneralTab.tsx | 6 +- .../src/components/schedules/JobDetailTab.tsx | 17 +- .../schedules/RemoteSchedulesButton.tsx | 34 ++ .../components/schedules/RunDetailPanel.tsx | 20 +- .../ScheduleJobDialog.serverTarget.test.tsx | 120 ++++ .../schedules/ScheduleJobDialog.tsx | 40 +- .../schedules/ScheduleRunDrawer.test.tsx | 6 +- .../schedules/ScheduleRunDrawer.tsx | 5 +- .../schedules/ServerSchedulesContent.tsx | 285 ++++++++++ .../schedules/ServerSchedulesDialog.tsx | 29 + .../components/schedules/schedule-utils.ts | 5 + .../session/SessionGoalBar.test.tsx | 1 + .../terminal/TerminalPanel.test.tsx | 8 +- .../src/components/terminal/TerminalPanel.tsx | 19 +- .../components/terminal/TerminalView.test.tsx | 4 +- .../src/components/terminal/TerminalView.tsx | 15 +- frontend/src/hooks/useMultiRuns.ts | 39 +- frontend/src/hooks/useOpenCode.ts | 5 +- frontend/src/hooks/useProviders.ts | 4 +- frontend/src/hooks/useSSE.ts | 10 +- frontend/src/hooks/useScheduleModels.ts | 4 +- frontend/src/hooks/useSchedules.ts | 262 +++++++++ frontend/src/hooks/useSessionGoals.test.tsx | 18 +- frontend/src/hooks/useSessionGoals.ts | 19 +- .../hooks/useSessionPermissionMode.test.tsx | 63 +++ .../src/hooks/useSessionPermissionMode.ts | 17 +- frontend/src/hooks/useTerminalSocket.test.tsx | 9 +- frontend/src/hooks/useTerminalSocket.ts | 18 +- frontend/src/lib/navigation.ts | 4 +- .../__tests__/openCodeEventStream.test.ts | 18 +- .../openCodeEventStream.ts | 7 +- .../src/lib/opencode-event-stream/types.ts | 2 +- frontend/src/lib/terminal-target.ts | 25 + frontend/src/pages/AssistantRedirect.tsx | 2 +- frontend/src/pages/GlobalSchedules.tsx | 105 ++-- frontend/src/pages/RemoteServer.tsx | 4 + frontend/src/pages/RepoDetail.tsx | 8 +- frontend/src/pages/SessionDetail.tsx | 23 +- .../pages/__tests__/GlobalSchedules.test.tsx | 24 +- .../__tests__/SessionDetail.remote.test.tsx | 40 +- shared/src/schemas/multi-runs.ts | 45 +- shared/src/schemas/notifications.ts | 2 + shared/src/schemas/schedule.ts | 13 +- shared/src/schemas/session-goals.ts | 2 + shared/src/schemas/session-permissions.ts | 1 + shared/src/schemas/sse.ts | 1 + shared/src/utils/repo.ts | 4 + 131 files changed, 6567 insertions(+), 948 deletions(-) create mode 100644 backend/src/db/migrations/202610181300-session-server-scope.ts create mode 100644 backend/src/db/migrations/202610181310-multi-run-targets.ts create mode 100644 backend/src/db/migrations/202610181320-remote-schedule-targets.ts create mode 100644 backend/src/routes/opencode-server-terminals.ts create mode 100644 backend/src/utils/terminal-cursor.ts create mode 100644 backend/test/db/multi-run-targets-migration.test.ts create mode 100644 backend/test/db/schedule-remote-targets.test.ts create mode 100644 backend/test/db/session-server-scope-migration.test.ts create mode 100644 backend/test/helpers/raw-websocket-handshake.ts create mode 100644 backend/test/routes/opencode-server-terminal-socket.test.ts create mode 100644 backend/test/routes/opencode-server-terminals.test.ts create mode 100644 backend/test/routes/server-schedules.test.ts create mode 100644 backend/test/services/multi-runs.remote.test.ts create mode 100644 backend/test/services/notification-remote.test.ts create mode 100644 backend/test/services/schedules-remote.test.ts create mode 100644 frontend/src/api/terminals.test.tsx create mode 100644 frontend/src/components/repo/RemoteMultiRunButton.test.tsx create mode 100644 frontend/src/components/repo/RemoteMultiRunButton.tsx create mode 100644 frontend/src/components/schedules/RemoteSchedulesButton.tsx create mode 100644 frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx create mode 100644 frontend/src/components/schedules/ServerSchedulesContent.tsx create mode 100644 frontend/src/components/schedules/ServerSchedulesDialog.tsx create mode 100644 frontend/src/hooks/useSessionPermissionMode.test.tsx create mode 100644 frontend/src/lib/terminal-target.ts diff --git a/backend/src/db/migrations/202610181300-session-server-scope.ts b/backend/src/db/migrations/202610181300-session-server-scope.ts new file mode 100644 index 000000000..56192a3c8 --- /dev/null +++ b/backend/src/db/migrations/202610181300-session-server-scope.ts @@ -0,0 +1,61 @@ +import type { Database } from 'bun:sqlite' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import type { Migration } from '../migration-runner' + +const SERVER_ID_COLUMN = 'server_id' + +function tableExists(db: Database, table: string): boolean { + return Boolean( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), + ) +} + +function hasColumn(db: Database, table: string, column: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return columns.some((entry) => entry.name === column) +} + +function addServerIdColumn(db: Database, table: string): void { + if (!tableExists(db, table) || hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`ALTER TABLE ${table} ADD COLUMN server_id TEXT NOT NULL DEFAULT '${LOCAL_OPENCODE_SERVER_ID}'`) +} + +function dropServerIdColumn(db: Database, table: string): void { + if (!tableExists(db, table) || !hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`ALTER TABLE ${table} DROP COLUMN ${SERVER_ID_COLUMN}`) +} + +const migration: Migration = { + id: '202610181300-session-server-scope', + + up(db) { + addServerIdColumn(db, 'session_permission_modes') + addServerIdColumn(db, 'session_goals') + + db.run('DROP INDEX IF EXISTS idx_session_goals_open') + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(server_id, session_id) + WHERE status IN ('active','paused') + `) + }, + + down(db) { + db.run('DROP INDEX IF EXISTS idx_session_goals_open') + if (tableExists(db, 'session_goals')) { + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(session_id) + WHERE status IN ('active','paused') + `) + } + dropServerIdColumn(db, 'session_goals') + dropServerIdColumn(db, 'session_permission_modes') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610181310-multi-run-targets.ts b/backend/src/db/migrations/202610181310-multi-run-targets.ts new file mode 100644 index 000000000..bd932c7da --- /dev/null +++ b/backend/src/db/migrations/202610181310-multi-run-targets.ts @@ -0,0 +1,84 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string + notnull: number +} + +const REBUILT_COLUMNS = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL +` + +function needsRebuild(columns: ColumnInfo[]): boolean { + if (columns.length === 0) return false + + const byName = new Map(columns.map((column) => [column.name, column])) + const repoId = byName.get('repo_id') + if (!repoId || repoId.notnull === 1) return true + return !byName.has('server_id') || !byName.has('directory') +} + +const migration: Migration = { + id: '202610181310-multi-run-targets', + + up(db) { + const columns = db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] + if (!needsRebuild(columns)) return + + const names = new Set(columns.map((column) => column.name)) + const serverId = names.has('server_id') ? 'server_id' : "'local'" + const directory = names.has('directory') ? 'directory' : 'NULL' + + db.run('DROP TABLE IF EXISTS multi_runs_targets') + db.run(`CREATE TABLE multi_runs_targets (${REBUILT_COLUMNS})`) + db.run(` + INSERT INTO multi_runs_targets (id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + SELECT id, repo_id, ${serverId}, ${directory}, name, prompt, isolated, base_ref, created_at + FROM multi_runs + `) + db.run('DROP TABLE multi_runs') + db.run('ALTER TABLE multi_runs_targets RENAME TO multi_runs') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_server ON multi_runs(server_id, directory, created_at DESC)') + }, + + down(db) { + const columns = db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] + if (columns.length === 0) return + + const names = new Set(columns.map((column) => column.name)) + if (!names.has('server_id') && !names.has('directory')) return + + db.run('DROP TABLE IF EXISTS multi_runs_repo_only') + db.run(` + CREATE TABLE multi_runs_repo_only ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL + ) + `) + db.run(` + INSERT INTO multi_runs_repo_only (id, repo_id, name, prompt, isolated, base_ref, created_at) + SELECT id, repo_id, name, prompt, isolated, base_ref, created_at + FROM multi_runs + WHERE repo_id IS NOT NULL + `) + db.run('DROP TABLE multi_runs') + db.run('ALTER TABLE multi_runs_repo_only RENAME TO multi_runs') + db.run('CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') + }, +} + +export default migration diff --git a/backend/src/db/migrations/202610181320-remote-schedule-targets.ts b/backend/src/db/migrations/202610181320-remote-schedule-targets.ts new file mode 100644 index 000000000..7ad36fafa --- /dev/null +++ b/backend/src/db/migrations/202610181320-remote-schedule-targets.ts @@ -0,0 +1,254 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string + notnull: number +} + +const LEGACY_SUFFIX = '_legacy_202610181320' + +function createJobsTable(db: { run: (sql: string) => void }, table: string): void { + db.run(` + CREATE TABLE ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + schedule_mode TEXT NOT NULL DEFAULT 'interval', + cron_expression TEXT, + timezone TEXT, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + permission_config TEXT, + mcp_servers TEXT, + branch TEXT, + workspace_mode TEXT NOT NULL DEFAULT 'worktree', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) +} + +function createRunsTable(db: { run: (sql: string) => void }, table: string): void { + db.run(` + CREATE TABLE ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE, + repo_id INTEGER REFERENCES repos(id) ON DELETE CASCADE, + server_id TEXT NOT NULL DEFAULT 'local', + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + viewed_at INTEGER, + created_at INTEGER NOT NULL, + session_id TEXT, + session_title TEXT, + log_text TEXT, + response_text TEXT, + error_text TEXT, + run_branch TEXT, + commit_hash TEXT, + worktree_path TEXT + ) + `) +} + +const migration: Migration = { + id: '202610181320-remote-schedule-targets', + + up(db) { + const jobColumns = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] + if (jobColumns.length === 0) { + return + } + + const runColumns = db.prepare('PRAGMA table_info(schedule_runs)').all() as ColumnInfo[] + const jobColumnNames = new Set(jobColumns.map((column) => column.name)) + const runColumnNames = new Set(runColumns.map((column) => column.name)) + const jobsNeedRebuild = + !jobColumnNames.has('server_id') || + !jobColumnNames.has('directory') || + jobColumns.find((column) => column.name === 'repo_id')?.notnull !== 0 + const runsNeedRebuild = + !runColumnNames.has('server_id') || + runColumns.find((column) => column.name === 'repo_id')?.notnull !== 0 + + if (!jobsNeedRebuild && !runsNeedRebuild) { + return + } + + const jobsLegacy = `schedule_jobs${LEGACY_SUFFIX}` + const runsLegacy = `schedule_runs${LEGACY_SUFFIX}` + + db.run(`DROP TABLE IF EXISTS ${runsLegacy}`) + db.run(`DROP TABLE IF EXISTS ${jobsLegacy}`) + + db.run(`ALTER TABLE schedule_jobs RENAME TO ${jobsLegacy}`) + db.run(`ALTER TABLE schedule_runs RENAME TO ${runsLegacy}`) + + createJobsTable(db, 'schedule_jobs') + createRunsTable(db, 'schedule_runs') + + const jobServerColumn = jobColumnNames.has('server_id') ? 'server_id' : "'local'" + const jobDirectoryColumn = jobColumnNames.has('directory') ? 'directory' : 'NULL' + const jobOptionalColumns = ['permission_config', 'mcp_servers', 'branch', 'workspace_mode'] + .map((column) => (jobColumnNames.has(column) ? column : 'NULL')) + + db.run(` + INSERT INTO schedule_jobs ( + id, repo_id, server_id, directory, name, description, enabled, interval_minutes, schedule_mode, + cron_expression, timezone, agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, + branch, workspace_mode, created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, repo_id, ${jobServerColumn}, ${jobDirectoryColumn}, name, description, enabled, interval_minutes, + COALESCE(schedule_mode, 'interval'), cron_expression, timezone, agent_slug, prompt, model, skill_metadata, + ${jobOptionalColumns[0]}, ${jobOptionalColumns[1]}, ${jobOptionalColumns[2]}, + COALESCE(${jobOptionalColumns[3]}, 'worktree'), created_at, updated_at, last_run_at, next_run_at + FROM ${jobsLegacy} + `) + + const runServerColumn = runColumnNames.has('server_id') ? 'server_id' : "'local'" + const runOptionalColumns = ['viewed_at', 'run_branch', 'commit_hash', 'worktree_path'] + .map((column) => (runColumnNames.has(column) ? column : 'NULL')) + + db.run(` + INSERT INTO schedule_runs ( + id, job_id, repo_id, server_id, trigger_source, status, started_at, finished_at, viewed_at, + created_at, session_id, session_title, log_text, response_text, error_text, run_branch, + commit_hash, worktree_path + ) + SELECT + id, job_id, repo_id, ${runServerColumn}, trigger_source, status, started_at, finished_at, + ${runOptionalColumns[0]}, created_at, session_id, session_title, log_text, response_text, error_text, + ${runOptionalColumns[1]}, ${runOptionalColumns[2]}, ${runOptionalColumns[3]} + FROM ${runsLegacy} + `) + + db.run(`DROP TABLE ${runsLegacy}`) + db.run(`DROP TABLE ${jobsLegacy}`) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_server ON schedule_jobs(server_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_session ON schedule_runs(session_id, started_at DESC)') + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_unread + ON schedule_runs(finished_at DESC) + WHERE viewed_at IS NULL + `) + }, + + down(db) { + db.run('DELETE FROM schedule_runs WHERE repo_id IS NULL') + db.run('DELETE FROM schedule_jobs WHERE repo_id IS NULL') + + const jobsLegacy = `schedule_jobs${LEGACY_SUFFIX}` + const runsLegacy = `schedule_runs${LEGACY_SUFFIX}` + + db.run(`DROP TABLE IF EXISTS ${runsLegacy}`) + db.run(`DROP TABLE IF EXISTS ${jobsLegacy}`) + + db.run(`ALTER TABLE schedule_jobs RENAME TO ${jobsLegacy}`) + db.run(`ALTER TABLE schedule_runs RENAME TO ${runsLegacy}`) + + db.run(` + CREATE TABLE schedule_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + schedule_mode TEXT NOT NULL DEFAULT 'interval', + cron_expression TEXT, + timezone TEXT, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + permission_config TEXT, + mcp_servers TEXT, + branch TEXT, + workspace_mode TEXT NOT NULL DEFAULT 'worktree', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) + + db.run(` + CREATE TABLE schedule_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + viewed_at INTEGER, + created_at INTEGER NOT NULL, + session_id TEXT, + session_title TEXT, + log_text TEXT, + response_text TEXT, + error_text TEXT, + run_branch TEXT, + commit_hash TEXT, + worktree_path TEXT + ) + `) + + db.run(` + INSERT INTO schedule_jobs ( + id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone, + agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, + created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone, + agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, + created_at, updated_at, last_run_at, next_run_at + FROM ${jobsLegacy} + `) + + db.run(` + INSERT INTO schedule_runs ( + id, job_id, repo_id, trigger_source, status, started_at, finished_at, viewed_at, created_at, + session_id, session_title, log_text, response_text, error_text, run_branch, commit_hash, worktree_path + ) + SELECT + id, job_id, repo_id, trigger_source, status, started_at, finished_at, viewed_at, created_at, + session_id, session_title, log_text, response_text, error_text, run_branch, commit_hash, worktree_path + FROM ${runsLegacy} + `) + + db.run(`DROP TABLE ${runsLegacy}`) + db.run(`DROP TABLE ${jobsLegacy}`) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_session ON schedule_runs(session_id, started_at DESC)') + db.run(` + CREATE INDEX IF NOT EXISTS idx_schedule_runs_unread + ON schedule_runs(finished_at DESC) + WHERE viewed_at IS NULL + `) + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 93cb73794..5ef12db80 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -34,6 +34,9 @@ import migration202610141200 from './202610141200-session-sandbox-modes' import migration202610151200 from './202610151200-change-walkthrough-sessions' import migration202610161200 from './202610161200-change-walkthrough-execution' import migration202610181200 from './202610181200-opencode-servers' +import migration202610181300 from './202610181300-session-server-scope' +import migration202610181310 from './202610181310-multi-run-targets' +import migration202610181320 from './202610181320-remote-schedule-targets' export const allMigrations: Migration[] = [ migration001, @@ -71,4 +74,7 @@ export const allMigrations: Migration[] = [ migration202610151200, migration202610161200, migration202610181200, + migration202610181300, + migration202610181310, + migration202610181320, ] diff --git a/backend/src/db/multi-runs.ts b/backend/src/db/multi-runs.ts index 8a039233a..14466a806 100644 --- a/backend/src/db/multi-runs.ts +++ b/backend/src/db/multi-runs.ts @@ -4,6 +4,7 @@ import { type MultiRunEntryStatus, type MultiRunFusionSource, type MultiRunFusionStatus, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' export interface MultiRunEntryRecord { @@ -37,7 +38,9 @@ export interface MultiRunFusionRecord { export interface MultiRunRecord { id: number - repoId: number + repoId: number | null + serverId: string + directory: string | null name: string prompt: string isolated: boolean @@ -48,7 +51,9 @@ export interface MultiRunRecord { } export interface CreateMultiRunGroup { - repoId: number + repoId: number | null + serverId: string + directory: string | null name: string prompt: string isolated: boolean @@ -81,7 +86,9 @@ export interface MultiRunFusionPatch { interface MultiRunRow { id: number - repo_id: number + repo_id: number | null + server_id: string + directory: string | null name: string prompt: string isolated: number @@ -118,7 +125,7 @@ interface MultiRunFusionRow { updated_at: number } -const MULTI_RUN_COLUMNS = 'id, repo_id, name, prompt, isolated, base_ref, created_at' +const MULTI_RUN_COLUMNS = 'id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at' const MULTI_RUN_ENTRY_COLUMNS = 'id, multi_run_id, model, status, session_id, directory, error, created_at, updated_at' @@ -129,7 +136,9 @@ export function ensureMultiRunTables(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS multi_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_id INTEGER NOT NULL, + repo_id INTEGER, + server_id TEXT NOT NULL DEFAULT 'local', + directory TEXT, name TEXT NOT NULL, prompt TEXT NOT NULL, isolated INTEGER NOT NULL, @@ -158,6 +167,10 @@ export function ensureMultiRunTables(db: Database): void { CREATE INDEX IF NOT EXISTS idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC) `) + db.run(` + CREATE INDEX IF NOT EXISTS idx_multi_runs_server + ON multi_runs(server_id, directory, created_at DESC) + `) } export function ensureMultiRunFusionTable(db: Database): void { @@ -194,8 +207,20 @@ export function createMultiRunWithEntries( const now = Date.now() const create = db.transaction(() => { const result = db - .prepare(`INSERT INTO multi_runs(repo_id, name, prompt, isolated, base_ref, created_at) VALUES(?,?,?,?,?,?)`) - .run(group.repoId, group.name, group.prompt, group.isolated ? 1 : 0, group.baseRef, now) + .prepare( + `INSERT INTO multi_runs(repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(?,?,?,?,?,?,?,?)`, + ) + .run( + group.repoId, + group.serverId, + group.directory, + group.name, + group.prompt, + group.isolated ? 1 : 0, + group.baseRef, + now, + ) const multiRunId = Number(result.lastInsertRowid) const insertEntry = db.prepare(` @@ -226,15 +251,20 @@ export function getMultiRun(db: Database, id: number): MultiRunRecord | null { return toMultiRunRecord(row, loadEntries(db, row.id), loadFusions(db, row.id)) } -export function listMultiRuns(db: Database, repoId: number, limit: number): MultiRunRecord[] { +export function listMultiRuns(db: Database, target: MultiRunTarget, limit: number): MultiRunRecord[] { + const [where, params] = + target.kind === 'repo' + ? ['repo_id = ?', [target.repoId] as const] + : ['server_id = ? AND directory = ?', [target.serverId, target.directory] as const] + const rows = db .prepare(` SELECT ${MULTI_RUN_COLUMNS} FROM multi_runs - WHERE repo_id = ? + WHERE ${where} ORDER BY created_at DESC, id DESC LIMIT ? `) - .all(repoId, limit) as MultiRunRow[] + .all(...params, limit) as MultiRunRow[] if (rows.length === 0) { return [] } @@ -445,6 +475,8 @@ function toMultiRunRecord( return { id: row.id, repoId: row.repo_id, + serverId: row.server_id, + directory: row.directory, name: row.name, prompt: row.prompt, isolated: row.isolated === 1, diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index db9f11f36..4d9e4e594 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite' import { + LOCAL_OPENCODE_SERVER_ID, ScheduleJobSchema, ScheduleMcpServersSchema, SchedulePermissionConfigSchema, @@ -20,7 +21,9 @@ import type { ScheduleJobPersistenceInput } from '../services/schedule-config' interface ScheduleJobRow { id: number - repo_id: number + repo_id: number | null + server_id: string + directory: string | null name: string description: string | null enabled: number @@ -45,7 +48,8 @@ interface ScheduleJobRow { interface ScheduleRunRow { id: number job_id: number - repo_id: number + repo_id: number | null + server_id: string trigger_source: string status: string started_at: number @@ -112,6 +116,8 @@ function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { return ScheduleJobSchema.parse({ id: row.id, repoId: row.repo_id, + serverId: row.server_id ?? 'local', + directory: row.directory ?? null, name: row.name, description: row.description, enabled: Boolean(row.enabled), @@ -139,6 +145,7 @@ function rowToScheduleRun(row: ScheduleRunRow): ScheduleRun { id: row.id, jobId: row.job_id, repoId: row.repo_id, + serverId: row.server_id ?? 'local', triggerSource: row.trigger_source, status: row.status, startedAt: row.started_at, @@ -177,13 +184,19 @@ function serializeMcpServers(mcpServers: ScheduleJobPersistenceInput['mcpServers } export function listScheduleJobsByRepo(db: Database, repoId: number): ScheduleJob[] { - const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') const rows = stmt.all(repoId) as ScheduleJobRow[] return rows.map(rowToScheduleJob) } +export function listScheduleJobsByServer(db: Database, serverId: string): ScheduleJob[] { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE server_id = ? ORDER BY created_at DESC') + const rows = stmt.all(serverId) as ScheduleJobRow[] + return rows.map(rowToScheduleJob) +} + export function listScheduleJobIdsByRepo(db: Database, repoId: number): number[] { - const stmt = db.prepare('SELECT id FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + const stmt = db.prepare('SELECT id FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') const rows = stmt.all(repoId) as Array<{ id: number }> return rows.map((row) => row.id) } @@ -194,28 +207,35 @@ export function listEnabledScheduleJobs(db: Database): ScheduleJob[] { return rows.map(rowToScheduleJob) } -export function getScheduleJobById(db: Database, repoId: number, jobId: number): ScheduleJob | null { - const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? AND id = ?') +export function getScheduleJobById(db: Database, repoId: number | null, jobId: number): ScheduleJob | null { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id IS ? AND id = ?') const row = stmt.get(repoId, jobId) as ScheduleJobRow | undefined return row ? rowToScheduleJob(row) : null } -export function createScheduleJob(db: Database, repoId: number, input: ScheduleJobPersistenceInput): ScheduleJob { +export function createScheduleJob( + db: Database, + repoId: number | null, + input: ScheduleJobPersistenceInput, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): ScheduleJob { const now = Date.now() const stmt = db.prepare(` INSERT INTO schedule_jobs ( - repo_id, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata, + repo_id, server_id, directory, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata, permission_config, mcp_servers, branch, workspace_mode, created_at, updated_at, last_run_at, next_run_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( repoId, + serverId, + input.directory ?? null, input.name, input.description ?? null, input.enabled ? 1 : 0, @@ -244,7 +264,7 @@ export function createScheduleJob(db: Database, repoId: number, input: ScheduleJ return job } -export function updateScheduleJob(db: Database, repoId: number, jobId: number, input: ScheduleJobPersistenceInput): ScheduleJob | null { +export function updateScheduleJob(db: Database, repoId: number | null, jobId: number, input: ScheduleJobPersistenceInput): ScheduleJob | null { const existing = getScheduleJobById(db, repoId, jobId) if (!existing) { return null @@ -255,8 +275,8 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i const stmt = db.prepare(` UPDATE schedule_jobs SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?, - agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, updated_at = ?, next_run_at = ? - WHERE repo_id = ? AND id = ? + agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, permission_config = ?, mcp_servers = ?, branch = ?, workspace_mode = ?, directory = ?, updated_at = ?, next_run_at = ? + WHERE repo_id IS ? AND id = ? `) stmt.run( @@ -275,6 +295,7 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i serializeMcpServers(input.mcpServers), input.branch, input.workspaceMode, + input.directory ?? null, now, input.nextRunAt, repoId, @@ -284,9 +305,9 @@ export function updateScheduleJob(db: Database, repoId: number, jobId: number, i return getScheduleJobById(db, repoId, jobId) } -export function deleteScheduleJob(db: Database, repoId: number, jobId: number): boolean { - db.prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?').run(repoId, jobId) - const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') +export function deleteScheduleJob(db: Database, repoId: number | null, jobId: number): boolean { + db.prepare('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ?').run(repoId, jobId) + const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') const result = stmt.run(repoId, jobId) return result.changes > 0 } @@ -298,9 +319,9 @@ export interface ScheduleRunArtifact { worktreePath: string | null } -export function listScheduleRunArtifactsByJob(db: Database, repoId: number, jobId: number): ScheduleRunArtifact[] { +export function listScheduleRunArtifactsByJob(db: Database, repoId: number | null, jobId: number): ScheduleRunArtifact[] { const rows = db - .prepare('SELECT id, status, run_branch, worktree_path FROM schedule_runs WHERE repo_id = ? AND job_id = ? ORDER BY id DESC') + .prepare('SELECT id, status, run_branch, worktree_path FROM schedule_runs WHERE repo_id IS ? AND job_id = ? ORDER BY id DESC') .all(repoId, jobId) as { id: number; status: string; run_branch: string | null; worktree_path: string | null }[] return rows.map((row) => ({ id: row.id, @@ -317,28 +338,28 @@ export interface ScheduleJobWorktreeOwner { } export function listScheduleJobWorktreeOwners(db: Database): ScheduleJobWorktreeOwner[] { - const rows = db.prepare('SELECT id, repo_id, name FROM schedule_jobs').all() as Array<{ id: number; repo_id: number; name: string }> + const rows = db.prepare('SELECT id, repo_id, name FROM schedule_jobs WHERE repo_id IS NOT NULL').all() as Array<{ id: number; repo_id: number; name: string }> return rows.map((row) => ({ id: row.id, repoId: row.repo_id, name: row.name })) } -export function clearScheduleRunWorktreePath(db: Database, repoId: number, jobId: number, worktreePath: string): number { +export function clearScheduleRunWorktreePath(db: Database, repoId: number | null, jobId: number, worktreePath: string): number { return db - .prepare('UPDATE schedule_runs SET worktree_path = NULL WHERE repo_id = ? AND job_id = ? AND worktree_path = ?') + .prepare('UPDATE schedule_runs SET worktree_path = NULL WHERE repo_id IS ? AND job_id = ? AND worktree_path = ?') .run(repoId, jobId, worktreePath).changes } -export function deleteScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): boolean { +export function deleteScheduleRunById(db: Database, repoId: number | null, jobId: number, runId: number): boolean { const result = db - .prepare('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + .prepare('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') .run(repoId, jobId, runId) return result.changes > 0 } -export function deleteScheduleRunsByIds(db: Database, repoId: number, jobId: number, runIds: number[]): number { +export function deleteScheduleRunsByIds(db: Database, repoId: number | null, jobId: number, runIds: number[]): number { if (runIds.length === 0) return 0 const placeholders = runIds.map(() => '?').join(', ') const result = db - .prepare(`DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id IN (${placeholders})`) + .prepare(`DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id IN (${placeholders})`) .run(repoId, jobId, ...runIds) return result.changes } @@ -346,22 +367,22 @@ export function deleteScheduleRunsByIds(db: Database, repoId: number, jobId: num export function cleanupOrphanedSchedules(db: Database): { orphanedJobs: number; orphanedRuns: number } { const runStmt = db.prepare(` DELETE FROM schedule_runs - WHERE (repo_id != ? AND repo_id NOT IN (SELECT id FROM repos)) + WHERE (repo_id IS NOT NULL AND repo_id != ? AND repo_id NOT IN (SELECT id FROM repos)) OR job_id NOT IN (SELECT id FROM schedule_jobs) `) const orphanedRuns = runStmt.run(ASSISTANT_REPO_ID).changes const jobStmt = db.prepare(` DELETE FROM schedule_jobs - WHERE repo_id != ? AND repo_id NOT IN (SELECT id FROM repos) + WHERE repo_id IS NOT NULL AND repo_id != ? AND repo_id NOT IN (SELECT id FROM repos) `) const orphanedJobs = jobStmt.run(ASSISTANT_REPO_ID).changes return { orphanedJobs, orphanedRuns } } -export function updateScheduleJobRunState(db: Database, repoId: number, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { - const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') +export function updateScheduleJobRunState(db: Database, repoId: number | null, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { + const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id IS ? AND id = ?') stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId) } @@ -374,7 +395,8 @@ export function createScheduleRun( db: Database, input: { jobId: number - repoId: number + repoId: number | null + serverId?: string triggerSource: ScheduleRunTriggerSource status: ScheduleRunStatus startedAt: number @@ -382,13 +404,14 @@ export function createScheduleRun( }, ): ScheduleRun { const stmt = db.prepare(` - INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO schedule_runs (job_id, repo_id, server_id, trigger_source, status, started_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( input.jobId, input.repoId, + input.serverId ?? LOCAL_OPENCODE_SERVER_ID, input.triggerSource, input.status, input.startedAt, @@ -404,7 +427,7 @@ export function createScheduleRun( export function updateScheduleRun( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { @@ -420,7 +443,7 @@ export function updateScheduleRun( const stmt = db.prepare(` UPDATE schedule_runs SET status = ?, finished_at = ?, session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -441,7 +464,7 @@ export function updateScheduleRun( export function updateScheduleRunMetadata( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { @@ -460,7 +483,7 @@ export function updateScheduleRunMetadata( const stmt = db.prepare(` UPDATE schedule_runs SET session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -479,7 +502,7 @@ export function updateScheduleRunMetadata( export function updateScheduleRunWorktree( db: Database, - repoId: number, + repoId: number | null, jobId: number, runId: number, input: { worktreePath?: string | null; runBranch?: string | null; commitHash?: string | null }, @@ -492,7 +515,7 @@ export function updateScheduleRunWorktree( const stmt = db.prepare(` UPDATE schedule_runs SET worktree_path = ?, run_branch = ?, commit_hash = ? - WHERE repo_id = ? AND job_id = ? AND id = ? + WHERE repo_id IS ? AND job_id = ? AND id = ? `) stmt.run( @@ -507,8 +530,8 @@ export function updateScheduleRunWorktree( return getScheduleRunById(db, repoId, jobId, runId) } -export function getScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): ScheduleRun | null { - const stmt = db.prepare('SELECT * FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') +export function getScheduleRunById(db: Database, repoId: number | null, jobId: number, runId: number): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') const row = stmt.get(repoId, jobId, runId) as ScheduleRunRow | undefined return row ? rowToScheduleRun(row) : null } @@ -519,10 +542,10 @@ export function getScheduleRunBySessionId(db: Database, sessionId: string): Sche return row ? rowToScheduleRun(row) : null } -export function getRunningScheduleRunByJob(db: Database, repoId: number, jobId: number): ScheduleRun | null { +export function getRunningScheduleRunByJob(db: Database, repoId: number | null, jobId: number): ScheduleRun | null { const stmt = db.prepare(` SELECT * FROM schedule_runs - WHERE repo_id = ? AND job_id = ? AND status = 'running' + WHERE repo_id IS ? AND job_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1 `) @@ -541,12 +564,13 @@ export function listRunningScheduleRuns(db: Database, limit: number = 100): Sche return rows.map(rowToScheduleRun) } -export function listScheduleRunsByJob(db: Database, repoId: number, jobId: number, limit: number = 20): ScheduleRun[] { +export function listScheduleRunsByJob(db: Database, repoId: number | null, jobId: number, limit: number = 20): ScheduleRun[] { const stmt = db.prepare(` SELECT id, job_id, repo_id, + server_id, trigger_source, status, started_at, @@ -562,7 +586,7 @@ export function listScheduleRunsByJob(db: Database, repoId: number, jobId: numbe commit_hash, worktree_path FROM schedule_runs - WHERE repo_id = ? AND job_id = ? + WHERE repo_id IS ? AND job_id = ? ORDER BY started_at DESC LIMIT ? `) @@ -583,6 +607,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + serverName: string | null lastRun: ScheduleRunSummary | null } @@ -591,6 +616,7 @@ interface ScheduleJobWithRepoRow extends ScheduleJobRow { repo_path: string | null repo_name: string | null repo_source_path: string | null + server_name: string | null last_run_id: number | null last_run_status: string | null last_run_started_at: number | null @@ -601,14 +627,19 @@ interface ScheduleJobWithRepoRow extends ScheduleJobRow { } interface RepoDisplayRow { - repo_id: number + repo_id: number | null repo_path: string | null repo_name: string | null repo_url?: string | null repo_source_path?: string | null + directory?: string | null + server_name?: string | null } function resolveRepoDisplay(row: RepoDisplayRow): { repoName: string; repoPath: string } { + if (row.repo_id === null) { + return { repoName: row.server_name ?? 'Linked server', repoPath: row.directory ?? '' } + } if (row.repo_id === ASSISTANT_REPO_ID) { return { repoName: ASSISTANT_REPO_NAME, repoPath: ASSISTANT_REPO_PATH } } @@ -641,6 +672,7 @@ function rowToScheduleJobWithRepo(row: ScheduleJobWithRepoRow): ScheduleJobWithR ...rowToScheduleJob(row), ...resolveRepoDisplay(row), repoUrl: row.repo_url ?? '', + serverName: row.server_name ?? null, lastRun: buildLastRunSummary(row), } } @@ -649,6 +681,7 @@ export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[ const stmt = db.prepare(` SELECT sj.*, r.repo_url, r.local_path as repo_path, r.name as repo_name, r.source_path as repo_source_path, + os.name AS server_name, sr.id AS last_run_id, sr.status AS last_run_status, sr.started_at AS last_run_started_at, @@ -658,10 +691,11 @@ export function listAllScheduleJobsWithRepos(db: Database): ScheduleJobWithRepo[ substr(sr.response_text, 1, 600) AS last_run_response_head FROM schedule_jobs sj LEFT JOIN repos r ON sj.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' LEFT JOIN schedule_runs sr ON sr.id = ( SELECT id FROM schedule_runs WHERE job_id = sj.id ORDER BY started_at DESC LIMIT 1 ) - ORDER BY COALESCE(r.local_path, ''), sj.name + ORDER BY COALESCE(r.local_path, sj.directory, ''), sj.name `) const rows = stmt.all() as ScheduleJobWithRepoRow[] return rows.map(rowToScheduleJobWithRepo) @@ -671,6 +705,7 @@ export interface ScheduleRunWithContext extends ScheduleRun { jobName: string repoName: string repoPath: string + serverName: string | null } interface ScheduleRunWithContextRow extends ScheduleRunRow { @@ -679,12 +714,15 @@ interface ScheduleRunWithContextRow extends ScheduleRunRow { repo_name: string | null repo_url: string | null repo_source_path: string | null + directory?: string | null + server_name?: string | null } function rowToScheduleRunWithContext(row: ScheduleRunWithContextRow): ScheduleRunWithContext { return { ...rowToScheduleRun(row), jobName: row.job_name, + serverName: row.server_name ?? null, ...resolveRepoDisplay(row), } } @@ -732,7 +770,7 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = const searchTerm = search?.trim() if (searchTerm) { const pattern = `%${escapeLikePattern(searchTerm)}%` - const searchColumns = ['sj.name', 'sr.session_title', 'sr.error_text', 'sr.run_branch', 'r.name', 'r.local_path'] + const searchColumns = ['sj.name', 'sr.session_title', 'sr.error_text', 'sr.run_branch', 'r.name', 'r.local_path', 'sj.directory', 'os.name'] conditions.push(`(${searchColumns.map((column) => `${column} LIKE ? ESCAPE '\\'`).join(' OR ')})`) params.push(...searchColumns.map(() => pattern)) } @@ -741,17 +779,18 @@ export function listAllScheduleRuns(db: Database, options: ListAllRunsOptions = const stmt = db.prepare(` SELECT - sr.id, sr.job_id, sr.repo_id, sr.trigger_source, sr.status, + sr.id, sr.job_id, sr.repo_id, sr.server_id, sr.trigger_source, sr.status, sr.started_at, sr.finished_at, sr.created_at, sr.session_id, sr.session_title, NULL AS log_text, NULL AS response_text, sr.error_text, sr.viewed_at, sr.run_branch, sr.commit_hash, sr.worktree_path, - sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, - r.repo_url AS repo_url, r.source_path AS repo_source_path + sj.name AS job_name, sj.directory AS directory, r.local_path AS repo_path, r.name AS repo_name, + r.repo_url AS repo_url, r.source_path AS repo_source_path, os.name AS server_name FROM schedule_runs sr JOIN schedule_jobs sj ON sr.job_id = sj.id LEFT JOIN repos r ON sr.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' ${whereClause} ORDER BY sr.started_at DESC LIMIT ? OFFSET ? @@ -810,17 +849,18 @@ interface UnreadScheduleRunRow extends ScheduleRunWithContextRow { export function listUnreadScheduleRuns(db: Database, limit: number = 20): ScheduleRunWithUnreadPreview[] { const stmt = db.prepare(` SELECT - sr.id, sr.job_id, sr.repo_id, sr.trigger_source, sr.status, + sr.id, sr.job_id, sr.repo_id, sr.server_id, sr.trigger_source, sr.status, sr.started_at, sr.finished_at, sr.created_at, sr.session_id, sr.session_title, sr.viewed_at, NULL AS log_text, NULL AS response_text, sr.error_text, sr.run_branch, sr.commit_hash, sr.worktree_path, - sj.name AS job_name, r.local_path AS repo_path, r.name AS repo_name, - r.repo_url AS repo_url, r.source_path AS repo_source_path, + sj.name AS job_name, sj.directory AS directory, r.local_path AS repo_path, r.name AS repo_name, + r.repo_url AS repo_url, r.source_path AS repo_source_path, os.name AS server_name, substr(sr.response_text, 1, 600) AS response_head FROM schedule_runs sr JOIN schedule_jobs sj ON sr.job_id = sj.id LEFT JOIN repos r ON sr.repo_id = r.id + LEFT JOIN opencode_servers os ON sj.server_id = os.id AND sj.server_id != 'local' WHERE sr.status IN ('completed', 'failed') AND sr.viewed_at IS NULL ORDER BY (sr.status = 'failed') DESC, sr.finished_at DESC LIMIT ? diff --git a/backend/src/db/session-goals.ts b/backend/src/db/session-goals.ts index 070977c78..5a2a4620e 100644 --- a/backend/src/db/session-goals.ts +++ b/backend/src/db/session-goals.ts @@ -1,14 +1,16 @@ import type { Database } from 'bun:sqlite' -import type { - SessionGoalStatus, - SessionGoalStopReason, - SessionGoalTurnState, - SessionGoalVerdict, +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionGoalStatus, + type SessionGoalStopReason, + type SessionGoalTurnState, + type SessionGoalVerdict, } from '@opencode-manager/shared/schemas' export interface SessionGoalRecord { id: number sessionId: string + serverId: string directory: string objective: string status: SessionGoalStatus @@ -29,6 +31,7 @@ export interface SessionGoalRecord { export interface InsertSessionGoalInput { sessionId: string + serverId?: string directory: string objective: string maxContinuations: number @@ -51,6 +54,7 @@ export interface SessionGoalPatch { interface SessionGoalRow { id: number session_id: string + server_id: string directory: string objective: string status: SessionGoalStatus @@ -70,7 +74,7 @@ interface SessionGoalRow { } const SESSION_GOAL_COLUMNS = ` - id, session_id, directory, objective, status, stop_reason, turn_state, + id, session_id, server_id, directory, objective, status, stop_reason, turn_state, continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at ` @@ -80,6 +84,7 @@ export function ensureSessionGoalsTable(db: Database): void { CREATE TABLE IF NOT EXISTS session_goals ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT 'local', directory TEXT NOT NULL, objective TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), @@ -100,7 +105,7 @@ export function ensureSessionGoalsTable(db: Database): void { `) db.run(` CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open - ON session_goals(session_id) + ON session_goals(server_id, session_id) WHERE status IN ('active','paused') `) db.run(` @@ -114,12 +119,13 @@ export function insertSessionGoal(db: Database, input: InsertSessionGoalInput): try { const result = db.prepare(` INSERT INTO session_goals( - session_id, directory, objective, status, stop_reason, turn_state, + session_id, server_id, directory, objective, status, stop_reason, turn_state, continuation_count, max_continuations, token_budget, tokens_at_start, tokens_used, consecutive_blocked, last_verdict, last_reason, created_at, updated_at, finished_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( input.sessionId, + input.serverId ?? LOCAL_OPENCODE_SERVER_ID, input.directory, input.objective, 'active', @@ -153,10 +159,19 @@ export function getSessionGoalById(db: Database, id: number): SessionGoalRecord return row ? mapSessionGoalRow(row) : null } -export function getLatestSessionGoal(db: Database, sessionId: string): SessionGoalRecord | null { +export function getLatestSessionGoal( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): SessionGoalRecord | null { const row = db - .prepare(`SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT 1`) - .get(sessionId) as SessionGoalRow | undefined + .prepare(` + SELECT ${SESSION_GOAL_COLUMNS} FROM session_goals + WHERE server_id = ? AND session_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1 + `) + .get(serverId, sessionId) as SessionGoalRow | undefined return row ? mapSessionGoalRow(row) : null } @@ -228,6 +243,7 @@ function mapSessionGoalRow(row: SessionGoalRow): SessionGoalRecord { return { id: row.id, sessionId: row.session_id, + serverId: row.server_id, directory: row.directory, objective: row.objective, status: row.status, diff --git a/backend/src/db/session-permission-modes.ts b/backend/src/db/session-permission-modes.ts index 2669bb2f0..08a97c1eb 100644 --- a/backend/src/db/session-permission-modes.ts +++ b/backend/src/db/session-permission-modes.ts @@ -1,38 +1,60 @@ import type { Database } from 'bun:sqlite' -import type { SessionPermissionMode } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, type SessionPermissionMode } from '@opencode-manager/shared/schemas' export function ensureSessionPermissionModesTable(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS session_permission_modes ( session_id TEXT PRIMARY KEY, + server_id TEXT NOT NULL DEFAULT 'local', mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), updated_at INTEGER NOT NULL ) `) } -export function getSessionPermissionMode(db: Database, sessionId: string): SessionPermissionMode | null { +export function getSessionPermissionMode( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): SessionPermissionMode | null { const row = db - .prepare('SELECT mode FROM session_permission_modes WHERE session_id = ?') - .get(sessionId) as { mode: SessionPermissionMode } | undefined + .prepare('SELECT mode FROM session_permission_modes WHERE server_id = ? AND session_id = ?') + .get(serverId, sessionId) as { mode: SessionPermissionMode } | undefined return row ? row.mode : null } -export function setSessionPermissionMode(db: Database, sessionId: string, mode: SessionPermissionMode): void { +export function setSessionPermissionMode( + db: Database, + sessionId: string, + mode: SessionPermissionMode, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { db.prepare(` - INSERT INTO session_permission_modes(session_id, mode, updated_at) - VALUES(?,?,?) - ON CONFLICT(session_id) DO UPDATE SET mode=excluded.mode, updated_at=excluded.updated_at - `).run(sessionId, mode, Date.now()) + INSERT INTO session_permission_modes(server_id, session_id, mode, updated_at) + VALUES(?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + server_id=excluded.server_id, + mode=excluded.mode, + updated_at=excluded.updated_at + `).run(serverId, sessionId, mode, Date.now()) } -export function insertSessionPermissionModeIfAbsent(db: Database, sessionId: string, mode: SessionPermissionMode): void { +export function insertSessionPermissionModeIfAbsent( + db: Database, + sessionId: string, + mode: SessionPermissionMode, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { db.prepare(` - INSERT OR IGNORE INTO session_permission_modes(session_id, mode, updated_at) - VALUES(?,?,?) - `).run(sessionId, mode, Date.now()) + INSERT OR IGNORE INTO session_permission_modes(server_id, session_id, mode, updated_at) + VALUES(?,?,?,?) + `).run(serverId, sessionId, mode, Date.now()) } -export function deleteSessionPermissionMode(db: Database, sessionId: string): void { - db.prepare('DELETE FROM session_permission_modes WHERE session_id = ?').run(sessionId) +export function deleteSessionPermissionMode( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): void { + db.prepare('DELETE FROM session_permission_modes WHERE server_id = ? AND session_id = ?').run(serverId, sessionId) } diff --git a/backend/src/index.ts b/backend/src/index.ts index 73ae3d4cb..7068b7325 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,7 +15,7 @@ import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'; import { createSTTRoutes } from './routes/stt' import { createFileRoutes } from './routes/files' import { createFilesystemRoutes } from './routes/filesystem' -import { createScheduleRoutes } from './routes/schedules' +import { createScheduleRoutes, createServerScheduleRoutes } from './routes/schedules' async function getAppVersion(): Promise { try { @@ -53,6 +53,10 @@ import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' import { createAuthenticatedOpenCodeProxyRoutes } from './routes/opencode-auth-proxy' import { createOpenCodeServerProxyRoutes } from './routes/opencode-server-proxy' import { createOpenCodeServerRoutes } from './routes/opencode-servers' +import { + createOpenCodeServerTerminalRoutes, + createOpenCodeServerTerminalSocketRoutes, +} from './routes/opencode-server-terminals' import { sseAggregator } from './services/sse-aggregator' import { RemoteServerEventHub } from './services/remote-server-events' import { ensureDirectoryExists, writeFileContent, fileExists } from './services/file-operations' @@ -93,7 +97,7 @@ import { getTrustedOrigins, ENV } from '@opencode-manager/shared/config/env' -import { NotificationEventType } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, NotificationEventType } from '@opencode-manager/shared/schemas' const { PORT, HOST } = ENV.SERVER @@ -250,14 +254,25 @@ try { } const terminalService = new TerminalService( - openCodeClient, + { + client: openCodeClient, + baseUrl: () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), + includeLocalCredentials: true, + }, new CredentialProvider(db), - () => getOpenCodeUpstreamBaseUrl(opencodeServerManager.getEffectiveServerHost()), ) +const resolveServerTerminalService = (serverId: string): TerminalService => { + const connection = opencodeServerRegistry.getConnection(serverId) + return terminalService.forTarget({ + client: opencodeServerRegistry.getClient(serverId), + baseUrl: () => connection.baseUrl, + includeLocalCredentials: false, + }) +} const gitService = createGitService(gitAuthService) const projectConfigService = new ProjectConfigService(db, gitService, gitAuthService) const scheduleWorktreeManager = new ScheduleWorktreeManager(gitAuthService, db) -const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager) +const scheduleService = new ScheduleService(db, openCodeClient, scheduleWorktreeManager, opencodeServerRegistry) scheduleService.setWorktreeRemovedHandler(async (directory) => { try { await terminalService.removeAll(directory) @@ -268,37 +283,31 @@ scheduleService.setWorktreeRemovedHandler(async (directory) => { const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService) const scheduleRunnerInstance = new ScheduleRunner(scheduleService) -const notificationService = new NotificationService(db) +const notificationService = new NotificationService(db, opencodeServerRegistry) const sessionSettingsService = new SettingsService(db) -const sessionAncestry = new SessionAncestry(openCodeClient) -const sessionPermissionModeService = new SessionPermissionModeService(db, openCodeClient, sessionSettingsService, sessionAncestry) +const sessionAncestry = new SessionAncestry((serverId) => opencodeServerRegistry.getClient(serverId)) +const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry) const sessionSandboxService = new SessionSandboxService(db, sessionAncestry, sessionSettingsService, { removeSessionSandbox: (rootSessionId) => new SandboxRuntimeService(db).removeSessionSandbox(rootSessionId), }) -const sessionGoalService = new SessionGoalService(db, openCodeClient, sessionSettingsService, { +const sessionGoalService = new SessionGoalService(db, opencodeServerRegistry, sessionSettingsService, { onOutcome: (goal) => { void notificationService.notifyGoalOutcome(goal).catch((error) => { logger.error('Goal outcome notification error:', error) }) }, - resolveSessionLock: async (sessionId) => - (await sessionPermissionModeService.getEffectiveMode(sessionId)).lockedReason, + resolveSessionLock: async (sessionId, serverId) => + (await sessionPermissionModeService.getEffectiveMode(sessionId, serverId)).lockedReason, }) sessionGoalService.loadOpenGoals() -const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService) +const multiRunService = new MultiRunService(db, openCodeClient, repoWorkspaces, sessionPermissionModeService, (serverId) => opencodeServerRegistry.getClient(serverId)) const changeWalkthroughService = new ChangeWalkthroughService(db, openCodeClient, sessionSettingsService, gitAuthService, gitService) sseAggregator.onEvent((directory, event) => { - sessionPermissionModeService.handleEvent(directory, event).catch((err) => { - logger.error('Session permission mode event handling error:', err) - }) sessionSandboxService.handleEvent(directory, event).catch((err) => { logger.error('Session sandbox event handling error:', err) }) - sessionGoalService.handleEvent(directory, event).catch((err) => { - logger.error('Session goal event handling error:', err) - }) try { changeWalkthroughService.handleEvent(event) } catch (err) { @@ -306,12 +315,21 @@ sseAggregator.onEvent((directory, event) => { } }) -notificationService.addEventSuppressor(async (event, sessionId) => { - if (event.type !== 'permission.asked' || !sessionId) return false - return (await sessionPermissionModeService.getEffectiveMode(sessionId)).mode === 'auto' +sseAggregator.onServerEvent((serverId, directory, event) => { + sessionPermissionModeService.handleEvent(directory, event, serverId).catch((err) => { + logger.error('Session permission mode event handling error:', err) + }) + sessionGoalService.handleEvent(directory, event, serverId).catch((err) => { + logger.error('Session goal event handling error:', err) + }) }) -notificationService.addEventSuppressor(async (event, sessionId) => { +notificationService.addEventSuppressor((event, sessionId, serverId) => + sessionPermissionModeService.suppressesPermissionNotification(event, sessionId, serverId), +) + +notificationService.addEventSuppressor(async (event, sessionId, serverId) => { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) return false if (!sessionId) return false if (event.type !== NotificationEventType.SESSION_IDLE && event.type !== NotificationEventType.SESSION_FAILED) return false return getChangeWalkthroughByWalkthroughSession(db, sessionId) !== null @@ -329,8 +347,8 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { privateKey: ENV.VAPID.PRIVATE_KEY, subject: ENV.VAPID.SUBJECT || 'mailto:push@localhost', }) - sseAggregator.onEvent((directory, event) => { - notificationService.handleSSEEvent(directory, event).catch((err) => { + sseAggregator.onServerEvent((serverId, directory, event) => { + notificationService.handleSSEEvent(directory, event, serverId).catch((err) => { logger.error('Push notification dispatch error:', err) }) }) @@ -338,9 +356,9 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { sseAggregator.setPendingActionsFetcher(openCodeClient) sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword()) -sseAggregator.onUpstreamConnected(() => { - void sessionPermissionModeService.acceptPendingRequestsForActiveSessions().catch((err) => { - logger.error('Failed to accept pending permission requests for active sessions:', err) +sseAggregator.onServerConnected((serverId) => { + void sessionPermissionModeService.acceptPendingRequestsForActiveSessions(serverId).catch((err) => { + logger.error(`Failed to accept pending permission requests for server ${serverId}:`, err) }) }) sseAggregator.start() @@ -387,6 +405,9 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/session-pins', createSessionPinRoutes(db)) +protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService)) +protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket)) +protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resolveServerTerminalService)) protectedApi.route('/opencode-servers', createOpenCodeServerRoutes(opencodeServerRegistry, () => remoteServerEvents.getStatuses())) protectedApi.route('/session-permission-modes', createSessionPermissionModeRoutes(sessionPermissionModeService)) protectedApi.route('/session-sandbox', createSessionSandboxRoutes(sessionSandboxService)) diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 00c3a718c..1d1d3870a 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -47,7 +47,7 @@ export function createInternalRoutes( const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) app.route('/schedules', createScheduleRoutes(scheduleService)) - app.route('/session-goals', createSessionGoalRoutes(sessionGoals)) + app.route('/session-goals', createSessionGoalRoutes(sessionGoals, { serverScoped: false })) app.route('/multi-runs', createMultiRunRoutes(multiRuns)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) diff --git a/backend/src/routes/internal/sessions.ts b/backend/src/routes/internal/sessions.ts index fedba716b..cf71ccf30 100644 --- a/backend/src/routes/internal/sessions.ts +++ b/backend/src/routes/internal/sessions.ts @@ -131,7 +131,7 @@ export function createInternalSessionRoutes( permissionModes.pinAsk(launched.sessionId) const url = buildSessionPath( - launched.repoId, + input.repoId, launched.sessionId, launched.workspaceDirectory ? { repoTab: 'workspaces' } : undefined, ) diff --git a/backend/src/routes/multi-runs.ts b/backend/src/routes/multi-runs.ts index 8aa2105eb..69939330a 100644 --- a/backend/src/routes/multi-runs.ts +++ b/backend/src/routes/multi-runs.ts @@ -1,16 +1,31 @@ import { Hono } from 'hono' -import { FuseMultiRunRequestSchema, LaunchMultiRunRequestSchema } from '@opencode-manager/shared/schemas' +import { + FuseMultiRunRequestSchema, + LaunchMultiRunRequestSchema, + type MultiRunTarget, +} from '@opencode-manager/shared/schemas' import { MultiRunError, type MultiRunService } from '../services/multi-runs' import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' import { ServiceError } from '../utils/service-error' +function listTarget(repoId: string | undefined, serverId: string | undefined, directory: string | undefined): MultiRunTarget { + if (serverId) { + if (!directory) { + throw new MultiRunError('Missing directory', 400) + } + return { kind: 'server', serverId, directory } + } + + return { kind: 'repo', repoId: parseId(repoId, 'repoId', MultiRunError) } +} + export function createMultiRunRoutes(service: MultiRunService) { const app = new Hono() app.get('/', (c) => { try { - const repoId = parseId(c.req.query('repoId'), 'repoId', MultiRunError) - return c.json({ runs: service.list(repoId) }) + const target = listTarget(c.req.query('repoId'), c.req.query('serverId'), c.req.query('directory')) + return c.json({ runs: service.list(target) }) } catch (error) { return handleServiceError(c, error, 'Failed to list multi-runs', ServiceError) } diff --git a/backend/src/routes/opencode-server-terminals.ts b/backend/src/routes/opencode-server-terminals.ts new file mode 100644 index 000000000..fa0a1d9af --- /dev/null +++ b/backend/src/routes/opencode-server-terminals.ts @@ -0,0 +1,189 @@ +import { Hono } from 'hono' +import type { Context, MiddlewareHandler } from 'hono' +import type { UpgradeWebSocket } from 'hono/ws' +import { getTrustedOrigins } from '@opencode-manager/shared/config/env' +import { + CreateTerminalRequestSchema, + LOCAL_OPENCODE_SERVER_ID, + ResizeTerminalRequestSchema, +} from '@opencode-manager/shared/schemas' +import { OpenCodeServerError } from '../services/opencode-server-registry' +import { TerminalNotFoundError, type TerminalService } from '../services/terminal' +import { parseTerminalCursor } from '../utils/terminal-cursor' +import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' +import { handleOpenCodeError, handleServiceError, parseJsonBody } from '../utils/route-helpers' + +export type ServerTerminalServiceResolver = (serverId: string) => TerminalService + +interface ServerTerminalSocketVariables { + terminalService: TerminalService + terminalDirectory: string + terminalCursor: number | undefined +} + +function isAbsoluteDirectory(directory: string): boolean { + return directory.startsWith('/') || /^[A-Za-z]:[\\/]/.test(directory) +} + +function readAbsoluteDirectory(raw: string | undefined): string | null { + if (raw === undefined) return null + const directory = raw.trim() + return directory.length > 0 && isAbsoluteDirectory(directory) ? directory : null +} + +function rejectLocalServer(c: Context, serverId: string): Response | null { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) return null + return c.json({ error: 'Local terminals are served by the repo terminal routes' }, 400) +} + +function rejectDirectory(c: Context): Response { + return c.json({ error: 'An absolute directory is required' }, 400) +} + +function handleTerminalError(c: Context, error: unknown) { + if (error instanceof TerminalNotFoundError) { + return c.json({ error: error.message }, 404) + } + if (error instanceof OpenCodeServerError) { + return handleServiceError(c, error, 'Terminal request failed', OpenCodeServerError) + } + return handleOpenCodeError(c, error, 'Terminal request failed') +} + +export function createOpenCodeServerTerminalRoutes(resolveTerminalService: ServerTerminalServiceResolver) { + const app = new Hono() + + app.get('/:serverId/terminals', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + try { + const terminals = await resolveTerminalService(serverId).list(directory) + return c.json({ terminals }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.post('/:serverId/terminals', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const parsed = await parseJsonBody(c, CreateTerminalRequestSchema, { allowEmpty: true }) + if (!parsed.ok) return parsed.response + + const directory = readAbsoluteDirectory(parsed.data.directory) + if (directory === null) return rejectDirectory(c) + + try { + const terminal = await resolveTerminalService(serverId).create(directory, { + kind: 'shell', + name: parsed.data.title ?? 'Terminal', + }) + return c.json(terminal) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.patch('/:serverId/terminals/:ptyID', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const parsed = await parseJsonBody(c, ResizeTerminalRequestSchema) + if (!parsed.ok) return parsed.response + + const directory = readAbsoluteDirectory(parsed.data.directory) + if (directory === null) return rejectDirectory(c) + + const ptyID = c.req.param('ptyID') + + try { + const terminalService = resolveTerminalService(serverId) + await terminalService.requireTerminal(directory, ptyID) + await terminalService.resize(directory, ptyID, { cols: parsed.data.cols, rows: parsed.data.rows }) + return c.json({ success: true }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + app.delete('/:serverId/terminals/:ptyID', async (c) => { + const serverId = c.req.param('serverId') + const local = rejectLocalServer(c, serverId) + if (local) return local + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + const ptyID = c.req.param('ptyID') + + try { + const terminalService = resolveTerminalService(serverId) + await terminalService.requireTerminal(directory, ptyID) + await terminalService.remove(directory, ptyID) + return c.json({ success: true }) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + }) + + return app +} + +export function createOpenCodeServerTerminalSocketRoutes( + resolveTerminalService: ServerTerminalServiceResolver, + upgradeWebSocket: UpgradeWebSocket, + trustedOrigins: readonly string[] = getTrustedOrigins(), +) { + const app = new Hono<{ Variables: ServerTerminalSocketVariables }>() + + const validate: MiddlewareHandler<{ Variables: ServerTerminalSocketVariables }> = async (c, next) => { + if (!isAllowedUpgradeOrigin(c.req.header('origin'), trustedOrigins)) { + return c.json({ error: 'Origin not allowed' }, 403) + } + + const serverId = c.req.param('serverId') ?? '' + const local = rejectLocalServer(c, serverId) + if (local) return local + + const cursor = parseTerminalCursor(c.req.query('cursor')) + if (cursor === null) { + return c.json({ error: 'Invalid cursor' }, 400) + } + + const directory = readAbsoluteDirectory(c.req.query('directory')) + if (directory === null) return rejectDirectory(c) + + try { + c.set('terminalService', resolveTerminalService(serverId)) + } catch (error: unknown) { + return handleTerminalError(c, error) + } + + c.set('terminalDirectory', directory) + c.set('terminalCursor', cursor) + await next() + } + + app.get( + '/:serverId/terminals/:ptyID/connect', + validate, + upgradeWebSocket((c) => { + const terminalService = c.get('terminalService') + const directory = c.get('terminalDirectory') + const cursor = c.get('terminalCursor') + const ptyID = c.req.param('ptyID') + + return createBridgedSocketEvents((peer) => terminalService.connect(directory, ptyID, cursor, peer)) + }), + ) + + return app +} diff --git a/backend/src/routes/repo-terminal-socket.ts b/backend/src/routes/repo-terminal-socket.ts index cfb27b66d..666e548ad 100644 --- a/backend/src/routes/repo-terminal-socket.ts +++ b/backend/src/routes/repo-terminal-socket.ts @@ -7,6 +7,7 @@ import type { GitAuthService } from '../services/git-auth' import type { OpenCodeClient } from '../services/opencode/client' import type { TerminalService } from '../services/terminal' import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge' +import { parseTerminalCursor } from '../utils/terminal-cursor' import { resolveRepoRequestDirectory } from './repo-directory' interface RepoTerminalSocketVariables { @@ -14,14 +15,6 @@ interface RepoTerminalSocketVariables { terminalCursor: number | undefined } -function parseCursor(raw: string | undefined): number | undefined | null { - if (raw === undefined) return undefined - if (!/^-?\d+$/.test(raw)) return null - const parsed = Number(raw) - if (!Number.isSafeInteger(parsed) || parsed < -1) return null - return parsed -} - export function createRepoTerminalSocketRoutes( database: Database, gitAuthService: GitAuthService, @@ -38,7 +31,7 @@ export function createRepoTerminalSocketRoutes( return c.json({ error: 'Origin not allowed' }, 403) } - const cursor = parseCursor(c.req.query('cursor')) + const cursor = parseTerminalCursor(c.req.query('cursor')) if (cursor === null) { return c.json({ error: 'Invalid cursor' }, 400) } diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts index 20f7bc7a3..331f5f221 100644 --- a/backend/src/routes/schedules.ts +++ b/backend/src/routes/schedules.ts @@ -9,6 +9,13 @@ import type { ScheduleRunWorktreesMode } from '@opencode-manager/shared/types' import { ScheduleService, ScheduleServiceError } from '../services/schedules' import { parseId, handleServiceError } from '../utils/route-helpers' +function parseServerId(value: string | undefined): string { + if (!value) { + throw new ScheduleServiceError('OpenCode server id is required', 400) + } + return value +} + function parseRunListLimit(value: string | undefined): number { if (value === undefined) { return 20 @@ -270,4 +277,133 @@ export function createScheduleRoutes(scheduleService: ScheduleService) { return app } +/** + * Schedule routes for a project directory on a linked OpenCode server. They mirror the + * repo routes without worktree endpoints, because remote runs execute in place. + */ +export function createServerScheduleRoutes(scheduleService: ScheduleService) { + const app = new Hono() + + app.get('/', (c) => { + try { + return c.json({ jobs: scheduleService.listServerJobs(parseServerId(c.req.param('serverId'))) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list schedules', ScheduleServiceError) + } + }) + + app.post('/', async (c) => { + try { + const serverId = parseServerId(c.req.param('serverId')) + const body = await c.req.json() + const input = CreateScheduleJobRequestSchema.parse(body) + if (!input.directory) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + const job = await scheduleService.createServerJob(serverId, input.directory, input) + return c.json({ job }, 201) + } catch (error) { + return handleServiceError(c, error, 'Failed to create schedule', ScheduleServiceError) + } + }) + + app.get('/:jobId', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const job = scheduleService.getServerJob(parseServerId(c.req.param('serverId')), jobId) + if (!job) { + return c.json({ error: 'Schedule not found' }, 404) + } + return c.json({ job }) + } catch (error) { + return handleServiceError(c, error, 'Failed to get schedule', ScheduleServiceError) + } + }) + + app.patch('/:jobId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const body = await c.req.json() + const input = UpdateScheduleJobRequestSchema.parse(body) + const job = await scheduleService.updateServerJob(parseServerId(c.req.param('serverId')), jobId, input) + return c.json({ job }) + } catch (error) { + return handleServiceError(c, error, 'Failed to update schedule', ScheduleServiceError) + } + }) + + app.delete('/:jobId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + await scheduleService.deleteServerJob(parseServerId(c.req.param('serverId')), jobId) + return c.json({ success: true }) + } catch (error) { + return handleServiceError(c, error, 'Failed to delete schedule', ScheduleServiceError) + } + }) + + app.post('/:jobId/run', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const run = await scheduleService.runServerJob(parseServerId(c.req.param('serverId')), jobId, 'manual') + return c.json({ run }) + } catch (error) { + return handleServiceError(c, error, 'Failed to run schedule', ScheduleServiceError) + } + }) + + app.get('/:jobId/runs', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const limit = parseRunListLimit(c.req.query('limit')) + return c.json({ runs: scheduleService.listServerRuns(parseServerId(c.req.param('serverId')), jobId, limit) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to list schedule runs', ScheduleServiceError) + } + }) + + app.get('/:jobId/runs/:runId', (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + return c.json({ run: scheduleService.getServerRun(parseServerId(c.req.param('serverId')), jobId, runId) }) + } catch (error) { + return handleServiceError(c, error, 'Failed to get schedule run', ScheduleServiceError) + } + }) + + app.post('/:jobId/runs/:runId/cancel', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + const run = await scheduleService.cancelServerRun(parseServerId(c.req.param('serverId')), jobId, runId) + return c.json({ run }) + } catch (error) { + return handleServiceError(c, error, 'Failed to cancel schedule run', ScheduleServiceError) + } + }) + + app.delete('/:jobId/runs', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + return c.json(await scheduleService.clearServerRunHistory(parseServerId(c.req.param('serverId')), jobId)) + } catch (error) { + return handleServiceError(c, error, 'Failed to clear schedule run history', ScheduleServiceError) + } + }) + + app.delete('/:jobId/runs/:runId', async (c) => { + try { + const jobId = parseId(c.req.param('jobId'), 'schedule id', ScheduleServiceError) + const runId = parseId(c.req.param('runId'), 'run id', ScheduleServiceError) + await scheduleService.deleteServerRun(parseServerId(c.req.param('serverId')), jobId, runId) + return c.json({ success: true }) + } catch (error) { + return handleServiceError(c, error, 'Failed to delete schedule run', ScheduleServiceError) + } + }) + + return app +} + diff --git a/backend/src/routes/session-goals.ts b/backend/src/routes/session-goals.ts index 9300ad49a..8f4ca4321 100644 --- a/backend/src/routes/session-goals.ts +++ b/backend/src/routes/session-goals.ts @@ -3,7 +3,15 @@ import { StartSessionGoalRequestSchema } from '@opencode-manager/shared/schemas' import { SessionGoalError, type SessionGoalService } from '../services/session-goals' import { handleServiceError, parseId, parseJsonBody } from '../utils/route-helpers' -export function createSessionGoalRoutes(service: SessionGoalService) { +export interface SessionGoalRouteOptions { + serverScoped?: boolean +} + +export function createSessionGoalRoutes( + service: SessionGoalService, + options: SessionGoalRouteOptions = {}, +) { + const serverScoped = options.serverScoped ?? true const app = new Hono() app.get('/', (c) => { @@ -12,7 +20,8 @@ export function createSessionGoalRoutes(service: SessionGoalService) { if (!sessionId) { return c.json({ error: 'Invalid request' }, 400) } - return c.json({ goal: service.getLatest(sessionId) }) + const serverId = serverScoped ? c.req.query('serverId') : undefined + return c.json({ goal: serverId ? service.getLatest(sessionId, serverId) : service.getLatest(sessionId) }) } catch (error) { return handleServiceError(c, error, 'Failed to read session goal', SessionGoalError) } @@ -25,7 +34,7 @@ export function createSessionGoalRoutes(service: SessionGoalService) { } try { - const goal = await service.start(parsed.data) + const goal = await service.start(serverScoped ? parsed.data : { ...parsed.data, serverId: undefined }) return c.json({ goal }, 201) } catch (error) { return handleServiceError(c, error, 'Failed to start session goal', SessionGoalError) diff --git a/backend/src/routes/session-permission-modes.ts b/backend/src/routes/session-permission-modes.ts index 19e17ded4..8afcc979d 100644 --- a/backend/src/routes/session-permission-modes.ts +++ b/backend/src/routes/session-permission-modes.ts @@ -8,7 +8,10 @@ export function createSessionPermissionModeRoutes(service: SessionPermissionMode app.get('/:sessionId', async (c) => { try { - const state = await service.getEffectiveMode(c.req.param('sessionId')) + const state = await service.getEffectiveMode( + c.req.param('sessionId'), + c.req.query('serverId') || undefined, + ) return c.json(state) } catch (error) { return handleServiceError(c, error, 'Failed to read session permission mode', SessionPermissionModeError) @@ -22,7 +25,12 @@ export function createSessionPermissionModeRoutes(service: SessionPermissionMode } try { - const state = await service.setMode(c.req.param('sessionId'), parsed.data.mode, parsed.data.directory) + const state = await service.setMode( + c.req.param('sessionId'), + parsed.data.mode, + parsed.data.directory, + parsed.data.serverId, + ) return c.json(state) } catch (error) { return handleServiceError(c, error, 'Failed to set session permission mode', SessionPermissionModeError) diff --git a/backend/src/routes/sse.ts b/backend/src/routes/sse.ts index e1af25cfd..2d4882ce2 100644 --- a/backend/src/routes/sse.ts +++ b/backend/src/routes/sse.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import { stream } from 'hono/streaming' import { sseAggregator } from '../services/sse-aggregator' -import { SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, SSESubscribeSchema, SSEVisibilitySchema } from '@opencode-manager/shared/schemas' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' import { createQueuedSSEWriter } from './sse-writer' @@ -92,7 +92,12 @@ export function createSSERoutes() { if (!result.success) { return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400) } - const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible, result.data.activeSessionId ?? null) + const success = sseAggregator.setClientVisibility( + result.data.clientId, + result.data.visible, + result.data.activeSessionId ?? null, + result.data.activeServerId ?? LOCAL_OPENCODE_SERVER_ID, + ) if (!success) { return c.json({ success: false, error: 'Client not found' }, 404) } diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 9c7d5cc88..76a24dde1 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -1,6 +1,12 @@ import type { Database } from 'bun:sqlite' import { existsSync } from 'node:fs' -import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type FuseMultiRunRequest, + type LaunchMultiRunRequest, + type MultiRun, + type MultiRunTarget, +} from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createMultiRunWithEntries, @@ -16,7 +22,6 @@ import { type MultiRunRecord, } from '../db/multi-runs' import { getRepoById } from '../db/queries' -import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' import { logger } from '../utils/logger' import { ServiceError } from '../utils/service-error' @@ -24,7 +29,15 @@ import { buildFusionPrompt, buildFusionSourcePermissionRuleset, collectFusionSou import type { OpenCodeClient } from './opencode/client' import { RepoWorkspaceError } from './repo' import type { RepoWorkspaceService } from './repo-workspace' -import { requireReadyRepo, SessionLauncher, SessionLaunchError, type LaunchedSession } from './session-launcher' +import { + resolveSessionTarget, + SessionLauncher, + SessionLaunchError, + sessionTargetInput, + sessionTargetOfRun, + type LaunchedSession, + type ResolvedSessionTarget, +} from './session-launcher' import type { SessionPermissionModeService } from './session-permission-modes' const MULTI_RUN_LIST_LIMIT = 20 @@ -35,6 +48,8 @@ function toMultiRun(record: MultiRunRecord): MultiRun { return { id: record.id, repoId: record.repoId, + serverId: record.serverId, + directory: record.directory, name: record.name, prompt: record.prompt, isolated: record.isolated, @@ -69,6 +84,22 @@ function toMultiRun(record: MultiRunRecord): MultiRun { } } +function multiRunTargetFromRequest( + request: Pick, +): MultiRunTarget { + if (request.serverId !== undefined) { + if (request.directory === undefined) { + throw new MultiRunError('A server multi-run needs a directory', 400) + } + return { kind: 'server', serverId: request.serverId, directory: request.directory } + } + + if (request.repoId === undefined) { + throw new MultiRunError('A multi-run needs a repository or server target', 400) + } + return { kind: 'repo', repoId: request.repoId } +} + export class MultiRunService { private readonly sessionLauncher: SessionLauncher private readonly discardingEntries = new Set() @@ -78,22 +109,21 @@ export class MultiRunService { private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, private readonly permissionModes: SessionPermissionModeService, + private readonly getServerClient?: (serverId: string) => OpenCodeClient, ) { - this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces) + this.sessionLauncher = new SessionLauncher(db, openCodeClient, repoWorkspaces, getServerClient) } async launch(request: LaunchMultiRunRequest): Promise { - let repo: Repo - try { - repo = requireReadyRepo(this.db, request.repoId) - } catch (error) { - throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 404) - } + const target = multiRunTargetFromRequest(request) + const resolved = await this.resolveTarget(target) const record = createMultiRunWithEntries( this.db, { - repoId: request.repoId, + repoId: target.kind === 'repo' ? target.repoId : null, + serverId: target.kind === 'server' ? target.serverId : LOCAL_OPENCODE_SERVER_ID, + directory: target.kind === 'server' ? target.directory : null, name: request.name, prompt: request.prompt, isolated: request.isolate, @@ -105,7 +135,7 @@ export class MultiRunService { const validations = await Promise.all( record.entries.map(async (entry) => { try { - await this.sessionLauncher.resolveModel(repo, entry.model) + await resolved.resolveModel(entry.model) return { entry, ok: true as const } } catch (error) { return { entry, ok: false as const, error } @@ -127,7 +157,7 @@ export class MultiRunService { }) const results = await Promise.allSettled( - launchable.map(({ entry, index }) => this.launchEntry(entry, index, request)), + launchable.map(({ entry, index }) => this.launchEntry(entry, index, request, target)), ) results.forEach((result, resultIndex) => { @@ -174,12 +204,11 @@ export class MultiRunService { }) } - let repo: Repo - try { - repo = requireReadyRepo(this.db, record.repoId) - } catch (error) { - throw new MultiRunError(getErrorMessage(error) || 'Repository unavailable', 404) + const target = sessionTargetOfRun(record) + if (!target) { + throw new MultiRunError('Multi-run target is no longer available', 409) } + const resolved = await this.resolveTarget(target) const entriesById = new Map(record.entries.map((entry) => [entry.id, entry])) const selectedEntries = request.entryIds.map((entryId) => { @@ -197,7 +226,7 @@ export class MultiRunService { ) try { - await this.sessionLauncher.resolveModel(repo, request.model) + await resolved.resolveModel(request.model) } catch (error) { if (error instanceof SessionLaunchError) { throw new MultiRunError(error.message, error.status) @@ -205,9 +234,9 @@ export class MultiRunService { throw new MultiRunError(getErrorMessage(error) || 'Failed to resolve model', 502) } - await this.reconcileUncertainFusions(record) + await this.reconcileUncertainFusions(record, resolved.client) - const collection = await collectFusionSources(this.openCodeClient, selectedEntries) + const collection = await collectFusionSources(resolved.client, selectedEntries) if (collection.unavailable.length > 0) { throw new MultiRunError('Some selected results are not ready to fuse', 409, { code: 'FUSION_SOURCES_UNAVAILABLE', @@ -239,7 +268,7 @@ export class MultiRunService { let launched: LaunchedSession | null = null try { launched = await this.sessionLauncher.launch({ - repoId: record.repoId, + ...sessionTargetInput(target), prompt: built.prompt, model: request.model, title: `${record.name} · fusion`, @@ -269,7 +298,11 @@ export class MultiRunService { if (launched) { try { - await this.permissionModes.applyDefaultMode(launched.sessionId, launched.directory) + await this.permissionModes.applyDefaultMode( + launched.sessionId, + launched.directory, + target.kind === 'server' ? target.serverId : LOCAL_OPENCODE_SERVER_ID, + ) } catch (error) { logger.error(`Failed to apply the default permission mode to fusion session ${launched.sessionId}:`, error) } @@ -278,8 +311,8 @@ export class MultiRunService { return { run: this.reload(multiRunId), created: true } } - list(repoId: number): MultiRun[] { - return listMultiRuns(this.db, repoId, MULTI_RUN_LIST_LIMIT).map(toMultiRun) + list(target: MultiRunTarget): MultiRun[] { + return listMultiRuns(this.db, target, MULTI_RUN_LIST_LIMIT).map(toMultiRun) } async discard(multiRunId: number, entryId: number): Promise { @@ -303,18 +336,8 @@ export class MultiRunService { this.discardingEntries.add(entryId) try { - if (entry.isolated && entry.directory && existsSync(entry.directory)) { - const repo = getRepoById(this.db, record.repoId) - if (!repo) { - throw new MultiRunError('Repository not found', 404) - } - - try { - await this.repoWorkspaces.remove(repo, entry.directory) - } catch (error) { - const status = error instanceof RepoWorkspaceError ? error.status : 502 - throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', status) - } + if (entry.isolated && entry.directory) { + await this.removeEntryWorkspace(record, entry.directory) } const updated = updateMultiRunEntry(this.db, entryId, ['started', 'failed'], { status: 'discarded' }) @@ -328,13 +351,69 @@ export class MultiRunService { } } + private async removeEntryWorkspace(record: MultiRunRecord, directory: string): Promise { + const target = sessionTargetOfRun(record) + if (!target) { + throw new MultiRunError('Multi-run target is no longer available', 409) + } + + if (target.kind === 'repo') { + if (!existsSync(directory)) { + return + } + + const repo = getRepoById(this.db, target.repoId) + if (!repo) { + throw new MultiRunError('Repository not found', 404) + } + + try { + await this.repoWorkspaces.remove(repo, directory) + } catch (error) { + const status = error instanceof RepoWorkspaceError ? error.status : 502 + throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', status) + } + return + } + + try { + const resolved = await this.resolveTarget(target) + await resolved.removeWorkspace(directory) + } catch (error) { + if (error instanceof MultiRunError) { + throw error + } + throw new MultiRunError(getErrorMessage(error) || 'Failed to remove workspace', 502) + } + } + + private async resolveTarget(target: MultiRunTarget): Promise { + try { + return await resolveSessionTarget( + { + db: this.db, + openCodeClient: this.openCodeClient, + repoWorkspaces: this.repoWorkspaces, + ...(this.getServerClient ? { getServerClient: this.getServerClient } : {}), + }, + target, + ) + } catch (error) { + if (error instanceof SessionLaunchError || error instanceof ServiceError) { + throw new MultiRunError(error.message, error.status) + } + throw new MultiRunError(getErrorMessage(error) || 'Multi-run target is unavailable', 502) + } + } + private async launchEntry( entry: MultiRunEntryRecord, index: number, request: LaunchMultiRunRequest, + target: MultiRunTarget, ): Promise { return this.sessionLauncher.launch({ - repoId: request.repoId, + ...sessionTargetInput(target), prompt: request.prompt, model: entry.model, title: `${request.name} · ${entry.model}`, @@ -345,7 +424,7 @@ export class MultiRunService { }) } - private async reconcileUncertainFusions(record: MultiRunRecord): Promise { + private async reconcileUncertainFusions(record: MultiRunRecord, client: OpenCodeClient): Promise { const uncertain = record.fusions.filter( (fusion): fusion is MultiRunFusionRecord & { sessionId: string } => fusion.status === 'failed' && fusion.sessionId !== null, @@ -358,7 +437,7 @@ export class MultiRunService { await Promise.all( uncertain.map(async (fusion) => { try { - const response = await this.openCodeClient.api.message.list({ + const response = await client.api.message.list({ sessionID: fusion.sessionId, type: 'user', order: 'asc', diff --git a/backend/src/services/notification.ts b/backend/src/services/notification.ts index 3ff7ecf49..1ae8b2a52 100644 --- a/backend/src/services/notification.ts +++ b/backend/src/services/notification.ts @@ -9,6 +9,8 @@ import type { import { NotificationEventType, DEFAULT_NOTIFICATION_PREFERENCES, + LOCAL_OPENCODE_SERVER_ID, + type OpenCodeServer, type SessionGoal, } from "@opencode-manager/shared/schemas"; import { @@ -23,10 +25,14 @@ import { sseAggregator, type SSEEvent } from "./sse-aggregator"; import { getRepoName } from "../db/queries"; import { getScheduleRunBySessionId } from "../db/schedules"; import type { Repo } from "../types/repo"; -import { buildSessionPath } from "@opencode-manager/shared/utils"; +import { buildServerSessionPath, buildSessionPath } from "@opencode-manager/shared/utils"; import { sessionIDFromEvent } from "@opencode-manager/shared/opencode"; import { resolveRepoForDirectory } from "./repo"; +export interface NotificationServerRegistry { + get(serverId: string): Pick | null; +} + interface VapidConfig { publicKey: string; privateKey: string; @@ -123,6 +129,8 @@ export function buildEventNotificationPayload( context: { repoName?: string; repoId?: number; + serverId?: string; + serverName?: string; sessionId?: string; directory?: string; url: string; @@ -134,8 +142,9 @@ export function buildEventNotificationPayload( const title = config.titleFn(event.data); const detail = config.bodyFn(event.data); - const rawBody = context.repoName - ? `${context.repoName} · ${detail}` + const displayName = context.repoName ?? context.serverName; + const rawBody = displayName + ? `${displayName} · ${detail}` : detail; const body = truncateWithEllipsis(rawBody, MAX_BODY_LENGTH); @@ -151,19 +160,28 @@ export function buildEventNotificationPayload( directory: context.directory, repoId: context.repoId, repoName: context.repoName, + serverId: context.serverId, + serverName: context.serverName, url: context.url, }, }; } -type EventSuppressor = (event: SSEEvent, sessionId: string | undefined) => Promise; +type EventSuppressor = ( + event: SSEEvent, + sessionId: string | undefined, + serverId: string +) => Promise; export class NotificationService { private vapidConfig: VapidConfig | null = null; private settingsService: SettingsService; private eventSuppressors: EventSuppressor[] = []; - constructor(private db: Database) { + constructor( + private db: Database, + private serverRegistry: NotificationServerRegistry | null = null + ) { this.settingsService = new SettingsService(db); this.initializePushSubscriptionsTable(); } @@ -335,17 +353,29 @@ export class NotificationService { async handleSSEEvent( directory: string | null, - event: SSEEvent + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID ): Promise { const config = EVENT_CONFIG[event.type]; if (!config) return; const sessionId = resolveEventSessionId(event); - if (sessionId && sseAggregator.isSessionBeingViewed(sessionId)) return; - if (sessionId && sseAggregator.isSubagentSession(sessionId)) return; + if (sessionId && sseAggregator.isSessionBeingViewed(sessionId, serverId)) return; + if (sessionId && sseAggregator.isSubagentSession(sessionId, serverId)) return; for (const suppressor of this.eventSuppressors) { - if (await suppressor(event, sessionId)) return; + if (await suppressor(event, sessionId, serverId)) return; + } + + if (serverId !== LOCAL_OPENCODE_SERVER_ID) { + await this.deliverRemoteEvent( + serverId, + directory, + event, + sessionId, + config.preferencesKey + ); + return; } const repo = directory ? await resolveRepoForDirectory(this.db, directory) : null; @@ -371,19 +401,53 @@ export class NotificationService { ); } + private async deliverRemoteEvent( + serverId: string, + directory: string | null, + event: SSEEvent, + sessionId: string | undefined, + preferencesKey: keyof typeof DEFAULT_NOTIFICATION_PREFERENCES.events + ): Promise { + const server = this.serverRegistry?.get(serverId) ?? null; + if (!server || !server.enabled) return; + + const url = sessionId + ? buildServerSessionPath(serverId, sessionId) + : `/servers/${encodeURIComponent(serverId)}`; + + const payload = buildEventNotificationPayload(event, { + serverId, + serverName: server.name, + sessionId, + directory: directory ?? undefined, + url, + }); + if (!payload) return; + + await this.deliverToSubscribers( + payload, + (preferences) => + preferences.enabled && + preferences.events[preferencesKey] === true + ); + } + async notifyGoalOutcome(goal: SessionGoal): Promise { if (goal.stopReason === "turn_error") return; if (goal.stopReason === "cancelled" || goal.stopReason === "user_paused") return; - if (sseAggregator.isSessionBeingViewed(goal.sessionId)) return; + if (sseAggregator.isSessionBeingViewed(goal.sessionId, goal.serverId)) return; - const repo = goal.directory + const isRemote = goal.serverId !== LOCAL_OPENCODE_SERVER_ID; + const repo = !isRemote && goal.directory ? await resolveRepoForDirectory(this.db, goal.directory) : null; + const server = isRemote ? this.serverRegistry?.get(goal.serverId) ?? null : null; const repoName = repo ? getRepoName(repo) : undefined; + const displayName = repoName ?? server?.name; const payload: PushNotificationPayload = { title: getGoalOutcomeTitle(goal.status), - body: buildGoalOutcomeBody(goal, repoName), + body: buildGoalOutcomeBody(goal, displayName), tag: `session-goal-${goal.id}`, timestamp: Date.now(), renotify: true, @@ -393,7 +457,11 @@ export class NotificationService { directory: goal.directory, repoId: repo?.id, repoName, - url: buildNotificationUrl(repo, goal.sessionId), + serverId: goal.serverId, + serverName: server?.name, + url: isRemote + ? buildServerSessionPath(goal.serverId, goal.sessionId) + : buildNotificationUrl(repo, goal.sessionId), }, }; diff --git a/backend/src/services/remote-server-events.ts b/backend/src/services/remote-server-events.ts index a91189bea..609542c29 100644 --- a/backend/src/services/remote-server-events.ts +++ b/backend/src/services/remote-server-events.ts @@ -24,6 +24,9 @@ export interface RemoteEventAggregator { sendRemoteResync(serverId: string, data: string): void hasClientDirectory(clientId: string, key: string): boolean setRemoteReplayHandler(handler: RemoteReplayHandler | null): void + notifyServerEvent(serverId: string, directory: string | null, event: V2Event): void + notifyServerConnected(serverId: string): void + setRemoteSubagentResolver(resolver: ((serverId: string, sessionId: string) => boolean) | null): void } export interface RemoteEventSource { @@ -51,6 +54,7 @@ interface RemoteServerStream { version: string | null versionRequested: boolean sessionDirectories: Map + subagentSessions: Set } type RemoteReplayEventType = 'permission.asked' | 'form.created' @@ -94,6 +98,7 @@ export class RemoteServerEventHub { this.started = true this.aggregator.setRemoteReplayHandler((clientId, serverId, directory) => this.replayPendingActions(clientId, serverId, directory)) + this.aggregator.setRemoteSubagentResolver((serverId, sessionId) => this.isSubagentSession(serverId, sessionId)) this.unsubscribeRegistry = this.registry.onChange(change => this.handleRegistryChange(change)) this.connectAll() } @@ -108,6 +113,11 @@ export class RemoteServerEventHub { this.forgetStream(serverId) } this.aggregator.setRemoteReplayHandler(null) + this.aggregator.setRemoteSubagentResolver(null) + } + + isSubagentSession(serverId: string, sessionId: string): boolean { + return this.streams.get(serverId)?.subagentSessions.has(sessionId) ?? false } getStatuses(): OpenCodeServerStatus[] { @@ -254,6 +264,7 @@ export class RemoteServerEventHub { stream.error = null stream.reconnectDelay = this.reconnectDelayMs logger.info(`Remote event stream connected: ${serverId}`) + this.aggregator.notifyServerConnected(serverId) this.aggregator.sendRemoteResync(serverId, JSON.stringify({ timestamp: Date.now() })) void this.refreshVersion(serverId) } @@ -308,10 +319,16 @@ export class RemoteServerEventHub { if (sessionID && event.type === 'session.deleted') { stream.sessionDirectories.delete(sessionID) + stream.subagentSessions.delete(sessionID) } else if (sessionID && rememberedDirectory) { stream.sessionDirectories.set(sessionID, rememberedDirectory) } + if (event.type === 'session.created' && event.data.parentID) { + stream.subagentSessions.add(event.data.sessionID) + } + + this.aggregator.notifyServerEvent(serverId, resolvedDirectory ?? null, event) this.aggregator.deliverRemoteEnvelope(serverId, resolvedDirectory ?? null, payloadJson) } catch (error) { logger.error(`Remote event stream failed to handle ${event.type} for ${serverId}:`, error) @@ -374,6 +391,7 @@ export class RemoteServerEventHub { version: null, versionRequested: false, sessionDirectories: new Map(), + subagentSessions: new Set(), } this.streams.set(serverId, stream) return stream diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts index 3a118fcf7..c6651b10f 100644 --- a/backend/src/services/schedule-config.ts +++ b/backend/src/services/schedule-config.ts @@ -28,6 +28,7 @@ export interface ScheduleJobPersistenceInput { mcpServers: ScheduleMcpServer[] branch: string | null workspaceMode: ScheduleWorkspaceMode + directory?: string | null nextRunAt: number | null } @@ -104,6 +105,7 @@ export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequ skillMetadata: input.skillMetadata, branch: input.branch?.trim() || null, workspaceMode: input.workspaceMode ?? 'worktree', + ...(input.directory !== undefined ? { directory: input.directory?.trim() || null } : {}), } const scheduleConfig = input.scheduleMode === 'cron' @@ -166,6 +168,9 @@ export function buildUpdatedSchedulePersistenceInput( mcpServers: input.mcpServers ?? existing.mcpServers, branch: input.branch === undefined ? existing.branch : (input.branch?.trim() || null), workspaceMode: input.workspaceMode ?? existing.workspaceMode, + ...(input.directory !== undefined || existing.directory !== undefined + ? { directory: input.directory === undefined ? (existing.directory ?? null) : (input.directory?.trim() || null) } + : {}), nextRunAt, } } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 82b41da9d..0d1f5d602 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -8,11 +8,12 @@ import { type ScheduleRun, type ScheduleRunTriggerSource, type ScheduleRunWorktreesMode, + type ScheduleWorkspaceMode, type ScheduleWorktree, type UpdateScheduleJobRequest, } from '@opencode-manager/shared/types' import { mcpStatusByName, openCodeLocation } from '@opencode-manager/shared/opencode' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID, buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' import { getRepoById } from '../db/queries' import type { ScheduleJobWithRepo } from '../db/schedules' import { @@ -36,6 +37,7 @@ import { listEnabledScheduleJobs, listScheduleJobIdsByRepo, listScheduleJobsByRepo, + listScheduleJobsByServer, listRunningScheduleRuns, listScheduleRunsByJob, updateScheduleJob, @@ -53,6 +55,7 @@ import { import { resolveOpenCodeModel } from './opencode-models' import { isSessionBusy, readLatestAssistantReply, sessionSettleSignal, type AssistantReplyState } from './session-reply' import type { OpenCodeClient } from './opencode/client' +import type { OpenCodeServerRegistry } from './opencode-server-registry' import type { ScheduleWorktreeManager } from './schedule-worktree' import type { Repo } from '../types/repo' import { sseAggregator, type ScheduledSessionRef } from './sse-aggregator' @@ -175,7 +178,7 @@ function buildRunStartedLog(input: { ].join('\n') } -function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { +function createSessionMonitor(directory: string, sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): SessionMonitor { const queued: SessionSignal[] = [] let waiting: ((signal: SessionSignal) => void) | null = null let disposed = false @@ -190,16 +193,29 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni queued.push(signal) } - const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { - if (eventDirectory !== directory) { - return - } - + const handleEvent = (event: Parameters[0]): void => { const signal = sessionSettleSignal(event, sessionId) if (signal) { push({ errorText: signal.errorText, disposed: false }) } - }) + } + + const unsubscribe = serverId === LOCAL_OPENCODE_SERVER_ID + ? sseAggregator.onEvent((eventDirectory, event) => { + if (eventDirectory !== directory) { + return + } + handleEvent(event) + }) + : sseAggregator.onServerEvent((eventServerId, eventDirectory, event) => { + if (eventServerId !== serverId) { + return + } + if (eventDirectory !== null && eventDirectory !== directory) { + return + } + handleEvent(event) + }) return { markSubmitted: () => { @@ -226,6 +242,18 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni } } +export type ScheduleJobTarget = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string; directory: string } + +interface ScheduleTargetContext { + repo: Repo | null + client: OpenCodeClient + directory: string + repoId: number | null + serverId: string +} + export class ScheduleService { private static activeRuns = new Map() private static pendingCancels = new Map() @@ -237,6 +265,7 @@ export class ScheduleService { private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly worktreeManager: ScheduleWorktreeManager, + private readonly serverRegistry: OpenCodeServerRegistry | null = null, ) {} setJobChangeHandler(handler: ((job: ScheduleJob | null, jobId: number) => void) | null): void { @@ -311,6 +340,7 @@ export class ScheduleService { for (const run of listRunningScheduleRuns(this.db)) { if (!run.sessionId) continue + if (run.repoId === null) continue const directory = run.worktreePath ?? this.findRepoPath(run.repoId) if (!directory) continue @@ -329,6 +359,96 @@ export class ScheduleService { } } + private assertServer(serverId: string) { + const server = this.serverRegistry?.get(serverId) + if (!server) { + throw new ScheduleServiceError('OpenCode server not found', 404) + } + if (!server.enabled) { + throw new ScheduleServiceError(`OpenCode server "${server.name}" is disabled`, 400) + } + return server + } + + private safeServerClient(serverId: string): OpenCodeClient { + try { + return this.serverClient(serverId) + } catch { + return this.openCodeClient + } + } + + private serverClient(serverId: string): OpenCodeClient { + if (!this.serverRegistry) { + throw new ScheduleServiceError('Linked OpenCode servers are not available', 500) + } + try { + return this.serverRegistry.getClient(serverId) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error) || 'OpenCode server is not available', 400) + } + } + + private resolveTargetContext(target: ScheduleJobTarget, directory?: string | null): ScheduleTargetContext { + if (target.kind === 'repo') { + const repo = this.assertRepo(target.repoId) + return { + repo, + client: this.openCodeClient, + directory: repo.fullPath, + repoId: target.repoId, + serverId: LOCAL_OPENCODE_SERVER_ID, + } + } + + this.assertServer(target.serverId) + const resolvedDirectory = directory ?? target.directory + if (!resolvedDirectory) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return { + repo: null, + client: this.serverClient(target.serverId), + directory: resolvedDirectory, + repoId: null, + serverId: target.serverId, + } + } + + private targetForJob(job: ScheduleJob): ScheduleJobTarget { + if (job.repoId !== null) { + return { kind: 'repo', repoId: job.repoId } + } + return { + kind: 'server', + serverId: job.serverId ?? LOCAL_OPENCODE_SERVER_ID, + directory: job.directory ?? '', + } + } + + private loadJobForTarget(target: ScheduleJobTarget, jobId: number): ScheduleJob { + const job = getScheduleJobById(this.db, target.kind === 'repo' ? target.repoId : null, jobId) + if (!job) { + throw new ScheduleServiceError('Schedule not found', 404) + } + if (target.kind === 'server' && (job.serverId ?? LOCAL_OPENCODE_SERVER_ID) !== target.serverId) { + throw new ScheduleServiceError('Schedule not found', 404) + } + return job + } + + private assertInPlaceWorkspace(target: ScheduleJobTarget, workspaceMode: ScheduleWorkspaceMode | undefined): void { + if (target.kind === 'server') { + if (workspaceMode !== undefined && workspaceMode !== 'in-place') { + throw new ScheduleServiceError('Schedules on a linked server run in place; worktree workspace modes are only available for local repositories.', 400) + } + return + } + if (workspaceMode === 'in-place') { + throw new ScheduleServiceError('In-place workspace mode is only available for schedules on a linked server.', 400) + } + } + listAllEnabledJobs(): ScheduleJob[] { return listEnabledScheduleJobs(this.db) } @@ -449,16 +569,46 @@ export class ScheduleService { return listScheduleJobsByRepo(this.db, repoId) } + listServerJobs(serverId: string): ScheduleJob[] { + this.assertServer(serverId) + return listScheduleJobsByServer(this.db, serverId) + } + getJob(repoId: number, jobId: number): ScheduleJob | null { return getScheduleJobById(this.db, repoId, jobId) } + getServerJob(serverId: string, jobId: number): ScheduleJob | null { + const job = getScheduleJobById(this.db, null, jobId) + return job && job.serverId === serverId ? job : null + } + async createJob(repoId: number, input: CreateScheduleJobRequest): Promise { - const repo = this.assertRepo(repoId) - await this.assertAgentAvailable(repo.fullPath, input.agentSlug?.trim() || null) + return this.createJobForTarget({ kind: 'repo', repoId }, input) + } + + async createServerJob(serverId: string, directory: string, input: CreateScheduleJobRequest): Promise { + if (!path.isAbsolute(directory)) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return this.createJobForTarget({ kind: 'server', serverId, directory }, input) + } + + private async createJobForTarget(target: ScheduleJobTarget, input: CreateScheduleJobRequest): Promise { + this.assertInPlaceWorkspace(target, input.workspaceMode) + const context = this.resolveTargetContext(target) + await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) + const request = target.kind === 'server' + ? { ...input, workspaceMode: 'in-place' as const, directory: context.directory, branch: null } + : { ...input, directory: undefined } try { - const job = createScheduleJob(this.db, repoId, buildCreateSchedulePersistenceInput(input)) + const job = createScheduleJob( + this.db, + context.repoId, + buildCreateSchedulePersistenceInput(request), + context.serverId, + ) this.onJobChange?.(job, job.id) return job } catch (error) { @@ -467,23 +617,40 @@ export class ScheduleService { } async updateJob(repoId: number, jobId: number, input: UpdateScheduleJobRequest): Promise { - const repo = this.assertRepo(repoId) - const existing = this.assertJob(repoId, jobId) + return this.updateJobForTarget({ kind: 'repo', repoId }, jobId, input) + } + + async updateServerJob(serverId: string, jobId: number, input: UpdateScheduleJobRequest): Promise { + if (input.directory !== undefined && input.directory !== null && !path.isAbsolute(input.directory)) { + throw new ScheduleServiceError('A remote schedule requires an absolute target directory', 400) + } + return this.updateJobForTarget({ kind: 'server', serverId, directory: '' }, jobId, input) + } + + private async updateJobForTarget(target: ScheduleJobTarget, jobId: number, input: UpdateScheduleJobRequest): Promise { + const existing = this.loadJobForTarget(target, jobId) + this.assertInPlaceWorkspace(target, input.workspaceMode) + const context = this.resolveTargetContext(target, input.directory ?? existing.directory) + if (input.agentSlug !== undefined) { - await this.assertAgentAvailable(repo.fullPath, input.agentSlug?.trim() || null) + await this.assertAgentAvailable(context.client, context.directory, input.agentSlug?.trim() || null) } if (input.workspaceMode !== undefined && input.workspaceMode !== existing.workspaceMode) { - const running = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, repoId, jobId) !== null + const running = ScheduleService.activeRuns.has(jobId) || getRunningScheduleRunByJob(this.db, context.repoId, jobId) !== null if (running) { throw new ScheduleServiceError('Cannot change where runs execute while a run is in progress.', 409) } } + const request = target.kind === 'server' + ? { ...input, workspaceMode: 'in-place' as const, directory: input.directory ?? existing.directory ?? context.directory, branch: null } + : { ...input, directory: undefined } + let job: ScheduleJob | null try { - job = updateScheduleJob(this.db, repoId, jobId, buildUpdatedSchedulePersistenceInput(existing, input)) + job = updateScheduleJob(this.db, context.repoId, jobId, buildUpdatedSchedulePersistenceInput(existing, request)) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error), 400) } @@ -496,14 +663,22 @@ export class ScheduleService { } async deleteJob(repoId: number, jobId: number): Promise { - this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.deleteJobForTarget({ kind: 'repo', repoId }, jobId) + } + + async deleteServerJob(serverId: string, jobId: number): Promise { + return this.deleteJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + } + + private async deleteJobForTarget(target: ScheduleJobTarget, jobId: number): Promise { + const job = this.loadJobForTarget(target, jobId) + const context = this.resolveTargetContext(target, job.directory) if (ScheduleService.activeRuns.has(jobId)) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } - const runningRun = getRunningScheduleRunByJob(this.db, repoId, jobId) + const runningRun = getRunningScheduleRunByJob(this.db, context.repoId, jobId) if (runningRun) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } @@ -512,9 +687,11 @@ export class ScheduleService { ScheduleService.activeRuns.set(jobId, { runId: DELETION_RESERVATION_RUN_ID, abort: new AbortController() }) try { - await this.removeWorktrees(repoId, jobId) + if (target.kind === 'repo') { + await this.removeWorktrees(target.repoId, jobId) + } - const deleted = deleteScheduleJob(this.db, repoId, jobId) + const deleted = deleteScheduleJob(this.db, context.repoId, jobId) if (!deleted) { throw new ScheduleServiceError('Schedule not found', 404) } @@ -564,6 +741,11 @@ export class ScheduleService { return listScheduleRunsByJob(this.db, repoId, jobId, limit) } + listServerRuns(serverId: string, jobId: number, limit: number = 20): ScheduleRun[] { + this.loadJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + return listScheduleRunsByJob(this.db, null, jobId, limit) + } + getRun(repoId: number, jobId: number, runId: number): ScheduleRun { this.assertJob(repoId, jobId) const run = getScheduleRunById(this.db, repoId, jobId, runId) @@ -573,6 +755,15 @@ export class ScheduleService { return run } + getServerRun(serverId: string, jobId: number, runId: number): ScheduleRun { + this.loadJobForTarget({ kind: 'server', serverId, directory: '' }, jobId) + const run = getScheduleRunById(this.db, null, jobId, runId) + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + return run + } + /** * Clears a job's run history: deletes every finished run's row plus its git * run branch and any leftover worktree. A run currently in progress is left @@ -581,16 +772,26 @@ export class ScheduleService { * discarded along with its branch (`discard`). */ async clearRunHistory(repoId: number, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.clearRunHistoryForTarget({ kind: 'repo', repoId }, jobId, worktrees) + } - const removable = listScheduleRunArtifactsByJob(this.db, repoId, jobId).filter((run) => run.status !== 'running') + async clearServerRunHistory(serverId: string, jobId: number): Promise<{ cleared: number }> { + return this.clearRunHistoryForTarget({ kind: 'server', serverId, directory: '' }, jobId) + } + + private async clearRunHistoryForTarget(target: ScheduleJobTarget, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { + const job = this.loadJobForTarget(target, jobId) + const context = this.resolveTargetContext(target, job.directory) + + const removable = listScheduleRunArtifactsByJob(this.db, context.repoId, jobId).filter((run) => run.status !== 'running') if (removable.length === 0) { return { cleared: 0 } } - await this.worktreeManager.pruneRunArtifacts(repo, job, removable, worktrees) - const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id)) + if (context.repo) { + await this.worktreeManager.pruneRunArtifacts(context.repo, job, removable, worktrees) + } + const cleared = deleteScheduleRunsByIds(this.db, context.repoId, jobId, removable.map((run) => run.id)) return { cleared } } @@ -601,24 +802,50 @@ export class ScheduleService { * discarded along with its branch (`discard`). */ async deleteRun(repoId: number, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) - const run = this.getRun(repoId, jobId, runId) + return this.deleteRunForTarget({ kind: 'repo', repoId }, jobId, runId, worktrees) + } + + async deleteServerRun(serverId: string, jobId: number, runId: number): Promise { + return this.deleteRunForTarget({ kind: 'server', serverId, directory: '' }, jobId, runId) + } + private async deleteRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { + const job = this.loadJobForTarget(target, jobId) + const context = this.resolveTargetContext(target, job.directory) + const run = getScheduleRunById(this.db, context.repoId, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } if (run.status === 'running') { throw new ScheduleServiceError('Cannot delete a run while it is in progress. Cancel it first.', 409) } - await this.worktreeManager.pruneRunArtifacts(repo, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) - const deleted = deleteScheduleRunById(this.db, repoId, jobId, runId) + if (context.repo) { + await this.worktreeManager.pruneRunArtifacts(context.repo, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) + } + const deleted = deleteScheduleRunById(this.db, context.repoId, jobId, runId) if (!deleted) { throw new ScheduleServiceError('Run not found', 404) } } async runJob(repoId: number, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) + return this.runJobForTarget({ kind: 'repo', repoId }, jobId, triggerSource) + } + + async runServerJob(serverId: string, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { + return this.runJobForTarget({ kind: 'server', serverId, directory: '' }, jobId, triggerSource) + } + + async runJobForJob(job: ScheduleJob, triggerSource: ScheduleRunTriggerSource): Promise { + return this.runJobForTarget(this.targetForJob(job), job.id, triggerSource) + } + + private async runJobForTarget(target: ScheduleJobTarget, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { + const job = this.loadJobForTarget(target, jobId) + const repoId = target.kind === 'repo' ? target.repoId : null + const serverId = target.kind === 'repo' ? LOCAL_OPENCODE_SERVER_ID : target.serverId const existingRunningRun = getRunningScheduleRunByJob(this.db, repoId, jobId) if (existingRunningRun) { @@ -633,6 +860,7 @@ export class ScheduleService { const run = createScheduleRun(this.db, { jobId, repoId, + serverId, triggerSource, status: 'running', startedAt, @@ -641,42 +869,48 @@ export class ScheduleService { const abort = new AbortController() ScheduleService.activeRuns.set(jobId, { runId: run.id, abort }) + let repo: Repo | null = null try { - // Prepare worktree for isolated runs - const wt = await this.worktreeManager.prepare(repo, job, run.id) - const runDirectory = wt?.directory ?? repo.fullPath - if (wt) { - updateScheduleRunWorktree(this.db, repoId, jobId, run.id, { - worktreePath: wt.worktreePath, - runBranch: wt.runBranch, - }) + const context = this.resolveTargetContext(target, job.directory) + repo = context.repo + + let runDirectory = context.directory + if (repo) { + const wt = await this.worktreeManager.prepare(repo, job, run.id) + if (wt) { + runDirectory = wt.directory + updateScheduleRunWorktree(this.db, repoId, jobId, run.id, { + worktreePath: wt.worktreePath, + runBranch: wt.runBranch, + }) + } } if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } - const runJob = { ...job, agentSlug: await this.resolveRunAgent(runDirectory, job.agentSlug) } - const model = await resolveOpenCodeModel(this.openCodeClient, runDirectory, { + const runJob = { ...job, agentSlug: await this.resolveRunAgent(context.client, runDirectory, job.agentSlug) } + const model = await resolveOpenCodeModel(context.client, runDirectory, { preferredModel: job.model, signal: abort.signal, }) if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } - await this.connectMcpServers(runDirectory, job.mcpServers) + await this.connectMcpServers(context.client, runDirectory, job.mcpServers) if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } const sessionTitle = buildSessionTitle(job) let session: { id: string } try { - session = await this.openCodeClient.api.session.create({ + session = await context.client.api.session.create({ title: sessionTitle, agent: runJob.agentSlug ?? undefined, model: { providerID: model.providerID, id: model.id, variant: model.variant }, @@ -685,13 +919,13 @@ export class ScheduleService { }) } catch (error) { if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo) } throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo, session.id) + return this.abandonCancelledStartup(context.client, repoId, jobId, run, job, repo, session.id) } const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { @@ -709,10 +943,12 @@ export class ScheduleService { throw new ScheduleServiceError('Failed to attach session to run', 500) } - const sessionMonitor = createSessionMonitor(runDirectory, session.id) + const sessionMonitor = createSessionMonitor(runDirectory, session.id, context.serverId) void this.submitPromptAndMonitor({ repoId, + repo, + client: context.client, job: runJob, runId: run.id, sessionId: session.id, @@ -720,12 +956,20 @@ export class ScheduleService { triggerSource, sessionMonitor, directory: runDirectory, + serverId: context.serverId, }) return runWithSession } catch (error) { if (abort.signal.aborted) { - return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + return this.abandonCancelledStartup( + target.kind === 'repo' ? this.openCodeClient : this.safeServerClient(target.serverId), + repoId, + jobId, + run, + job, + repo, + ) } const finishedAt = Date.now() @@ -753,7 +997,6 @@ export class ScheduleService { logger.error(`Failed to update job state for job ${jobId}:`, updateError) } - // Teardown worktree if one was created before the error await this.teardownWorktree(repoId, jobId, run.id, job, repo) ScheduleService.releaseActiveRun(jobId, run.id) @@ -771,9 +1014,21 @@ export class ScheduleService { } async cancelRun(repoId: number, jobId: number, runId: number): Promise { - const repo = this.assertRepo(repoId) - const job = this.assertJob(repoId, jobId) - const run = this.getRun(repoId, jobId, runId) + return this.cancelRunForTarget({ kind: 'repo', repoId }, jobId, runId) + } + + async cancelServerRun(serverId: string, jobId: number, runId: number): Promise { + return this.cancelRunForTarget({ kind: 'server', serverId, directory: '' }, jobId, runId) + } + + private async cancelRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number): Promise { + const job = this.loadJobForTarget(target, jobId) + const context = this.resolveTargetContext(target, job.directory) + const run = getScheduleRunById(this.db, context.repoId, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } if (run.status !== 'running') { throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) @@ -781,7 +1036,7 @@ export class ScheduleService { if (!run.sessionId) { ScheduleService.abortActiveRun(jobId, runId) - return this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + return this.finalizeCancelledRun(context.repo, job, run, context.repoId, jobId, runId) } ScheduleService.requestCancel(jobId, runId) @@ -789,7 +1044,7 @@ export class ScheduleService { try { let outcome: AssistantOutcome try { - outcome = await this.readAssistantOutcome(run.sessionId) + outcome = await this.readAssistantOutcome(context.client, run.sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } @@ -799,25 +1054,33 @@ export class ScheduleService { status: outcome.errorText ? 'failed' : 'completed', responseText: outcome.responseText, errorText: outcome.errorText, - }, repo) + }, context.repo) ScheduleService.resolveCancel(jobId, runId, 'settled') - return this.getRun(repoId, jobId, runId) + return this.getRunById(context.repoId, jobId, runId) } try { - await this.openCodeClient.api.session.interrupt({ sessionID: run.sessionId }) + await context.client.api.session.interrupt({ sessionID: run.sessionId }) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } - return await this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + return await this.finalizeCancelledRun(context.repo, job, run, context.repoId, jobId, runId) } catch (error) { - this.settleCancelFromState(repoId, jobId, runId) + this.settleCancelFromState(context.repoId, jobId, runId) throw error } } - private settleCancelFromState(repoId: number, jobId: number, runId: number): void { + private getRunById(repoId: number | null, jobId: number, runId: number): ScheduleRun { + const run = getScheduleRunById(this.db, repoId, jobId, runId) + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + return run + } + + private settleCancelFromState(repoId: number | null, jobId: number, runId: number): void { let status: ScheduleRun['status'] | undefined try { status = getScheduleRunById(this.db, repoId, jobId, runId)?.status @@ -829,10 +1092,10 @@ export class ScheduleService { } private async finalizeCancelledRun( - repo: Repo, + repo: Repo | null, job: ScheduleJob, run: ScheduleRun, - repoId: number, + repoId: number | null, jobId: number, runId: number, ): Promise { @@ -873,7 +1136,10 @@ export class ScheduleService { } private async submitPromptAndMonitor(input: { - repoId: number + repoId: number | null + repo: Repo | null + client: OpenCodeClient + serverId: string job: ScheduleJob runId: number sessionId: string @@ -882,21 +1148,19 @@ export class ScheduleService { sessionMonitor: SessionMonitor directory: string }): Promise { - const repo = this.assertRepo(input.repoId) - try { if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return } - const skills = await this.resolveSkillAttachments(input.directory, input.job.skillMetadata) + const skills = await this.resolveSkillAttachments(input.client, input.directory, input.job.skillMetadata) if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return } const notes = input.job.skillMetadata?.notes?.trim() - await this.openCodeClient.api.session.prompt({ + await input.client.api.session.prompt({ sessionID: input.sessionId, text: notes ? `${input.job.prompt}\n\nSkill notes: ${notes}` : input.job.prompt, ...(skills.length > 0 ? { skills } : {}), @@ -911,6 +1175,9 @@ export class ScheduleService { await this.monitorRunCompletion({ sessionMonitor: input.sessionMonitor, repoId: input.repoId, + repo: input.repo, + client: input.client, + serverId: input.serverId, job: input.job, runId: input.runId, sessionId: input.sessionId, @@ -955,7 +1222,7 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, repo) + await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } @@ -963,7 +1230,10 @@ export class ScheduleService { private async monitorRunCompletion(input: { sessionMonitor: SessionMonitor - repoId: number + repoId: number | null + repo: Repo | null + client: OpenCodeClient + serverId: string job: ScheduleJob runId: number sessionId: string @@ -971,8 +1241,7 @@ export class ScheduleService { triggerSource: ScheduleRunTriggerSource }): Promise { try { - const repo = this.assertRepo(input.repoId) - const currentAssistantState = await this.readSettledAssistantState(input.sessionId) + const currentAssistantState = await this.readSettledAssistantState(input.client, input.sessionId) if (currentAssistantState) { if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return @@ -989,11 +1258,11 @@ export class ScheduleService { status: currentAssistantState.errorText ? 'failed' : 'completed', responseText: currentAssistantState.responseText, errorText: currentAssistantState.errorText, - }, repo) + }, input.repo) return } - const response = await this.waitForAssistantMessage(input.sessionId, input.sessionMonitor) + const response = await this.waitForAssistantMessage(input.client, input.sessionId, input.sessionMonitor) if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { return @@ -1083,16 +1352,20 @@ export class ScheduleService { } finally { await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() - await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, this.assertRepo(input.repoId)) + await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, input.repo) ScheduleService.releaseActiveRun(input.job.id, input.runId) ScheduleService.clearCancel(input.job.id, input.runId) } } private async recoverRunningRun(job: ScheduleJob, run: ScheduleRun): Promise { + const target = this.targetForJob(job) + let repo: Repo | null = null + try { - const repo = this.assertRepo(job.repoId) - const runDirectory = run.worktreePath ?? repo.fullPath + const context = this.resolveTargetContext(target, job.directory) + repo = context.repo + const runDirectory = run.worktreePath ?? context.directory if (!run.sessionId) { await this.finalizeRecoveredRun(job, run, { @@ -1102,7 +1375,7 @@ export class ScheduleService { return } - const outcome = await this.readAssistantOutcome(run.sessionId) + const outcome = await this.readAssistantOutcome(context.client, run.sessionId) if (outcome.kind === 'settled') { await this.finalizeRecoveredRun(job, run, { @@ -1114,10 +1387,13 @@ export class ScheduleService { } if (outcome.kind === 'busy') { - const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId) + const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId, context.serverId) void this.monitorRunCompletion({ sessionMonitor, repoId: run.repoId, + repo, + client: context.client, + serverId: context.serverId, job, runId: run.id, sessionId: run.sessionId, @@ -1138,7 +1414,7 @@ export class ScheduleService { await this.finalizeRecoveredRun(job, run, { status: 'failed', errorText, - }, this.assertRepo(job.repoId)) + }, repo) } } @@ -1150,7 +1426,7 @@ export class ScheduleService { responseText?: string | null errorText?: string | null }, - repo: Repo, + repo: Repo | null, ): Promise { const finishedAt = Date.now() @@ -1181,28 +1457,29 @@ export class ScheduleService { ScheduleService.releaseActiveRun(job.id, run.id) } - private async interruptCancelledSession(sessionId: string): Promise { + private async interruptCancelledSession(client: OpenCodeClient, sessionId: string): Promise { try { - await this.openCodeClient.api.session.interrupt({ sessionID: sessionId }) + await client.api.session.interrupt({ sessionID: sessionId }) } catch (error) { logger.error(`Failed to interrupt cancelled schedule session ${sessionId}:`, error) } } - private loadRun(repoId: number, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { + private loadRun(repoId: number | null, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { return getScheduleRunById(this.db, repoId, jobId, runId) ?? fallback } private async abandonCancelledStartup( - repoId: number, + client: OpenCodeClient, + repoId: number | null, jobId: number, run: ScheduleRun, job: ScheduleJob, - repo: Repo, + repo: Repo | null, sessionId?: string, ): Promise { if (sessionId) { - await this.interruptCancelledSession(sessionId) + await this.interruptCancelledSession(client, sessionId) } ScheduleService.clearCancel(jobId, run.id) await this.teardownWorktree(repoId, jobId, run.id, job, repo) @@ -1210,7 +1487,8 @@ export class ScheduleService { return this.loadRun(repoId, jobId, run.id, run) } - private async teardownWorktree(repoId: number, jobId: number, runId: number, job: ScheduleJob, repo: Repo): Promise { + private async teardownWorktree(repoId: number | null, jobId: number, runId: number, job: ScheduleJob, repo: Repo | null): Promise { + if (!repo) return const key = `${repoId}:${jobId}:${runId}` if (ScheduleService.activeTeardowns.has(key)) return ScheduleService.activeTeardowns.add(key) @@ -1241,12 +1519,12 @@ export class ScheduleService { * settles one message per tool call. Only a session that has gone idle has * finished, and only then is the final message guaranteed to carry its content. */ - private async readAssistantOutcome(sessionId: string): Promise { - if (await this.isSessionActive(sessionId)) { + private async readAssistantOutcome(client: OpenCodeClient, sessionId: string): Promise { + if (await this.isSessionActive(client, sessionId)) { return { kind: 'busy' } } - const assistantState = await this.readAssistantReply(sessionId) + const assistantState = await this.readAssistantReply(client, sessionId) if (assistantState?.completed || assistantState?.errorText) { return { kind: 'settled', responseText: assistantState.responseText, errorText: assistantState.errorText } } @@ -1255,15 +1533,17 @@ export class ScheduleService { } private async readSettledAssistantState( + client: OpenCodeClient, sessionId: string, ): Promise<{ responseText: string | null; errorText: string | null } | null> { - const outcome = await this.readAssistantOutcome(sessionId) + const outcome = await this.readAssistantOutcome(client, sessionId) return outcome.kind === 'settled' ? { responseText: outcome.responseText, errorText: outcome.errorText } : null } private async waitForAssistantMessage( + client: OpenCodeClient, sessionId: string, sessionMonitor: SessionMonitor, ): Promise<{ responseText: string | null; errorText: string | null }> { @@ -1271,14 +1551,14 @@ export class ScheduleService { const signal = await sessionMonitor.nextSignal() if (signal.errorText || signal.disposed) { - const assistantState = await this.readAssistantReply(sessionId) + const assistantState = await this.readAssistantReply(client, sessionId) return { responseText: assistantState?.responseText ?? null, errorText: signal.errorText ?? SESSION_STOPPED_ERROR, } } - const outcome = await this.readAssistantOutcome(sessionId) + const outcome = await this.readAssistantOutcome(client, sessionId) if (outcome.kind === 'busy') { continue @@ -1292,25 +1572,25 @@ export class ScheduleService { } } - private async readAssistantReply(sessionId: string): Promise { + private async readAssistantReply(client: OpenCodeClient, sessionId: string): Promise { try { - return await readLatestAssistantReply(this.openCodeClient, sessionId) + return await readLatestAssistantReply(client, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch session messages', 502) } } - private async isSessionActive(sessionId: string): Promise { + private async isSessionActive(client: OpenCodeClient, sessionId: string): Promise { try { - return await isSessionBusy(this.openCodeClient, sessionId) + return await isSessionBusy(client, sessionId) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to fetch active sessions', 502) } } - private async listAgentIds(directory: string): Promise> { + private async listAgentIds(client: OpenCodeClient, directory: string): Promise> { try { - const response = await this.openCodeClient.api.agent.list(openCodeLocation(directory)) + const response = await client.api.agent.list(openCodeLocation(directory)) return new Set(response.data.map((agent) => agent.id)) } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to list agents', 502) @@ -1321,12 +1601,12 @@ export class ScheduleService { * OpenCode admits a prompt for a session whose agent does not exist at its location * but never delivers it, so a scheduled run falls back to the default agent when its agent is gone. */ - private async resolveRunAgent(directory: string, agentSlug: string | null): Promise { + private async resolveRunAgent(client: OpenCodeClient, directory: string, agentSlug: string | null): Promise { if (!agentSlug) { return null } - if ((await this.listAgentIds(directory)).has(agentSlug)) { + if ((await this.listAgentIds(client, directory)).has(agentSlug)) { return agentSlug } @@ -1334,12 +1614,12 @@ export class ScheduleService { return null } - private async assertAgentAvailable(directory: string, agentSlug: string | null): Promise { + private async assertAgentAvailable(client: OpenCodeClient, directory: string, agentSlug: string | null): Promise { if (!agentSlug) { return } - if (!(await this.listAgentIds(directory)).has(agentSlug)) { + if (!(await this.listAgentIds(client, directory)).has(agentSlug)) { throw new ScheduleServiceError( `Agent "${agentSlug}" is not available in ${directory}. Choose an agent defined for this repo in the schedule settings, or clear the agent to use the default.`, 400, @@ -1352,12 +1632,12 @@ export class ScheduleService { * session starts. Schedule-only servers are added to that location, and configured servers * are connected even when their config disables them; both last until OpenCode restarts. */ - private async connectMcpServers(directory: string, servers: ScheduleJob['mcpServers']): Promise { + private async connectMcpServers(client: OpenCodeClient, directory: string, servers: ScheduleJob['mcpServers']): Promise { if (servers.length === 0) { return } - const api = this.openCodeClient.api + const api = client.api const location = openCodeLocation(directory) const readStatuses = async () => mcpStatusByName((await api.mcp.list(location)).data) @@ -1395,6 +1675,7 @@ export class ScheduleService { } private async resolveSkillAttachments( + client: OpenCodeClient, directory: string, skillMetadata: ScheduleJob['skillMetadata'], ): Promise { @@ -1405,7 +1686,7 @@ export class ScheduleService { let availableSkills: Set try { - const response = await this.openCodeClient.api.skill.list(openCodeLocation(directory)) + const response = await client.api.skill.list(openCodeLocation(directory)) availableSkills = new Set(response.data.map((skill) => skill.id)) } catch (error) { logger.warn('Failed to list skills for the scheduled run; continuing without skill attachments:', error) @@ -1521,7 +1802,7 @@ export class ScheduleRunner { } const cron = new Cron(job.cronExpression, options, () => { logger.info(`Cron triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) }) this.cronJobs.set(job.id, cron) logger.info(`Cron job created for ${job.id}: next run at ${cron.nextRun()?.toISOString()}`) @@ -1548,7 +1829,7 @@ export class ScheduleRunner { const now = new Date() if (nextRunDate <= now) { - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) } const cronOptions = { @@ -1557,7 +1838,7 @@ export class ScheduleRunner { } const cron = new Cron(cronExpression, cronOptions, () => { logger.info(`Cron triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id) + void this.executeJob(job) }) this.cronJobs.set(job.id, cron) } else { @@ -1573,7 +1854,7 @@ export class ScheduleRunner { isRunning = true logger.info(`Interval timer triggered for job ${job.id}: ${job.name}`) try { - await this.executeJob(job.repoId, job.id) + await this.executeJob(job) } finally { isRunning = false scheduleNext() @@ -1587,7 +1868,7 @@ export class ScheduleRunner { if (isStopped || isRunning) return isRunning = true logger.info(`Interval timer triggered for job ${job.id}: ${job.name}`) - void this.executeJob(job.repoId, job.id).finally(() => { + void this.executeJob(job).finally(() => { isRunning = false scheduleNext() }) @@ -1595,7 +1876,7 @@ export class ScheduleRunner { } else { if (!isStopped) { isRunning = true - void this.executeJob(job.repoId, job.id).finally(() => { + void this.executeJob(job).finally(() => { isRunning = false scheduleNext() }) @@ -1619,11 +1900,11 @@ export class ScheduleRunner { } } - private async executeJob(repoId: number, jobId: number): Promise { + private async executeJob(job: ScheduleJob): Promise { try { - await this.scheduleService.runJob(repoId, jobId, 'schedule') + await this.scheduleService.runJobForJob(job, 'schedule') } catch (error) { - logger.error(`Scheduled run failed for job ${jobId}:`, error) + logger.error(`Scheduled run failed for job ${job.id}:`, error) } } diff --git a/backend/src/services/session-ancestry.ts b/backend/src/services/session-ancestry.ts index ea893cf9b..7adcf1018 100644 --- a/backend/src/services/session-ancestry.ts +++ b/backend/src/services/session-ancestry.ts @@ -1,25 +1,42 @@ +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { OpenCodeClient } from './opencode/client' const MAX_PARENT_HOPS = 10 +export type SessionClientResolver = (serverId: string) => OpenCodeClient + +function sessionKey(serverId: string, sessionId: string): string { + return `${serverId}:${sessionId}` +} + export class SessionAncestry { private readonly parentBySession = new Map() + private readonly resolveClient: SessionClientResolver - constructor(private readonly openCodeClient: OpenCodeClient) {} + constructor(clientOrResolver: OpenCodeClient | SessionClientResolver) { + this.resolveClient = typeof clientOrResolver === 'function' ? clientOrResolver : () => clientOrResolver + } - rememberParent(sessionId: string, parentId: string | null | undefined): void { - this.parentBySession.set(sessionId, parentId ?? null) + rememberParent( + sessionId: string, + parentId: string | null | undefined, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): void { + this.parentBySession.set(sessionKey(serverId, sessionId), parentId ?? null) } - forget(sessionId: string): void { - this.parentBySession.delete(sessionId) + forget(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): void { + this.parentBySession.delete(sessionKey(serverId, sessionId)) } - async resolveRootSessionId(sessionId: string): Promise { + async resolveRootSessionId( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { let current = sessionId for (let hops = 0; hops < MAX_PARENT_HOPS; hops += 1) { - const parentId = await this.getParentId(current) + const parentId = await this.getParentId(current, serverId) if (!parentId) { return current } @@ -29,14 +46,15 @@ export class SessionAncestry { return current } - private async getParentId(sessionId: string): Promise { - if (this.parentBySession.has(sessionId)) { - return this.parentBySession.get(sessionId) ?? null + private async getParentId(sessionId: string, serverId: string): Promise { + const key = sessionKey(serverId, sessionId) + if (this.parentBySession.has(key)) { + return this.parentBySession.get(key) ?? null } - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const session = await this.resolveClient(serverId).api.session.get({ sessionID: sessionId }) const parentId = session.parentID ?? null - this.parentBySession.set(sessionId, parentId) + this.parentBySession.set(key, parentId) return parentId } } diff --git a/backend/src/services/session-goals.ts b/backend/src/services/session-goals.ts index d1a32feca..c17e0e98b 100644 --- a/backend/src/services/session-goals.ts +++ b/backend/src/services/session-goals.ts @@ -1,6 +1,7 @@ import type { Database } from 'bun:sqlite' import { DEFAULT_SESSION_DEFAULTS, + LOCAL_OPENCODE_SERVER_ID, type SessionGoal, type SessionGoalStatus, type SessionGoalStopReason, @@ -43,10 +44,17 @@ const RECOVERY_RETRY_MS = 5000 type PrerequisiteResult = { ok: true; value: T } | { ok: false } +export interface SessionServerRegistry { + getClient(serverId: string): OpenCodeClient + get(serverId: string): { enabled: boolean } | null +} + +export type SessionServerSource = OpenCodeClient | SessionServerRegistry + export interface SessionGoalServiceOptions { quietMs?: number onOutcome?: (goal: SessionGoal) => void - resolveSessionLock?: (sessionId: string) => Promise + resolveSessionLock?: (sessionId: string, serverId: string) => Promise } export class SessionGoalError extends Error { @@ -62,10 +70,29 @@ export function sessionTokenTotal(tokens: { input: number; output: number; reaso return tokens.input + tokens.output + tokens.reasoning } +function goalKey(serverId: string, sessionId: string): string { + return `${serverId}:${sessionId}` +} + +function isServerRegistry(source: SessionServerSource): source is SessionServerRegistry { + return typeof (source as SessionServerRegistry).getClient === 'function' +} + +function toServerRegistry(source: SessionServerSource): SessionServerRegistry { + if (isServerRegistry(source)) { + return source + } + return { + getClient: () => source, + get: (serverId) => (serverId === LOCAL_OPENCODE_SERVER_ID ? { enabled: true } : null), + } +} + function toSessionGoal(record: SessionGoalRecord): SessionGoal { return { id: record.id, sessionId: record.sessionId, + serverId: record.serverId, directory: record.directory, objective: record.objective, status: record.status, @@ -91,16 +118,18 @@ export class SessionGoalService { private readonly auditing = new Set() private readonly auditRequested = new Set() private readonly auditGeneration = new Map() + private readonly servers: SessionServerRegistry private readonly quietMs: number private readonly onOutcome: (goal: SessionGoal) => void - private readonly resolveSessionLock: (sessionId: string) => Promise + private readonly resolveSessionLock: (sessionId: string, serverId: string) => Promise constructor( private readonly db: Database, - private readonly openCodeClient: OpenCodeClient, + servers: SessionServerSource, private readonly settingsService: SettingsService, options: SessionGoalServiceOptions = {}, ) { + this.servers = toServerRegistry(servers) this.quietMs = options.quietMs ?? DEFAULT_QUIET_MS this.onOutcome = options.onOutcome ?? (() => {}) this.resolveSessionLock = options.resolveSessionLock ?? (async () => null) @@ -109,7 +138,7 @@ export class SessionGoalService { loadOpenGoals(): void { this.goalBySession.clear() for (const record of listOpenSessionGoals(this.db)) { - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) } } @@ -118,6 +147,9 @@ export class SessionGoalService { if (record.status !== 'active' || record.turnState !== 'running') { continue } + if (!this.isServerAvailable(record.serverId)) { + continue + } try { await this.recoverRunningGoal(record) } catch (error) { @@ -127,73 +159,93 @@ export class SessionGoalService { } private async recoverRunningGoal(record: SessionGoalRecord): Promise { - const generation = this.currentAuditGeneration(record.sessionId) + const key = goalKey(record.serverId, record.sessionId) + const generation = this.currentAuditGeneration(key) + const client = this.servers.getClient(record.serverId) try { - await this.openCodeClient.api.session.get({ sessionID: record.sessionId }) + await client.api.session.get({ sessionID: record.sessionId }) } catch (error) { if (isSessionNotFoundError(error)) { this.finishGoal(record.id, 'stopped', 'session_deleted') return } logger.error(`Session goal recovery could not read session ${record.sessionId}:`, error) - this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + this.scheduleRecoveryRetry(key, record.id, generation) return } const busy = await this.readPrerequisite(record.id, 'busy check', () => - isSessionBusy(this.openCodeClient, record.sessionId), + isSessionBusy(client, record.sessionId), ) if (!busy.ok) { - this.scheduleRecoveryRetry(record.sessionId, record.id, generation) + this.scheduleRecoveryRetry(key, record.id, generation) return } if (busy.value) { return } - this.scheduleAudit(record.sessionId, record.id, 0) + this.scheduleAudit(key, record.id, 0) + } + + private isServerAvailable(serverId: string): boolean { + if (serverId === LOCAL_OPENCODE_SERVER_ID) { + return true + } + try { + const server = this.servers.get(serverId) + return Boolean(server?.enabled) + } catch (error) { + logger.error(`Failed to resolve OpenCode server ${serverId}:`, error) + return false + } } - private scheduleRecoveryRetry(sessionId: string, goalId: number, generation: number): void { - this.clearRecoveryTimer(sessionId) + private scheduleRecoveryRetry(key: string, goalId: number, generation: number): void { + this.clearRecoveryTimer(key) const timer = setTimeout(() => { - this.recoveryTimers.delete(sessionId) - void this.retryRecovery(sessionId, goalId, generation).catch((error) => { + this.recoveryTimers.delete(key) + void this.retryRecovery(key, goalId, generation).catch((error) => { logger.error(`Session goal recovery retry failed for goal ${goalId}:`, error) }) }, RECOVERY_RETRY_MS) - this.recoveryTimers.set(sessionId, timer) + this.recoveryTimers.set(key, timer) } - private async retryRecovery(sessionId: string, goalId: number, generation: number): Promise { + private async retryRecovery(key: string, goalId: number, generation: number): Promise { const record = getSessionGoalById(this.db, goalId) if (!record || record.status !== 'active' || record.turnState !== 'running') { return } - if (this.currentAuditGeneration(sessionId) !== generation) { + if (!this.isServerAvailable(record.serverId)) { + return + } + if (this.currentAuditGeneration(key) !== generation) { return } await this.recoverRunningGoal(record) } - private clearRecoveryTimer(sessionId: string): void { - const timer = this.recoveryTimers.get(sessionId) + private clearRecoveryTimer(key: string): void { + const timer = this.recoveryTimers.get(key) if (timer !== undefined) { clearTimeout(timer) - this.recoveryTimers.delete(sessionId) + this.recoveryTimers.delete(key) } } async start(input: StartSessionGoalRequest): Promise { - await this.assertSessionCanStartGoal(input.sessionId) + const serverId = input.serverId ?? LOCAL_OPENCODE_SERVER_ID + await this.assertSessionCanStartGoal(input.sessionId, serverId) const defaults = this.settingsService.getSettings().preferences.sessionDefaults const maxContinuations = input.maxContinuations ?? defaults?.goalMaxContinuations ?? DEFAULT_SESSION_DEFAULTS.goalMaxContinuations const tokenBudget = input.tokenBudget ?? defaults?.goalTokenBudget ?? null - const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId) + const tokensAtStart = await this.readSessionTokenTotalOrDefault(input.sessionId, serverId) const record = insertSessionGoal(this.db, { sessionId: input.sessionId, + serverId, directory: input.directory, objective: input.objective, maxContinuations, @@ -205,19 +257,19 @@ export class SessionGoalService { throw new SessionGoalError('This session already has an open goal', 409) } - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(goalKey(record.serverId, record.sessionId), record.id) return toSessionGoal(record) } - getLatest(sessionId: string): SessionGoal | null { - const record = getLatestSessionGoal(this.db, sessionId) + getLatest(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): SessionGoal | null { + const record = getLatestSessionGoal(this.db, sessionId, serverId) return record ? toSessionGoal(record) : null } - private async assertSessionCanStartGoal(sessionId: string): Promise { + private async assertSessionCanStartGoal(sessionId: string, serverId: string): Promise { let lock: SessionLockReason | null try { - lock = await this.resolveSessionLock(sessionId) + lock = await this.resolveSessionLock(sessionId, serverId) } catch (error) { logger.error(`Failed to resolve session lock for ${sessionId}:`, error) return @@ -256,41 +308,46 @@ export class SessionGoalService { }) } - async handleEvent(directory: string | null, event: SSEEvent): Promise { + async handleEvent( + directory: string | null, + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { const sessionId = sessionIDFromEvent(event) if (!sessionId) { return } - const goalId = this.goalBySession.get(sessionId) + const key = goalKey(serverId, sessionId) + const goalId = this.goalBySession.get(key) if (goalId === undefined) { return } switch (event.type) { case 'session.execution.started': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.applyTransition(goalId, ['active'], { turnState: 'running' }) return case 'session.idle': case 'session.execution.succeeded': - this.scheduleAuditFromEvent(sessionId, goalId) + this.scheduleAuditFromEvent(key, goalId) return case 'session.status': if (event.data.status.type === 'idle') { - this.scheduleAuditFromEvent(sessionId, goalId) + this.scheduleAuditFromEvent(key, goalId) } return case 'session.execution.failed': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'stopped', 'turn_error') return case 'session.execution.interrupted': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'paused', 'interrupted') return case 'session.deleted': - this.clearAuditTimer(sessionId) + this.clearAuditTimer(key) this.finishGoal(goalId, 'stopped', 'session_deleted', ['active', 'paused']) return default: @@ -298,50 +355,51 @@ export class SessionGoalService { } } - private scheduleAuditFromEvent(sessionId: string, goalId: number): void { + private scheduleAuditFromEvent(key: string, goalId: number): void { const record = getSessionGoalById(this.db, goalId) if (!record || record.status !== 'active' || record.turnState !== 'running') { return } - this.scheduleAudit(sessionId, goalId, this.quietMs) + this.scheduleAudit(key, goalId, this.quietMs) } private async scheduleAuditWhenIdle(goal: SessionGoal): Promise { - const generation = this.currentAuditGeneration(goal.sessionId) + const key = goalKey(goal.serverId, goal.sessionId) + const generation = this.currentAuditGeneration(key) const busy = await this.readPrerequisite(goal.id, 'busy check', () => - isSessionBusy(this.openCodeClient, goal.sessionId), + isSessionBusy(this.servers.getClient(goal.serverId), goal.sessionId), ) if (!busy.ok) { - this.failAudit(goal.id, goal.sessionId, generation) + this.failAudit(goal.id, key, generation) return } - if (!this.isAuditCurrent(goal.id, goal.sessionId, generation)) { + if (!this.isAuditCurrent(goal.id, key, generation)) { return } if (busy.value) { return } - this.scheduleAudit(goal.sessionId, goal.id, 0) + this.scheduleAudit(key, goal.id, 0) } - private scheduleAudit(sessionId: string, goalId: number, delay: number): void { - this.clearRecoveryTimer(sessionId) - this.clearAuditTimer(sessionId) + private scheduleAudit(key: string, goalId: number, delay: number): void { + this.clearRecoveryTimer(key) + this.clearAuditTimer(key) const timer = setTimeout(() => { - this.auditTimers.delete(sessionId) + this.auditTimers.delete(key) void this.runAudit(goalId).catch((error) => { logger.error(`Session goal audit failed for goal ${goalId}:`, error) }) }, delay) - this.auditTimers.set(sessionId, timer) + this.auditTimers.set(key, timer) } - private clearAuditTimer(sessionId: string): void { - const timer = this.auditTimers.get(sessionId) + private clearAuditTimer(key: string): void { + const timer = this.auditTimers.get(key) if (timer !== undefined) { clearTimeout(timer) - this.auditTimers.delete(sessionId) + this.auditTimers.delete(key) } } @@ -371,27 +429,29 @@ export class SessionGoalService { } const sessionId = goal.sessionId - const generation = this.currentAuditGeneration(sessionId) + const key = goalKey(goal.serverId, sessionId) + const generation = this.currentAuditGeneration(key) + const client = this.servers.getClient(goal.serverId) const busy = await this.readPrerequisite(goalId, 'busy check', () => - isSessionBusy(this.openCodeClient, sessionId), + isSessionBusy(client, sessionId), ) if (!busy.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation) || busy.value) { + if (!this.isAuditCurrent(goalId, key, generation) || busy.value) { return } const total = await this.readPrerequisite(goalId, 'token read', () => - this.readSessionTokenTotal(sessionId), + this.readSessionTokenTotal(sessionId, goal.serverId), ) if (!total.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -407,13 +467,13 @@ export class SessionGoalService { } const reply = await this.readPrerequisite(goalId, 'reply read', () => - readLatestAssistantReply(this.openCodeClient, sessionId), + readLatestAssistantReply(client, sessionId), ) if (!reply.ok) { - this.failAudit(goalId, sessionId, generation) + this.failAudit(goalId, key, generation) return } - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -423,7 +483,7 @@ export class SessionGoalService { } const verdict = await this.evaluateAudit(updated, reply.value) - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } if (!verdict) { @@ -431,16 +491,17 @@ export class SessionGoalService { return } - await this.applyVerdict(goalId, sessionId, generation, updated, verdict) + await this.applyVerdict(goalId, key, generation, updated, verdict) } private async evaluateAudit(goal: SessionGoalRecord, reply: AssistantReplyState | null): Promise { const prompt = buildGoalAuditPrompt({ objective: goal.objective, reply: reply?.responseText ?? null }) const model = this.resolveAuditorModel() + const localClient = this.servers.getClient(LOCAL_OPENCODE_SERVER_ID) for (let attempt = 0; attempt < AUDITOR_ATTEMPTS; attempt += 1) { try { - const result = await this.openCodeClient.api.generate.text(model ? { prompt, model } : { prompt }) + const result = await localClient.api.generate.text(model ? { prompt, model } : { prompt }) return parseGoalVerdict(result.text) ?? { verdict: 'continue', reason: GOAL_AUDIT_UNPARSED_REASON } } catch (error) { logger.error(`Goal auditor call failed for goal ${goal.id} (attempt ${attempt + 1}):`, error) @@ -457,12 +518,12 @@ export class SessionGoalService { private async applyVerdict( goalId: number, - sessionId: string, + key: string, generation: number, goal: SessionGoalRecord, verdict: GoalVerdict, ): Promise { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -487,11 +548,11 @@ export class SessionGoalService { return } - await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, patch) + await this.continueGoal(goalId, key, generation, goal, verdict.reason, patch) return } - await this.continueGoal(goalId, sessionId, generation, goal, verdict.reason, { + await this.continueGoal(goalId, key, generation, goal, verdict.reason, { consecutiveBlocked: 0, lastVerdict: 'continue', lastReason: verdict.reason, @@ -500,13 +561,13 @@ export class SessionGoalService { private async continueGoal( goalId: number, - sessionId: string, + key: string, generation: number, goal: SessionGoalRecord, reason: string, patch: SessionGoalPatch, ): Promise { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + if (!this.isAuditCurrent(goalId, key, generation)) { return } @@ -525,13 +586,13 @@ export class SessionGoalService { } try { - await this.openCodeClient.api.session.prompt({ + await this.servers.getClient(record.serverId).api.session.prompt({ sessionID: record.sessionId, text: buildGoalContinuationPrompt({ objective: record.objective, reason }), }) } catch (error) { logger.error(`Failed to send continuation for goal ${goalId}:`, error) - if (this.isAuditCurrent(goalId, sessionId, generation, 'waiting')) { + if (this.isAuditCurrent(goalId, key, generation, 'waiting')) { this.finishGoal(goalId, 'stopped', 'turn_error') } } @@ -555,8 +616,8 @@ export class SessionGoalService { } } - private failAudit(goalId: number, sessionId: string, generation: number): void { - if (!this.isAuditCurrent(goalId, sessionId, generation)) { + private failAudit(goalId: number, key: string, generation: number): void { + if (!this.isAuditCurrent(goalId, key, generation)) { return } this.finishGoal(goalId, 'paused', 'audit_failed') @@ -579,19 +640,19 @@ export class SessionGoalService { private isAuditCurrent( goalId: number, - sessionId: string, + key: string, generation: number, turnState: SessionGoalTurnState = 'running', ): boolean { - if (this.currentAuditGeneration(sessionId) !== generation) { + if (this.currentAuditGeneration(key) !== generation) { return false } const record = getSessionGoalById(this.db, goalId) return Boolean(record && record.status === 'active' && record.turnState === turnState) } - private currentAuditGeneration(sessionId: string): number { - return this.auditGeneration.get(sessionId) ?? 0 + private currentAuditGeneration(key: string): number { + return this.auditGeneration.get(key) ?? 0 } private applyTransition( @@ -632,14 +693,15 @@ export class SessionGoalService { } private syncGoalIndex(record: SessionGoalRecord): void { + const key = goalKey(record.serverId, record.sessionId) if (record.status === 'active' || record.status === 'paused') { - this.goalBySession.set(record.sessionId, record.id) + this.goalBySession.set(key, record.id) } else { - this.goalBySession.delete(record.sessionId) - this.clearAuditTimer(record.sessionId) + this.goalBySession.delete(key) + this.clearAuditTimer(key) } if (record.status !== 'active') { - this.clearRecoveryTimer(record.sessionId) + this.clearRecoveryTimer(key) } } @@ -647,18 +709,19 @@ export class SessionGoalService { if (patch.status === undefined && patch.turnState !== 'running') { return } - this.auditGeneration.set(record.sessionId, this.currentAuditGeneration(record.sessionId) + 1) + const key = goalKey(record.serverId, record.sessionId) + this.auditGeneration.set(key, this.currentAuditGeneration(key) + 1) this.auditRequested.delete(record.id) } - private async readSessionTokenTotal(sessionId: string): Promise { - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + private async readSessionTokenTotal(sessionId: string, serverId: string): Promise { + const session = await this.servers.getClient(serverId).api.session.get({ sessionID: sessionId }) return sessionTokenTotal(session.tokens) } - private async readSessionTokenTotalOrDefault(sessionId: string): Promise { + private async readSessionTokenTotalOrDefault(sessionId: string, serverId: string): Promise { try { - return await this.readSessionTokenTotal(sessionId) + return await this.readSessionTokenTotal(sessionId, serverId) } catch (error) { logger.error(`Failed to read token usage for session ${sessionId}:`, error) return 0 diff --git a/backend/src/services/session-launcher.ts b/backend/src/services/session-launcher.ts index 21202f359..f0bbdc928 100644 --- a/backend/src/services/session-launcher.ts +++ b/backend/src/services/session-launcher.ts @@ -1,17 +1,18 @@ import type { Database } from 'bun:sqlite' import { openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { LOCAL_OPENCODE_SERVER_ID, type MultiRunTarget } from '@opencode-manager/shared/schemas' import { sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' import { getRepoById } from '../db/queries' import type { Repo } from '../types/repo' import { getErrorMessage } from '../utils/error-utils' import type { OpenCodeClient } from './opencode/client' import { resolveOpenCodeModel, type ResolvedOpenCodeModel } from './opencode-models' +import { resolveRepoProjectId } from './repo' import type { RepoWorkspaceService } from './repo-workspace' type SessionCreateInput = NonNullable[0]> -interface LaunchSessionInput { - repoId: number +interface LaunchSessionCommon { prompt: string title?: string model?: string @@ -20,9 +21,12 @@ interface LaunchSessionInput { permissions?: SessionCreateInput['permissions'] } +export type LaunchSessionInput = LaunchSessionCommon & + ({ repoId: number } | { serverId: string; directory: string }) + export interface LaunchedSession { sessionId: string - repoId: number + repoId: number | null directory: string workspaceDirectory: string | null model: string @@ -48,11 +52,31 @@ export class SessionLaunchError extends Error { } } +export interface SessionTargetDeps { + db: Database + openCodeClient: OpenCodeClient + repoWorkspaces: RepoWorkspaceService + getServerClient?: (serverId: string) => OpenCodeClient +} + +/** + * A resolved place to run sessions: the local repository checkout or a directory on a linked + * OpenCode server. Workspaces and models are resolved through the same client the sessions run on. + */ +export interface ResolvedSessionTarget { + client: OpenCodeClient + directory: string + repo: Repo | null + resolveModel(requestedModel?: string): Promise + createWorkspace(options?: { name?: string; ref?: string }): Promise + removeWorkspace(directory: string): Promise +} + function withWorkspace(message: string, workspaceDirectory: string | null): string { return workspaceDirectory ? `${message} (workspace: ${workspaceDirectory})` : message } -export function requireReadyRepo(db: Database, repoId: number): Repo { +function requireReadyRepo(db: Database, repoId: number): Repo { const repo = getRepoById(db, repoId) if (!repo || repo.cloneStatus !== 'ready') { throw new SessionLaunchError('Repository not found or not ready', 404) @@ -60,48 +84,137 @@ export function requireReadyRepo(db: Database, repoId: number): Repo { return repo } +export function sessionTargetInput( + target: MultiRunTarget, +): { repoId: number } | { serverId: string; directory: string } { + return target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory } +} + +export function sessionTargetOfRun(run: { + repoId: number | null + serverId: string + directory: string | null +}): MultiRunTarget | null { + if (run.repoId !== null) { + return { kind: 'repo', repoId: run.repoId } + } + if (run.serverId === LOCAL_OPENCODE_SERVER_ID || run.directory === null) { + return null + } + return { kind: 'server', serverId: run.serverId, directory: run.directory } +} + +async function resolveModelIn( + client: OpenCodeClient, + directory: string, + requestedModel?: string, +): Promise { + let resolved: ResolvedOpenCodeModel + try { + resolved = await resolveOpenCodeModel(client, directory, { preferredModel: requestedModel }) + } catch (error) { + throw new SessionLaunchError(getErrorMessage(error) || 'Failed to resolve OpenCode model', 502) + } + + if (requestedModel) { + const requestedRef = parseOpenCodeModelRef(requestedModel) + if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { + throw new SessionLaunchError(`Model ${requestedModel} is not available`, 400) + } + } + + return resolved +} + +function repoSessionTarget(deps: SessionTargetDeps, repoId: number): ResolvedSessionTarget { + const repo = requireReadyRepo(deps.db, repoId) + const client = deps.openCodeClient + + return { + client, + directory: repo.fullPath, + repo, + resolveModel: (requestedModel) => resolveModelIn(client, repo.fullPath, requestedModel), + createWorkspace: async (options) => (await deps.repoWorkspaces.create(repo, options)).directory, + removeWorkspace: (directory) => deps.repoWorkspaces.remove(repo, directory), + } +} + +function serverSessionTarget( + deps: SessionTargetDeps, + serverId: string, + directory: string, +): ResolvedSessionTarget { + if (!deps.getServerClient) { + throw new SessionLaunchError('Remote OpenCode servers are not available', 400) + } + + const client = deps.getServerClient(serverId) + + return { + client, + directory, + repo: null, + resolveModel: (requestedModel) => resolveModelIn(client, directory, requestedModel), + createWorkspace: async (options) => { + const projectID = await resolveRepoProjectId(client, directory) + const worktree = await client.api.worktree.create({ + projectID, + ...(options?.name ? { name: options.name } : {}), + ...(options?.ref ? { branch: options.ref } : {}), + }) + return worktree.directory + }, + removeWorkspace: async (workspaceDirectory) => { + const projectID = await resolveRepoProjectId(client, directory) + await client.api.worktree.remove({ projectID, directory: workspaceDirectory, force: true }) + }, + } +} + +export async function resolveSessionTarget( + deps: SessionTargetDeps, + target: MultiRunTarget, +): Promise { + return target.kind === 'repo' + ? repoSessionTarget(deps, target.repoId) + : serverSessionTarget(deps, target.serverId, target.directory) +} + export class SessionLauncher { constructor( private readonly db: Database, private readonly openCodeClient: OpenCodeClient, private readonly repoWorkspaces: RepoWorkspaceService, + private readonly getServerClient?: (serverId: string) => OpenCodeClient, ) {} - async resolveModel(repo: Repo, requestedModel?: string): Promise { - let resolved: ResolvedOpenCodeModel - try { - resolved = await resolveOpenCodeModel(this.openCodeClient, repo.fullPath, { - preferredModel: requestedModel, - }) - } catch (error) { - throw new SessionLaunchError(getErrorMessage(error) || 'Failed to resolve OpenCode model', 502) - } - - if (requestedModel) { - const requestedRef = parseOpenCodeModelRef(requestedModel) - if (!requestedRef || resolved.providerID !== requestedRef.providerID || resolved.id !== requestedRef.id) { - throw new SessionLaunchError(`Model ${requestedModel} is not available`, 400) - } - } - - return resolved - } - async launch(input: LaunchSessionInput): Promise { - const repo = requireReadyRepo(this.db, input.repoId) - const model = await this.resolveModel(repo, input.model) + const target = await resolveSessionTarget( + { + db: this.db, + openCodeClient: this.openCodeClient, + repoWorkspaces: this.repoWorkspaces, + ...(this.getServerClient ? { getServerClient: this.getServerClient } : {}), + }, + 'repoId' in input + ? { kind: 'repo', repoId: input.repoId } + : { kind: 'server', serverId: input.serverId, directory: input.directory }, + ) + const model = await target.resolveModel(input.model) - let directory = repo.fullPath + let directory = target.directory let workspaceDirectory: string | null = null if (input.workspace) { try { - const workspace = await this.repoWorkspaces.create(repo, { + directory = await target.createWorkspace({ name: sanitizeRepoDirectoryName(input.workspace.name ?? '', 'session'), ...(input.workspace.ref ? { ref: input.workspace.ref } : {}), }) - directory = workspace.directory - workspaceDirectory = workspace.directory + workspaceDirectory = directory } catch (error) { throw new SessionLaunchError(getErrorMessage(error) || 'Failed to create workspace', 502) } @@ -109,7 +222,7 @@ export class SessionLauncher { let session: { id: string; title?: string | null } try { - session = await this.openCodeClient.api.session.create({ + session = await target.client.api.session.create({ ...(input.title ? { title: input.title } : {}), ...(input.agent ? { agent: input.agent } : {}), ...(input.permissions ? { permissions: input.permissions } : {}), @@ -129,7 +242,7 @@ export class SessionLauncher { } try { - await this.openCodeClient.api.session.prompt({ sessionID: session.id, text: input.prompt }) + await target.client.api.session.prompt({ sessionID: session.id, text: input.prompt }) } catch (error) { throw new SessionLaunchError( withWorkspace(getErrorMessage(error) || 'Failed to create OpenCode session', workspaceDirectory), @@ -141,7 +254,7 @@ export class SessionLauncher { return { sessionId: session.id, - repoId: repo.id, + repoId: target.repo?.id ?? null, directory, workspaceDirectory, model: model.model, diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index c7ff9ace0..fed1eb175 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -1,8 +1,9 @@ import type { Database } from 'bun:sqlite' -import type { - SessionLockReason, - SessionPermissionMode, - SessionPermissionModeState, +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionLockReason, + type SessionPermissionMode, + type SessionPermissionModeState, } from '@opencode-manager/shared/schemas' import { openCodeLocation, type PermissionRequest } from '@opencode-manager/shared/opencode' import { getScheduleRunBySessionId } from '../db/schedules' @@ -18,6 +19,16 @@ import type { SessionAncestry } from './session-ancestry' import type { SSEEvent } from './sse-aggregator' import type { SettingsService } from './settings' +export interface SessionClientProvider { + getClient(serverId: string): OpenCodeClient +} + +export type SessionClientSource = OpenCodeClient | SessionClientProvider + +function isClientProvider(source: SessionClientSource): source is SessionClientProvider { + return typeof (source as SessionClientProvider).getClient === 'function' +} + export class SessionPermissionModeError extends Error { status: number @@ -28,55 +39,71 @@ export class SessionPermissionModeError extends Error { } export class SessionPermissionModeService { + private readonly clients: SessionClientProvider + constructor( private readonly db: Database, - private readonly openCodeClient: OpenCodeClient, + clients: SessionClientSource, private readonly settingsService: SettingsService, private readonly ancestry: SessionAncestry, - ) {} + ) { + this.clients = isClientProvider(clients) ? clients : { getClient: () => clients } + } - async handleEvent(directory: string | null, event: SSEEvent): Promise { + async handleEvent( + directory: string | null, + event: SSEEvent, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { switch (event.type) { case 'session.created': { const { sessionID, parentID, permissions } = event.data - this.ancestry.rememberParent(sessionID, parentID) + this.ancestry.rememberParent(sessionID, parentID, serverId) const hasExplicitPermissions = Array.isArray(permissions) && permissions.length > 0 if (!parentID && !hasExplicitPermissions && this.defaultMode() === 'auto') { - insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto', serverId) } return } case 'session.forked': { const { sessionID, parentID } = event.data - this.ancestry.rememberParent(sessionID, null) - const sourceMode = await this.getEffectiveMode(parentID) + this.ancestry.rememberParent(sessionID, null, serverId) + const sourceMode = await this.getEffectiveMode(parentID, serverId) if (sourceMode.mode === 'auto') { - insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto') + insertSessionPermissionModeIfAbsent(this.db, sessionID, 'auto', serverId) } return } case 'permission.asked': { const { sessionID, id } = event.data - await this.autoAcceptRequest(sessionID, id) + await this.autoAcceptRequest(sessionID, id, serverId) return } case 'session.deleted': { const { sessionID } = event.data - this.ancestry.forget(sessionID) - deleteSessionPermissionMode(this.db, sessionID) + this.ancestry.forget(sessionID, serverId) + deleteSessionPermissionMode(this.db, sessionID, serverId) return } } } - async getEffectiveMode(sessionId: string): Promise { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) - return this.effectiveModeForRoot(sessionId, rootSessionId) + async getEffectiveMode( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId, serverId) + return this.effectiveModeForRoot(sessionId, rootSessionId, serverId) } - async setMode(sessionId: string, mode: SessionPermissionMode, directory: string): Promise { - const rootSessionId = await this.ancestry.resolveRootSessionId(sessionId) - const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + async setMode( + sessionId: string, + mode: SessionPermissionMode, + directory: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + const rootSessionId = await this.ancestry.resolveRootSessionId(sessionId, serverId) + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId, serverId) if (lockedReason === 'schedule') { throw new SessionPermissionModeError( 'Scheduled runs use their own permission configuration', @@ -91,15 +118,15 @@ export class SessionPermissionModeService { ) } - setSessionPermissionMode(this.db, sessionId, mode) + setSessionPermissionMode(this.db, sessionId, mode, serverId) if (mode === 'auto') { - await this.acceptPendingRequestsInDirectory(directory, sessionId) + await this.acceptPendingRequestsInDirectory(directory, serverId, sessionId) } return { sessionId, rootSessionId, mode, lockedReason: null } } - pinAsk(sessionId: string): void { - setSessionPermissionMode(this.db, sessionId, 'ask') + pinAsk(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): void { + setSessionPermissionMode(this.db, sessionId, 'ask', serverId) } defaultMode(): SessionPermissionMode { @@ -112,66 +139,93 @@ export class SessionPermissionModeService { * * Does nothing when the default mode is `ask`, since that needs no stored row. */ - async applyDefaultMode(sessionId: string, directory: string): Promise { + async applyDefaultMode( + sessionId: string, + directory: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { if (this.defaultMode() !== 'auto') { return } - insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto') - await this.acceptPendingRequestsInDirectory(directory, sessionId) + insertSessionPermissionModeIfAbsent(this.db, sessionId, 'auto', serverId) + await this.acceptPendingRequestsInDirectory(directory, serverId, sessionId) } - async acceptPendingRequestsForActiveSessions(): Promise { + async acceptPendingRequestsForActiveSessions(serverId: string = LOCAL_OPENCODE_SERVER_ID): Promise { try { - const active = await this.openCodeClient.api.session.active() + const client = this.clients.getClient(serverId) + const active = await client.api.session.active() const directories = new Set() for (const sessionId of Object.keys(active ?? {})) { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId) + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionId, serverId) if (!rootSessionId) continue - if (this.effectiveModeForRoot(sessionId, rootSessionId).mode !== 'auto') continue - const directory = await this.resolveSessionDirectory(sessionId) + if (this.effectiveModeForRoot(sessionId, rootSessionId, serverId).mode !== 'auto') continue + const directory = await this.resolveSessionDirectory(sessionId, serverId) if (directory) directories.add(directory) } for (const directory of directories) { - await this.acceptPendingRequestsInDirectory(directory) + await this.acceptPendingRequestsInDirectory(directory, serverId) } } catch (error) { - logger.error('Failed to accept pending permission requests for active sessions:', error) + logger.error(`Failed to accept pending permission requests for server ${serverId}:`, error) } } + async suppressesPermissionNotification( + event: SSEEvent, + sessionId: string | undefined, + serverId: string = LOCAL_OPENCODE_SERVER_ID, + ): Promise { + if (event.type !== 'permission.asked' || !sessionId) { + return false + } + return (await this.getEffectiveMode(sessionId, serverId)).mode === 'auto' + } + private effectiveModeForRoot( sessionId: string, rootSessionId: string | null, + serverId: string, ): SessionPermissionModeState { if (!rootSessionId) { return { sessionId, rootSessionId: sessionId, mode: 'ask', lockedReason: null } } - const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId) + const lockedReason = this.lockReasonForRoot(sessionId, rootSessionId, serverId) if (lockedReason === 'schedule') { return { sessionId, rootSessionId, mode: 'ask', lockedReason } } - const stored = getSessionPermissionMode(this.db, rootSessionId) + const stored = getSessionPermissionMode(this.db, rootSessionId, serverId) return { sessionId, rootSessionId, mode: stored ?? 'ask', lockedReason } } - private lockReasonForRoot(sessionId: string, rootSessionId: string): SessionLockReason | null { - if (getScheduleRunBySessionId(this.db, rootSessionId)) { + private lockReasonForRoot( + sessionId: string, + rootSessionId: string, + serverId: string, + ): SessionLockReason | null { + if (serverId === LOCAL_OPENCODE_SERVER_ID && getScheduleRunBySessionId(this.db, rootSessionId)) { return 'schedule' } return rootSessionId !== sessionId ? 'child' : null } - private async acceptPendingRequestsInDirectory(directory: string, expectedRootSessionId?: string): Promise { + private async acceptPendingRequestsInDirectory( + directory: string, + serverId: string, + expectedRootSessionId?: string, + ): Promise { let requests: PermissionRequest[] try { - const result = await this.openCodeClient.api.permission.request.list(openCodeLocation(directory)) + const result = await this.clients + .getClient(serverId) + .api.permission.request.list(openCodeLocation(directory)) requests = result.data } catch (error) { logger.error(`Failed to list pending permission requests for directory ${directory}:`, error) @@ -179,13 +233,13 @@ export class SessionPermissionModeService { } for (const request of requests) { - await this.autoAcceptRequest(request.sessionID, request.id, expectedRootSessionId) + await this.autoAcceptRequest(request.sessionID, request.id, serverId, expectedRootSessionId) } } - private async resolveSessionDirectory(sessionId: string): Promise { + private async resolveSessionDirectory(sessionId: string, serverId: string): Promise { try { - const session = await this.openCodeClient.api.session.get({ sessionID: sessionId }) + const session = await this.clients.getClient(serverId).api.session.get({ sessionID: sessionId }) return session.location.directory } catch (error) { logger.error(`Failed to resolve directory for session ${sessionId}:`, error) @@ -196,9 +250,10 @@ export class SessionPermissionModeService { private async autoAcceptRequest( sessionID: string, requestID: string, + serverId: string, expectedRootSessionId?: string, ): Promise { - const rootSessionId = await this.resolveRootSessionIdOrNull(sessionID) + const rootSessionId = await this.resolveRootSessionIdOrNull(sessionID, serverId) if (!rootSessionId) { return } @@ -207,25 +262,27 @@ export class SessionPermissionModeService { return } - if (this.effectiveModeForRoot(sessionID, rootSessionId).mode !== 'auto') { + if (this.effectiveModeForRoot(sessionID, rootSessionId, serverId).mode !== 'auto') { return } - await this.replyOnce(sessionID, requestID) + await this.replyOnce(sessionID, requestID, serverId) } - private async replyOnce(sessionID: string, requestID: string): Promise { + private async replyOnce(sessionID: string, requestID: string, serverId: string): Promise { try { - await this.openCodeClient.api.permission.reply({ sessionID, requestID, decision: 'once' }) + await this.clients + .getClient(serverId) + .api.permission.reply({ sessionID, requestID, decision: 'once' }) logger.info(`Auto-accepted permission request ${requestID} for session ${sessionID}`) } catch (error) { logger.error(`Failed to auto-accept permission request ${requestID} for session ${sessionID}:`, error) } } - private async resolveRootSessionIdOrNull(sessionId: string): Promise { + private async resolveRootSessionIdOrNull(sessionId: string, serverId: string): Promise { try { - return await this.ancestry.resolveRootSessionId(sessionId) + return await this.ancestry.resolveRootSessionId(sessionId, serverId) } catch { return null } diff --git a/backend/src/services/session-reply.ts b/backend/src/services/session-reply.ts index 1b6920162..6118170f2 100644 --- a/backend/src/services/session-reply.ts +++ b/backend/src/services/session-reply.ts @@ -5,6 +5,7 @@ import { type SessionMessageAssistant, type SessionMessageInfo, } from '@opencode-manager/shared/opencode' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { truncateText } from '../utils/text-truncate' import type { OpenCodeClient } from './opencode/client' import { sseAggregator, type SSEEvent } from './sse-aggregator' @@ -47,7 +48,12 @@ export function sessionSettleSignal(event: SSEEvent, sessionId: string): Session } } -export function waitForSessionSettled(client: OpenCodeClient, sessionId: string, timeoutMs: number): Promise { +export function waitForSessionSettled( + client: OpenCodeClient, + sessionId: string, + timeoutMs: number, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { if (timeoutMs <= 0) { return Promise.resolve() } @@ -69,7 +75,10 @@ export function waitForSessionSettled(client: OpenCodeClient, sessionId: string, resolve() } - unsubscribe = sseAggregator.onEvent((_directory, event) => { + unsubscribe = sseAggregator.onServerEvent((eventServerId, _directory, event) => { + if (eventServerId !== serverId) { + return + } if (sessionSettleSignal(event, sessionId)) { finish() } diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index ffde2e546..b8f712ff9 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -10,6 +10,9 @@ import { encodeSSEFrame } from '../utils/sse-frame' type SSEClientCallback = (event: string, data: string) => void type SSEClientFrameWriter = (frame: Uint8Array) => void type SSEEventListener = (directory: string | null, event: SSEEvent) => void +type ServerEventListener = (serverId: string, directory: string | null, event: SSEEvent) => void +type ServerConnectedListener = (serverId: string) => void +type RemoteSubagentResolver = (serverId: string, sessionId: string) => boolean export type RemoteReplayHandler = (clientId: string, serverId: string, directory: string) => void | Promise @@ -20,6 +23,7 @@ interface SSEClient { directories: Set visible: boolean activeSessionId: string | null + activeServerId: string | null } export type SSEEvent = V2Event @@ -54,7 +58,10 @@ class SSEAggregator { private activeSessions: Map> = new Map() private sessionDirectories: Map = new Map() private eventListeners: Set = new Set() + private serverEventListeners: Set = new Set() private upstreamConnectedListeners: Set<() => void> = new Set() + private serverConnectedListeners: Set = new Set() + private remoteSubagentResolver: RemoteSubagentResolver | null = null private subagentSessions: Map> = new Map() private upstream: EventSource | null = null private reconnectTimeout: ReturnType | null = null @@ -122,7 +129,8 @@ class SSEAggregator { writeFrame, directories: new Set(directories), visible: false, - activeSessionId: null + activeSessionId: null, + activeServerId: null } this.clients.set(id, client) directories.forEach(dir => this.indexClientDirectory(id, dir)) @@ -436,6 +444,7 @@ class SSEAggregator { this.upstreamConnectedListeners.forEach(listener => { try { listener() } catch { /* ignore listener errors */ } }) + this.notifyServerConnected(LOCAL_OPENCODE_SERVER_ID) } private broadcastResync(): void { @@ -456,11 +465,37 @@ class SSEAggregator { return () => { this.eventListeners.delete(listener) } } + onServerEvent(listener: ServerEventListener): () => void { + this.serverEventListeners.add(listener) + return () => { this.serverEventListeners.delete(listener) } + } + onUpstreamConnected(listener: () => void): () => void { this.upstreamConnectedListeners.add(listener) return () => { this.upstreamConnectedListeners.delete(listener) } } + onServerConnected(listener: ServerConnectedListener): () => void { + this.serverConnectedListeners.add(listener) + return () => { this.serverConnectedListeners.delete(listener) } + } + + notifyServerEvent(serverId: string, directory: string | null, event: SSEEvent): void { + this.serverEventListeners.forEach(listener => { + try { listener(serverId, directory, event) } catch { /* ignore listener errors */ } + }) + } + + notifyServerConnected(serverId: string): void { + this.serverConnectedListeners.forEach(listener => { + try { listener(serverId) } catch { /* ignore listener errors */ } + }) + } + + setRemoteSubagentResolver(resolver: RemoteSubagentResolver | null): void { + this.remoteSubagentResolver = resolver + } + private handleUpstreamMessage(data: string): void { let event: V2Event try { @@ -531,6 +566,7 @@ class SSEAggregator { this.eventListeners.forEach(listener => { try { listener(directory, event) } catch { /* ignore listener errors */ } }) + this.notifyServerEvent(LOCAL_OPENCODE_SERVER_ID, directory, event) } private writeEnvelopeToClients(clientIds: Iterable, directory: string | null, payloadJson: string): void { @@ -692,7 +728,12 @@ class SSEAggregator { return this.clients.size } - setClientVisibility(id: string, visible: boolean, activeSessionId: string | null = null): boolean { + setClientVisibility( + id: string, + visible: boolean, + activeSessionId: string | null = null, + activeServerId: string | null = null + ): boolean { const client = this.clients.get(id) if (!client) { logger.warn(`setClientVisibility: client ${id} not found`) @@ -700,19 +741,23 @@ class SSEAggregator { } client.visible = visible client.activeSessionId = visible ? activeSessionId : null + client.activeServerId = visible ? (activeServerId ?? LOCAL_OPENCODE_SERVER_ID) : null return true } - isSessionBeingViewed(sessionId: string): boolean { + isSessionBeingViewed(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): boolean { for (const client of this.clients.values()) { - if (client.visible && client.activeSessionId === sessionId) { + if (client.visible && client.activeSessionId === sessionId && client.activeServerId === serverId) { return true } } return false } - isSubagentSession(sessionId: string): boolean { + isSubagentSession(sessionId: string, serverId: string = LOCAL_OPENCODE_SERVER_ID): boolean { + if (serverId !== LOCAL_OPENCODE_SERVER_ID) { + return this.remoteSubagentResolver?.(serverId, sessionId) ?? false + } for (const sessions of this.subagentSessions.values()) { if (sessions.has(sessionId)) { return true @@ -760,7 +805,10 @@ class SSEAggregator { this.directoryClients.clear() this.clients.clear() this.eventListeners.clear() + this.serverEventListeners.clear() this.upstreamConnectedListeners.clear() + this.serverConnectedListeners.clear() + this.remoteSubagentResolver = null this.remoteReplayHandler = null } diff --git a/backend/src/services/terminal.ts b/backend/src/services/terminal.ts index d32f42fdd..d96def17e 100644 --- a/backend/src/services/terminal.ts +++ b/backend/src/services/terminal.ts @@ -23,6 +23,12 @@ export interface TerminalSize { rows: number } +export interface TerminalTarget { + client: OpenCodeClient + baseUrl: () => string + includeLocalCredentials: boolean +} + export class TerminalNotFoundError extends Error { constructor(ptyID: string) { super(`Terminal not found: ${ptyID}`) @@ -32,24 +38,30 @@ export class TerminalNotFoundError extends Error { export class TerminalService { constructor( - private readonly openCodeClient: OpenCodeClient, + private readonly target: TerminalTarget, private readonly credentialProvider: CredentialProvider, - private readonly upstreamBaseUrl: () => string, private readonly openSocket: (url: string) => WebSocket = (url) => new WebSocket(url), ) {} + forTarget(target: TerminalTarget): TerminalService { + return new TerminalService(target, this.credentialProvider, this.openSocket) + } + async list(directory: string): Promise { - const result = await this.openCodeClient.api.pty.list({ location: { directory } }) + const result = await this.target.client.api.pty.list({ location: { directory } }) return result.data.map((pty) => this.toTerminalInfo(pty)) } async create(directory: string, input: CreateTerminalInput): Promise { - const result = await this.openCodeClient.api.pty.create({ + const result = await this.target.client.api.pty.create({ location: { directory }, title: formatTerminalTitle({ kind: input.kind, name: input.name, actionId: input.actionId }), ...(input.command !== undefined ? { command: input.command } : {}), ...(input.args !== undefined ? { args: input.args } : {}), - env: { ...this.credentialProvider.getGhCliEnv({ cwd: directory }), ...input.env }, + env: { + ...(this.target.includeLocalCredentials ? this.credentialProvider.getGhCliEnv({ cwd: directory }) : {}), + ...input.env, + }, }) return this.toTerminalInfo(result.data) } @@ -64,11 +76,11 @@ export class TerminalService { } async resize(directory: string, ptyID: string, size: TerminalSize): Promise { - await this.openCodeClient.api.pty.update({ ptyID, location: { directory }, size }) + await this.target.client.api.pty.update({ ptyID, location: { directory }, size }) } async remove(directory: string, ptyID: string): Promise { - await this.openCodeClient.api.pty.remove({ ptyID, location: { directory } }) + await this.target.client.api.pty.remove({ ptyID, location: { directory } }) } async removeAll(directory: string): Promise { @@ -92,13 +104,13 @@ export class TerminalService { ): Promise { await this.requireTerminal(directory, ptyID) - const result = await this.openCodeClient.api.pty.connect.token({ + const result = await this.target.client.api.pty.connect.token({ ptyID, location: { directory }, 'x-opencode-ticket': '1', }) - const base = new URL(this.upstreamBaseUrl()) + const base = new URL(this.target.baseUrl()) if (!base.pathname.endsWith('/')) base.pathname += '/' const socketUrl = new URL(`api/pty/${encodeURIComponent(ptyID)}/connect`, base) socketUrl.searchParams.set('ticket', result.data.ticket) @@ -111,7 +123,7 @@ export class TerminalService { private async removePersistentTerminals(directory: string): Promise { let cursor: string | undefined do { - const page = await this.openCodeClient.api.session.list({ directory, cursor }) + const page = await this.target.client.api.session.list({ directory, cursor }) for (const session of page.data) { await this.removeSessionPersistentTerminals(session.id) } @@ -122,7 +134,7 @@ export class TerminalService { private async removeSessionPersistentTerminals(sessionID: string): Promise { let terminals: OpenCodePersistentPty[] try { - terminals = await this.openCodeClient.api.experimental.persistentPty.list({ sessionID }) + terminals = await this.target.client.api.experimental.persistentPty.list({ sessionID }) } catch (error: unknown) { if (openCodeErrorStatus(error) === 404) return throw error @@ -130,7 +142,7 @@ export class TerminalService { for (const terminal of terminals) { try { - await this.openCodeClient.api.experimental.persistentPty.remove({ ptyID: terminal.id }) + await this.target.client.api.experimental.persistentPty.remove({ ptyID: terminal.id }) } catch (error: unknown) { if (openCodeErrorStatus(error) === 404) continue throw error diff --git a/backend/src/utils/terminal-cursor.ts b/backend/src/utils/terminal-cursor.ts new file mode 100644 index 000000000..dd57cc701 --- /dev/null +++ b/backend/src/utils/terminal-cursor.ts @@ -0,0 +1,7 @@ +export function parseTerminalCursor(raw: string | undefined): number | undefined | null { + if (raw === undefined) return undefined + if (!/^-?\d+$/.test(raw)) return null + const parsed = Number(raw) + if (!Number.isSafeInteger(parsed) || parsed < -1) return null + return parsed +} diff --git a/backend/test/db/multi-run-fusions.test.ts b/backend/test/db/multi-run-fusions.test.ts index 24aa281f5..dd0bbcae6 100644 --- a/backend/test/db/multi-run-fusions.test.ts +++ b/backend/test/db/multi-run-fusions.test.ts @@ -53,7 +53,7 @@ describe('multi-run fusions', () => { function createRun(models = ['openai/a', 'openai/b']): number { const run = createMultiRunWithEntries( db, - { repoId: REPO_ID, name: 'Sweep', prompt: 'go', isolated: true, baseRef: null }, + { repoId: REPO_ID, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: true, baseRef: null }, models, ) return run.id @@ -167,7 +167,7 @@ describe('multi-run fusions', () => { truncated: false, }) - const listed = listMultiRuns(db, REPO_ID, 20) + const listed = listMultiRuns(db, { kind: 'repo', repoId: REPO_ID }, 20) expect(listed[0]?.fusions).toHaveLength(1) expect(listed[0]?.fusions[0]?.sources).toHaveLength(2) }) @@ -175,7 +175,7 @@ describe('multi-run fusions', () => { it('returns an empty fusion list for runs without fusions', () => { const multiRunId = createRun() expect(getMultiRun(db, multiRunId)?.fusions).toEqual([]) - expect(listMultiRuns(db, REPO_ID, 20)[0]?.fusions).toEqual([]) + expect(listMultiRuns(db, { kind: 'repo', repoId: REPO_ID }, 20)[0]?.fusions).toEqual([]) }) it('reads a fusion by its request id', () => { diff --git a/backend/test/db/multi-run-targets-migration.test.ts b/backend/test/db/multi-run-targets-migration.test.ts new file mode 100644 index 000000000..3481baa38 --- /dev/null +++ b/backend/test/db/multi-run-targets-migration.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import migration from '../../src/db/migrations/202610181310-multi-run-targets' +import { ensureMultiRunTables } from '../../src/db/multi-runs' + +interface ColumnInfo { + name: string + notnull: number + dflt_value: string | null +} + +function createLegacyTable(db: Database): void { + db.run(` + CREATE TABLE multi_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + isolated INTEGER NOT NULL, + base_ref TEXT, + created_at INTEGER NOT NULL + ) + `) + db.run('CREATE INDEX idx_multi_runs_repo ON multi_runs(repo_id, created_at DESC)') +} + +function columns(db: Database): ColumnInfo[] { + return db.prepare('PRAGMA table_info(multi_runs)').all() as ColumnInfo[] +} + +describe('202610181310-multi-run-targets migration', () => { + let db: Database + + afterEach(() => { + db.close() + }) + + it('makes repo_id nullable and adds the server target columns', () => { + db = new Database(':memory:') + createLegacyTable(db) + + migration.up(db) + + const byName = new Map(columns(db).map((column) => [column.name, column])) + expect(byName.get('repo_id')?.notnull).toBe(0) + expect(byName.get('server_id')).toMatchObject({ notnull: 1, dflt_value: "'local'" }) + expect(byName.has('directory')).toBe(true) + }) + + it('preserves existing rows and their ids', () => { + db = new Database(':memory:') + createLegacyTable(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, name, prompt, isolated, base_ref, created_at) + VALUES(7, 3, 'Sweep', 'go', 1, 'main', 1700000000000)`, + ) + + migration.up(db) + + expect( + db.prepare('SELECT id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at FROM multi_runs').get(), + ).toEqual({ + id: 7, + repo_id: 3, + server_id: 'local', + directory: null, + name: 'Sweep', + prompt: 'go', + isolated: 1, + base_ref: 'main', + created_at: 1700000000000, + }) + }) + + it('recreates the repo and server indexes', () => { + db = new Database(':memory:') + createLegacyTable(db) + + migration.up(db) + + const indexes = ( + db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'multi_runs'").all() as Array<{ + name: string + }> + ).map((index) => index.name) + expect(indexes).toEqual(expect.arrayContaining(['idx_multi_runs_repo', 'idx_multi_runs_server'])) + }) + + it('is idempotent', () => { + db = new Database(':memory:') + createLegacyTable(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, name, prompt, isolated, base_ref, created_at) + VALUES(1, 2, 'Sweep', 'go', 0, NULL, 1)`, + ) + + migration.up(db) + migration.up(db) + + expect(db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get()).toEqual({ count: 1 }) + expect(columns(db).filter((column) => column.name === 'server_id')).toHaveLength(1) + }) + + it('skips a table that already has the new schema', () => { + db = new Database(':memory:') + ensureMultiRunTables(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(4, NULL, 'srv_kitchen', '/srv/app', 'Remote sweep', 'go', 1, NULL, 2)`, + ) + + migration.up(db) + + expect(db.prepare('SELECT id, repo_id, server_id, directory FROM multi_runs').get()).toEqual({ + id: 4, + repo_id: null, + server_id: 'srv_kitchen', + directory: '/srv/app', + }) + }) + + it('drops remote rows and restores the repo-only schema on down', () => { + db = new Database(':memory:') + ensureMultiRunTables(db) + db.run( + `INSERT INTO multi_runs(id, repo_id, server_id, directory, name, prompt, isolated, base_ref, created_at) + VALUES(1, 5, 'local', NULL, 'Local', 'go', 0, NULL, 1), + (2, NULL, 'srv_kitchen', '/srv/app', 'Remote', 'go', 0, NULL, 2)`, + ) + + migration.down(db) + + expect(db.prepare('SELECT id, repo_id FROM multi_runs').all()).toEqual([{ id: 1, repo_id: 5 }]) + expect(columns(db).map((column) => column.name)).toEqual([ + 'id', + 'repo_id', + 'name', + 'prompt', + 'isolated', + 'base_ref', + 'created_at', + ]) + }) +}) diff --git a/backend/test/db/schedule-remote-targets.test.ts b/backend/test/db/schedule-remote-targets.test.ts new file mode 100644 index 000000000..8d0a66be6 --- /dev/null +++ b/backend/test/db/schedule-remote-targets.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import migration from '../../src/db/migrations/202610181320-remote-schedule-targets' + +interface ColumnInfo { + name: string + notnull: number +} + +const MIGRATION_ID = '202610181320-remote-schedule-targets' + +function columns(db: Database, table: string): ColumnInfo[] { + return db.prepare(`PRAGMA table_info(${table})`).all() as ColumnInfo[] +} + +describe('remote schedule target migration', () => { + it('preserves jobs and runs while making repo_id nullable', () => { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = ON') + migrate(db, allMigrations.filter((entry) => entry.id !== MIGRATION_ID)) + + const now = Date.now() + db.exec( + `INSERT INTO repos (id, repo_url, local_path, branch, default_branch, clone_status, cloned_at) + VALUES (1, 'https://github.com/test/my-repo', 'repos/my-repo', 'main', 'main', 'ready', ${now})`, + ) + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, name, enabled, schedule_mode, prompt, workspace_mode, created_at, updated_at) + VALUES (7, 1, 'Local job', 1, 'interval', 'Run it', 'shared-worktree', ?, ?)`, + ).run(now, now) + db.prepare( + `INSERT INTO schedule_runs (id, job_id, repo_id, trigger_source, status, started_at, created_at, session_id) + VALUES (5, 7, 1, 'manual', 'completed', ?, ?, 'ses_local')`, + ).run(now, now) + + migration.up(db) + + const jobColumns = columns(db, 'schedule_jobs') + expect(jobColumns.find((column) => column.name === 'repo_id')?.notnull).toBe(0) + expect(jobColumns.find((column) => column.name === 'server_id')?.notnull).toBe(1) + expect(jobColumns.some((column) => column.name === 'directory')).toBe(true) + + const runColumns = columns(db, 'schedule_runs') + expect(runColumns.find((column) => column.name === 'repo_id')?.notnull).toBe(0) + expect(runColumns.some((column) => column.name === 'server_id')).toBe(true) + + const job = db.prepare('SELECT * FROM schedule_jobs WHERE id = 7').get() as Record + expect(job).toMatchObject({ + repo_id: 1, + server_id: 'local', + directory: null, + name: 'Local job', + workspace_mode: 'shared-worktree', + }) + + const run = db.prepare('SELECT * FROM schedule_runs WHERE id = 5').get() as Record + expect(run).toMatchObject({ repo_id: 1, server_id: 'local', session_id: 'ses_local', status: 'completed' }) + + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, server_id, directory, name, enabled, schedule_mode, prompt, created_at, updated_at) + VALUES (8, NULL, 'srv_kitchen', '/srv/project', 'Remote job', 1, 'interval', 'Run remotely', ?, ?)`, + ).run(now, now) + db.prepare( + `INSERT INTO schedule_runs (id, job_id, repo_id, server_id, trigger_source, status, started_at, created_at) + VALUES (6, 8, NULL, 'srv_kitchen', 'manual', 'running', ?, ?)`, + ).run(now, now) + + expect(db.prepare('SELECT COUNT(*) AS count FROM schedule_runs').get()).toEqual({ count: 2 }) + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) + + it('is idempotent', () => { + const db = new Database(':memory:') + migrate(db, allMigrations) + + const now = Date.now() + db.prepare( + `INSERT INTO schedule_jobs (id, repo_id, server_id, directory, name, enabled, schedule_mode, prompt, created_at, updated_at) + VALUES (3, NULL, 'srv_kitchen', '/srv/project', 'Remote job', 1, 'interval', 'Run remotely', ?, ?)`, + ).run(now, now) + + migration.up(db) + + expect(db.prepare('SELECT COUNT(*) AS count FROM schedule_jobs').get()).toEqual({ count: 1 }) + expect(db.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) +}) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index 3a2ded491..de497112f 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -66,7 +66,7 @@ describe('schedule database queries', () => { const jobs = schedulesDb.listScheduleJobsByRepo(mockDb, 42) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') expect(jobs[0]).toMatchObject({ id: 7, repoId: 42, @@ -86,7 +86,7 @@ describe('schedule database queries', () => { const jobIds = schedulesDb.listScheduleJobIdsByRepo(mockDb, 42) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT id FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT id FROM schedule_jobs WHERE repo_id IS ? ORDER BY created_at DESC') expect(stmt.all).toHaveBeenCalledWith(42) expect(jobIds).toEqual([7, 8]) }) @@ -122,6 +122,8 @@ describe('schedule database queries', () => { expect(insertStmt.run).toHaveBeenCalledWith( 42, + 'local', + null, 'Weekly engineering summary', 'Summarize repo health and recent changes.', 1, @@ -196,6 +198,7 @@ describe('schedule database queries', () => { null, null, 'worktree', + null, expect.any(Number), null, 42, @@ -222,9 +225,9 @@ describe('schedule database queries', () => { const deleted = schedulesDb.deleteScheduleJob(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(1, 'DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ?') expect(deleteRunsStmt.run).toHaveBeenCalledWith(42, 7) - expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenNthCalledWith(2, 'DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(deleteJobStmt.run).toHaveBeenCalledWith(42, 7) expect(deleted).toBe(true) }) @@ -341,7 +344,7 @@ describe('schedule database queries', () => { createdAt: Date.UTC(2026, 2, 9, 12, 0, 0), }) - expect(insertStmt.run).toHaveBeenCalledWith(7, 42, 'manual', 'running', expect.any(Number), expect.any(Number)) + expect(insertStmt.run).toHaveBeenCalledWith(7, 42, 'local', 'manual', 'running', expect.any(Number), expect.any(Number)) expect(run.id).toBe(5) }) @@ -416,7 +419,7 @@ describe('schedule database queries', () => { const job = schedulesDb.getScheduleJobById(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(stmt.get).toHaveBeenCalledWith(42, 7) expect(job).toMatchObject({ id: 7, repoId: 42, name: 'Weekly engineering summary', branch: null }) }) @@ -451,7 +454,7 @@ describe('schedule database queries', () => { const result = schedulesDb.deleteScheduleJob(mockDb, 42, 7) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_jobs WHERE repo_id IS ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(42, 7) expect(result).toBe(true) }) @@ -477,7 +480,7 @@ describe('schedule database queries', () => { schedulesDb.updateScheduleJobRunState(mockDb, 42, 7, { lastRunAt, nextRunAt }) - expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id IS ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(lastRunAt, nextRunAt, expect.any(Number), 42, 7) }) @@ -525,7 +528,7 @@ describe('schedule database queries', () => { const run = schedulesDb.getScheduleRunById(mockDb, 42, 7, 5) - expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') expect(stmt.get).toHaveBeenCalledWith(42, 7, 5) expect(run).toMatchObject({ id: 5, jobId: 7, repoId: 42, status: 'running', runBranch: null, commitHash: null, worktreePath: null }) }) @@ -629,7 +632,7 @@ describe('schedule database queries', () => { expect(sql).toContain("sj.name LIKE ? ESCAPE '\\'") expect(sql).toContain("r.local_path LIKE ? ESCAPE '\\'") const pattern = '%50\\%\\_off%' - expect(stmt.all).toHaveBeenCalledWith(pattern, pattern, pattern, pattern, pattern, pattern, 50, 0) + expect(stmt.all).toHaveBeenCalledWith(pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern, 50, 0) }) it('ignores a blank search term', () => { @@ -696,7 +699,7 @@ describe('schedule database queries', () => { const deleted = schedulesDb.deleteScheduleRunById(mockDb, 42, 7, 5) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id = ?') expect(stmt.run).toHaveBeenCalledWith(42, 7, 5) expect(deleted).toBe(true) }) @@ -707,7 +710,7 @@ describe('schedule database queries', () => { const cleared = schedulesDb.deleteScheduleRunsByIds(mockDb, 42, 7, [3, 1]) - expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id IN (?, ?)') + expect(mockDb.prepare).toHaveBeenCalledWith('DELETE FROM schedule_runs WHERE repo_id IS ? AND job_id = ? AND id IN (?, ?)') expect(stmt.run).toHaveBeenCalledWith(42, 7, 3, 1) expect(cleared).toBe(2) }) diff --git a/backend/test/db/session-server-scope-migration.test.ts b/backend/test/db/session-server-scope-migration.test.ts new file mode 100644 index 000000000..507c799d5 --- /dev/null +++ b/backend/test/db/session-server-scope-migration.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import migration from '../../src/db/migrations/202610181300-session-server-scope' +import { getSessionGoalById, insertSessionGoal } from '../../src/db/session-goals' +import { getSessionPermissionMode } from '../../src/db/session-permission-modes' + +function createTestDb(): Database { + const db = new Database(':memory:') + migrate(db, allMigrations) + return db +} + +function createLegacyTables(db: Database): void { + db.run(` + CREATE TABLE session_permission_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + db.run(` + CREATE TABLE session_goals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + directory TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active','paused','completed','blocked','stopped')), + stop_reason TEXT, + turn_state TEXT NOT NULL DEFAULT 'waiting', + continuation_count INTEGER NOT NULL DEFAULT 0, + max_continuations INTEGER NOT NULL, + token_budget INTEGER, + tokens_at_start INTEGER NOT NULL DEFAULT 0, + tokens_used INTEGER NOT NULL DEFAULT 0, + consecutive_blocked INTEGER NOT NULL DEFAULT 0, + last_verdict TEXT, + last_reason TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ) + `) + db.run(` + CREATE UNIQUE INDEX idx_session_goals_open + ON session_goals(session_id) + WHERE status IN ('active','paused') + `) +} + +function tableColumns(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((column) => column.name) +} + +function openGoalIndexColumns(db: Database): string[] { + return (db.prepare("PRAGMA index_info('idx_session_goals_open')").all() as Array<{ name: string }>).map( + (column) => column.name, + ) +} + +describe('202610181300 session server scope migration', () => { + let db: Database + + beforeEach(() => { + db = createTestDb() + }) + + afterEach(() => { + db.close() + }) + + it('adds server_id to both session tables on a fresh database', () => { + expect(tableColumns(db, 'session_permission_modes')).toContain('server_id') + expect(tableColumns(db, 'session_goals')).toContain('server_id') + expect(openGoalIndexColumns(db)).toEqual(['server_id', 'session_id']) + }) + + it('scopes the open goal index by server', () => { + const input = { + sessionId: 'ses_shared', + directory: '/repo', + objective: 'Ship it', + maxContinuations: 1, + tokenBudget: null, + tokensAtStart: 0, + } + + const local = insertSessionGoal(db, input) + const remote = insertSessionGoal(db, { ...input, serverId: 'srv_remote' }) + const duplicateRemote = insertSessionGoal(db, { ...input, serverId: 'srv_remote' }) + + expect(local).not.toBeNull() + expect(remote).not.toBeNull() + expect(duplicateRemote).toBeNull() + }) + + it('upgrades legacy tables, keeps existing rows local and tolerates a second run', () => { + const legacyDb = new Database(':memory:') + createLegacyTables(legacyDb) + legacyDb + .prepare('INSERT INTO session_permission_modes(session_id, mode, updated_at) VALUES(?,?,?)') + .run('ses_root', 'auto', Date.now()) + legacyDb + .prepare(` + INSERT INTO session_goals(session_id, directory, objective, status, turn_state, max_continuations, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?) + `) + .run('ses_root', '/repo', 'Ship it', 'active', 'waiting', 1, Date.now(), Date.now()) + const legacyGoalId = (legacyDb.prepare('SELECT id FROM session_goals LIMIT 1').get() as { id: number }).id + + migration.up(legacyDb) + migration.up(legacyDb) + + expect(tableColumns(legacyDb, 'session_permission_modes')).toContain('server_id') + expect(tableColumns(legacyDb, 'session_goals')).toContain('server_id') + expect(openGoalIndexColumns(legacyDb)).toEqual(['server_id', 'session_id']) + expect(getSessionPermissionMode(legacyDb, 'ses_root')).toBe('auto') + expect(getSessionGoalById(legacyDb, legacyGoalId)).toMatchObject({ serverId: 'local' }) + + legacyDb.close() + }) +}) diff --git a/backend/test/helpers/raw-websocket-handshake.ts b/backend/test/helpers/raw-websocket-handshake.ts new file mode 100644 index 000000000..1f9fa5a28 --- /dev/null +++ b/backend/test/helpers/raw-websocket-handshake.ts @@ -0,0 +1,42 @@ +import { randomBytes } from 'node:crypto' +import { request as httpRequest } from 'node:http' + +export interface RawHandshakeResult { + upgraded: boolean + status?: number +} + +export function rawWebSocketHandshake(options: { + port: number + path: string + origin?: string + cookie?: string +}): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), + 'Sec-WebSocket-Version': '13', + } + if (options.origin !== undefined) headers.Origin = options.origin + if (options.cookie) headers.Cookie = options.cookie + + const request = httpRequest({ host: '127.0.0.1', port: options.port, path: options.path, method: 'GET', headers }) + let settled = false + request.on('upgrade', (_res, socket) => { + settled = true + socket.destroy() + resolve({ upgraded: true }) + }) + request.on('response', (res) => { + settled = true + res.resume() + resolve({ upgraded: false, status: res.statusCode }) + }) + request.on('error', (error) => { + if (!settled) reject(error) + }) + request.end() + }) +} diff --git a/backend/test/index.test.ts b/backend/test/index.test.ts index 2b4fa3cdb..2772bab6f 100644 --- a/backend/test/index.test.ts +++ b/backend/test/index.test.ts @@ -122,11 +122,16 @@ vi.mock('../src/services/skills', () => ({ const sseAggregatorMock = vi.hoisted(() => ({ onEvent: vi.fn(), + onServerEvent: vi.fn(), onUpstreamConnected: vi.fn(), + onServerConnected: vi.fn(), setPendingActionsFetcher: vi.fn(), setPasswordResolver: vi.fn(), setScheduledSessionsResolver: vi.fn(), setRemoteReplayHandler: vi.fn(), + setRemoteSubagentResolver: vi.fn(), + notifyServerEvent: vi.fn(), + notifyServerConnected: vi.fn(), start: vi.fn(), shutdown: vi.fn(), reconnect: vi.fn(), diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts index abe5ac39c..f55815a88 100644 --- a/backend/test/routes/internal-multi-runs.test.ts +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -20,6 +20,8 @@ function createRun(overrides: Record = {}) { return { id: 1, repoId: 1, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -69,7 +71,7 @@ describe('internal-multi-runs routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ runs: [] }) - expect(multiRuns.list).toHaveBeenCalledWith(1) + expect(multiRuns.list).toHaveBeenCalledWith({ kind: 'repo', repoId: 1 }) }) it('POST /api/internal/multi-runs/3/entries/4/discard discards the entry', async () => { diff --git a/backend/test/routes/multi-runs.test.ts b/backend/test/routes/multi-runs.test.ts index 2287153f8..b0b0500c5 100644 --- a/backend/test/routes/multi-runs.test.ts +++ b/backend/test/routes/multi-runs.test.ts @@ -8,6 +8,8 @@ function createRun(overrides: Partial = {}): MultiRun { return { id: 1, repoId: 1, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -61,7 +63,27 @@ describe('multi-run routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ runs: [createRun()] }) - expect(service.list).toHaveBeenCalledWith(1) + expect(service.list).toHaveBeenCalledWith({ kind: 'repo', repoId: 1 }) + }) + + it('GET returns the runs of a server directory', async () => { + service.list.mockReturnValue([]) + + const res = await app.request('/multi-runs?serverId=srv_kitchen&directory=%2Fsrv%2Fapp') + + expect(res.status).toBe(200) + expect(service.list).toHaveBeenCalledWith({ + kind: 'server', + serverId: 'srv_kitchen', + directory: '/srv/app', + }) + }) + + it('GET rejects a server target without a directory with 400', async () => { + const res = await app.request('/multi-runs?serverId=srv_kitchen') + + expect(res.status).toBe(400) + expect(service.list).not.toHaveBeenCalled() }) it('GET rejects a missing repoId with 400', async () => { @@ -91,6 +113,61 @@ describe('multi-run routes', () => { }) }) + it('POST launches a run for a server directory', async () => { + service.launch.mockResolvedValue(createRun({ repoId: null, serverId: 'srv_kitchen', directory: '/srv/app' })) + + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }), + }) + + expect(res.status).toBe(201) + expect(service.launch).toHaveBeenCalledWith({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }) + }) + + it('POST rejects a launch with both a repository and a server target', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: launchBody({ serverId: 'srv_kitchen', directory: '/srv/app' }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + + it('POST rejects a server target without a directory', async () => { + const res = await app.request('/multi-runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: 'srv_kitchen', + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + }), + }) + + expect(res.status).toBe(400) + expect(service.launch).not.toHaveBeenCalled() + }) + it('POST rejects more than five models with 400', async () => { const res = await app.request('/multi-runs', { method: 'POST', diff --git a/backend/test/routes/opencode-server-terminal-socket.test.ts b/backend/test/routes/opencode-server-terminal-socket.test.ts new file mode 100644 index 000000000..e881b81fb --- /dev/null +++ b/backend/test/routes/opencode-server-terminal-socket.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' +import { Hono } from 'hono' +import { createNodeWebSocket } from '@hono/node-ws' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { + createOpenCodeServerTerminalSocketRoutes, + type ServerTerminalServiceResolver, +} from '../../src/routes/opencode-server-terminals' +import { TerminalService } from '../../src/services/terminal' +import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { CredentialProvider } from '../../src/services/credential-provider' +import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' +import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' + +const TRUSTED_ORIGIN = 'http://localhost:5003' +const SERVER_ID = 'srv_kitchen' +const UNKNOWN_SERVER_ID = 'srv_missing' +const DIRECTORY = '/home/me/app' + +const ptyList = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: [ + { id: 'pty-1', title: 'Terminal', command: 'sh', args: [], cwd: DIRECTORY, status: 'running' as const, pid: 1 }, + ], +})) +const connectToken = vi.fn(async () => ({ + location: { directory: DIRECTORY }, + data: { ticket: 'remote-ticket', expires_in: 60 }, +})) + +const remoteClient = { + api: { pty: { list: ptyList, connect: { token: connectToken } } }, +} as unknown as OpenCodeClient + +const credentialProvider = { getGhCliEnv: vi.fn(() => ({})) } as unknown as CredentialProvider + +let upstream: WebSocketTestServer +let manager: WebSocketTestServer +let managerPort = 0 +let capturedUrl: URL | undefined + +const upstreamApp = new Hono() +const upstreamWs = createNodeWebSocket({ app: upstreamApp }) + +upstreamApp.get( + '/api/pty/:ptyID/connect', + upstreamWs.upgradeWebSocket((c) => { + capturedUrl = new URL(c.req.url) + return { + onMessage(event, ws) { + if (typeof event.data === 'string') ws.send(event.data) + }, + } + }), +) + +const managerApp = new Hono() +const managerWs = createNodeWebSocket({ app: managerApp }) + +const terminalService = new TerminalService( + { client: remoteClient, baseUrl: () => `http://127.0.0.1:${upstream.port}`, includeLocalCredentials: false }, + credentialProvider, +) + +const resolveTerminalService: ServerTerminalServiceResolver = (serverId) => { + if (serverId === UNKNOWN_SERVER_ID) throw new OpenCodeServerNotFoundError(serverId) + return terminalService +} + +managerApp.route( + '/', + createOpenCodeServerTerminalSocketRoutes(resolveTerminalService, managerWs.upgradeWebSocket, [TRUSTED_ORIGIN]), +) + +function clientUrl(ptyID: string, directory = DIRECTORY, cursor?: number): string { + const url = new URL(`ws://127.0.0.1:${managerPort}/${SERVER_ID}/terminals/${ptyID}/connect`) + url.searchParams.set('directory', directory) + if (cursor !== undefined) url.searchParams.set('cursor', String(cursor)) + return url.toString() +} + +function openEvent(socket: WebSocket): Promise { + return new Promise((resolve, reject) => { + if (socket.readyState === WebSocket.OPEN) { + resolve() + return + } + socket.addEventListener('open', () => resolve(), { once: true }) + socket.addEventListener('error', () => reject(new Error('WebSocket error before open')), { once: true }) + }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting for condition') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +beforeAll(async () => { + upstream = await startWebSocketServer({ app: upstreamApp, injectWebSocket: upstreamWs.injectWebSocket }) + manager = await startWebSocketServer({ app: managerApp, injectWebSocket: managerWs.injectWebSocket }) + managerPort = manager.port +}) + +afterAll(async () => { + upstreamWs.wss.close() + managerWs.wss.close() + await upstream.close() + await manager.close() +}) + +describe('OpenCode Server Terminal Socket Route', () => { + it('bridges to the remote base URL with a ticket, directory and cursor', async () => { + const socket = new WebSocket(clientUrl('pty-1', DIRECTORY, 3)) + socket.binaryType = 'arraybuffer' + const messages: Array = [] + socket.addEventListener('message', (event) => { + messages.push(event.data as string | ArrayBuffer) + }) + + await openEvent(socket) + socket.send('echo remote') + await waitFor(() => messages.includes('echo remote')) + + expect(ptyList).toHaveBeenCalledWith({ location: { directory: DIRECTORY } }) + expect(connectToken).toHaveBeenCalledWith({ + ptyID: 'pty-1', + location: { directory: DIRECTORY }, + 'x-opencode-ticket': '1', + }) + expect(capturedUrl?.searchParams.get('ticket')).toBe('remote-ticket') + expect(capturedUrl?.searchParams.get('location[directory]')).toBe(DIRECTORY) + expect(capturedUrl?.searchParams.get('cursor')).toBe('3') + + socket.close() + }) + + it('rejects an untrusted origin before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + origin: 'http://localhost:5004', + }) + + expect(outcome).toEqual({ upgraded: false, status: 403 }) + }) + + it('rejects the local server before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${LOCAL_OPENCODE_SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 400 }) + }) + + it('returns 404 for an unknown server before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${UNKNOWN_SERVER_ID}/terminals/pty-1/connect?directory=${encodeURIComponent(DIRECTORY)}`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 404 }) + }) + + it('rejects a relative directory before the upgrade', async () => { + const outcome = await rawWebSocketHandshake({ + port: managerPort, + path: `/${SERVER_ID}/terminals/pty-1/connect?directory=relative/path`, + }) + + expect(outcome).toEqual({ upgraded: false, status: 400 }) + }) +}) diff --git a/backend/test/routes/opencode-server-terminals.test.ts b/backend/test/routes/opencode-server-terminals.test.ts new file mode 100644 index 000000000..069b24a08 --- /dev/null +++ b/backend/test/routes/opencode-server-terminals.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi } from 'vitest' +import { Hono } from 'hono' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { + createOpenCodeServerTerminalRoutes, + type ServerTerminalServiceResolver, +} from '../../src/routes/opencode-server-terminals' +import { TerminalService } from '../../src/services/terminal' +import { OpenCodeServerNotFoundError } from '../../src/services/opencode-server-registry' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { CredentialProvider } from '../../src/services/credential-provider' + +const DIRECTORY = '/home/me/app' +const SERVER_ID = 'srv_kitchen' +const UNKNOWN_SERVER_ID = 'srv_missing' +const jsonHeaders = { 'Content-Type': 'application/json' } + +function createPty(overrides: Record = {}) { + return { + id: 'pty-1', + title: 'Terminal', + command: 'sh', + args: [], + cwd: DIRECTORY, + status: 'running' as const, + pid: 1, + ...overrides, + } +} + +function createRemoteClient(terminals: Array> = [createPty()]) { + const list = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: terminals, + })) + const create = vi.fn(async (input: { location: { directory: string }; title: string; env: Record }) => ({ + location: input.location, + data: createPty({ id: 'pty-new', title: input.title }), + })) + const update = vi.fn(async () => ({ location: { directory: DIRECTORY }, data: createPty() })) + const remove = vi.fn(async () => undefined) + const client = { api: { pty: { list, create, update, remove } } } as unknown as OpenCodeClient + return { client, list, create, update, remove } +} + +function createService(includeLocalCredentials = false, terminals?: Array>) { + const remote = createRemoteClient(terminals) + const credentialProvider = { + getGhCliEnv: vi.fn(() => ({ GH_TOKEN: 'local-token' })), + } as unknown as CredentialProvider + const service = new TerminalService( + { client: remote.client, baseUrl: () => 'https://remote.internal', includeLocalCredentials }, + credentialProvider, + ) + return { service, credentialProvider, ...remote } +} + +function createResolver(service: TerminalService): ServerTerminalServiceResolver { + return (serverId) => { + if (serverId === UNKNOWN_SERVER_ID) throw new OpenCodeServerNotFoundError(serverId) + return service + } +} + +function createApp(service: TerminalService): Hono { + const app = new Hono() + app.route('/', createOpenCodeServerTerminalRoutes(createResolver(service))) + return app +} + +describe('OpenCode Server Terminal Routes', () => { + describe('GET /:serverId/terminals', () => { + it('lists the terminals of the remote directory', async () => { + const { service, list } = createService() + const res = await createApp(service).request( + `/${SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + terminals: [{ id: 'pty-1', title: 'Terminal', kind: 'shell', cwd: DIRECTORY, status: 'running' }], + }) + expect(list).toHaveBeenCalledWith({ location: { directory: DIRECTORY } }) + }) + + it('rejects the local server', async () => { + const { service } = createService() + const res = await createApp(service).request( + `/${LOCAL_OPENCODE_SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(400) + }) + + it('returns 404 for an unknown server', async () => { + const { service } = createService() + const res = await createApp(service).request( + `/${UNKNOWN_SERVER_ID}/terminals?directory=${encodeURIComponent(DIRECTORY)}`, + ) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: `OpenCode server not found: ${UNKNOWN_SERVER_ID}` }) + }) + + it('requires an absolute directory', async () => { + const { service } = createService() + const app = createApp(service) + + for (const path of [`/${SERVER_ID}/terminals`, `/${SERVER_ID}/terminals?directory=relative/path`]) { + const res = await app.request(path) + expect(res.status).toBe(400) + } + }) + }) + + describe('POST /:serverId/terminals', () => { + it('creates a shell without local credentials', async () => { + const { service, create, credentialProvider } = createService() + const res = await createApp(service).request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, title: 'Remote shell' }), + }) + + expect(res.status).toBe(200) + expect(credentialProvider.getGhCliEnv).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledWith({ + location: { directory: DIRECTORY }, + title: 'Remote shell', + env: {}, + }) + expect(await res.json()).toEqual({ + id: 'pty-new', + title: 'Remote shell', + kind: 'shell', + cwd: DIRECTORY, + status: 'running', + }) + }) + + it('defaults the title and rejects a missing directory', async () => { + const { service, create } = createService() + const app = createApp(service) + + const created = await app.request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY }), + }) + expect(created.status).toBe(200) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ title: 'Terminal' })) + + const missing = await app.request(`/${SERVER_ID}/terminals`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({}), + }) + expect(missing.status).toBe(400) + }) + }) + + describe('PATCH /:serverId/terminals/:ptyID', () => { + it('resizes a terminal of the remote server', async () => { + const { service, update } = createService() + const res = await createApp(service).request(`/${SERVER_ID}/terminals/pty-1`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, cols: 120, rows: 40 }), + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(update).toHaveBeenCalledWith({ + ptyID: 'pty-1', + location: { directory: DIRECTORY }, + size: { cols: 120, rows: 40 }, + }) + }) + + it('returns 404 for an unknown PTY id', async () => { + const { service } = createService(false, []) + const res = await createApp(service).request(`/${SERVER_ID}/terminals/missing`, { + method: 'PATCH', + headers: jsonHeaders, + body: JSON.stringify({ directory: DIRECTORY, cols: 80, rows: 24 }), + }) + + expect(res.status).toBe(404) + }) + }) + + describe('DELETE /:serverId/terminals/:ptyID', () => { + it('removes a terminal of the remote server', async () => { + const { service, remove } = createService() + const res = await createApp(service).request( + `/${SERVER_ID}/terminals/pty-1?directory=${encodeURIComponent(DIRECTORY)}`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect(remove).toHaveBeenCalledWith({ ptyID: 'pty-1', location: { directory: DIRECTORY } }) + }) + + it('returns 404 for an unknown PTY id', async () => { + const { service } = createService(false, []) + const res = await createApp(service).request( + `/${SERVER_ID}/terminals/missing?directory=${encodeURIComponent(DIRECTORY)}`, + { method: 'DELETE' }, + ) + + expect(res.status).toBe(404) + }) + }) +}) diff --git a/backend/test/routes/repo-terminal-socket.test.ts b/backend/test/routes/repo-terminal-socket.test.ts index 3531c55b4..9ec905a7f 100644 --- a/backend/test/routes/repo-terminal-socket.test.ts +++ b/backend/test/routes/repo-terminal-socket.test.ts @@ -1,6 +1,4 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest' -import { randomBytes } from 'node:crypto' -import { request as httpRequest } from 'node:http' import { Hono } from 'hono' import { cors } from 'hono/cors' import { createNodeWebSocket } from '@hono/node-ws' @@ -12,6 +10,7 @@ import type { GitAuthService } from '../../src/services/git-auth' import type { OpenCodeClient } from '../../src/services/opencode/client' import type { CredentialProvider } from '../../src/services/credential-provider' import { startWebSocketServer, type WebSocketTestServer } from '../helpers/websocket-server' +import { rawWebSocketHandshake } from '../helpers/raw-websocket-handshake' vi.mock('../../src/services/repo', () => ({ resolveRepoOrAssistant: vi.fn(), @@ -79,7 +78,10 @@ upstreamApp.get( }), ) -const terminalService = new TerminalService(openCodeClient, credentialProvider, () => upstreamBase) +const terminalService = new TerminalService( + { client: openCodeClient, baseUrl: () => upstreamBase, includeLocalCredentials: true }, + credentialProvider, +) const managerApp = new Hono() const managerWs = createNodeWebSocket({ app: managerApp }) @@ -119,41 +121,6 @@ authenticatedApp.route( let authenticatedPort = 0 -interface RawHandshakeResult { - upgraded: boolean - status?: number -} - -function rawHandshake(options: { port: number; path: string; origin?: string; cookie?: string }): Promise { - return new Promise((resolve, reject) => { - const headers: Record = { - Connection: 'Upgrade', - Upgrade: 'websocket', - 'Sec-WebSocket-Key': randomBytes(16).toString('base64'), - 'Sec-WebSocket-Version': '13', - } - if (options.origin !== undefined) headers.Origin = options.origin - if (options.cookie) headers.Cookie = options.cookie - - const request = httpRequest({ host: '127.0.0.1', port: options.port, path: options.path, method: 'GET', headers }) - let settled = false - request.on('upgrade', (_res, socket) => { - settled = true - socket.destroy() - resolve({ upgraded: true }) - }) - request.on('response', (res) => { - settled = true - res.resume() - resolve({ upgraded: false, status: res.statusCode }) - }) - request.on('error', (error) => { - if (!settled) reject(error) - }) - request.end() - }) -} - function clientUrl(ptyID: string, directory = '/tmp/repo'): string { const url = new URL(`ws://127.0.0.1:${managerPort}/1/terminals/${ptyID}/connect`) url.searchParams.set('directory', directory) @@ -267,7 +234,7 @@ describe('Repo Terminal Socket Origin validation', () => { const path = `/1/terminals/pty-1/connect?directory=${encodeURIComponent('/tmp/repo')}` it('accepts a trusted Origin with a valid session cookie', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN, @@ -278,13 +245,13 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('allows a non-browser client without an Origin header', async () => { - const outcome = await rawHandshake({ port: authenticatedPort, path, cookie: 'session=valid' }) + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, cookie: 'session=valid' }) expect(outcome).toEqual({ upgraded: true }) }) it('rejects an untrusted same-site Origin before the upgrade', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: 'http://localhost:5004', @@ -295,7 +262,7 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('rejects a null Origin before the upgrade', async () => { - const outcome = await rawHandshake({ + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: 'null', @@ -306,7 +273,7 @@ describe('Repo Terminal Socket Origin validation', () => { }) it('rejects the upgrade without a session cookie', async () => { - const outcome = await rawHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN }) + const outcome = await rawWebSocketHandshake({ port: authenticatedPort, path, origin: TRUSTED_ORIGIN }) expect(outcome).toEqual({ upgraded: false, status: 401 }) }) diff --git a/backend/test/routes/server-schedules.test.ts b/backend/test/routes/server-schedules.test.ts new file mode 100644 index 000000000..c0e82039b --- /dev/null +++ b/backend/test/routes/server-schedules.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' +import { ScheduleServiceError } from '../../src/services/schedules' + +const scheduleService = { + listServerJobs: vi.fn(), + createServerJob: vi.fn(), + getServerJob: vi.fn(), + updateServerJob: vi.fn(), + deleteServerJob: vi.fn(), + runServerJob: vi.fn(), + listServerRuns: vi.fn(), + getServerRun: vi.fn(), + cancelServerRun: vi.fn(), + clearServerRunHistory: vi.fn(), + deleteServerRun: vi.fn(), +} + +vi.mock('../../src/services/schedules', async () => { + const actual = await vi.importActual('../../src/services/schedules') + return { + ...actual, + ScheduleService: vi.fn().mockImplementation(() => scheduleService), + } +}) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createServerScheduleRoutes } from '../../src/routes/schedules' + +describe('Server schedule routes', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + app = new Hono() + app.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService as unknown as import('../../src/services/schedules').ScheduleService)) + }) + + it('lists jobs for a linked server', async () => { + scheduleService.listServerJobs.mockReturnValue([{ id: 7 }]) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules') + const body = await response.json() as { jobs: Array<{ id: number }> } + + expect(response.status).toBe(200) + expect(body.jobs).toHaveLength(1) + expect(scheduleService.listServerJobs).toHaveBeenCalledWith('srv_kitchen') + }) + + it('creates a remote schedule with its target directory', async () => { + scheduleService.createServerJob.mockResolvedValue({ id: 7, serverId: 'srv_kitchen' }) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly audit', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + directory: '/srv/projects/audit', + }), + }) + + expect(response.status).toBe(201) + expect(scheduleService.createServerJob).toHaveBeenCalledWith( + 'srv_kitchen', + '/srv/projects/audit', + expect.objectContaining({ directory: '/srv/projects/audit' }), + ) + }) + + it('rejects a remote schedule without a directory', async () => { + const response = await app.request('/opencode-servers/srv_kitchen/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Nightly audit', + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Audit the project.', + }), + }) + + expect(response.status).toBe(400) + expect(scheduleService.createServerJob).not.toHaveBeenCalled() + }) + + it('updates, runs, and cancels a remote schedule', async () => { + scheduleService.updateServerJob.mockResolvedValue({ id: 7 }) + scheduleService.runServerJob.mockResolvedValue({ id: 11, status: 'running' }) + scheduleService.cancelServerRun.mockResolvedValue({ id: 11, status: 'cancelled' }) + + const updateResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated audit' }), + }) + expect(updateResponse.status).toBe(200) + expect(scheduleService.updateServerJob).toHaveBeenCalledWith('srv_kitchen', 7, { name: 'Updated audit' }) + + const runResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/run', { method: 'POST' }) + expect(runResponse.status).toBe(200) + expect(scheduleService.runServerJob).toHaveBeenCalledWith('srv_kitchen', 7, 'manual') + + const cancelResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11/cancel', { method: 'POST' }) + expect(cancelResponse.status).toBe(200) + expect(scheduleService.cancelServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + }) + + it('lists and deletes runs for a remote schedule', async () => { + scheduleService.listServerRuns.mockReturnValue([{ id: 11 }]) + scheduleService.getServerRun.mockReturnValue({ id: 11 }) + scheduleService.clearServerRunHistory.mockResolvedValue({ cleared: 1 }) + scheduleService.deleteServerRun.mockResolvedValue(undefined) + + const listResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs') + expect(listResponse.status).toBe(200) + expect(scheduleService.listServerRuns).toHaveBeenCalledWith('srv_kitchen', 7, 20) + + const getResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11') + expect(getResponse.status).toBe(200) + expect(scheduleService.getServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + + const clearResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs', { method: 'DELETE' }) + expect(clearResponse.status).toBe(200) + expect(scheduleService.clearServerRunHistory).toHaveBeenCalledWith('srv_kitchen', 7) + + const deleteResponse = await app.request('/opencode-servers/srv_kitchen/schedules/7/runs/11', { method: 'DELETE' }) + expect(deleteResponse.status).toBe(200) + expect(scheduleService.deleteServerRun).toHaveBeenCalledWith('srv_kitchen', 7, 11) + }) + + it('maps service errors to their status', async () => { + scheduleService.runServerJob.mockRejectedValue(new ScheduleServiceError('OpenCode server "Kitchen" is disabled', 400)) + + const response = await app.request('/opencode-servers/srv_kitchen/schedules/7/run', { method: 'POST' }) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'OpenCode server "Kitchen" is disabled' }) + }) +}) diff --git a/backend/test/routes/session-goals.test.ts b/backend/test/routes/session-goals.test.ts index f7178d6b6..dbe672219 100644 --- a/backend/test/routes/session-goals.test.ts +++ b/backend/test/routes/session-goals.test.ts @@ -190,4 +190,23 @@ describe('session goal routes', () => { expect(res.status).toBe(404) }) + + it('scopes goals by serverId', async () => { + const app = createTestApp(db) + + const postRes = await app.request('/session-goals', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: startBody({ serverId: 'srv_remote' }), + }) + expect(postRes.status).toBe(201) + const created = await postRes.json() as { goal: { serverId: string } } + expect(created.goal.serverId).toBe('srv_remote') + + const remoteRes = await app.request('/session-goals?sessionId=ses_1&serverId=srv_remote') + await expect(remoteRes.json()).resolves.toMatchObject({ goal: { serverId: 'srv_remote' } }) + + const localRes = await app.request('/session-goals?sessionId=ses_1') + await expect(localRes.json()).resolves.toEqual({ goal: null }) + }) }) diff --git a/backend/test/routes/session-permission-modes.test.ts b/backend/test/routes/session-permission-modes.test.ts index 05536e374..4f6639ab8 100644 --- a/backend/test/routes/session-permission-modes.test.ts +++ b/backend/test/routes/session-permission-modes.test.ts @@ -115,4 +115,21 @@ describe('session permission mode routes', () => { }) expect(res.status).toBe(400) }) + + it('scopes PUT and GET by serverId', async () => { + const app = createTestApp(db, { ses_root: null }) + + const putRes = await app.request('/session-permission-modes/ses_root', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/abs/repo', mode: 'auto', serverId: 'srv_remote' }), + }) + expect(putRes.status).toBe(200) + + const remoteRes = await app.request('/session-permission-modes/ses_root?serverId=srv_remote') + await expect(remoteRes.json()).resolves.toMatchObject({ mode: 'auto' }) + + const localRes = await app.request('/session-permission-modes/ses_root') + await expect(localRes.json()).resolves.toMatchObject({ mode: 'ask' }) + }) }) diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts index ed5f7dd4c..95ed9f488 100644 --- a/backend/test/routes/sse.test.ts +++ b/backend/test/routes/sse.test.ts @@ -240,7 +240,7 @@ describe('SSE Routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ success: true }) - expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9') + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9', 'local') }) it('defaults the active session id to null when omitted', async () => { @@ -254,7 +254,20 @@ describe('SSE Routes', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ success: true }) - expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', false, null) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', false, null, 'local') + }) + + it('forwards an explicit active server id', async () => { + sseMocks.setClientVisibility.mockReturnValue(true) + + const res = await app.fetch(new Request('http://localhost/visibility', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clientId: 'client-1', visible: true, activeSessionId: 'session-9', activeServerId: 'srv_a' }), + })) + + expect(res.status).toBe(200) + expect(sseMocks.setClientVisibility).toHaveBeenCalledWith('client-1', true, 'session-9', 'srv_a') }) it('returns 400 for an invalid visibility body', async () => { diff --git a/backend/test/services/multi-runs.remote.test.ts b/backend/test/services/multi-runs.remote.test.ts new file mode 100644 index 000000000..fcc0e861b --- /dev/null +++ b/backend/test/services/multi-runs.remote.test.ts @@ -0,0 +1,326 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { getMultiRun } from '../../src/db/multi-runs' +import { MultiRunService } from '../../src/services/multi-runs' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import { assistantMessage } from '../helpers/stub-schedule-api' + +const SERVER_ID = 'srv_kitchen' +const REMOTE_DIR = '/srv/projects/app' +const FUSE_REQUEST_ID = '11111111-1111-4111-8111-111111111111' + +const mocks = vi.hoisted(() => ({ + resolveOpenCodeModel: vi.fn(), + existsSync: vi.fn(), + executeCommand: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync: mocks.existsSync } +}) + +vi.mock('../../src/utils/process', () => ({ + executeCommand: mocks.executeCommand, +})) + +interface FakeRemoteClient { + client: OpenCodeClient + sessionCreate: ReturnType + sessionPrompt: ReturnType + locationGet: ReturnType + worktreeCreate: ReturnType + worktreeRemove: ReturnType +} + +function createRemoteClient(options: { worktreeDirectory?: string } = {}): FakeRemoteClient { + let sessionCounter = 0 + let worktreeCounter = 0 + + const locationGet = vi.fn(async () => ({ project: { id: 'proj_remote' } })) + const worktreeCreate = vi.fn(async () => { + worktreeCounter += 1 + return { directory: options.worktreeDirectory ?? `/srv/worktrees/app-${worktreeCounter}` } + }) + const worktreeRemove = vi.fn(async () => undefined) + + const sessionCreate = vi.fn(async (input: { title?: string }) => { + sessionCounter += 1 + return { id: `ses_remote_${sessionCounter}`, title: input?.title } + }) + const sessionPrompt = vi.fn(async () => ({})) + const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + outcome: 'succeeded', + })) + const sessionActive = vi.fn(async () => ({})) + const sessionDiff = vi.fn(async () => []) + const messageList = vi.fn(async (input: { sessionID: string; type?: string }) => { + if (input.type === 'user') { + return { data: [{ id: `${input.sessionID}-user`, type: 'user', time: { created: 0 }, text: 'hi' }], cursor: {} } + } + return { data: [assistantMessage('remote reply', { completed: true })], cursor: {} } + }) + + const client = { + api: { + location: { get: locationGet }, + worktree: { create: worktreeCreate, remove: worktreeRemove }, + session: { + create: sessionCreate, + prompt: sessionPrompt, + get: sessionGet, + active: sessionActive, + diff: sessionDiff, + }, + message: { list: messageList }, + }, + } as unknown as OpenCodeClient + + return { client, sessionCreate, sessionPrompt, locationGet, worktreeCreate, worktreeRemove } +} + +describe('MultiRunService on a linked server', () => { + let db: Database + let localClient: OpenCodeClient + let localSessionCreate: ReturnType + let repoWorkspaces: { create: ReturnType; remove: ReturnType } + let applyDefaultMode: ReturnType + let remote: FakeRemoteClient + + beforeEach(() => { + vi.clearAllMocks() + db = new Database(':memory:') + migrate(db, allMigrations) + + localSessionCreate = vi.fn(async () => ({ id: 'ses_local' })) + localClient = { + api: { + session: { create: localSessionCreate, prompt: vi.fn(), get: vi.fn(), active: vi.fn(), diff: vi.fn() }, + message: { list: vi.fn() }, + }, + } as unknown as OpenCodeClient + + repoWorkspaces = { create: vi.fn(), remove: vi.fn() } + applyDefaultMode = vi.fn(async () => undefined) + remote = createRemoteClient() + + mocks.resolveOpenCodeModel.mockImplementation(async (_client, directory, options) => { + const preferred = options?.preferredModel + if (!preferred) { + return { providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5', directory } + } + if (preferred === 'openai/retired') { + return { providerID: 'openai', id: 'retired-x', model: 'openai/retired-x' } + } + const [providerID, ...rest] = preferred.split('/') + return { providerID, id: rest.join('/'), model: preferred } + }) + }) + + afterEach(() => { + db.close() + }) + + function createService(): MultiRunService { + const permissionModes = { applyDefaultMode } as unknown as SessionPermissionModeService + + return new MultiRunService( + db, + localClient, + repoWorkspaces as unknown as RepoWorkspaceService, + permissionModes, + () => remote.client, + ) + } + + function launchRemote(overrides: Record = {}) { + return createService().launch({ + serverId: SERVER_ID, + directory: REMOTE_DIR, + name: 'Remote sweep', + prompt: 'go', + models: ['openai/a'], + isolate: true, + ...overrides, + }) + } + + it('launches every model through the remote client and remote worktrees', async () => { + const run = await launchRemote({ models: ['openai/a', 'openai/b'], baseRef: 'develop' }) + + expect(run).toMatchObject({ + repoId: null, + serverId: SERVER_ID, + directory: REMOTE_DIR, + name: 'Remote sweep', + isolated: true, + }) + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'started']) + expect(run.entries.map((entry) => entry.directory)).toEqual([ + '/srv/worktrees/app-1', + '/srv/worktrees/app-2', + ]) + + expect(remote.locationGet).toHaveBeenCalledWith({ location: { directory: REMOTE_DIR } }) + expect(remote.worktreeCreate).toHaveBeenCalledTimes(2) + expect(remote.worktreeCreate).toHaveBeenCalledWith({ + projectID: 'proj_remote', + name: 'Remote-sweep-1', + branch: 'develop', + }) + expect(remote.sessionCreate).toHaveBeenCalledTimes(2) + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ location: { directory: '/srv/worktrees/app-1' } }), + ) + expect(remote.sessionPrompt).toHaveBeenCalledWith({ sessionID: 'ses_remote_1', text: 'go' }) + + expect(localSessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(mocks.existsSync).not.toHaveBeenCalled() + expect(mocks.executeCommand).not.toHaveBeenCalled() + expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith(remote.client, REMOTE_DIR, { preferredModel: 'openai/a' }) + }) + + it('runs in the target directory without a worktree when isolation is off', async () => { + const run = await launchRemote({ isolate: false }) + + expect(run.entries[0]!.directory).toBe(REMOTE_DIR) + expect(remote.worktreeCreate).not.toHaveBeenCalled() + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ location: { directory: REMOTE_DIR } }), + ) + }) + + it('fails an entry whose model the remote catalog does not offer', async () => { + const run = await launchRemote({ models: ['openai/a', 'openai/retired'] }) + + expect(run.entries.map((entry) => entry.status)).toEqual(['started', 'failed']) + expect(run.entries[1]!.error).toBe('Model openai/retired is not available') + expect(remote.worktreeCreate).toHaveBeenCalledTimes(1) + }) + + it('lists remote runs by server and directory without mixing in repository runs', async () => { + const service = createService() + await launchRemote({ name: 'Remote one' }) + + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: REMOTE_DIR }).map((run) => run.name)).toEqual([ + 'Remote one', + ]) + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: '/srv/other' })).toEqual([]) + expect(service.list({ kind: 'repo', repoId: 1 })).toEqual([]) + }) + + it('removes a remote worktree on discard without touching the local filesystem', async () => { + const service = createService() + const run = await launchRemote() + const entry = run.entries[0]! + + const discarded = await service.discard(run.id, entry.id) + + expect(discarded.entries[0]!.status).toBe('discarded') + expect(remote.worktreeRemove).toHaveBeenCalledWith({ + projectID: 'proj_remote', + directory: '/srv/worktrees/app-1', + force: true, + }) + expect(mocks.existsSync).not.toHaveBeenCalled() + expect(repoWorkspaces.remove).not.toHaveBeenCalled() + expect(mocks.executeCommand).not.toHaveBeenCalled() + }) + + it('keeps a remote entry retryable when worktree removal fails', async () => { + const service = createService() + const run = await launchRemote() + remote.worktreeRemove.mockRejectedValueOnce(new Error('worktree busy')) + + const error = await service.discard(run.id, run.entries[0]!.id).catch((caught: unknown) => caught) + + expect(error).toMatchObject({ status: 502 }) + expect(service.list({ kind: 'server', serverId: SERVER_ID, directory: REMOTE_DIR })[0]!.entries[0]!.status).toBe( + 'started', + ) + }) + + it('fuses remote results in a remote worktree and reads sources from the remote client', async () => { + const service = createService() + const run = await launchRemote({ models: ['openai/a', 'openai/b'] }) + const entryIds = run.entries.map((entry) => entry.id) + remote.sessionCreate.mockClear() + + const { run: fused, created } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds, + model: 'openai/c', + baseRef: 'develop', + }) + + expect(created).toBe(true) + const fusion = fused.fusions[0]! + expect(fusion.status).toBe('started') + expect(fusion.directory).toBe('/srv/worktrees/app-3') + expect(fusion.sources.map((source) => source.sessionId)).toEqual(['ses_remote_1', 'ses_remote_2']) + + expect(remote.worktreeCreate).toHaveBeenLastCalledWith({ + projectID: 'proj_remote', + name: 'Remote-sweep-fusion-1', + branch: 'develop', + }) + expect(remote.sessionCreate).toHaveBeenCalledTimes(1) + expect(remote.sessionCreate).toHaveBeenCalledWith( + expect.objectContaining({ + location: { directory: '/srv/worktrees/app-3' }, + permissions: [ + ...buildSchedulePermissionRuleset(null), + { action: 'external_directory', resource: '/srv/worktrees/app-1/*', effect: 'allow' }, + { action: 'edit', resource: '/srv/worktrees/app-1/*', effect: 'deny' }, + { action: 'external_directory', resource: '/srv/worktrees/app-2/*', effect: 'allow' }, + { action: 'edit', resource: '/srv/worktrees/app-2/*', effect: 'deny' }, + ], + }), + ) + expect(localSessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(applyDefaultMode).toHaveBeenCalledWith('ses_remote_3', '/srv/worktrees/app-3', 'srv_kitchen') + }) + + it('records a failed remote fusion without a local fallback', async () => { + const service = createService() + const run = await launchRemote({ models: ['openai/a', 'openai/b'] }) + remote.sessionCreate.mockRejectedValueOnce(new Error('remote boom')) + + const { run: fused } = await service.fuse(run.id, { + requestId: FUSE_REQUEST_ID, + entryIds: run.entries.map((entry) => entry.id), + model: 'openai/c', + }) + + expect(fused.fusions[0]!.status).toBe('failed') + expect(fused.fusions[0]!.error).toContain('remote boom') + expect(fused.fusions[0]!.directory).toBe('/srv/worktrees/app-3') + expect(localSessionCreate).not.toHaveBeenCalled() + }) + + it('persists the server target on the run row', async () => { + const run = await launchRemote() + + expect(getMultiRun(db, run.id)).toMatchObject({ + repoId: null, + serverId: SERVER_ID, + directory: REMOTE_DIR, + }) + }) +}) diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index 314478966..53465799d 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -473,7 +473,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(MultiRunError) expect(error).toMatchObject({ status: 400 }) expect(repoWorkspaces.remove).toHaveBeenCalledTimes(1) - expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + expect(service.list({ kind: 'repo', repoId })[0]!.entries[0]!.status).toBe('started') }) it('records the workspace of a failed isolated launch when prompting fails and removes it on discard', async () => { @@ -585,7 +585,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(MultiRunError) expect(error).toMatchObject({ status: 502 }) - expect(service.list(repoId)[0]!.entries[0]!.status).toBe('started') + expect(service.list({ kind: 'repo', repoId })[0]!.entries[0]!.status).toBe('started') const discarded = await service.discard(run.id, entry.id) @@ -602,7 +602,7 @@ describe('MultiRunService', () => { await service.launch({ repoId, name: 'First', prompt: 'go', models: ['openai/a'], isolate: false }) await service.launch({ repoId, name: 'Second', prompt: 'go', models: ['openai/b'], isolate: false }) - const runs = service.list(repoId) + const runs = service.list({ kind: 'repo', repoId }) expect(runs.map((run) => run.name)).toEqual(['Second', 'First']) }) @@ -846,7 +846,7 @@ describe('MultiRunService', () => { unavailableSources: [expect.objectContaining({ reason: 'running' })], }) expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('rejects fusion when the prompt exceeds the context limit', async () => { @@ -872,7 +872,7 @@ describe('MultiRunService', () => { expect(error).toBeInstanceOf(FusionContextLimitError) expect(error).toMatchObject({ status: 413, code: 'FUSION_CONTEXT_LIMIT' }) expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('rejects fusion with an unavailable synthesis model before creating a fusion', async () => { @@ -899,7 +899,7 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 400 }) expect((error as Error).message).toContain('not available') expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('records a failed fusion with the workspace directory when the launch fails', async () => { @@ -1006,7 +1006,7 @@ describe('MultiRunService', () => { }) expect(sessionCreate).not.toHaveBeenCalled() - const recovered = service.list(repoId)[0]!.fusions[0]! + const recovered = service.list({ kind: 'repo', repoId })[0]!.fusions[0]! expect(recovered.status).toBe('started') expect(recovered.error).toBeNull() }) @@ -1053,7 +1053,7 @@ describe('MultiRunService', () => { const service = createService(client, repoWorkspaces.service) const record = createMultiRunWithEntries( db, - { repoId: ASSISTANT_REPO_ID, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, + { repoId: ASSISTANT_REPO_ID, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, ['openai/a', 'openai/b'], ) @@ -1096,7 +1096,7 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 400 }) expect((error as Error).message).toBe('Unknown multi-run entry 9999') expect(sessionCreate).not.toHaveBeenCalled() - expect(service.list(repoId)[0]!.fusions).toEqual([]) + expect(service.list({ kind: 'repo', repoId })[0]!.fusions).toEqual([]) }) it('persists the group and its entries in one transaction', () => { @@ -1104,10 +1104,11 @@ describe('MultiRunService', () => { db.run(`CREATE TRIGGER fail_entry BEFORE INSERT ON multi_run_entries BEGIN SELECT RAISE(ABORT, 'nope'); END`) expect(() => - createMultiRunWithEntries(db, { repoId, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, [ - 'openai/a', - 'openai/b', - ]), + createMultiRunWithEntries( + db, + { repoId, serverId: 'local', directory: null, name: 'Sweep', prompt: 'go', isolated: false, baseRef: null }, + ['openai/a', 'openai/b'], + ), ).toThrow() const count = db.prepare('SELECT COUNT(*) AS count FROM multi_runs').get() as { count: number } diff --git a/backend/test/services/notification-remote.test.ts b/backend/test/services/notification-remote.test.ts new file mode 100644 index 000000000..87a7c7428 --- /dev/null +++ b/backend/test/services/notification-remote.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' +import * as repoService from '../../src/services/repo' +import type { PushNotificationPayload } from '@opencode-manager/shared/types' + +vi.mock('../../src/services/repo', () => ({ + resolveRepoForDirectory: vi.fn(async () => null), +})) + +const USER_ID = 'user-1' +const SERVER_ID = 'srv_a' +const DIRECTORY = '/remote/repo' + +const sendResult = { delivered: 0, expired: 0, failed: 0, total: 0 } + +function permissionAskedEvent(sessionID: string): SSEEvent { + return { + id: 'evt_perm_1', + created: 1700000000000, + type: 'permission.asked', + location: { directory: DIRECTORY }, + data: { + id: 'perm-1', + sessionID, + action: 'shell', + resources: ['ls -la'], + }, + } +} + +function createService(server: { name: string; enabled: boolean } | null = { name: 'Studio', enabled: true }) { + const db = new Database(':memory:') + migrate(db, allMigrations) + const registry = { get: vi.fn(() => server) } + const service = new NotificationService(db, registry) + vi.spyOn(service, 'isConfigured').mockReturnValue(true) + service.saveSubscription(USER_ID, 'https://push.example.com/sub-1', 'p256dh-key', 'auth-key') + new SettingsService(db).updateSettings( + { + notifications: { + enabled: true, + events: { permissionAsked: true, questionAsked: true, sessionError: true, sessionIdle: true }, + }, + }, + USER_ID, + ) + return { service, registry } +} + +describe('NotificationService remote server events', () => { + beforeEach(() => { + sseAggregator.shutdown() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('builds a server session URL with the server name and skips repo resolution', async () => { + const { service, registry } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(repoService.resolveRepoForDirectory).not.toHaveBeenCalled() + expect(registry.get).toHaveBeenCalledWith(SERVER_ID) + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.title).toBe('Run Command') + expect(payload.body).toBe('Studio · ls -la') + expect(payload.data?.url).toBe('/servers/srv_a/sessions/ses_remote') + expect(payload.data?.serverId).toBe(SERVER_ID) + expect(payload.data?.serverName).toBe('Studio') + expect(payload.data?.repoId).toBeUndefined() + expect(payload.data?.repoName).toBeUndefined() + }) + + it('drops events for a disabled server', async () => { + const { service } = createService({ name: 'Studio', enabled: false }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(send).not.toHaveBeenCalled() + }) + + it('drops events for a deleted server', async () => { + const { service } = createService(null) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(send).not.toHaveBeenCalled() + }) + + it('suppresses a remote subagent session using the server-scoped check', async () => { + const { service } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const isSubagent = vi.spyOn(sseAggregator, 'isSubagentSession').mockReturnValue(true) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_sub'), SERVER_ID) + + expect(isSubagent).toHaveBeenCalledWith('ses_sub', SERVER_ID) + expect(send).not.toHaveBeenCalled() + }) + + it('suppresses a remote session the user is viewing on that server', async () => { + const { service } = createService() + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + const viewed = vi.spyOn(sseAggregator, 'isSessionBeingViewed').mockReturnValue(true) + + await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_remote'), SERVER_ID) + + expect(viewed).toHaveBeenCalledWith('ses_remote', SERVER_ID) + expect(send).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/notification-service.test.ts b/backend/test/services/notification-service.test.ts index 9a61c7532..ff522fb3b 100644 --- a/backend/test/services/notification-service.test.ts +++ b/backend/test/services/notification-service.test.ts @@ -4,7 +4,7 @@ import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' -import { NotificationService } from '../../src/services/notification' +import { NotificationService, type NotificationServerRegistry } from '../../src/services/notification' import { SettingsService } from '../../src/services/settings' import { sseAggregator, type SSEEvent } from '../../src/services/sse-aggregator' import type { PushNotificationPayload } from '@opencode-manager/shared/types' @@ -47,7 +47,10 @@ function permissionAskedEvent(sessionID: string): SSEEvent { } } -function createService(db = new Database(':memory:')): NotificationService { +function createService( + db = new Database(':memory:'), + registry: NotificationServerRegistry | null = null, +): NotificationService { migrate(db, allMigrations) createRepo(db, { localPath: 'repo-one', @@ -58,7 +61,7 @@ function createService(db = new Database(':memory:')): NotificationService { isLocal: true, }) - const service = new NotificationService(db) + const service = new NotificationService(db, registry) vi.spyOn(service, 'isConfigured').mockReturnValue(true) service.saveSubscription(USER_ID, 'https://push.example.com/sub-1', 'p256dh-key', 'auth-key') new SettingsService(db).updateSettings( @@ -168,7 +171,7 @@ describe('NotificationService.handleSSEEvent session routing', () => { await service.handleSSEEvent(DIRECTORY, permissionAskedEvent('ses_perm')) - expect(suppressor).toHaveBeenCalledWith(expect.objectContaining({ type: 'permission.asked' }), 'ses_perm') + expect(suppressor).toHaveBeenCalledWith(expect.objectContaining({ type: 'permission.asked' }), 'ses_perm', 'local') expect(send).not.toHaveBeenCalled() }) @@ -195,6 +198,7 @@ describe('NotificationService goal outcomes', () => { const completedGoal: SessionGoal = { id: 1, sessionId: 'ses_goal', + serverId: 'local', directory: DIRECTORY, objective: 'Ship the feature', status: 'completed', @@ -229,6 +233,27 @@ describe('NotificationService goal outcomes', () => { expect(payload.data?.url).toBe('/repos/1/sessions/ses_goal') }) + it('notifies a remote goal outcome with its server in the payload', async () => { + const service = createService(new Database(':memory:'), { + get: (serverId) => (serverId === 'srv_kitchen' ? { name: 'Kitchen', enabled: true } : null), + }) + const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) + + await service.notifyGoalOutcome({ + ...completedGoal, + sessionId: 'ses_remote', + serverId: 'srv_kitchen', + directory: '/home/me/app', + }) + + expect(send).toHaveBeenCalledTimes(1) + const payload = send.mock.calls[0]?.[1] as PushNotificationPayload + expect(payload.body).toContain('Kitchen') + expect(payload.data?.serverId).toBe('srv_kitchen') + expect(payload.data?.serverName).toBe('Kitchen') + expect(payload.data?.url).toBe('/servers/srv_kitchen/sessions/ses_remote') + }) + it('reports the stop reason instead of a stale verdict reason', async () => { const service = createService() const send = vi.spyOn(service, 'sendToUser').mockResolvedValue(sendResult) diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts index 41fcaa8d8..937316d1a 100644 --- a/backend/test/services/remote-server-events.test.ts +++ b/backend/test/services/remote-server-events.test.ts @@ -371,6 +371,51 @@ describe('RemoteServerEventHub event delivery', () => { expect(client.frames).toHaveLength(0) }) + + it('notifies server event listeners with the remote server id and resolved directory', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + hub.start() + const source = sources.sources[0]! + source.message(remoteEvent('session.created', { sessionID: 'ses_1' }, '/repo')) + source.message(remoteEvent('session.execution.succeeded', { sessionID: 'ses_1' })) + + expect(seen).toEqual([ + { serverId: 'srv_a', directory: '/repo', type: 'session.created' }, + { serverId: 'srv_a', directory: '/repo', type: 'session.execution.succeeded' }, + ]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + }) + + it('tracks remote subagent sessions per server', () => { + hub.start() + const source = sources.sources[0]! + + source.message(remoteEvent('session.created', { sessionID: 'ses_sub', parentID: 'ses_root' }, '/repo')) + + expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(true) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(true) + expect(sseAggregator.isSubagentSession('ses_sub')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_b')).toBe(false) + + source.message(remoteEvent('session.deleted', { sessionID: 'ses_sub' })) + + expect(hub.isSubagentSession('srv_a', 'ses_sub')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_sub', 'srv_a')).toBe(false) + }) + + it('notifies server connected listeners when a remote stream opens', () => { + const seen: string[] = [] + sseAggregator.onServerConnected(serverId => { seen.push(serverId) }) + + hub.start() + sources.sources[1]!.open() + + expect(seen).toEqual(['srv_b']) + }) }) describe('RemoteServerEventHub reconnection', () => { diff --git a/backend/test/services/schedules-remote.test.ts b/backend/test/services/schedules-remote.test.ts new file mode 100644 index 000000000..5ef18b753 --- /dev/null +++ b/backend/test/services/schedules-remote.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { getScheduleJobById, listScheduleRunsByJob } from '../../src/db/schedules' +import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { ScheduleService } from '../../src/services/schedules' +import { createStubOpenCodeClient, stubLoadedModelCatalog } from '../helpers/stub-opencode-client' +import { createStubScheduleApi } from '../helpers/stub-schedule-api' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +const REMOTE_SERVER = { + id: 'srv_kitchen', + name: 'Kitchen server', + baseUrl: 'http://kitchen.local:5551', + username: 'opencode', + enabled: true, + createdAt: 0, + updatedAt: 0, +} + +function createDatabase(): Database { + const db = new Database(':memory:') + db.exec('PRAGMA foreign_keys = OFF') + migrate(db, allMigrations) + const now = Date.now() + db.prepare( + `INSERT INTO opencode_servers (id, name, base_url, username, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(REMOTE_SERVER.id, REMOTE_SERVER.name, REMOTE_SERVER.baseUrl, REMOTE_SERVER.username, 1, now, now) + return db +} + +function createClient() { + const stub = createStubScheduleApi() + const api = { ...stub.api, ...stubLoadedModelCatalog() } as unknown as OpenCodeApi + return { client: createStubOpenCodeClient({ api }), api: stub.api } +} + +function createService(db: Database, localClient: ReturnType, remoteClient: ReturnType, enabled = true) { + const worktreeManager = { + prepare: vi.fn().mockResolvedValue(null), + finalize: vi.fn().mockResolvedValue({ commitHash: null }), + pruneRunArtifacts: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockReturnValue([]), + releaseWorktree: vi.fn().mockResolvedValue(undefined), + } + const registry = { + get: vi.fn(() => ({ ...REMOTE_SERVER, enabled })), + getClient: vi.fn(() => remoteClient), + } + const service = new ScheduleService(db, localClient, worktreeManager as never, registry as never) + return { service, worktreeManager, registry } +} + +const baseRequest = { + name: 'Nightly audit', + enabled: true, + scheduleMode: 'interval' as const, + intervalMinutes: 60, + prompt: 'Audit the project.', +} + +describe('remote schedule targets', () => { + let db: Database + + beforeEach(() => { + db = createDatabase() + const staticState = ScheduleService as unknown as { activeRuns: Map; pendingCancels: Map } + staticState.activeRuns.clear() + staticState.pendingCancels.clear() + }) + + it('creates a remote job with an in-place workspace mode', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + expect(job.repoId).toBeNull() + expect(job.serverId).toBe(REMOTE_SERVER.id) + expect(job.directory).toBe('/srv/projects/audit') + expect(job.workspaceMode).toBe('in-place') + expect(localClient.api.session.create).not.toHaveBeenCalled() + }) + + it('rejects worktree workspace modes for a remote job', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + + await expect( + service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', { ...baseRequest, workspaceMode: 'worktree' }), + ).rejects.toMatchObject({ status: 400 }) + expect(getScheduleJobById(db, null, 1)).toBeNull() + }) + + it('runs a remote job on the remote client without touching local git', async () => { + const localClient = createClient().client + const { client, api } = createClient() + const { service, worktreeManager } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + + expect(run.repoId).toBeNull() + expect(run.serverId).toBe(REMOTE_SERVER.id) + expect(run.status).toBe('running') + expect(api.session.create).toHaveBeenCalledWith(expect.objectContaining({ + location: { directory: '/srv/projects/audit' }, + })) + expect(localClient.api.session.create).not.toHaveBeenCalled() + expect(worktreeManager.prepare).not.toHaveBeenCalled() + expect(worktreeManager.finalize).not.toHaveBeenCalled() + }) + + it('records a failed run when the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const disabled = createService(db, localClient, client, false) + + await expect(disabled.service.runServerJob(REMOTE_SERVER.id, job.id, 'manual')).rejects.toMatchObject({ status: 400 }) + + const runs = listScheduleRunsByJob(db, null, job.id) + expect(runs).toHaveLength(1) + expect(runs[0]).toMatchObject({ status: 'failed', serverId: REMOTE_SERVER.id }) + expect(runs[0]?.errorText).toContain('disabled') + }) + + it('lists remote jobs and runs with the linked server name', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + + const jobs = service.listAllJobsWithRepos() + expect(jobs.find((entry) => entry.id === job.id)).toMatchObject({ + repoId: null, + serverId: REMOTE_SERVER.id, + directory: '/srv/projects/audit', + serverName: REMOTE_SERVER.name, + repoName: REMOTE_SERVER.name, + }) + + const runs = service.listAllRuns() + expect(runs[0]).toMatchObject({ serverId: REMOTE_SERVER.id, serverName: REMOTE_SERVER.name }) + }) +}) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts index d9b317875..57273be28 100644 --- a/backend/test/services/session-goals.test.ts +++ b/backend/test/services/session-goals.test.ts @@ -1100,3 +1100,156 @@ describe('SessionGoalService recovery', () => { expect(fake.auditorCalls).toHaveLength(0) }) }) + +describe('SessionGoalService remote servers', () => { + let db: Database + + const REMOTE_SERVER_ID = 'srv_remote' + + function createRemoteRegistry( + local: FakeSessionGoalClient, + remote: FakeSessionGoalClient, + options: { missing?: boolean; disabled?: boolean } = {}, + ) { + return { + getClient: (serverId: string) => + serverId === 'local' ? local.client : remote.client, + get: (serverId: string) => { + if (serverId === 'local') return { enabled: true } + if (options.missing) return null + return { enabled: !options.disabled } + }, + } + } + + beforeEach(() => { + vi.useFakeTimers() + db = createTestDb() + }) + + afterEach(() => { + vi.useRealTimers() + db.close() + }) + + it('continues a remote goal through the remote client while the auditor stays local', async () => { + const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"continue","reason":"keep going"}'] }) + const remote = createFakeSessionGoalClient({ + tokens: { ses_remote: { input: 10, output: 5, reasoning: 0 } }, + }) + const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { + quietMs: 0, + }) + + const goal = await service.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote work', + }) + + expect(goal.serverId).toBe(REMOTE_SERVER_ID) + + await service.handleEvent(DIRECTORY, executionStarted('ses_remote'), REMOTE_SERVER_ID) + await service.handleEvent(DIRECTORY, sessionIdle('ses_remote'), REMOTE_SERVER_ID) + await vi.runAllTimersAsync() + + expect(remote.promptCalls).toHaveLength(1) + expect(remote.promptCalls[0]?.sessionID).toBe('ses_remote') + expect(local.promptCalls).toHaveLength(0) + expect(local.auditorCalls).toHaveLength(1) + expect(remote.auditorCalls).toHaveLength(0) + }) + + it('keeps goal state scoped to the server that owns the session', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient({ + tokens: { ses_shared: { input: 0, output: 0, reasoning: 0 } }, + }) + const service = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + + await service.start({ + sessionId: 'ses_shared', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote only', + }) + + expect(service.getLatest('ses_shared')).toBeNull() + expect(service.getLatest('ses_shared', REMOTE_SERVER_ID)?.objective).toBe('Remote only') + }) + + it('recovers a remote goal using the remote client', async () => { + const local = createFakeSessionGoalClient({ auditorReplies: ['{"verdict":"done","reason":"finished"}'] }) + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Remote recovery', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db), { + quietMs: 0, + }) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(local.auditorCalls).toHaveLength(1) + expect(remote.auditorCalls).toHaveLength(0) + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)?.status).toBe('completed') + }) + + it('leaves remote goals open when their server is missing', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Orphaned', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService( + db, + createRemoteRegistry(local, remote, { missing: true }), + new SettingsService(db), + { quietMs: 0 }, + ) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) + expect(local.auditorCalls).toHaveLength(0) + expect(remote.auditorCalls).toHaveLength(0) + }) + + it('leaves remote goals open when their server is disabled', async () => { + const local = createFakeSessionGoalClient() + const remote = createFakeSessionGoalClient() + const starter = new SessionGoalService(db, createRemoteRegistry(local, remote), new SettingsService(db)) + const goal = await starter.start({ + sessionId: 'ses_remote', + serverId: REMOTE_SERVER_ID, + directory: DIRECTORY, + objective: 'Disabled', + }) + transitionSessionGoal(db, goal.id, ['active'], { turnState: 'running' }) + + const restarted = new SessionGoalService( + db, + createRemoteRegistry(local, remote, { disabled: true }), + new SettingsService(db), + { quietMs: 0 }, + ) + await restarted.recoverOpenGoals() + await vi.runAllTimersAsync() + + expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) + expect(remote.auditorCalls).toHaveLength(0) + }) +}) diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index 5e1c05282..589bca0a3 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -42,6 +42,21 @@ function createPermissionModeService( return new SessionPermissionModeService(db, client, settingsService, new SessionAncestry(client)) } +function createMultiServerService( + db: Database, + clients: { local: OpenCodeClient } & Record, + settingsService: SettingsService, +): SessionPermissionModeService { + const local = clients.local + const resolve = (serverId: string) => (serverId === 'local' ? local : clients[serverId] ?? local) + return new SessionPermissionModeService( + db, + { getClient: resolve }, + settingsService, + new SessionAncestry(resolve), + ) +} + function permissionAskedEvent(sessionID: string, id: string): SSEEvent { return { id: `evt_${id}`, @@ -555,4 +570,116 @@ describe('SessionPermissionModeService', () => { expect(client.replyPermission).not.toHaveBeenCalled() }) + + describe('remote servers', () => { + it('scopes stored modes by server', async () => { + setSessionPermissionMode(db, 'ses_remote', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient({ parents: { ses_remote: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_remote: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_remote')).resolves.toMatchObject({ mode: 'ask' }) + await expect(service.getEffectiveMode('ses_remote', 'srv_remote')).resolves.toMatchObject({ mode: 'auto' }) + + await service.setMode('ses_remote', 'auto', DIRECTORY, 'srv_remote') + + expect(getSessionPermissionMode(db, 'ses_remote')).toBeNull() + expect(getSessionPermissionMode(db, 'ses_remote', 'srv_remote')).toBe('auto') + expect(remote.replyPermission).not.toHaveBeenCalled() + }) + + it('auto-accepts a remote permission request through the remote client', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_root', 'perm-remote'), 'srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledTimes(1) + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_root', + requestID: 'perm-remote', + decision: 'once', + }) + expect(local.replyPermission).not.toHaveBeenCalled() + }) + + it('resolves remote session parents through the remote client', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ parents: { ses_child: 'ses_root', ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.handleEvent(DIRECTORY, permissionAskedEvent('ses_child', 'perm-child'), 'srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_child', + requestID: 'perm-child', + decision: 'once', + }) + }) + + it('accepts pending requests for active sessions on the remote server', async () => { + setSessionPermissionMode(db, 'ses_remote', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ + parents: { ses_remote: null }, + directories: { ses_remote: DIRECTORY }, + activeSessions: ['ses_remote'], + pendingRequests: { [DIRECTORY]: [{ id: 'perm-remote', sessionID: 'ses_remote' }] }, + }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await service.acceptPendingRequestsForActiveSessions('srv_remote') + + expect(remote.replyPermission).toHaveBeenCalledWith({ + sessionID: 'ses_remote', + requestID: 'perm-remote', + decision: 'once', + }) + expect(local.replyPermission).not.toHaveBeenCalled() + }) + + it('reports schedule locks only for the local server', async () => { + createRepo(db, { + localPath: 'repo-one', + sourcePath: '/abs/repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + }) + const run = createScheduleRun(db, { + jobId: 1, + repoId: 1, + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, 1, 1, run.id, { sessionId: 'ses_scheduled' }) + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled')).resolves.toMatchObject({ lockedReason: 'schedule' }) + await expect(service.getEffectiveMode('ses_scheduled', 'srv_remote')).resolves.toMatchObject({ + lockedReason: null, + }) + }) + + it('suppresses permission notifications for remote auto sessions', async () => { + setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') + const local = createFakeSessionPermissionClient() + const remote = createFakeSessionPermissionClient({ parents: { ses_root: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + const event = permissionAskedEvent('ses_root', 'perm-remote') + + await expect(service.suppressesPermissionNotification(event, 'ses_root', 'srv_remote')).resolves.toBe(true) + await expect(service.suppressesPermissionNotification(event, 'ses_root')).resolves.toBe(false) + await expect(service.suppressesPermissionNotification(event, undefined, 'srv_remote')).resolves.toBe(false) + }) + }) }) diff --git a/backend/test/services/session-reply.test.ts b/backend/test/services/session-reply.test.ts index 66fd0f1c5..86befdf0b 100644 --- a/backend/test/services/session-reply.test.ts +++ b/backend/test/services/session-reply.test.ts @@ -14,13 +14,13 @@ import { } from '../../src/services/session-reply' import { assistantMessage } from '../helpers/stub-schedule-api' -const mockOnEvent = vi.fn() +const mockOnServerEvent = vi.fn() vi.mock('../../src/services/sse-aggregator', () => ({ - sseAggregator: { onEvent: (...args: unknown[]) => mockOnEvent(...args) }, + sseAggregator: { onServerEvent: (...args: unknown[]) => mockOnServerEvent(...args) }, })) beforeEach(() => { - mockOnEvent.mockReset() + mockOnServerEvent.mockReset() }) function createFakeClient(messages: SessionMessageInfo[], active: Record = {}): OpenCodeClient { @@ -166,14 +166,14 @@ describe('sessionSettleSignal', () => { describe('waitForSessionSettled', () => { function captureListener() { - let listener: ((directory: string, event: unknown) => void) | undefined + let listener: ((serverId: string, directory: string | null, event: unknown) => void) | undefined const unsubscribe = vi.fn() - mockOnEvent.mockImplementation((callback: (directory: string, event: unknown) => void) => { + mockOnServerEvent.mockImplementation((callback: (serverId: string, directory: string | null, event: unknown) => void) => { listener = callback return unsubscribe }) return { - emit: (event: unknown) => listener?.('', event), + emit: (event: unknown, serverId = 'local') => listener?.(serverId, null, event), unsubscribe, } } @@ -182,7 +182,7 @@ describe('waitForSessionSettled', () => { const client = createFakeClient([], { 'ses-1': {} }) await expect(waitForSessionSettled(client, 'ses-1', 0)).resolves.toBeUndefined() - expect(mockOnEvent).not.toHaveBeenCalled() + expect(mockOnServerEvent).not.toHaveBeenCalled() }) it('resolves once the session settles and unsubscribes', async () => { @@ -224,4 +224,17 @@ describe('waitForSessionSettled', () => { await expect(waitForSessionSettled(client, 'ses-1', 20)).resolves.toBeUndefined() expect(unsubscribe).toHaveBeenCalledTimes(1) }) + + it('ignores settle events from other servers when a server id is provided', async () => { + const client = createFakeClient([], { 'ses-1': {} }) + const { emit, unsubscribe } = captureListener() + + const settled = waitForSessionSettled(client, 'ses-1', 1000, 'srv_a') + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }, 'local') + expect(unsubscribe).not.toHaveBeenCalled() + + emit({ type: 'session.idle', data: { sessionID: 'ses-1' } }, 'srv_a') + await expect(settled).resolves.toBeUndefined() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) }) diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index 378f641e4..551cefd19 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -836,6 +836,96 @@ describe('SSEAggregator directory-less session event routing', () => { }) }) +describe('SSEAggregator server-aware event feed', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('notifies server listeners for local events with the local server id and resolved directory', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + + expect(seen).toEqual([ + { serverId: 'local', directory: '/r', type: 'session.created' }, + { serverId: 'local', directory: '/r', type: 'session.execution.succeeded' }, + ]) + }) + + it('delivers remote events with the remote server id without touching local active or subagent state', () => { + const seen: Array<{ serverId: string; directory: string | null; type: string }> = [] + sseAggregator.onServerEvent((serverId, directory, event) => { + seen.push({ serverId, directory, type: event.type }) + }) + + sseAggregator.notifyServerEvent('srv_a', '/repo', { + id: 'evt_remote', + created: 1, + type: 'session.created', + location: { directory: '/repo' }, + data: { sessionID: 'ses_remote', parentID: 'ses_parent' }, + } as never) + + expect(seen).toEqual([{ serverId: 'srv_a', directory: '/repo', type: 'session.created' }]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(sseAggregator.isSubagentSession('ses_remote')).toBe(false) + expect(sseAggregator.isSubagentSession('ses_remote', 'srv_a')).toBe(false) + }) + + it('isolates a throwing server event listener from the others', () => { + const throwing = vi.fn(() => { throw new Error('boom') }) + const healthy = vi.fn() + sseAggregator.onServerEvent(throwing) + const unsubscribe = sseAggregator.onServerEvent(healthy) + + sseAggregator.notifyServerEvent('srv_a', '/repo', { type: 'session.idle', data: { sessionID: 'ses_1' } } as never) + + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + + unsubscribe() + sseAggregator.notifyServerEvent('srv_a', '/repo', { type: 'session.idle', data: { sessionID: 'ses_1' } } as never) + expect(healthy).toHaveBeenCalledTimes(1) + }) + + it('notifies server-connected listeners for the local upstream and for remote streams', () => { + const seen: string[] = [] + sseAggregator.onServerConnected(serverId => { seen.push(serverId) }) + + ;(sseAggregator as unknown as { handleUpstreamOpen(wasConnectedBefore: boolean): void }).handleUpstreamOpen(false) + sseAggregator.notifyServerConnected('srv_a') + + expect(seen).toEqual(['local', 'srv_a']) + }) + + it('scopes session visibility to the active server id', () => { + sseAggregator.addClient('viewed-local', () => {}, () => {}, ['/r']) + sseAggregator.addClient('viewed-remote', () => {}, () => {}, ['srv_a|/r']) + + sseAggregator.setClientVisibility('viewed-local', true, 'ses_1') + sseAggregator.setClientVisibility('viewed-remote', true, 'ses_2', 'srv_a') + + expect(sseAggregator.isSessionBeingViewed('ses_1')).toBe(true) + expect(sseAggregator.isSessionBeingViewed('ses_1', 'srv_a')).toBe(false) + expect(sseAggregator.isSessionBeingViewed('ses_2', 'srv_a')).toBe(true) + expect(sseAggregator.isSessionBeingViewed('ses_2')).toBe(false) + }) + + it('clears the active server id when a client reports it is hidden', () => { + sseAggregator.addClient('viewed-hidden', () => {}, () => {}, ['srv_a|/r']) + sseAggregator.setClientVisibility('viewed-hidden', true, 'ses_1', 'srv_a') + sseAggregator.setClientVisibility('viewed-hidden', false) + + expect(sseAggregator.isSessionBeingViewed('ses_1', 'srv_a')).toBe(false) + }) +}) + describe('SSEAggregator location-scoped catalog event broadcast', () => { beforeEach(() => { sseAggregator.shutdown() diff --git a/backend/test/services/terminal.test.ts b/backend/test/services/terminal.test.ts index d9a1d71b1..ebc610b29 100644 --- a/backend/test/services/terminal.test.ts +++ b/backend/test/services/terminal.test.ts @@ -31,8 +31,13 @@ function createTerminalService( provider: CredentialProvider, openSocket?: (url: string) => WebSocket, baseUrl = 'http://127.0.0.1:5551', + includeLocalCredentials = true, ): TerminalService { - return new TerminalService(client, provider, () => baseUrl, openSocket) + return new TerminalService( + { client, baseUrl: () => baseUrl, includeLocalCredentials }, + provider, + openSocket, + ) } class FakeUpstreamSocket { @@ -167,6 +172,83 @@ describe('TerminalService', () => { }) }) + describe('remote target', () => { + it('does not inject local GitHub credentials when creating a terminal', async () => { + const create = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: createPty({ id: 'pty-remote', title: 'Terminal' }), + })) + const credentialProvider = createCredentialProvider({ GH_TOKEN: 'gh' }) + const service = createTerminalService( + createClient({ create }), + credentialProvider, + undefined, + 'https://remote.internal', + false, + ) + + await service.create('/repo', { kind: 'shell', name: 'Terminal', env: { FOO: 'bar' } }) + + expect(credentialProvider.getGhCliEnv).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledWith(expect.objectContaining({ env: { FOO: 'bar' } })) + }) + + it('keeps a remote shell environment empty without input env', async () => { + let captured: Record | undefined + const create = vi.fn(async (input: Record) => { + captured = input + return { location: { directory: '/repo' }, data: createPty() } + }) + const service = createTerminalService( + createClient({ create }), + createCredentialProvider({ GH_TOKEN: 'gh' }), + undefined, + 'https://remote.internal', + false, + ) + + await service.create('/repo', { kind: 'shell', name: 'Terminal' }) + + expect(captured?.env).toEqual({}) + }) + + it('builds the connect URL from the target base URL', async () => { + const list = vi.fn(async ({ location }: { location: { directory: string } }) => ({ + location, + data: [createPty({ id: 'pty-1' })], + })) + const token = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: { ticket: 'remote-ticket', expires_in: 60 }, + })) + const remoteClient = createClient({ list, connect: { token } }) + let opened: FakeUpstreamSocket | undefined + const service = createTerminalService( + createClient({}), + createCredentialProvider(), + (url) => { + opened = new FakeUpstreamSocket(url) + return opened as unknown as WebSocket + }, + 'https://local.internal', + ) + const remote = service.forTarget({ + client: remoteClient, + baseUrl: () => 'https://remote.internal', + includeLocalCredentials: false, + }) + + await remote.connect('/repo', 'pty-1', undefined, createPeer()) + + expect(list).toHaveBeenCalledWith({ location: { directory: '/repo' } }) + const url = new URL(opened!.url) + expect(url.protocol).toBe('wss:') + expect(url.host).toBe('remote.internal') + expect(url.pathname).toBe('/api/pty/pty-1/connect') + expect(url.searchParams.get('ticket')).toBe('remote-ticket') + }) + }) + describe('requireTerminal', () => { it('returns the terminal when the id exists in the directory', async () => { const list = vi.fn(async () => ({ diff --git a/frontend/src/api/multiRuns.ts b/frontend/src/api/multiRuns.ts index 4c2aff15f..3409113ec 100644 --- a/frontend/src/api/multiRuns.ts +++ b/frontend/src/api/multiRuns.ts @@ -1,10 +1,21 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { FuseMultiRunRequest, LaunchMultiRunRequest, MultiRun } from '@opencode-manager/shared/schemas' +import type { + FuseMultiRunRequest, + LaunchMultiRunRequest, + MultiRun, + MultiRunTarget, +} from '@opencode-manager/shared/schemas' -export async function listMultiRuns(repoId: number): Promise { +function targetParams(target: MultiRunTarget): Record { + return target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory } +} + +export async function listMultiRuns(target: MultiRunTarget): Promise { const res = await fetchWrapper<{ runs: MultiRun[] }>(`${API_BASE_URL}/api/multi-runs`, { - params: { repoId }, + params: targetParams(target), }) return res.runs } diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index 715c81777..2391b282c 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -24,6 +24,7 @@ export interface ScheduleJobWithRepo extends ScheduleJob { repoName: string repoPath: string repoUrl: string + serverName: string | null lastRun: ScheduleRunSummary | null retainedWorktreeCount: number } @@ -32,6 +33,12 @@ export interface ScheduleRunWithContext extends ScheduleRun { jobName: string repoName: string repoPath: string + serverName: string | null +} + +export interface ScheduleServerTarget { + serverId: string + directory: string } export interface UnreadScheduleRun extends ScheduleRunWithContext { @@ -100,6 +107,9 @@ export async function getScheduleCounts(): Promise> { const counts = new Map() jobs.forEach((job) => { + if (job.repoId === null) { + return + } const existing = counts.get(job.repoId) if (existing) { existing.total += 1 @@ -185,3 +195,55 @@ export async function deleteRepoScheduleRun(repoId: number, jobId: number, runId method: 'DELETE', }) } + +function serverSchedulesPath(serverId: string): string { + return `${API_BASE_URL}/api/opencode-servers/${encodeURIComponent(serverId)}/schedules` +} + +export async function getServerSchedule(serverId: string, jobId: number): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}`) +} + +export async function createServerSchedule(serverId: string, data: CreateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(serverSchedulesPath(serverId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function updateServerSchedule(serverId: string, jobId: number, data: UpdateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function deleteServerSchedule(serverId: string, jobId: number): Promise { + return fetchWrapperVoid(`${serverSchedulesPath(serverId)}/${jobId}`, { method: 'DELETE' }) +} + +export async function runServerSchedule(serverId: string, jobId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/run`, { method: 'POST' }) +} + +export async function listServerScheduleRuns(serverId: string, jobId: number, limit: number = 20): Promise<{ runs: ScheduleRun[] }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs?limit=${limit}`) +} + +export async function getServerScheduleRun(serverId: string, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}`) +} + +export async function cancelServerScheduleRun(serverId: string, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}/cancel`, { method: 'POST' }) +} + +export async function clearServerScheduleRuns(serverId: string, jobId: number): Promise<{ cleared: number }> { + return fetchWrapper(`${serverSchedulesPath(serverId)}/${jobId}/runs`, { method: 'DELETE' }) +} + +export async function deleteServerScheduleRun(serverId: string, jobId: number, runId: number): Promise { + return fetchWrapperVoid(`${serverSchedulesPath(serverId)}/${jobId}/runs/${runId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/sessionGoals.ts b/frontend/src/api/sessionGoals.ts index e0d402a52..a42dd9802 100644 --- a/frontend/src/api/sessionGoals.ts +++ b/frontend/src/api/sessionGoals.ts @@ -1,10 +1,17 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { SessionGoal, StartSessionGoalRequest } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionGoal, + type StartSessionGoalRequest, +} from '@opencode-manager/shared/schemas' -export async function getLatestSessionGoal(sessionId: string): Promise { +export async function getLatestSessionGoal( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { const res = await fetchWrapper<{ goal: SessionGoal | null }>(`${API_BASE_URL}/api/session-goals`, { - params: { sessionId }, + params: { sessionId, serverId }, }) return res.goal } diff --git a/frontend/src/api/sessionPermissionModes.test.ts b/frontend/src/api/sessionPermissionModes.test.ts index a1d9e4e17..e6cac29cc 100644 --- a/frontend/src/api/sessionPermissionModes.test.ts +++ b/frontend/src/api/sessionPermissionModes.test.ts @@ -20,11 +20,23 @@ describe('sessionPermissionModes', () => { const result = await getSessionPermissionMode('ses_1') expect(result).toEqual(state) - expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining('/api/session-permission-modes/ses_1')) + const url = new URL(String(fetchMock.mock.calls[0][0])) + expect(url.pathname).toBe('/api/session-permission-modes/ses_1') + expect(url.searchParams.get('serverId')).toBe('local') expect(fetchMock.mock.calls[0][1].method).toBeUndefined() }) - it('calls PUT /api/session-permission-modes/:id with the mode and directory', async () => { + it('sends the remote server id when reading a remote session mode', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto', lockedReason: null } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + await getSessionPermissionMode('ses_1', 'srv_remote') + + const url = new URL(String(fetchMock.mock.calls[0][0])) + expect(url.searchParams.get('serverId')).toBe('srv_remote') + }) + + it('calls PUT /api/session-permission-modes/:id with the mode, directory and server', async () => { const input = { directory: '/repo', mode: 'auto' as const } const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) @@ -36,6 +48,16 @@ describe('sessionPermissionModes', () => { const options = fetchMock.mock.calls[0][1] expect(options.method).toBe('PUT') expect(options.headers['Content-Type']).toBe('application/json') - expect(JSON.parse(options.body)).toEqual(input) + expect(JSON.parse(options.body)).toEqual({ ...input, serverId: 'local' }) + }) + + it('sends the remote server id when writing a remote session mode', async () => { + const input = { directory: '/repo', mode: 'auto' as const } + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + fetchMock.mockResolvedValue(new Response(JSON.stringify(state), { status: 200 })) + + await setSessionPermissionMode('ses_1', input, 'srv_remote') + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ ...input, serverId: 'srv_remote' }) }) }) diff --git a/frontend/src/api/sessionPermissionModes.ts b/frontend/src/api/sessionPermissionModes.ts index 0be4a141d..feb8c6b4d 100644 --- a/frontend/src/api/sessionPermissionModes.ts +++ b/frontend/src/api/sessionPermissionModes.ts @@ -1,23 +1,32 @@ import { fetchWrapper } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' +import { + LOCAL_OPENCODE_SERVER_ID, + type SessionPermissionModeState, + type SetSessionPermissionModeRequest, +} from '@opencode-manager/shared/schemas' -export async function getSessionPermissionMode(sessionId: string): Promise { +export async function getSessionPermissionMode( + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): Promise { return fetchWrapper( `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, + { params: { serverId } }, ) } export async function setSessionPermissionMode( sessionId: string, input: SetSessionPermissionModeRequest, + serverId: string = LOCAL_OPENCODE_SERVER_ID, ): Promise { return fetchWrapper( `${API_BASE_URL}/api/session-permission-modes/${encodeURIComponent(sessionId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), + body: JSON.stringify({ ...input, serverId }), }, ) } diff --git a/frontend/src/api/terminals.test.tsx b/frontend/src/api/terminals.test.tsx new file mode 100644 index 000000000..75be4b3a6 --- /dev/null +++ b/frontend/src/api/terminals.test.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resizeTerminal, useCreateTerminal, useRemoveTerminal, useTerminals } from './terminals' + +const fetchMock = vi.fn() + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }) +} + +function createWrapper(queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +function lastRequestUrl(): string { + return String(fetchMock.mock.calls.at(-1)?.[0]) +} + +describe('terminal api targets', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lists repo terminals through the repo route', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + + const { result } = renderHook( + () => useTerminals({ kind: 'repo', repoId: 1 }, '/repo', { enabled: true }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/repos/1/terminals') + expect(url.searchParams.get('directory')).toBe('/repo') + }) + + it('lists linked server terminals through the server route', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + + const { result } = renderHook( + () => useTerminals({ kind: 'server', serverId: 'srv_kitchen' }, '/home/me/app', { enabled: true }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals') + expect(url.searchParams.get('directory')).toBe('/home/me/app') + }) + + it('scopes the query key to the linked server', async () => { + fetchMock.mockResolvedValue(jsonResponse({ terminals: [] })) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const { result } = renderHook( + () => useTerminals({ kind: 'server', serverId: 'srv_kitchen' }, '/home/me/app', { enabled: true }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect( + queryClient.getQueryCache().findAll({ queryKey: ['terminals', 'srv_kitchen'] }), + ).toHaveLength(1) + }) + + it('creates a linked server terminal through the server route', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ id: 'pty-1', title: 'Terminal', kind: 'shell', cwd: '/home/me/app', status: 'running' }), + ) + + const { result } = renderHook( + () => useCreateTerminal({ kind: 'server', serverId: 'srv_kitchen' }), + { wrapper: createWrapper() }, + ) + + await act(() => result.current.mutateAsync({ directory: '/home/me/app' })) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('POST') + }) + + it('removes a linked server terminal with the directory', async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 204 })) + + const { result } = renderHook( + () => useRemoveTerminal({ kind: 'server', serverId: 'srv_kitchen' }), + { wrapper: createWrapper() }, + ) + + await act(() => result.current.mutateAsync({ ptyID: 'pty 1', directory: '/home/me/app' })) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty%201') + expect(url.searchParams.get('directory')).toBe('/home/me/app') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('DELETE') + }) + + it('resizes a linked server terminal', async () => { + fetchMock.mockResolvedValue(jsonResponse({ success: true })) + + await resizeTerminal({ kind: 'server', serverId: 'srv_kitchen' }, 'pty-1', { + directory: '/home/me/app', + cols: 80, + rows: 24, + }) + + const url = new URL(lastRequestUrl()) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty-1') + expect(fetchMock.mock.calls.at(-1)?.[1]?.method).toBe('PATCH') + }) +}) diff --git a/frontend/src/api/terminals.ts b/frontend/src/api/terminals.ts index 20e2c1fc9..44ee4c854 100644 --- a/frontend/src/api/terminals.ts +++ b/frontend/src/api/terminals.ts @@ -1,42 +1,47 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { CreateTerminalRequest, ResizeTerminalRequest, TerminalInfo } from '@opencode-manager/shared/types' import { API_BASE_URL } from '@/config' +import { terminalCollectionPath, terminalItemPath, terminalTargetScope, type TerminalTarget } from '@/lib/terminal-target' import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' -function terminalsQueryKey(repoId: number, directory: string | undefined) { - return ['terminals', repoId, directory ?? null] as const +function terminalsQueryKey(target: TerminalTarget, directory: string | undefined) { + return ['terminals', terminalTargetScope(target), directory ?? null] as const } -function terminalsRepoQueryKey(repoId: number) { - return ['terminals', repoId] as const +function terminalsScopeQueryKey(target: TerminalTarget) { + return ['terminals', terminalTargetScope(target)] as const } -async function listTerminals(repoId: number, directory?: string): Promise { +async function listTerminals(target: TerminalTarget, directory?: string): Promise { const data = await fetchWrapper<{ terminals: TerminalInfo[] }>( - `${API_BASE_URL}/api/repos/${repoId}/terminals`, + `${API_BASE_URL}${terminalCollectionPath(target)}`, { params: { directory } }, ) return data.terminals } -async function createTerminal(repoId: number, body: CreateTerminalRequest): Promise { - return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/terminals`, { +async function createTerminal(target: TerminalTarget, body: CreateTerminalRequest): Promise { + return fetchWrapper(`${API_BASE_URL}${terminalCollectionPath(target)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } -export async function resizeTerminal(repoId: number, ptyID: string, body: ResizeTerminalRequest): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}`, { +export async function resizeTerminal( + target: TerminalTarget, + ptyID: string, + body: ResizeTerminalRequest, +): Promise { + return fetchWrapperVoid(`${API_BASE_URL}${terminalItemPath(target, ptyID)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } -async function removeTerminal(repoId: number, ptyID: string, directory?: string): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}`, { +async function removeTerminal(target: TerminalTarget, ptyID: string, directory?: string): Promise { + return fetchWrapperVoid(`${API_BASE_URL}${terminalItemPath(target, ptyID)}`, { method: 'DELETE', params: { directory }, }) @@ -47,21 +52,21 @@ interface UseTerminalsOptions { refetchInterval?: number | false } -export function useTerminals(repoId: number, directory: string | undefined, options: UseTerminalsOptions) { +export function useTerminals(target: TerminalTarget, directory: string | undefined, options: UseTerminalsOptions) { return useQuery({ - queryKey: terminalsQueryKey(repoId, directory), - queryFn: () => listTerminals(repoId, directory), + queryKey: terminalsQueryKey(target, directory), + queryFn: () => listTerminals(target, directory), enabled: options.enabled, refetchInterval: options.refetchInterval, }) } -export function useCreateTerminal(repoId: number) { +export function useCreateTerminal(target: TerminalTarget) { const queryClient = useQueryClient() return useMutation({ - mutationFn: (body: CreateTerminalRequest) => createTerminal(repoId, body), + mutationFn: (body: CreateTerminalRequest) => createTerminal(target, body), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: terminalsRepoQueryKey(repoId) }) + queryClient.invalidateQueries({ queryKey: terminalsScopeQueryKey(target) }) }, }) } @@ -71,12 +76,12 @@ interface RemoveTerminalVariables { directory?: string } -export function useRemoveTerminal(repoId: number) { +export function useRemoveTerminal(target: TerminalTarget) { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ ptyID, directory }: RemoveTerminalVariables) => removeTerminal(repoId, ptyID, directory), + mutationFn: ({ ptyID, directory }: RemoveTerminalVariables) => removeTerminal(target, ptyID, directory), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: terminalsRepoQueryKey(repoId) }) + queryClient.invalidateQueries({ queryKey: terminalsScopeQueryKey(target) }) }, }) } diff --git a/frontend/src/components/message/ComposerToolsMenu.test.tsx b/frontend/src/components/message/ComposerToolsMenu.test.tsx index 67a832a2a..fb9517f74 100644 --- a/frontend/src/components/message/ComposerToolsMenu.test.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ComposerToolsMenu } from './ComposerToolsMenu' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { useSessionPermissionMode, useSetSessionPermissionMode } from '@/hooks/useSessionPermissionMode' import { useSessionSandbox, useSetSessionSandbox } from '@/hooks/useSessionSandbox' import { FetchError } from '@/api/fetchWrapper' @@ -72,6 +73,25 @@ function renderMenu(overrides: Partial> = {}) { + const props = { + sessionID: 'ses_remote', + directory: '/remote/repo', + goalArmed: false, + goalDisabled: false, + goalLabel: 'Goal mode: the next message becomes the objective', + onToggleGoal: vi.fn(), + onAttachFile: vi.fn(), + ...overrides, + } + render( + + + , + ) + return props +} + describe('ComposerToolsMenu', () => { beforeEach(() => { vi.clearAllMocks() @@ -112,6 +132,18 @@ describe('ComposerToolsMenu', () => { expect(mutate).toHaveBeenCalledWith({ directory: '/repo', mode: 'auto' }) }) + it('offers permission and goal controls on a remote server without sandbox controls', async () => { + const user = userEvent.setup() + mockPermissionMode() + renderRemoteMenu() + + await user.click(screen.getByRole('button', { name: 'Composer options' })) + + expect(await screen.findByRole('menuitemcheckbox', { name: /Accept all permissions/ })).toBeInTheDocument() + expect(screen.getByRole('menuitemcheckbox', { name: /Goal mode/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitemcheckbox', { name: /Sandbox commands/ })).not.toBeInTheDocument() + }) + it('shows why permissions cannot be changed', async () => { const user = userEvent.setup() mockPermissionMode({ lockedReason: 'child' }) diff --git a/frontend/src/components/message/ComposerToolsMenu.tsx b/frontend/src/components/message/ComposerToolsMenu.tsx index 70b201f94..27094ade4 100644 --- a/frontend/src/components/message/ComposerToolsMenu.tsx +++ b/frontend/src/components/message/ComposerToolsMenu.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { getIconToggleButtonClassName } from '@/components/ui/icon-toggle-button' +import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { usePermissionModeToggle } from '@/hooks/usePermissionModeToggle' import { useSandboxToggle } from '@/hooks/useSandboxToggle' import { shouldShowSandboxResources } from '@/lib/sandboxResources' @@ -25,7 +26,6 @@ interface ComposerToolsMenuProps { onToggleGoal: () => void onAttachFile: () => void onDictate?: () => void - allowManagerTools?: boolean } export function ComposerToolsMenu({ @@ -37,10 +37,10 @@ export function ComposerToolsMenu({ onToggleGoal, onAttachFile, onDictate, - allowManagerTools = true, }: ComposerToolsMenuProps) { - const permission = usePermissionModeToggle(sessionID, directory, { enabled: allowManagerTools }) - const sandbox = useSandboxToggle(sessionID, { enabled: allowManagerTools }) + const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId()) + const permission = usePermissionModeToggle(sessionID, directory) + const sandbox = useSandboxToggle(sessionID, { enabled: isLocalServer }) const [resourcesOpen, setResourcesOpen] = useState(false) return ( @@ -57,30 +57,28 @@ export function ComposerToolsMenu({ - {allowManagerTools ? ( - <> - - : } - title="Accept all permissions" - detail={permission.disabled ? permission.label : undefined} - /> - - setResourcesOpen(true)} /> - - } - title="Goal mode" - detail={goalDisabled ? goalLabel : undefined} - /> - - - - ) : null} + + : } + title="Accept all permissions" + detail={permission.disabled ? permission.label : undefined} + /> + + {isLocalServer && ( + setResourcesOpen(true)} /> + )} + + } + title="Goal mode" + detail={goalDisabled ? goalLabel : undefined} + /> + + } title="Attach image or PDF" /> diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 7702fdc9f..fff193da3 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -252,7 +252,7 @@ export const PromptInput = memo(forwardRef( const sessionAgent = useSessionAgent(sessionID, directory) const hasBackgroundActivity = useHasBackgroundActivity(sessionID) - const allowManagerTools = isLocalOpenCodeServer(useOpenCodeServerId()) + const isLocalServer = isLocalOpenCodeServer(useOpenCodeServerId()) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) const sendPrompt = useSendPrompt(directory) @@ -260,8 +260,8 @@ export const PromptInput = memo(forwardRef( const startGoal = useStartSessionGoal() const isPromptSubmitPending = sendPrompt.isPending || sendShell.isPending || startGoal.isPending const interruptSession = useInterruptSession() - const { data: sessionGoal } = useSessionGoal(sessionID, { enabled: allowManagerTools }) - const { data: permissionMode } = useSessionPermissionMode(sessionID, { enabled: allowManagerTools }) + const { data: sessionGoal } = useSessionGoal(sessionID) + const { data: permissionMode } = useSessionPermissionMode(sessionID) const { searchCommands, findCommand, recentNames } = useCommands({ directory }) const recordCommand = useRecentCommandsStore((state) => state.recordCommand) const isExactCommandPrompt = (value: string) => { @@ -1579,10 +1579,10 @@ return ( onAgentChange={handleAgentChange} isBashMode={isBashMode} /> - {directory && !isMobile && allowManagerTools ? ( + {directory && !isMobile ? ( <> - + {isLocalServer && } void startVoiceRecording() : undefined} /> )} diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 91e58f273..d0f7a79f0 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -81,18 +81,21 @@ export function DesktopSidebar() { )} {collapsed ? ( -
- {collapsedItems.map((item: MoreDrawerItem) => ( - handleItemClick(item)} - danger={item.danger} - /> - ))} -
+ <> +
+ {collapsedItems.map((item: MoreDrawerItem) => ( + handleItemClick(item)} + danger={item.danger} + /> + ))} +
+ + ) : ( <> diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index caaf40e87..a206b8be9 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { LinkedServersNav } from './LinkedServersNav' @@ -34,6 +34,14 @@ function renderNav(pathname = '/') { ) } +function renderRail(pathname = '/') { + return render( + + + , + ) +} + describe('LinkedServersNav', () => { beforeEach(() => { vi.clearAllMocks() @@ -75,4 +83,38 @@ describe('LinkedServersNav', () => { expect(screen.getByRole('link', { name: /Kitchen/ })).toBeInTheDocument() expect(screen.queryByRole('link', { name: /Office/ })).not.toBeInTheDocument() }) + + it('renders enabled servers as icon links in the collapsed rail', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + + renderRail('/servers/srv_kitchen/sessions/abc') + + expect(screen.getByRole('link', { name: 'Kitchen' })).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(screen.queryByText('Servers')).not.toBeInTheDocument() + }) + + it('renders nothing in the collapsed rail when no linked servers exist', () => { + const { container } = renderRail() + + expect(container).toBeEmptyDOMElement() + }) + + it('hands navigation to the caller in the sheet variant', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + const onNavigate = vi.fn() + + render( + + + , + ) + + expect(screen.getByRole('heading', { name: 'Servers' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Kitchen/ })) + + expect(onNavigate).toHaveBeenCalledWith('/servers/srv_kitchen') + }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 77436ed61..360b32ada 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -5,7 +5,33 @@ import { SidebarSection } from '@/components/ui/sidebar' import { getServerPath } from '@/lib/navigation' import { cn } from '@/lib/utils' -export function LinkedServersNav() { +export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' + +interface LinkedServersNavProps { + /** `sidebar` renders the labelled section, `rail` the collapsed icon column, `sheet` the mobile sheet group. */ + variant?: LinkedServersVariant + /** When provided, each row is a button that hands navigation to the caller instead of rendering a link. */ + onNavigate?: (path: string) => void +} + +function isActiveServerPath(pathname: string, path: string) { + return pathname === path || pathname.startsWith(`${path}/`) +} + +function ServerConnectionDot({ connected }: { connected: boolean }) { + const label = connected ? 'Connected' : 'Disconnected' + + return ( + + ) +} + +export function LinkedServersNav({ variant = 'sidebar', onNavigate }: LinkedServersNavProps) { const location = useLocation() const { servers, isLoading } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() @@ -15,37 +41,87 @@ export function LinkedServersNav() { return null } - return ( - - {enabledServers.map((server) => { - const path = getServerPath(server.id) - const active = location.pathname === path || location.pathname.startsWith(`${path}/`) - const connected = statusById[server.id]?.connected ?? false - - return ( - - - {server.name} - + {enabledServers.map((server) => { + const path = getServerPath(server.id) + const active = isActiveServerPath(location.pathname, path) + + return ( + - - ) - })} + > + + {server.name} + + ) + })} + + ) + } + + const rows = enabledServers.map((server) => { + const path = getServerPath(server.id) + const active = isActiveServerPath(location.pathname, path) + const connected = statusById[server.id]?.connected ?? false + const className = cn( + 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', + variant === 'sheet' && 'mx-2 p-2 text-left', + ) + const content = ( + <> + + {server.name} + + + ) + + if (onNavigate) { + return ( + + ) + } + + return ( + + {content} + + ) + }) + + if (variant === 'sheet') { + return ( +
+

+ Servers +

+ {rows} +
+ ) + } + + return ( + + {rows} ) } diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index ca09303ff..e95ebfa3f 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -361,7 +361,7 @@ describe('MoreDrawer', () => { expect(handleClose).not.toHaveBeenCalled() }) - it('offers only account items on a linked-server route', () => { + it('offers the terminal plus account items on a linked-server route', () => { mockAuth() mockServerHealth() setActivityGroups({ recent: [createActiveWorkItem()] }) @@ -373,9 +373,9 @@ describe('MoreDrawer', () => { expect(screen.getByText('Home')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() + expect(screen.getByText('Terminal')).toBeInTheDocument() expect(screen.queryByText('Files')).not.toBeInTheDocument() expect(screen.queryByText('Source Control')).not.toBeInTheDocument() - expect(screen.queryByText('Terminal')).not.toBeInTheDocument() expect(screen.queryByText('MCP')).not.toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() expect(screen.queryByText('Repos')).not.toBeInTheDocument() diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 14641d520..2bbf856c3 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -38,6 +38,16 @@ const activityMocks = vi.hoisted(() => ({ useActivityWindowMinutes: vi.fn(), })) +const serverMocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: serverMocks.useOpenCodeServers, + useOpenCodeServerStatuses: serverMocks.useOpenCodeServerStatuses, +})) + vi.mock('@/hooks/useActiveWork', () => ({ useActiveWork: activityMocks.useActiveWork, useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, @@ -132,6 +142,33 @@ function setActivityGroups(groups: { }) } +function createLinkedServer(id: string, name: string, enabled = true) { + return { + id, + name, + baseUrl: `https://${id}.example.com`, + username: 'opencode', + enabled, + hasPassword: true, + createdAt: 1, + updatedAt: 2, + } +} + +function setLinkedServers(servers: Array>) { + serverMocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) + serverMocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: Object.fromEntries( + servers.map((server) => [ + server.id, + { serverId: server.id, connected: true, version: null, error: null }, + ]), + ), + isLoading: false, + }) +} + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -173,6 +210,7 @@ describe('RepoQuickSwitchSheet', () => { permissionSessions.current.clear() activityMocks.useActivityWindowMinutes.mockReturnValue(30) setActivityGroups({}) + setLinkedServers([]) }) it('renders with loading state', async () => { @@ -927,4 +965,60 @@ describe('RepoQuickSwitchSheet', () => { expect(pathname).toBe('/repos/2/sessions/new-session') expect(handleClose).toHaveBeenCalled() }) + + it('renders the Servers group with enabled linked servers', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + setLinkedServers([ + createLinkedServer('srv_kitchen', 'Kitchen'), + createLinkedServer('srv_office', 'Office', false), + ]) + + render(, { wrapper: createWrapper() }) + + expect(await screen.findByRole('heading', { name: 'Servers' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Office/ })).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + }) + + it('hides the Servers group when no linked servers exist', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + }) + + expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull() + }) + + it('navigates to a linked server and closes the sheet', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) + const handleClose = vi.fn() + + render( + + + + + + + + } + /> + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /Kitchen/ })) + + expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen') + expect(handleClose).toHaveBeenCalled() + }) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index 2fe8bae10..709122ff0 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -11,6 +11,7 @@ import { FolderGit2, Plus } from 'lucide-react' import { useUrlParams } from '@/hooks/useUrlParams' import { getAssistantPath, getRepoPath } from '@/lib/navigation' import { NewSessionButton, RepoSessionNavList, SearchClearButton } from '@/components/navigation/RepoSessionNav' +import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' import { getActiveRepoId } from '@/components/navigation/sidebar-session-tree' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' @@ -96,6 +97,12 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr + {isOpen && ( + navigateAndClose(path, { replace: true })} + /> + )} {isOpen && !searchQuery.trim() && ( navigateAndClose(path)} /> )} diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index ed36232da..9248c4278 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -26,6 +26,7 @@ import { useOpenNavItem } from '@/hooks/useOpenNavItem' import { ActivityList } from '@/components/activity/ActivityList' import { ActivityCountPill } from '@/components/activity/ActivityCountPill' import { withQueryParam } from '@/lib/navigation' +import type { TerminalTarget } from '@/lib/terminal-target' import { useActiveWorkCounts, useActivityWindowMinutes } from '@/hooks/useActiveWork' import { formatActivityWindow } from '@/lib/activeWork' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' @@ -84,6 +85,8 @@ interface ToolSidePanelProps { sessionId?: string directory?: string terminalDirectory?: string + terminalTarget?: TerminalTarget + allowedPanelTools?: readonly PanelTool[] repoDirectory?: string currentBranch?: string selectedFilePath?: string @@ -146,6 +149,8 @@ export function ToolSidePanel({ sessionId, directory, terminalDirectory, + terminalTarget, + allowedPanelTools, repoDirectory, currentBranch = 'main', selectedFilePath, @@ -172,10 +177,16 @@ export function ToolSidePanel({ const panelTools: Array<{ item: MoreDrawerItem; tool: PanelTool }> = [] const actionItems: MoreDrawerItem[] = [] + const terminalWorkspaceTarget: TerminalTarget | undefined = + terminalTarget ?? (repoId === undefined ? undefined : { kind: 'repo', repoId }) for (const item of buildToolItems(location.pathname)) { const tool = toolKeyOf(item) - if (isPanelTool(tool)) panelTools.push({ item, tool }) - else actionItems.push(item) + if (isPanelTool(tool)) { + if (allowedPanelTools && !allowedPanelTools.includes(tool)) continue + panelTools.push({ item, tool }) + } else { + actionItems.push(item) + } } const activeLabel = panelTools.find(({ tool }) => tool === activeTool)?.item.label @@ -197,12 +208,15 @@ export function ToolSidePanel({ if (tool === 'mcp') return repoDirectory ? : null if (tool === 'walkthrough') return sessionId ? : null if (tool === 'activity') return + if (tool === 'terminal') { + return terminalWorkspaceTarget + ? + : null + } if (repoId === undefined) return null switch (tool) { case 'sourceControl': return - case 'terminal': - return case 'actions': return case 'skills': diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 8d759616b..a1138311f 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -164,6 +164,19 @@ describe('buildToolItems', () => { ]) }) + it('returns the linked server session tools without Manager-only entries', () => { + expect(buildToolItems('/servers/srv_kitchen/sessions/ses_1').map((item) => item.key)).toEqual([ + 'activity', + 'terminal', + ]) + expect(buildMoreItems('/servers/srv_kitchen/sessions/ses_1').map((item) => item.key)).toEqual([ + 'home', + 'terminal', + 'settings', + 'logout', + ]) + }) + it('returns the home route tools', () => { expect(buildToolItems('/').map((item) => item.key)).toEqual(['activity', 'all-schedules', 'files']) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 5be89385f..426a850ea 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -58,7 +58,6 @@ const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) const MANAGER_ONLY_ITEM_KEYS: ReadonlySet = new Set([ 'files', 'source-control', - 'terminal', 'preview', 'walkthrough', 'actions', @@ -136,6 +135,17 @@ function buildRouteNavModel(pathname: string): NavModel { } } + const serverSessionMatch = /^\/servers\/[^/]+\/sessions\/[^/]+$/.exec(pathname) + if (serverSessionMatch) { + return { + primary: [], + items: [ + { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, + ...baseItems, + ], + } + } + if (isAssistantPath(pathname)) { const items: MoreDrawerItem[] = [ { key: 'files', label: 'Files', icon: Folder, dialog: 'files' }, diff --git a/frontend/src/components/repo/FusionComposer.tsx b/frontend/src/components/repo/FusionComposer.tsx index ede706f94..36bef67a3 100644 --- a/frontend/src/components/repo/FusionComposer.tsx +++ b/frontend/src/components/repo/FusionComposer.tsx @@ -1,6 +1,7 @@ import { useId, useState } from 'react' import { ArrowRight, ArrowUpRight, Combine, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { ModelCombobox } from '@/components/model/ModelCombobox' @@ -11,6 +12,7 @@ import { FusionRecoveredDetailsSchema, MULTI_RUN_FUSION_MIN_SOURCES, type MultiRun, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' export interface FusionComposerSubmission { @@ -20,7 +22,7 @@ export interface FusionComposerSubmission { } interface FusionComposerProps { - repoId: number + target: MultiRunTarget directory?: string run: MultiRun selectedEntryIds: number[] @@ -38,7 +40,7 @@ function errorCode(error: unknown): string | undefined { } export function FusionComposer({ - repoId, + target, directory, run, selectedEntryIds, @@ -85,15 +87,26 @@ export function FusionComposer({ Base ref
- + {target.kind === 'repo' ? ( + + ) : ( + setBaseRef(event.target.value)} + placeholder="Current HEAD" + autoComplete="off" + className="font-mono text-sm" + /> + )}
diff --git a/frontend/src/components/repo/MultiRunCard.tsx b/frontend/src/components/repo/MultiRunCard.tsx index 718f7df5f..2df5404ce 100644 --- a/frontend/src/components/repo/MultiRunCard.tsx +++ b/frontend/src/components/repo/MultiRunCard.tsx @@ -42,7 +42,7 @@ interface MultiRunCardProps { onExitSelection: () => void onToggleEntry: (entryId: number, checked: boolean) => void onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void onDiscard: (entry: MultiRunEntry) => void } @@ -278,7 +278,7 @@ interface EntryRowProps { unavailableReason: string | null onToggle: (checked: boolean) => void onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void onDiscard: () => void } @@ -344,7 +344,7 @@ function EntryRow({ {sessionId ? ( onOpenSession(sessionId, entry.isolated)} /> ) : null} - {sessionId ? ( + {sessionId && onWalkthrough ? ( onWalkthrough(sessionId)} /> ) : null} {canDiscard ? ( @@ -375,7 +375,7 @@ function EntryRow({ interface FusionRowProps { fusion: MultiRunFusion onOpenSession: (sessionId: string, isolated: boolean) => void - onWalkthrough: (sessionId: string) => void + onWalkthrough?: (sessionId: string) => void } function FusionRow({ fusion, onOpenSession, onWalkthrough }: FusionRowProps) { @@ -403,7 +403,7 @@ function FusionRow({ fusion, onOpenSession, onWalkthrough }: FusionRowProps) { {sessionId ? (
onOpenSession(sessionId, fusion.isolated)} /> - onWalkthrough(sessionId)} /> + {onWalkthrough ? onWalkthrough(sessionId)} /> : null}
) : null} diff --git a/frontend/src/components/repo/MultiRunSheet.test.tsx b/frontend/src/components/repo/MultiRunSheet.test.tsx index 1b47ae264..cb257a6b4 100644 --- a/frontend/src/components/repo/MultiRunSheet.test.tsx +++ b/frontend/src/components/repo/MultiRunSheet.test.tsx @@ -119,6 +119,8 @@ function fusion(overrides: Partial = {}): MultiRunFusion { const oneStartedRun: MultiRun = { id: 3, repoId: 7, + serverId: 'local', + directory: null, name: 'Sweep', prompt: 'go', isolated: true, @@ -162,7 +164,14 @@ function createWrapper() { function renderSheet(overrides: Partial> = {}) { const onOpenChange = vi.fn() const view = render( - , + , { wrapper: createWrapper() }, ) return { ...view, onOpenChange } @@ -714,4 +723,93 @@ describe('MultiRunSheet', () => { expect(mockNavigate).toHaveBeenCalledWith('/repos/7/sessions/ses_recovered') }) }) + + describe('Linked server target', () => { + const serverTarget = { kind: 'server', serverId: 'srv_kitchen', directory: '/srv/app' } as const + const serverRun: MultiRun = { + ...oneStartedRun, + repoId: null, + serverId: 'srv_kitchen', + directory: '/srv/app', + } + + function renderServerSheet() { + return renderSheet({ target: serverTarget, directory: '/srv/app', defaultBaseRef: undefined }) + } + + it('lists the runs of the server directory', async () => { + mocks.listMultiRuns.mockResolvedValue([serverRun]) + renderServerSheet() + + expect(await screen.findByRole('region', { name: 'Sweep' })).toBeInTheDocument() + expect(mocks.listMultiRuns).toHaveBeenCalledWith(serverTarget) + }) + + it('launches with the server target instead of a repository', async () => { + const user = userEvent.setup() + mocks.launchMultiRun.mockResolvedValue(serverRun) + renderServerSheet() + + await fillLaunchForm(user) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith({ + serverId: 'srv_kitchen', + directory: '/srv/app', + name: 'Sweep', + prompt: 'go', + models: ['openai/gpt-4o'], + isolate: true, + }) + }) + }) + + it('takes the base ref as text because server branches are not listed', async () => { + const user = userEvent.setup() + mocks.launchMultiRun.mockResolvedValue(serverRun) + renderServerSheet() + + await openNewRun(user) + expect(screen.getByRole('textbox', { name: 'Start from' })).toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: 'Start from' })).not.toBeInTheDocument() + await user.type(screen.getByLabelText('Start from'), 'release') + expect(mocks.listBranches).not.toHaveBeenCalled() + + await user.type(screen.getByLabelText('Group name'), 'Sweep') + await user.type(screen.getByLabelText('Prompt'), 'go') + await user.click(screen.getByRole('checkbox', { name: 'GPT-4o' })) + await user.click(screen.getByRole('button', { name: 'Launch' })) + + await waitFor(() => { + expect(mocks.launchMultiRun).toHaveBeenCalledWith(expect.objectContaining({ baseRef: 'release' })) + }) + }) + + it('opens sessions on the server route and hides the local-only walkthrough', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([serverRun]) + const { onOpenChange } = renderServerSheet() + + const card = await screen.findByRole('region', { name: 'Sweep' }) + expect(within(card).queryByRole('button', { name: /walkthrough/i })).not.toBeInTheDocument() + + await user.click(within(card).getByRole('button', { name: 'Open' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockNavigate).toHaveBeenCalledWith('/servers/srv_kitchen/sessions/ses_11') + }) + + it('opens a server fusion session on the server route', async () => { + const user = userEvent.setup() + mocks.listMultiRuns.mockResolvedValue([{ ...serverRun, fusions: [fusion()] }]) + renderServerSheet() + + const card = await screen.findByRole('region', { name: 'Sweep' }) + const fusionRow = within(card).getByText('from 1 source (truncated)').closest('li') as HTMLElement + await user.click(within(fusionRow).getByRole('button', { name: 'Open' })) + + expect(mockNavigate).toHaveBeenCalledWith('/servers/srv_kitchen/sessions/ses_fusion') + }) + }) }) diff --git a/frontend/src/components/repo/MultiRunSheet.tsx b/frontend/src/components/repo/MultiRunSheet.tsx index a1f0f6bff..580a19445 100644 --- a/frontend/src/components/repo/MultiRunSheet.tsx +++ b/frontend/src/components/repo/MultiRunSheet.tsx @@ -17,18 +17,19 @@ import { ChangesWalkthroughSheet } from '@/components/session/ChangesWalkthrough import { useModelSections } from '@/hooks/useModelSections' import { useDiscardMultiRunEntry, useFuseMultiRun, useLaunchMultiRun, useMultiRuns } from '@/hooks/useMultiRuns' import { filterModelSections } from '@/lib/modelSections' +import { getServerSessionPath, getSessionPath } from '@/lib/navigation' import { randomId } from '@/lib/utils' -import { buildSessionPath } from '@opencode-manager/shared/utils' import { FusionUnavailableDetailsSchema, MULTI_RUN_MAX_MODELS, type FuseMultiRunRequest, type LaunchMultiRunRequest, type MultiRunEntry, + type MultiRunTarget, } from '@opencode-manager/shared/schemas' interface MultiRunSheetProps { - repoId: number + target: MultiRunTarget directory?: string defaultBaseRef?: string open: boolean @@ -55,7 +56,14 @@ function unavailableReasonsFromError(error: unknown): ReadonlyMap [source.entryId, source.message])) } -export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenChange }: MultiRunSheetProps) { +function multiRunSessionPath(target: MultiRunTarget, sessionId: string, isolated: boolean): string { + if (target.kind === 'server') { + return getServerSessionPath(target.serverId, sessionId) + } + return getSessionPath(target.repoId, sessionId, isolated ? { repoTab: 'workspaces' } : undefined) +} + +export function MultiRunSheet({ target, directory, defaultBaseRef, open, onOpenChange }: MultiRunSheetProps) { const navigate = useNavigate() const [activeTab, setActiveTab] = useState('runs') const [expandedRuns, setExpandedRuns] = useState>({}) @@ -64,9 +72,9 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC const [pendingDiscard, setPendingDiscard] = useState(null) const [walkthroughSessionId, setWalkthroughSessionId] = useState(null) - const runsQuery = useMultiRuns(repoId, open) - const fuse = useFuseMultiRun(repoId) - const discard = useDiscardMultiRunEntry(repoId) + const runsQuery = useMultiRuns(target, open) + const fuse = useFuseMultiRun(target) + const discard = useDiscardMultiRunEntry(target) const resetFuse = fuse.reset useEffect(() => { @@ -85,7 +93,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC const openSession = (sessionId: string, isolated: boolean) => { onOpenChange(false) - navigate(buildSessionPath(repoId, sessionId, isolated ? { repoTab: 'workspaces' } : undefined)) + navigate(multiRunSessionPath(target, sessionId, isolated)) } const startSelection = (runId: number) => { @@ -197,7 +205,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC onExitSelection={exitSelection} onToggleEntry={toggleEntry} onOpenSession={openSession} - onWalkthrough={setWalkthroughSessionId} + onWalkthrough={target.kind === 'repo' ? setWalkthroughSessionId : undefined} onDiscard={(entry) => setPendingDiscard({ runId: run.id, entry })} /> )) @@ -207,7 +215,7 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC {selection && selectedRun ? ( { if (!next) setWalkthroughSessionId(null) }} - onOpenSession={(id) => navigate(buildSessionPath(repoId, id))} + onOpenSession={(id) => navigate(multiRunSessionPath(target, id, false))} /> ) : null} @@ -266,14 +274,14 @@ export function MultiRunSheet({ repoId, directory, defaultBaseRef, open, onOpenC } interface NewRunFormProps { - repoId: number + target: MultiRunTarget directory?: string defaultBaseRef?: string open: boolean onLaunched: () => void } -function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: NewRunFormProps) { +function NewRunForm({ target, directory, defaultBaseRef, open, onLaunched }: NewRunFormProps) { const [name, setName] = useState('') const [prompt, setPrompt] = useState('') const [selectedModels, setSelectedModels] = useState([]) @@ -283,7 +291,7 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New const deferredModelSearch = useDeferredValue(modelSearch) const { sections: modelSections } = useModelSections(directory, { enabled: open }) - const launch = useLaunchMultiRun(repoId) + const launch = useLaunchMultiRun(target) const visibleModelSections = useMemo( () => filterModelSections(modelSections, deferredModelSearch), @@ -306,7 +314,9 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New const handleLaunch = () => { if (!canSubmit) return const request: LaunchMultiRunRequest = { - repoId, + ...(target.kind === 'repo' + ? { repoId: target.repoId } + : { serverId: target.serverId, directory: target.directory }), name: name.trim(), prompt: prompt.trim(), models: selectedModels, @@ -378,14 +388,25 @@ function NewRunForm({ repoId, directory, defaultBaseRef, open, onLaunched }: New {isolate ? (
- + {target.kind === 'repo' ? ( + + ) : ( + setBaseRef(event.target.value)} + placeholder="Current HEAD" + autoComplete="off" + className="font-mono text-sm" + /> + )}

Each isolated workspace starts from this branch.

) : null} diff --git a/frontend/src/components/repo/ProjectActionsMenu.tsx b/frontend/src/components/repo/ProjectActionsMenu.tsx index f7e7963a1..74f7ebcd3 100644 --- a/frontend/src/components/repo/ProjectActionsMenu.tsx +++ b/frontend/src/components/repo/ProjectActionsMenu.tsx @@ -61,12 +61,12 @@ export function ProjectActionsMenu({ repoId, directory }: ProjectActionsMenuProp const openPreview = useOpenPreview() const openTerminal = useOpenTerminal() const configQuery = useProjectConfig(repoId, directory, menuOpen && !!directory) - const terminalsQuery = useTerminals(repoId, directory, { + const terminalsQuery = useTerminals({ kind: 'repo', repoId }, directory, { enabled: menuOpen && !!directory, refetchInterval: menuOpen ? 5000 : false, }) const runAction = useRunProjectAction(repoId, directory) - const removeTerminal = useRemoveTerminal(repoId) + const removeTerminal = useRemoveTerminal({ kind: 'repo', repoId }) const trustConfig = useTrustRepoConfig(repoId) const [pendingTrust, setPendingTrust] = useState(null) const scopeRef = useRef({ repoId, directory }) diff --git a/frontend/src/components/repo/RemoteMultiRunButton.test.tsx b/frontend/src/components/repo/RemoteMultiRunButton.test.tsx new file mode 100644 index 000000000..613868bcc --- /dev/null +++ b/frontend/src/components/repo/RemoteMultiRunButton.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { RemoteMultiRunButton } from './RemoteMultiRunButton' + +vi.mock('@/components/repo/MultiRunSheet', () => ({ + MultiRunSheet: ({ + target, + directory, + open, + }: { + target: { kind: string; serverId?: string; directory?: string } + directory?: string + open: boolean + }) => ( +
+ ), +})) + +describe('RemoteMultiRunButton', () => { + it('opens the multi-run sheet for the project directory on the linked server', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByTestId('multi-run-sheet')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Multi-run' })) + + const sheet = screen.getByTestId('multi-run-sheet') + expect(sheet).toHaveAttribute('data-open', 'true') + expect(sheet).toHaveAttribute('data-kind', 'server') + expect(sheet).toHaveAttribute('data-server', 'srv_kitchen') + expect(sheet).toHaveAttribute('data-target-directory', '/srv/app') + expect(sheet).toHaveAttribute('data-directory', '/srv/app') + }) +}) diff --git a/frontend/src/components/repo/RemoteMultiRunButton.tsx b/frontend/src/components/repo/RemoteMultiRunButton.tsx new file mode 100644 index 000000000..6396a4149 --- /dev/null +++ b/frontend/src/components/repo/RemoteMultiRunButton.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import { Layers } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { MultiRunSheet } from '@/components/repo/MultiRunSheet' + +interface RemoteMultiRunButtonProps { + serverId: string + directory: string +} + +export function RemoteMultiRunButton({ serverId, directory }: RemoteMultiRunButtonProps) { + const [mounted, setMounted] = useState(false) + const [open, setOpen] = useState(false) + + const handleOpen = () => { + setMounted(true) + setOpen(true) + } + + return ( + <> + + {mounted ? ( + + ) : null} + + ) +} diff --git a/frontend/src/components/schedules/GeneralTab.tsx b/frontend/src/components/schedules/GeneralTab.tsx index 83f8acf04..4bb0ea261 100644 --- a/frontend/src/components/schedules/GeneralTab.tsx +++ b/frontend/src/components/schedules/GeneralTab.tsx @@ -30,6 +30,7 @@ type GeneralTabProps = { workspaceMode: ScheduleWorkspaceMode onWorkspaceModeChange: (value: ScheduleWorkspaceMode) => void showWorkspaceMode: boolean + showBranch?: boolean showRepoSelector?: boolean isEditing: boolean repoId?: number @@ -74,6 +75,7 @@ export function GeneralTab({ workspaceMode, onWorkspaceModeChange, showWorkspaceMode, + showBranch = true, showRepoSelector, isEditing, repoId, @@ -171,10 +173,11 @@ export function GeneralTab({ name="schedule-workspace-mode" value={workspaceMode} onChange={onWorkspaceModeChange} - options={workspaceModeOptions} + options={workspaceModeOptions.filter((option) => option.value !== 'in-place')} />
)} + {showBranch && (
@@ -190,6 +193,7 @@ export function GeneralTab({ clearable />
+ )}
diff --git a/frontend/src/components/schedules/JobDetailTab.tsx b/frontend/src/components/schedules/JobDetailTab.tsx index 6d18403e9..85660b6b9 100644 --- a/frontend/src/components/schedules/JobDetailTab.tsx +++ b/frontend/src/components/schedules/JobDetailTab.tsx @@ -40,10 +40,13 @@ export function JobDetailTab({ runningRun, isJobFetching, }: JobDetailTabProps) { - const { scheduleTarget } = useScheduleTarget(selectedJob?.repoId) - const { availableModels } = useScheduleModels(Boolean(selectedJob), scheduleTarget?.fullPath) + const repoId = selectedJob?.repoId ?? undefined + const isRemote = selectedJob?.repoId === null + const { scheduleTarget } = useScheduleTarget(repoId) + const modelDirectory = isRemote ? (selectedJob?.directory ?? undefined) : scheduleTarget?.fullPath + const { availableModels } = useScheduleModels(Boolean(selectedJob), modelDirectory, isRemote ? selectedJob?.serverId : undefined) const resolvedModel = resolveScheduleModel(selectedJob?.model, availableModels) - const { data: worktrees = [] } = useScheduleWorktrees(selectedJob?.repoId, selectedJob?.id ?? null) + const { data: worktrees = [] } = useScheduleWorktrees(repoId, selectedJob?.id ?? null) const removeWorktrees = useRemoveScheduleWorktrees() const idleWorktreeCount = worktrees.filter((worktree) => !worktree.inUse).length @@ -119,7 +122,7 @@ export function JobDetailTab({ )} - {worktrees.length > 0 && ( + {!isRemote && worktrees.length > 0 && (
@@ -130,7 +133,7 @@ export function JobDetailTab({ variant="outline" size="sm" aria-label="Remove all worktrees" - onClick={() => removeWorktrees.mutate({ repoId: selectedJob.repoId, jobId: selectedJob.id })} + onClick={() => { if (repoId !== undefined) removeWorktrees.mutate({ repoId, jobId: selectedJob.id }) }} disabled={removeWorktrees.isPending || idleWorktreeCount === 0} > {removeWorktrees.isPending ? : } @@ -152,7 +155,7 @@ export function JobDetailTab({ size="sm" aria-label={`Remove worktree ${worktree.branch}`} title={worktree.inUse ? 'In use by a running run' : 'Remove worktree'} - onClick={() => removeWorktrees.mutate({ repoId: selectedJob.repoId, jobId: selectedJob.id, worktreePath: worktree.worktreePath })} + onClick={() => { if (repoId !== undefined) removeWorktrees.mutate({ repoId, jobId: selectedJob.id, worktreePath: worktree.worktreePath }) }} disabled={worktree.inUse || removeWorktrees.isPending} > @@ -184,7 +187,7 @@ export function JobDetailTab({

Model

{resolvedModel ?? 'Workspace default'}

- {selectedJob.repoId !== ASSISTANT_REPO_ID && ( + {(isRemote || selectedJob.repoId !== ASSISTANT_REPO_ID) && (

Workspace

{formatWorkspaceMode(selectedJob.workspaceMode)}

diff --git a/frontend/src/components/schedules/RemoteSchedulesButton.tsx b/frontend/src/components/schedules/RemoteSchedulesButton.tsx new file mode 100644 index 000000000..775db89f6 --- /dev/null +++ b/frontend/src/components/schedules/RemoteSchedulesButton.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { CalendarClock } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { ScheduleServerTarget } from '@/api/schedules' +import { ServerSchedulesDialog } from './ServerSchedulesDialog' + +interface RemoteSchedulesButtonProps { + serverId: string + directory: string +} + +/** + * Opens the schedule list for one project on a linked OpenCode server. + */ +export function RemoteSchedulesButton({ serverId, directory }: RemoteSchedulesButtonProps) { + const [open, setOpen] = useState(false) + const target: ScheduleServerTarget = { serverId, directory } + + return ( + <> + + + + ) +} diff --git a/frontend/src/components/schedules/RunDetailPanel.tsx b/frontend/src/components/schedules/RunDetailPanel.tsx index 0ef87e448..a129bd73d 100644 --- a/frontend/src/components/schedules/RunDetailPanel.tsx +++ b/frontend/src/components/schedules/RunDetailPanel.tsx @@ -9,7 +9,7 @@ import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' import { getRepo } from '@/api/repos' import { createSessionWithContext } from '@/api/opencode' import { getWorkspaceFilePath } from '@/lib/markdownLinks' -import { getSessionPath } from '@/lib/navigation' +import { getServerSessionPath, getSessionPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' import { showToast } from '@/lib/toast' import { useMarkScheduleRunViewed } from '@/hooks/useSchedules' @@ -17,20 +17,22 @@ import { Loader2 } from 'lucide-react' import type { ScheduleRun } from '@opencode-manager/shared/types' interface RunDetailPanelProps { - repoId: number + repoId: number | null + serverId?: string activeRun: ScheduleRun | null selectedRunLoading: boolean onCancelRun: () => void cancelRunPending: boolean } -export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { +export function RunDetailPanel({ repoId, serverId, activeRun, selectedRunLoading, onCancelRun, cancelRunPending }: RunDetailPanelProps) { const navigate = useNavigate() const [selectedFilePath, setSelectedFilePath] = useState(null) const [openingSession, setOpeningSession] = useState(false) const { data: repo, isPending: repoPending, isError: repoError, refetch: refetchRepo } = useQuery({ queryKey: ['repo', repoId], - queryFn: () => getRepo(repoId), + queryFn: () => getRepo(repoId!), + enabled: repoId !== null, }) const markRunViewed = useMarkScheduleRunViewed() const viewedRunIdRef = useRef(null) @@ -74,6 +76,10 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel const opensNewRepoSession = Boolean(activeRun.runBranch) && !activeRun.worktreePath && activeRun.status !== 'running' const handleOpenSession = async () => { + if (repoId === null) { + if (sessionId && serverId) navigate(getServerSessionPath(serverId, sessionId)) + return + } if (!opensNewRepoSession) { if (sessionId) navigate(getSessionPath(repoId, sessionId)) return @@ -130,10 +136,10 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel
) : activeRun.responseText ? ( <> - {repoPending && ( + {repoId !== null && repoPending && (
Loading repository details for local links.
)} - {repoError && ( + {repoId !== null && repoError && (
Could not load repository details for local links. @@ -160,7 +166,7 @@ export function RunDetailPanel({ repoId, activeRun, selectedRunLoading, onCancel onClose={() => setSelectedFilePath(null)} basePath={repo?.localPath} repoName={repo ? getRepoDisplayName(repo) : undefined} - repoId={repoId} + repoId={repoId ?? undefined} initialSelectedFile={selectedFilePath ?? undefined} /> diff --git a/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx new file mode 100644 index 000000000..77d8c5d71 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.serverTarget.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ScheduleJob } from '@opencode-manager/shared/types' +import { ScheduleJobDialog } from './ScheduleJobDialog' + +Element.prototype.scrollIntoView = vi.fn() + +vi.mock('@/hooks/usePromptTemplates', () => ({ + usePromptTemplates: () => ({ data: [], isLoading: false }), + useCreatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useUpdatePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), + useDeletePromptTemplate: () => ({ mutate: vi.fn(), isPending: false }), +})) + +vi.mock('@/hooks/useScheduleTarget', () => ({ + useScheduleTarget: () => ({ scheduleTarget: undefined, isLoading: false, isError: false }), +})) + +vi.mock('@/hooks/useScheduleModels', () => ({ + useScheduleModels: () => ({ availableModels: null, isLoading: false }), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useAgents: () => ({ data: [], isSuccess: true }), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + listManagedSkills: () => Promise.resolve([]), + }, +})) + +vi.mock('@/api/mcp', () => ({ + mcpApi: { + getStatus: () => Promise.resolve({}), + }, +})) + +vi.mock('@/api/repos', () => ({ + listRepos: () => Promise.resolve([]), +})) + +const remoteJob: ScheduleJob = { + id: 7, + repoId: null, + serverId: 'srv_kitchen', + directory: '/srv/projects/audit', + name: 'Nightly audit', + description: null, + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Audit the project.', + model: null, + skillMetadata: null, + permissionConfig: null, + mcpServers: [], + branch: null, + workspaceMode: 'in-place', + createdAt: 0, + updatedAt: 0, + lastRunAt: null, + nextRunAt: null, +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('ScheduleJobDialog — server target', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('hides worktree workspace mode and submits an in-place target directory', async () => { + const onSubmit = vi.fn() + + render( + , + { wrapper: createWrapper() }, + ) + + await waitFor(() => { + expect(screen.getByText('Edit schedule')).toBeInTheDocument() + }) + + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Base branch')).not.toBeInTheDocument() + expect(screen.queryByText('Fresh worktree, cleaned up')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + workspaceMode: 'in-place', + directory: '/srv/projects/audit', + branch: null, + })) + }) + }) +}) diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx index 68fbbcffb..7c3f13164 100644 --- a/frontend/src/components/schedules/ScheduleJobDialog.tsx +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -9,6 +9,7 @@ import { settingsApi } from '@/api/settings' import { mcpApi } from '@/api/mcp' import { listRepos } from '@/api/repos' import type { Repo } from '@/api/types' +import type { ScheduleServerTarget } from '@/api/schedules' import { Button } from '@/components/ui/button' import type { ComboboxOption } from '@/components/ui/combobox' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -45,9 +46,10 @@ type ScheduleJobDialogProps = { repoId?: number onRepoChange?: (repoId: number | undefined) => void isEditing?: boolean + serverTarget?: ScheduleServerTarget } -export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, showRepoSelector, repoId: selectedRepoId, onRepoChange }: ScheduleJobDialogProps) { +export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, showRepoSelector, repoId: selectedRepoId, onRepoChange, serverTarget }: ScheduleJobDialogProps) { const [schedulePreset, setSchedulePreset] = useState('interval') const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -81,17 +83,19 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, const { data: templates = EMPTY_TEMPLATES } = usePromptTemplates() const deleteTemplateMutation = useDeletePromptTemplate() - const effectiveRepoId = selectedRepoId ?? job?.repoId - const { scheduleTarget } = useScheduleTarget(open ? effectiveRepoId : undefined) - const scheduleDirectory = scheduleTarget?.fullPath + const serverTargetId = serverTarget?.serverId + const serverTargetDirectory = serverTarget?.directory + const effectiveRepoId = selectedRepoId ?? job?.repoId ?? undefined + const { scheduleTarget } = useScheduleTarget(serverTargetId ? undefined : (open ? effectiveRepoId : undefined)) + const scheduleDirectory = serverTargetDirectory ?? scheduleTarget?.fullPath - const { availableModels } = useScheduleModels(open, scheduleDirectory) + const { availableModels } = useScheduleModels(open, scheduleDirectory, serverTargetId) const resolvedModel = useMemo( () => (modelDirty ? (model.trim() || null) : resolveScheduleModel(model, availableModels)), [model, modelDirty, availableModels], ) - const { data: agents = [], isSuccess: agentsLoaded } = useAgents(scheduleDirectory, { enabled: !!scheduleDirectory }) + const { data: agents = [], isSuccess: agentsLoaded } = useAgents(scheduleDirectory, { enabled: !!scheduleDirectory, serverId: serverTargetId }) const { data: skills = [], isLoading: skillsLoading } = useQuery({ queryKey: ['managed-skills'], @@ -101,8 +105,10 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, }) const { data: mcpStatuses = {}, isLoading: mcpStatusesLoading } = useQuery({ - queryKey: ['mcp-status', scheduleDirectory], - queryFn: () => mcpApi.getStatus(scheduleDirectory), + queryKey: ['mcp-status', scheduleDirectory, serverTargetId], + queryFn: () => (serverTargetId + ? mcpApi.getStatus(scheduleDirectory, serverTargetId) + : mcpApi.getStatus(scheduleDirectory)), enabled: open && !!scheduleDirectory, }) @@ -167,12 +173,12 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, initialSkillSlugsRef.current = initialSkillSlugs initialSkillNotesRef.current = initialSkillNotes setMcpServers(job?.mcpServers ?? []) - setBranch(job?.branch ?? '') - setWorkspaceMode(job?.workspaceMode ?? 'worktree') + setBranch(serverTargetDirectory ? '' : (job?.branch ?? '')) + setWorkspaceMode(serverTargetDirectory ? 'in-place' : (job?.workspaceMode ?? 'worktree')) setAllowExternalDirectory(job?.permissionConfig?.allowExternalDirectory ?? false) setAllowQuestions(job?.permissionConfig?.allowQuestions ?? false) setBashDenyPatterns(job?.permissionConfig?.bashDenyPatterns ?? [...DEFAULT_DESTRUCTIVE_BASH_PATTERNS]) - }, [job, open]) + }, [job, open, serverTargetDirectory]) useEffect(() => { if (!open) { @@ -216,8 +222,9 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, agentSlug: selectedAgentSlug.trim() || undefined, model: resolvedModel ?? undefined, prompt: prompt.trim(), - branch: branch.trim() || null, - workspaceMode, + branch: serverTargetDirectory ? null : (branch.trim() || null), + workspaceMode: serverTargetDirectory ? 'in-place' : workspaceMode, + ...(serverTargetDirectory ? { directory: serverTargetDirectory } : {}), mcpServers, permissionConfig: { allowExternalDirectory, @@ -266,7 +273,7 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, {job ? 'Edit schedule' : 'New schedule'} - Create a reusable repo job with a visual schedule builder, manual runs, and optional advanced metadata. + {serverTarget ? 'Create a reusable job for this remote project' : 'Create a reusable repo job'} with a visual schedule builder, manual runs, and optional advanced metadata. @@ -296,10 +303,11 @@ export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit, onEnabledChange={setEnabled} branch={branch} onBranchChange={setBranch} - branchRepoId={open && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} + branchRepoId={!serverTargetDirectory && open && effectiveRepoId !== undefined && effectiveRepoId !== ASSISTANT_REPO_ID ? effectiveRepoId : undefined} workspaceMode={workspaceMode} onWorkspaceModeChange={setWorkspaceMode} - showWorkspaceMode={effectiveRepoId !== ASSISTANT_REPO_ID} + showWorkspaceMode={!serverTargetDirectory && effectiveRepoId !== ASSISTANT_REPO_ID} + showBranch={!serverTargetDirectory} showRepoSelector={showRepoSelector} isEditing={!!job} repoId={selectedRepoId} diff --git a/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx b/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx index 548f8cda8..bdc481a4a 100644 --- a/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx +++ b/frontend/src/components/schedules/ScheduleRunDrawer.test.tsx @@ -8,13 +8,13 @@ import type { ScheduleRunWithContext } from '@/api/schedules' import type { Repo } from '@/api/types' const mocks = vi.hoisted(() => ({ - useRepoScheduleRun: vi.fn(), + useScheduleRunDetail: vi.fn(), useMarkScheduleRunViewed: vi.fn(), getRepo: vi.fn(), })) vi.mock('@/hooks/useSchedules', () => ({ - useRepoScheduleRun: mocks.useRepoScheduleRun, + useScheduleRunDetail: mocks.useScheduleRunDetail, useMarkScheduleRunViewed: mocks.useMarkScheduleRunViewed, })) @@ -85,7 +85,7 @@ function renderDrawer(props: Partial> = describe('ScheduleRunDrawer', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useScheduleRunDetail.mockReturnValue({ data: undefined, isLoading: false }) mocks.useMarkScheduleRunViewed.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.getRepo.mockResolvedValue(repo) }) diff --git a/frontend/src/components/schedules/ScheduleRunDrawer.tsx b/frontend/src/components/schedules/ScheduleRunDrawer.tsx index cafc41274..cdb82feb3 100644 --- a/frontend/src/components/schedules/ScheduleRunDrawer.tsx +++ b/frontend/src/components/schedules/ScheduleRunDrawer.tsx @@ -5,7 +5,7 @@ import { SideDrawer, SideDrawerContent, SideDrawerHeader } from '@/components/ui import { RunDetailPanel } from './RunDetailPanel' import { formatRunBranch, getRunRepoName, getRunStatusIcon, getRunStatusLabel, getRunTitle, type ScheduleRunRow } from './schedule-run-display' import { formatRunDuration } from './schedule-utils' -import { useRepoScheduleRun } from '@/hooks/useSchedules' +import { useScheduleRunDetail } from '@/hooks/useSchedules' interface ScheduleRunDrawerProps { run: ScheduleRunRow | null @@ -36,7 +36,7 @@ export function ScheduleRunDrawer({ onPrev, onNext, }: ScheduleRunDrawerProps) { - const { data: detail, isLoading } = useRepoScheduleRun(run?.repoId, run?.jobId ?? null, run?.id ?? null) + const { data: detail, isLoading } = useScheduleRunDetail(run) const activeRun = detail ?? run const title = run ? getRunTitle(run) : 'Run' @@ -90,6 +90,7 @@ export function ScheduleRunDrawer({ {activeRun ? ( ('jobs') + const [jobId, setJobId] = useState(null) + const [runId, setRunId] = useState(null) + const [dialog, setDialog] = useState(null) + const [search, setSearch] = useState('') + const [clearRunsOpen, setClearRunsOpen] = useState(false) + const [runToDelete, setRunToDelete] = useState(null) + + const { data: allSchedules, isLoading } = useAllSchedules() + const jobs = useMemo( + () => allSchedules?.filter((job) => job.serverId === serverId && job.directory === directory), + [allSchedules, directory, serverId], + ) + const selectedJob = useMemo( + () => jobs?.find((job) => job.id === jobId), + [jobs, jobId], + ) + const { data: fetchedJob, isFetching: isJobFetching } = useServerSchedule(serverId, jobId) + const { data: runs, isLoading: runsLoading } = useServerScheduleRuns(serverId, jobId, 30) + + const createMutation = useCreateServerSchedule(serverId) + const updateMutation = useUpdateServerSchedule(serverId) + const deleteMutation = useDeleteServerSchedule(serverId) + const runMutation = useRunServerSchedule(serverId) + const cancelRunMutation = useCancelServerScheduleRun(serverId) + const clearRunsMutation = useClearServerScheduleRuns(serverId) + const deleteRunMutation = useDeleteServerScheduleRun(serverId) + + const visibleJobs = useMemo( + () => (jobs ?? []).filter((job) => matchesScheduleJobSearch(job, search)), + [jobs, search], + ) + const clearableRuns = useMemo(() => (runs ?? []).filter((run) => run.status !== 'running'), [runs]) + const runningRun = useMemo(() => runs?.find((run) => run.status === 'running') ?? null, [runs]) + const activeJob = selectedJob ?? fetchedJob + const editingJob = dialog === 'edit' ? selectedJob : undefined + + const openJob = (job: ScheduleJob) => { + setJobId(job.id) + setTab('detail') + } + + const handleCreate = (data: CreateScheduleJobRequest) => { + createMutation.mutate({ ...data, directory }, { + onSuccess: (job) => { + setDialog(null) + setJobId(job.id) + setTab('detail') + }, + }) + } + + const handleUpdate = (data: CreateScheduleJobRequest) => { + if (dialog !== 'edit' || jobId === null) return + updateMutation.mutate({ jobId, data: toUpdateScheduleRequest(data) }, { + onSuccess: () => setDialog(null), + }) + } + + const handleDelete = () => { + if (dialog !== 'delete' || jobId === null) return + deleteMutation.mutate(jobId, { + onSuccess: () => { + setDialog(null) + setJobId(null) + setTab('jobs') + }, + }) + } + + const handleToggleEnabled = (job: ScheduleJob) => { + updateMutation.mutate({ jobId: job.id, data: { enabled: !job.enabled } }) + } + + const handleRunNow = (job: ScheduleJob) => { + runMutation.mutate(job.id, { + onSuccess: (run) => { + setTab('runs') + setRunId(run.id) + }, + }) + } + + const handleCancelRun = () => { + const run = runId !== null ? runs?.find((entry) => entry.id === runId) : null + if (!run || run.status !== 'running') return + cancelRunMutation.mutate({ jobId: run.jobId, runId: run.id }) + } + + const handleCancelJobRun = (job: ScheduleJob) => { + const lastRun = jobs?.find((entry) => entry.id === job.id)?.lastRun + if (!lastRun) return + cancelRunMutation.mutate({ jobId: job.id, runId: lastRun.id }) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const hasJobs = (jobs?.length ?? 0) > 0 + + return ( + <> +
+ {!hasJobs ? ( + + +
+ +
+
+

No schedules yet

+

+ Create a schedule for this remote project to automate recurring agent work in {directory}. +

+
+ +
+
+ ) : ( + <> + {tab === 'jobs' && ( +
+ + + +
+
+ { setJobId(job.id); setDialog('edit') }} + onDelete={(job) => { setJobId(job.id); setDialog('delete') }} + onCancelRun={handleCancelJobRun} + runPending={runMutation.isPending} + cancelPending={cancelRunMutation.isPending} + /> +
+
+
+ )} + {tab === 'detail' && ( + { setJobId(job.id); setDialog('edit') }} + onDelete={(id) => { setJobId(id); setDialog('delete') }} + onToggleEnabled={() => { if (activeJob) handleToggleEnabled(activeJob) }} + onRunNow={() => { if (activeJob) handleRunNow(activeJob) }} + updatePending={updateMutation.isPending} + runPending={runMutation.isPending} + runningRun={Boolean(runningRun)} + isJobFetching={isJobFetching} + /> + )} + {tab === 'runs' && ( + setClearRunsOpen(true)} + clearHistoryPending={clearRunsMutation.isPending} + onDeleteRun={setRunToDelete} + deleteRunPending={deleteRunMutation.isPending} + /> + )} + + )} +
+ + {hasJobs && ( + + )} + + { if (!open) setDialog(null) }} + job={editingJob} + isSaving={createMutation.isPending || updateMutation.isPending} + onSubmit={dialog === 'edit' ? handleUpdate : handleCreate} + serverTarget={target} + /> + + { if (!open) setDialog(null) }} + onConfirm={handleDelete} + onCancel={() => setDialog(null)} + title="Delete Schedule" + description={DELETE_SCHEDULE_DESCRIPTION} + isDeleting={deleteMutation.isPending} + /> + + { if (!open) setClearRunsOpen(false) }} + title="Clear run history" + description={ + <>This permanently deletes all {clearableRuns.length} finished run{clearableRuns.length === 1 ? '' : 's'} for this schedule. A run in progress is kept. This cannot be undone. + } + affectedWorktreeCount={0} + isPending={clearRunsMutation.isPending} + onCancel={() => setClearRunsOpen(false)} + onConfirm={() => { + if (jobId === null) return + clearRunsMutation.mutate(jobId, { onSuccess: () => setClearRunsOpen(false) }) + }} + /> + + { if (!open) setRunToDelete(null) }} + title="Delete run" + description="This permanently deletes this run. This cannot be undone." + affectedWorktreeCount={0} + isPending={deleteRunMutation.isPending} + onCancel={() => setRunToDelete(null)} + onConfirm={() => { + if (jobId === null || runToDelete === null) return + deleteRunMutation.mutate({ jobId, runId: runToDelete }, { onSuccess: () => setRunToDelete(null) }) + }} + /> + + ) +} diff --git a/frontend/src/components/schedules/ServerSchedulesDialog.tsx b/frontend/src/components/schedules/ServerSchedulesDialog.tsx new file mode 100644 index 000000000..630f30e91 --- /dev/null +++ b/frontend/src/components/schedules/ServerSchedulesDialog.tsx @@ -0,0 +1,29 @@ +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import type { ScheduleServerTarget } from '@/api/schedules' +import { ServerSchedulesContent } from './ServerSchedulesContent' + +interface ServerSchedulesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + target: ScheduleServerTarget | null +} + +export function ServerSchedulesDialog({ open, onOpenChange, target }: ServerSchedulesDialogProps) { + return ( + + + + Schedules + + {target?.directory ?? ''} + + + {target ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index a62f4bd1a..f2f068921 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -56,6 +56,11 @@ export const workspaceModeOptions: Array> = [ label: 'Shared worktree', description: 'Every run reuses one worktree and branch, so work builds up from run to run.', }, + { + value: 'in-place', + label: 'In place', + description: 'Runs directly in the target project directory. Used for projects on a linked server.', + }, ] export function formatWorkspaceMode(mode: ScheduleWorkspaceMode): string { diff --git a/frontend/src/components/session/SessionGoalBar.test.tsx b/frontend/src/components/session/SessionGoalBar.test.tsx index fe3fb2838..5b0b0384e 100644 --- a/frontend/src/components/session/SessionGoalBar.test.tsx +++ b/frontend/src/components/session/SessionGoalBar.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/hooks/useSessionGoals') const baseGoal: SessionGoal = { id: 1, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'Ship the feature', status: 'active', diff --git a/frontend/src/components/terminal/TerminalPanel.test.tsx b/frontend/src/components/terminal/TerminalPanel.test.tsx index 63e2b5239..962ab58a4 100644 --- a/frontend/src/components/terminal/TerminalPanel.test.tsx +++ b/frontend/src/components/terminal/TerminalPanel.test.tsx @@ -80,7 +80,7 @@ function mockTerminalHooks( function renderPanel(initialEntry = '/repos/1?dialog=terminal', onClose = vi.fn()) { return render( - + , ) } @@ -123,7 +123,7 @@ describe('TerminalPanel', () => { mockTerminalHooks([], { isSuccess: true }) rerender( - + , ) @@ -147,7 +147,7 @@ describe('TerminalPanel', () => { mockTerminalHooks([{ ...runningTerminal, status: 'exited', exitCode: 0 }]) rerender( - + , ) @@ -183,7 +183,7 @@ describe('TerminalPanel', () => { const panel = (isOpen: boolean) => ( - + ) diff --git a/frontend/src/components/terminal/TerminalPanel.tsx b/frontend/src/components/terminal/TerminalPanel.tsx index 62113afc4..d1a3fef81 100644 --- a/frontend/src/components/terminal/TerminalPanel.tsx +++ b/frontend/src/components/terminal/TerminalPanel.tsx @@ -10,6 +10,7 @@ import { useOpenPreview } from '@/hooks/useOpenPreview' import { useUrlParams } from '@/hooks/useUrlParams' import { openUrlFromManager } from '@/lib/open-url' import { cn } from '@/lib/utils' +import type { TerminalTarget } from '@/lib/terminal-target' import { TerminalKeyBar } from './TerminalKeyBar' import type { TerminalViewHandle } from './TerminalView' @@ -18,28 +19,28 @@ const TerminalView = lazy(() => ) interface TerminalPanelProps { - repoId: number + target: TerminalTarget directory: string | undefined isOpen: boolean onClose: () => void } interface TerminalWorkspaceProps { - repoId: number + target: TerminalTarget directory: string | undefined isOpen: boolean } -export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspaceProps) { +export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspaceProps) { const isMobile = useMobile() const { searchParams, updateParams } = useUrlParams() const openPreview = useOpenPreview() - const { data, isLoading, isSuccess, isFetching, refetch, error } = useTerminals(repoId, directory, { + const { data, isLoading, isSuccess, isFetching, refetch, error } = useTerminals(target, directory, { enabled: isOpen && !!directory, refetchInterval: isOpen ? 5000 : false, }) - const { mutate: createTerminal, isPending: isCreating } = useCreateTerminal(repoId) - const { mutate: removeTerminal, isPending: isRemoving } = useRemoveTerminal(repoId) + const { mutate: createTerminal, isPending: isCreating } = useCreateTerminal(target) + const { mutate: removeTerminal, isPending: isRemoving } = useRemoveTerminal(target) const [ctrlArmed, setCtrlArmed] = useState(false) const [pendingClose, setPendingClose] = useState(null) @@ -182,7 +183,7 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa if (handle) viewHandlesRef.current.set(terminal.id, handle) else viewHandlesRef.current.delete(terminal.id) }} - repoId={repoId} + target={target} directory={directory} ptyID={terminal.id} active={terminal.id === activeTerminalId} @@ -218,7 +219,7 @@ export function TerminalWorkspace({ repoId, directory, isOpen }: TerminalWorkspa ) } -export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPanelProps) { +export function TerminalPanel({ target, directory, isOpen, onClose }: TerminalPanelProps) { const isMobile = useMobile() return ( @@ -250,7 +251,7 @@ export function TerminalPanel({ repoId, directory, isOpen, onClose }: TerminalPa )} - + ) diff --git a/frontend/src/components/terminal/TerminalView.test.tsx b/frontend/src/components/terminal/TerminalView.test.tsx index e7c44f8a4..e5d8d66f7 100644 --- a/frontend/src/components/terminal/TerminalView.test.tsx +++ b/frontend/src/components/terminal/TerminalView.test.tsx @@ -59,7 +59,7 @@ function renderView(overrides: Partial> = {} const element = ( { const ref = createRef() const onCtrlConsumed = vi.fn() const props: ComponentProps = { - repoId: 1, + target: { kind: 'repo', repoId: 1 }, directory: '/repo', ptyID: 't1', active: true, diff --git a/frontend/src/components/terminal/TerminalView.tsx b/frontend/src/components/terminal/TerminalView.tsx index 32a234acf..5017e96e9 100644 --- a/frontend/src/components/terminal/TerminalView.tsx +++ b/frontend/src/components/terminal/TerminalView.tsx @@ -6,6 +6,7 @@ import '@xterm/xterm/css/xterm.css' import { resizeTerminal } from '@/api/terminals' import { applyCtrlModifier } from '@/lib/terminal-protocol' import { resolveTerminalTheme } from '@/lib/terminal-theme' +import { terminalTargetKey, type TerminalTarget } from '@/lib/terminal-target' import { useTerminalSocket } from '@/hooks/useTerminalSocket' import { useMobile } from '@/hooks/useMobile' import { cn } from '@/lib/utils' @@ -19,7 +20,7 @@ export interface TerminalViewHandle { } interface TerminalViewProps { - repoId: number + target: TerminalTarget directory: string | undefined ptyID: string active: boolean @@ -30,7 +31,7 @@ interface TerminalViewProps { } export const TerminalView = forwardRef(function TerminalView({ - repoId, + target, directory, ptyID, active, @@ -50,8 +51,12 @@ export const TerminalView = forwardRef(fu const isMobile = useMobile() const isMobileRef = useRef(isMobile) const activeRef = useRef(active) + const targetRef = useRef(target) + const targetKey = terminalTargetKey(target) const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null) + targetRef.current = target + ctrlArmedRef.current = ctrlArmed onCtrlConsumedRef.current = onCtrlConsumed onOpenLinkRef.current = onOpenLink @@ -78,7 +83,7 @@ export const TerminalView = forwardRef(fu }, []) const { send } = useTerminalSocket({ - repoId, + target, directory, ptyID, onOutput: handleOutput, @@ -170,7 +175,7 @@ export const TerminalView = forwardRef(fu const lastSize = lastSizeRef.current if (lastSize && lastSize.cols === size.cols && lastSize.rows === size.rows) return lastSizeRef.current = size - resizeTerminal(repoId, ptyID, { directory, cols: size.cols, rows: size.rows }).catch(() => {}) + resizeTerminal(targetRef.current, ptyID, { directory, cols: size.cols, rows: size.rows }).catch(() => {}) }, RESIZE_DEBOUNCE_MS) } @@ -182,7 +187,7 @@ export const TerminalView = forwardRef(fu observer.disconnect() if (timer) clearTimeout(timer) } - }, [repoId, directory, ptyID]) + }, [targetKey, directory, ptyID]) useEffect(() => { if (!active) return diff --git a/frontend/src/hooks/useMultiRuns.ts b/frontend/src/hooks/useMultiRuns.ts index 0d7b5508b..566a224fe 100644 --- a/frontend/src/hooks/useMultiRuns.ts +++ b/frontend/src/hooks/useMultiRuns.ts @@ -7,15 +7,18 @@ import type { LaunchMultiRunRequest, MultiRun, MultiRunFusion, + MultiRunTarget, } from '@opencode-manager/shared/schemas' -function multiRunQueryKey(repoId: number) { - return ['multi-runs', repoId] as const +function multiRunQueryKey(target: MultiRunTarget | undefined) { + return ['multi-runs', target] as const } -function invalidateMultiRunCaches(queryClient: QueryClient, repoId: number) { - queryClient.invalidateQueries({ queryKey: multiRunQueryKey(repoId) }) - queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(repoId) }) +function invalidateMultiRunCaches(queryClient: QueryClient, target: MultiRunTarget) { + queryClient.invalidateQueries({ queryKey: multiRunQueryKey(target) }) + if (target.kind === 'repo') { + queryClient.invalidateQueries({ queryKey: repoSiblingsQueryKey(target.repoId) }) + } } function buildStartingFusion(run: MultiRun, request: FuseMultiRunRequest): MultiRunFusion { @@ -45,22 +48,22 @@ function countEntries(run: MultiRun, status: MultiRun['entries'][number]['status return run.entries.filter((entry) => entry.status === status).length } -export function useMultiRuns(repoId: number | undefined, enabled: boolean) { +export function useMultiRuns(target: MultiRunTarget | undefined, enabled: boolean) { return useQuery({ - queryKey: multiRunQueryKey(repoId ?? 0), - queryFn: () => listMultiRuns(repoId!), - enabled: enabled && !!repoId && repoId > 0, + queryKey: multiRunQueryKey(target), + queryFn: () => listMultiRuns(target!), + enabled: enabled && target !== undefined, staleTime: 10_000, }) } -export function useLaunchMultiRun(repoId: number) { +export function useLaunchMultiRun(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: (request: LaunchMultiRunRequest) => launchMultiRun(request), onSuccess: (run) => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) const started = countEntries(run, 'started') const failed = countEntries(run, 'failed') if (failed === 0) { @@ -77,14 +80,14 @@ export function useLaunchMultiRun(repoId: number) { }) } -export function useFuseMultiRun(repoId: number) { +export function useFuseMultiRun(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ runId, request }: { runId: number; request: FuseMultiRunRequest }) => fuseMultiRun(runId, request), onMutate: async ({ runId, request }) => { - const queryKey = multiRunQueryKey(repoId) + const queryKey = multiRunQueryKey(target) await queryClient.cancelQueries({ queryKey }) queryClient.setQueryData(queryKey, (runs) => runs?.map((run) => @@ -95,10 +98,10 @@ export function useFuseMultiRun(repoId: number) { ) }, onSuccess: (run, variables) => { - queryClient.setQueryData(multiRunQueryKey(repoId), (runs) => + queryClient.setQueryData(multiRunQueryKey(target), (runs) => runs?.map((candidate) => (candidate.id === run.id ? run : candidate)), ) - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) const fusion = run.fusions.find((candidate) => candidate.requestId === variables.request.requestId) if (fusion?.status === 'started') { showToast.success('Fusion started') @@ -107,19 +110,19 @@ export function useFuseMultiRun(repoId: number) { } }, onError: () => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) }, }) } -export function useDiscardMultiRunEntry(repoId: number) { +export function useDiscardMultiRunEntry(target: MultiRunTarget) { const queryClient = useQueryClient() return useMutation({ mutationFn: ({ runId, entryId }: { runId: number; entryId: number }) => discardMultiRunEntry(runId, entryId), onSuccess: () => { - invalidateMultiRunCaches(queryClient, repoId) + invalidateMultiRunCaches(queryClient, target) showToast.success('Run discarded') }, onError: (error: unknown) => { diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 9a29201d3..5d23e2389 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -608,8 +608,9 @@ export const useSendShell = (directory?: string) => { }); }; -export const useAgents = (directory?: string, options?: { enabled?: boolean }) => { - const serverId = useOpenCodeServerId(); +export const useAgents = (directory?: string, options?: { enabled?: boolean; serverId?: string }) => { + const defaultServerId = useOpenCodeServerId() + const serverId = options?.serverId ?? defaultServerId; return useQuery({ queryKey: serverScopedKey(["opencode", "agents", directory], serverId), queryFn: () => listAgents(directory, serverId), diff --git a/frontend/src/hooks/useProviders.ts b/frontend/src/hooks/useProviders.ts index c5f77e178..0d8773000 100644 --- a/frontend/src/hooks/useProviders.ts +++ b/frontend/src/hooks/useProviders.ts @@ -5,10 +5,12 @@ import { serverScopedKey } from '@/lib/serverScope' interface UseProvidersOptions { enabled?: boolean + serverId?: string } export function useProviders(directory?: string, options: UseProvidersOptions = {}) { - const serverId = useOpenCodeServerId() + const defaultServerId = useOpenCodeServerId() + const serverId = options.serverId ?? defaultServerId return useQuery({ queryKey: serverScopedKey(['opencode', 'providers', directory], serverId), diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 075264ec2..62c615fd5 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -208,7 +208,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) setError(null) syncCurrentSession() invalidateShellCaches(queryClient) - eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) + eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current, serverId) } else { setError('Connection lost. Reconnecting...') } @@ -234,7 +234,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) } const handleVisibilityChange = () => { - subscription.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) + subscription.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current, serverId) } document.addEventListener('visibilitychange', handleVisibilityChange) @@ -246,7 +246,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) document.removeEventListener('visibilitychange', handleVisibilityChange) window.removeEventListener('focus', handleReconnect) window.removeEventListener('online', handleReconnect) - subscription.reportVisibility(false, undefined) + subscription.reportVisibility(false, undefined, serverId) subscription.dispose() if (eventStreamSubscriptionRef.current === subscription) { eventStreamSubscriptionRef.current = null @@ -256,9 +256,9 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) useEffect(() => { if (isConnected && document.visibilityState === 'visible') { - eventStreamSubscriptionRef.current?.reportVisibility(true, currentSessionId) + eventStreamSubscriptionRef.current?.reportVisibility(true, currentSessionId, serverId) } - }, [currentSessionId, isConnected]) + }, [currentSessionId, isConnected, serverId]) return { isConnected, error, isReconnecting } } diff --git a/frontend/src/hooks/useScheduleModels.ts b/frontend/src/hooks/useScheduleModels.ts index 90f357e09..69abbdfe2 100644 --- a/frontend/src/hooks/useScheduleModels.ts +++ b/frontend/src/hooks/useScheduleModels.ts @@ -1,8 +1,8 @@ import { useProviders } from '@/hooks/useProviders' import type { ModelInfo } from '@opencode-manager/shared/opencode' -export function useScheduleModels(enabled: boolean, directory?: string) { - const providersQuery = useProviders(directory, { enabled }) +export function useScheduleModels(enabled: boolean, directory?: string, serverId?: string) { + const providersQuery = useProviders(directory, { enabled, serverId }) const availableModels: ModelInfo[] | null = providersQuery.isSuccess && !providersQuery.isPlaceholderData ? providersQuery.data?.models ?? [] diff --git a/frontend/src/hooks/useSchedules.ts b/frontend/src/hooks/useSchedules.ts index a3dd7c246..1a7765e5f 100644 --- a/frontend/src/hooks/useSchedules.ts +++ b/frontend/src/hooks/useSchedules.ts @@ -2,28 +2,51 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { CreateScheduleJobRequest, ScheduleRunWorktreesMode, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' import { cancelRepoScheduleRun, + cancelServerScheduleRun, clearRepoScheduleRuns, + clearServerScheduleRuns, createRepoSchedule, + createServerSchedule, deleteRepoSchedule, deleteRepoScheduleRun, + deleteServerSchedule, + deleteServerScheduleRun, getRepoSchedule, getRepoScheduleRun, + getServerSchedule, + getServerScheduleRun, listAllScheduleRuns, listAllSchedules, listRepoScheduleRuns, + listServerScheduleRuns, listScheduleWorktrees, listUnreadScheduleRuns, markAllScheduleRunsViewed, markScheduleRunViewed, removeScheduleWorktrees, runRepoSchedule, + runServerSchedule, updateRepoSchedule, + updateServerSchedule, } from '@/api/schedules' import { showToast } from '@/lib/toast' import type { ListAllRunsParams, ScheduleJobWithRepo, ScheduleRunWithContext } from '@/api/schedules' export const UNREAD_SCHEDULE_RUNS_QUERY_KEY = ['schedule-runs-unread'] as const +export type ScheduleTargetRef = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string } + +function invalidateScheduleQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs'] }) +} + export function useAllSchedules() { return useQuery({ queryKey: ['all-schedules'], @@ -289,3 +312,242 @@ export function useRemoveScheduleWorktrees() { }, }) } + +export function useServerSchedule(serverId: string | undefined, jobId: number | null) { + return useQuery({ + queryKey: ['server-schedule', serverId, jobId], + queryFn: async () => (await getServerSchedule(serverId!, jobId!)).job, + enabled: Boolean(serverId) && jobId !== null, + refetchInterval: jobId !== null ? 5000 : false, + }) +} + +export function useServerScheduleRuns(serverId: string | undefined, jobId: number | null, limit: number = 20) { + return useQuery({ + queryKey: ['server-schedule-runs', serverId, jobId, limit], + queryFn: async () => (await listServerScheduleRuns(serverId!, jobId!, limit)).runs, + enabled: Boolean(serverId) && jobId !== null, + refetchInterval: jobId !== null ? 5000 : false, + }) +} + +export function useServerScheduleRun(serverId: string | undefined, jobId: number | null, runId: number | null) { + return useQuery({ + queryKey: ['server-schedule-run', serverId, jobId, runId], + queryFn: async () => (await getServerScheduleRun(serverId!, jobId!, runId!)).run, + enabled: Boolean(serverId) && jobId !== null && runId !== null, + refetchInterval: runId !== null ? 5000 : false, + }) +} + +export function useScheduleRunDetail(run: { repoId: number | null; serverId?: string; jobId: number; id: number } | null) { + const serverId = run && run.repoId === null ? run.serverId : undefined + return useQuery({ + queryKey: ['schedule-run-detail', run?.repoId ?? null, serverId ?? null, run?.jobId ?? null, run?.id ?? null], + queryFn: async () => { + if (!run) return null + if (run.repoId === null) { + if (!run.serverId) return null + return (await getServerScheduleRun(run.serverId, run.jobId, run.id)).run + } + return (await getRepoScheduleRun(run.repoId, run.jobId, run.id)).run + }, + enabled: run !== null && (run.repoId !== null || Boolean(run.serverId)), + refetchInterval: run !== null ? 5000 : false, + }) +} + +export function useCreateServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: CreateScheduleJobRequest) => (await createServerSchedule(serverId, data)).job, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule created') + }, + onError: (error: unknown) => { + showToast.error(`Failed to create schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useUpdateServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ jobId, data }: { jobId: number; data: UpdateScheduleJobRequest }) => + (await updateServerSchedule(serverId, jobId, data)).job, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule', serverId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule updated') + }, + onError: (error: unknown) => { + showToast.error(`Failed to update schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (jobId: number) => deleteServerSchedule(serverId, jobId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useRunServerSchedule(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (jobId: number) => (await runServerSchedule(serverId, jobId)).run, + onSuccess: (run) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success(run.status === 'running' ? 'Schedule started' : 'Schedule run completed') + }, + onError: (error: unknown) => { + showToast.error(`Failed to run schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useCancelServerScheduleRun(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ jobId, runId }: { jobId: number; runId: number }) => + (await cancelServerScheduleRun(serverId, jobId, runId)).run, + onSuccess: (run) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, run.jobId] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-run', serverId, run.jobId, run.id] }) + queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) + showToast.success('Schedule run cancelled') + }, + onError: (error: unknown) => { + showToast.error(`Failed to cancel schedule run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useClearServerScheduleRuns(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (jobId: number) => clearServerScheduleRuns(serverId, jobId), + onSuccess: (result, jobId) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + showToast.success(result.cleared > 0 ? `Cleared ${result.cleared} run${result.cleared === 1 ? '' : 's'}` : 'No runs to clear') + }, + onError: (error: unknown) => { + showToast.error(`Failed to clear run history: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteServerScheduleRun(serverId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ jobId, runId }: { jobId: number; runId: number }) => deleteServerScheduleRun(serverId, jobId, runId), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['server-schedule-runs', serverId, variables.jobId] }) + queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + showToast.success('Run deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useUpdateScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId, data }: { target: ScheduleTargetRef; jobId: number; data: UpdateScheduleJobRequest }) => { + const response = target.kind === 'repo' + ? await updateRepoSchedule(target.repoId, jobId, data) + : await updateServerSchedule(target.serverId, jobId, data) + return response.job + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule updated') + }, + onError: (error: unknown) => { + showToast.error(`Failed to update schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useDeleteScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId }: { target: ScheduleTargetRef; jobId: number }) => { + if (target.kind === 'repo') { + await deleteRepoSchedule(target.repoId, jobId) + return + } + await deleteServerSchedule(target.serverId, jobId) + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule deleted') + }, + onError: (error: unknown) => { + showToast.error(`Failed to delete schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useRunScheduleTarget() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId }: { target: ScheduleTargetRef; jobId: number }) => { + const response = target.kind === 'repo' + ? await runRepoSchedule(target.repoId, jobId) + : await runServerSchedule(target.serverId, jobId) + return response.run + }, + onSuccess: (run) => { + invalidateScheduleQueries(queryClient) + showToast.success(run.status === 'running' ? 'Schedule started' : 'Schedule run completed') + }, + onError: (error: unknown) => { + showToast.error(`Failed to run schedule: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} + +export function useCancelScheduleTargetRun() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ target, jobId, runId }: { target: ScheduleTargetRef; jobId: number; runId: number }) => { + const response = target.kind === 'repo' + ? await cancelRepoScheduleRun(target.repoId, jobId, runId) + : await cancelServerScheduleRun(target.serverId, jobId, runId) + return response.run + }, + onSuccess: () => { + invalidateScheduleQueries(queryClient) + showToast.success('Schedule run cancelled') + }, + onError: (error) => { + showToast.error(`Failed to cancel schedule run: ${error instanceof Error ? error.message : String(error)}`) + }, + }) +} diff --git a/frontend/src/hooks/useSessionGoals.test.tsx b/frontend/src/hooks/useSessionGoals.test.tsx index 36300f6ea..d20f4f418 100644 --- a/frontend/src/hooks/useSessionGoals.test.tsx +++ b/frontend/src/hooks/useSessionGoals.test.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useSessionGoal } from './useSessionGoals' import { getLatestSessionGoal } from '@/api/sessionGoals' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import type { SessionGoal, SessionGoalStatus } from '@opencode-manager/shared/schemas' vi.mock('@/api/sessionGoals', () => ({ @@ -21,6 +22,7 @@ vi.mock('@/lib/toast', () => ({ const baseGoal: SessionGoal = { id: 1, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'Ship the feature', status: 'active', @@ -38,9 +40,11 @@ const baseGoal: SessionGoal = { finishedAt: null, } -function createWrapper(queryClient: QueryClient) { +function createWrapper(queryClient: QueryClient, serverId?: string) { return ({ children }: { children: ReactNode }) => ( - {children} + + {serverId ? {children} : children} + ) } @@ -69,4 +73,14 @@ describe('useSessionGoal', () => { expect(refetchInterval!({ state: { data: { status: 'stopped' } } })).toBe(false) expect(refetchInterval!({ state: {} })).toBe(false) }) + + it('reads the goal for the current server and scopes its cache key', async () => { + vi.mocked(getLatestSessionGoal).mockResolvedValue({ ...baseGoal, serverId: 'srv_remote' }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderHook(() => useSessionGoal('ses_1'), { wrapper: createWrapper(queryClient, 'srv_remote') }) + + await waitFor(() => expect(getLatestSessionGoal).toHaveBeenCalledWith('ses_1', 'srv_remote')) + + expect(queryClient.getQueryCache().find({ queryKey: ['session-goal', 'ses_1', 'srv_remote'] })).toBeDefined() + }) }) diff --git a/frontend/src/hooks/useSessionGoals.ts b/frontend/src/hooks/useSessionGoals.ts index 8a2a774b1..56b3acf0e 100644 --- a/frontend/src/hooks/useSessionGoals.ts +++ b/frontend/src/hooks/useSessionGoals.ts @@ -6,17 +6,20 @@ import { resumeSessionGoal, startSessionGoal, } from '@/api/sessionGoals' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { serverScopedKey } from '@/lib/serverScope' import { showToast } from '@/lib/toast' import { SESSION_GOAL_POLL_INTERVAL_MS, type SessionGoal, type StartSessionGoalRequest } from '@opencode-manager/shared/schemas' -function sessionGoalQueryKey(sessionId: string) { - return ['session-goal', sessionId] as const +function sessionGoalQueryKey(sessionId: string, serverId: string) { + return serverScopedKey(['session-goal', sessionId], serverId) } export function useSessionGoal(sessionId: string, options: { enabled?: boolean } = {}) { + const serverId = useOpenCodeServerId() return useQuery({ - queryKey: sessionGoalQueryKey(sessionId), - queryFn: () => getLatestSessionGoal(sessionId), + queryKey: sessionGoalQueryKey(sessionId, serverId), + queryFn: () => getLatestSessionGoal(sessionId, serverId), enabled: options.enabled ?? true, refetchInterval: (query) => (query.state.data?.status === 'active' ? SESSION_GOAL_POLL_INTERVAL_MS : false), }) @@ -30,7 +33,7 @@ function useGoalMutation( return useMutation({ mutationFn, onSuccess: (goal: SessionGoal) => { - queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId), goal) + queryClient.setQueryData(sessionGoalQueryKey(goal.sessionId, goal.serverId), goal) }, onError: (error: unknown) => { showToast.error(error instanceof Error ? error.message : errorMessage) @@ -39,7 +42,11 @@ function useGoalMutation( } export function useStartSessionGoal() { - return useGoalMutation((input: StartSessionGoalRequest) => startSessionGoal(input), 'Failed to start goal') + const serverId = useOpenCodeServerId() + return useGoalMutation( + (input: StartSessionGoalRequest) => startSessionGoal({ ...input, serverId }), + 'Failed to start goal', + ) } export function usePauseSessionGoal() { diff --git a/frontend/src/hooks/useSessionPermissionMode.test.tsx b/frontend/src/hooks/useSessionPermissionMode.test.tsx new file mode 100644 index 000000000..034b134e0 --- /dev/null +++ b/frontend/src/hooks/useSessionPermissionMode.test.tsx @@ -0,0 +1,63 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSessionPermissionMode, useSetSessionPermissionMode } from './useSessionPermissionMode' +import { getSessionPermissionMode, setSessionPermissionMode } from '@/api/sessionPermissionModes' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' + +vi.mock('@/api/sessionPermissionModes', () => ({ + getSessionPermissionMode: vi.fn(), + setSessionPermissionMode: vi.fn(), +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn() }, +})) + +function createWrapper(queryClient: QueryClient, serverId: string) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ) +} + +describe('useSessionPermissionMode', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads the mode for the current server and scopes its cache key', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + vi.mocked(getSessionPermissionMode).mockResolvedValue(state) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + renderHook(() => useSessionPermissionMode('ses_1'), { wrapper: createWrapper(queryClient, 'srv_remote') }) + + await waitFor(() => expect(getSessionPermissionMode).toHaveBeenCalledWith('ses_1', 'srv_remote')) + + expect(queryClient.getQueryCache().find({ queryKey: ['session-permission-mode', 'ses_1', 'srv_remote'] })).toBeDefined() + }) + + it('writes the mode through the current server', async () => { + const state = { sessionId: 'ses_1', rootSessionId: 'ses_1', mode: 'auto' as const, lockedReason: null } + vi.mocked(setSessionPermissionMode).mockResolvedValue(state) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const { result } = renderHook(() => useSetSessionPermissionMode('ses_1'), { + wrapper: createWrapper(queryClient, 'srv_remote'), + }) + + await act(async () => { + await result.current.mutateAsync({ directory: '/repo', mode: 'auto' }) + }) + + expect(setSessionPermissionMode).toHaveBeenCalledWith( + 'ses_1', + { directory: '/repo', mode: 'auto' }, + 'srv_remote', + ) + expect( + queryClient.getQueryData(['session-permission-mode', 'ses_1', 'srv_remote']), + ).toEqual(state) + }) +}) diff --git a/frontend/src/hooks/useSessionPermissionMode.ts b/frontend/src/hooks/useSessionPermissionMode.ts index 6f49862a9..e85eda916 100644 --- a/frontend/src/hooks/useSessionPermissionMode.ts +++ b/frontend/src/hooks/useSessionPermissionMode.ts @@ -1,16 +1,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getSessionPermissionMode, setSessionPermissionMode } from '@/api/sessionPermissionModes' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { serverScopedKey } from '@/lib/serverScope' import { showToast } from '@/lib/toast' import type { SessionPermissionModeState, SetSessionPermissionModeRequest } from '@opencode-manager/shared/schemas' -function sessionPermissionModeQueryKey(sessionId: string) { - return ['session-permission-mode', sessionId] as const +function sessionPermissionModeQueryKey(sessionId: string, serverId: string) { + return serverScopedKey(['session-permission-mode', sessionId], serverId) } export function useSessionPermissionMode(sessionId: string, options: { enabled?: boolean } = {}) { + const serverId = useOpenCodeServerId() return useQuery({ - queryKey: sessionPermissionModeQueryKey(sessionId), - queryFn: () => getSessionPermissionMode(sessionId), + queryKey: sessionPermissionModeQueryKey(sessionId, serverId), + queryFn: () => getSessionPermissionMode(sessionId, serverId), enabled: options.enabled ?? true, staleTime: 30000, }) @@ -18,10 +21,12 @@ export function useSessionPermissionMode(sessionId: string, options: { enabled?: export function useSetSessionPermissionMode(sessionId: string) { const queryClient = useQueryClient() + const serverId = useOpenCodeServerId() return useMutation({ - mutationFn: (input: SetSessionPermissionModeRequest) => setSessionPermissionMode(sessionId, input), + mutationFn: (input: SetSessionPermissionModeRequest) => + setSessionPermissionMode(sessionId, input, serverId), onSuccess: (state: SessionPermissionModeState) => { - queryClient.setQueryData(sessionPermissionModeQueryKey(sessionId), state) + queryClient.setQueryData(sessionPermissionModeQueryKey(sessionId, serverId), state) }, onError: () => { showToast.error('Failed to update permission mode') diff --git a/frontend/src/hooks/useTerminalSocket.test.tsx b/frontend/src/hooks/useTerminalSocket.test.tsx index 3e09c7474..b6f963c83 100644 --- a/frontend/src/hooks/useTerminalSocket.test.tsx +++ b/frontend/src/hooks/useTerminalSocket.test.tsx @@ -57,7 +57,7 @@ function metaFrame(cursor: number): ArrayBuffer { function renderSocket(overrides: Partial[0]> = {}) { return renderHook(() => useTerminalSocket({ - repoId: 1, + target: { kind: 'repo', repoId: 1 }, directory: '/work/repo', ptyID: 'pty-1', onOutput: vi.fn(), @@ -87,6 +87,13 @@ describe('useTerminalSocket', () => { expect(url.searchParams.get('directory')).toBe('/work/repo') }) + it('builds the connection url for a linked server target', () => { + renderSocket({ target: { kind: 'server', serverId: 'srv_kitchen' } }) + const url = new URL(FakeWebSocket.instances[0].url) + expect(url.pathname).toBe('/api/opencode-servers/srv_kitchen/terminals/pty-1/connect') + expect(url.searchParams.get('directory')).toBe('/work/repo') + }) + it('accumulates the cursor and sends it when reconnecting after 1006', () => { renderSocket() const first = FakeWebSocket.instances[0] diff --git a/frontend/src/hooks/useTerminalSocket.ts b/frontend/src/hooks/useTerminalSocket.ts index 5cf39a8dd..34735a2be 100644 --- a/frontend/src/hooks/useTerminalSocket.ts +++ b/frontend/src/hooks/useTerminalSocket.ts @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { terminalConnectPath, type TerminalTarget } from '@/lib/terminal-target' import { TerminalFrameDecoder } from '@/lib/terminal-protocol' import { toWebSocketUrl } from '@/lib/websocket-url' export type TerminalSocketStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' export interface UseTerminalSocketOptions { - repoId: number + target: TerminalTarget directory: string | undefined ptyID: string onOutput: (text: string) => void @@ -17,8 +18,7 @@ const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 5000, 5000] const TERMINAL_CLOSE_CODES = new Set([1000, 4404]) function buildSocketUrl( - repoId: number, - ptyID: string, + connectPath: string, directory: string | undefined, cursor: number | undefined, ): string { @@ -26,13 +26,11 @@ function buildSocketUrl( if (directory !== undefined) params.set('directory', directory) if (cursor !== undefined) params.set('cursor', String(cursor)) const query = params.toString() - return toWebSocketUrl( - `/api/repos/${repoId}/terminals/${encodeURIComponent(ptyID)}/connect${query ? `?${query}` : ''}`, - ) + return toWebSocketUrl(`${connectPath}${query ? `?${query}` : ''}`) } export function useTerminalSocket({ - repoId, + target, directory, ptyID, onOutput, @@ -46,6 +44,8 @@ export function useTerminalSocket({ onOutputRef.current = onOutput onExitRef.current = onExit + const connectPath = terminalConnectPath(target, ptyID) + const [status, setStatus] = useState(enabled ? 'connecting' : 'closed') const send = useCallback((data: string) => { @@ -71,7 +71,7 @@ export function useTerminalSocket({ if (disposed) return setStatus(attempt === 0 ? 'connecting' : 'reconnecting') - const socket = new WebSocket(buildSocketUrl(repoId, ptyID, directory, cursorRef.current)) + const socket = new WebSocket(buildSocketUrl(connectPath, directory, cursorRef.current)) socket.binaryType = 'arraybuffer' socketRef.current = socket @@ -129,7 +129,7 @@ export function useTerminalSocket({ socket.close() } } - }, [repoId, directory, ptyID, enabled]) + }, [connectPath, directory, enabled]) return { send, status } } diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 243070797..b22e3d3ce 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,4 +1,4 @@ -import { ASSISTANT_REPO_ID, buildSessionPath } from '@opencode-manager/shared/utils'; +import { ASSISTANT_REPO_ID, buildServerSessionPath, buildSessionPath } from '@opencode-manager/shared/utils'; import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas'; export function getAssistantPath(): string { @@ -33,7 +33,7 @@ export function getServerPath(serverId: string): string { } export function getServerSessionPath(serverId: string, sessionId: string): string { - return `${getServerPath(serverId)}/sessions/${encodeURIComponent(sessionId)}`; + return buildServerSessionPath(serverId, sessionId); } export function parseServerSessionRoute(pathname: string): { serverId: string; sessionId: string } | null { diff --git a/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts b/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts index 36e8fcbf5..a2443c5ff 100644 --- a/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts +++ b/frontend/src/lib/opencode-event-stream/__tests__/openCodeEventStream.test.ts @@ -236,7 +236,23 @@ describe('OpenCodeEventStream', () => { expect(transport.posts).toContainEqual({ path: '/api/sse/visibility', - body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1' }, + body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1', activeServerId: 'local' }, + }) + }) + + it('reports the active server id for a remote server route', async () => { + const transport = new TestEventStreamTransport() + const stream = new OpenCodeEventStream({ transport }) + + const subscription = stream.subscribeGlobalMonitor({ directories: ['srv_a|/repo'], onEvent: vi.fn() }) + transport.openConnection() + transport.connected('client-1') + subscription.reportVisibility(true, 'session-1', 'srv_a') + await Promise.resolve() + + expect(transport.posts).toContainEqual({ + path: '/api/sse/visibility', + body: { clientId: 'client-1', visible: true, activeSessionId: 'session-1', activeServerId: 'srv_a' }, }) }) diff --git a/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts b/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts index f1a5250a3..a39055f45 100644 --- a/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts +++ b/frontend/src/lib/opencode-event-stream/openCodeEventStream.ts @@ -1,4 +1,5 @@ import { DEFAULTS } from '@opencode-manager/shared/config' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { createBrowserEventStreamTransport } from './browserTransport' import type { EventStreamHealthState, @@ -75,7 +76,8 @@ export class OpenCodeEventStream { return { updateDirectories: (directories) => this.updateSubscriberDirectories(id, directories), reconnect: () => this.reconnect(), - reportVisibility: (visible, activeSessionId) => this.reportVisibility(visible, activeSessionId), + reportVisibility: (visible, activeSessionId, activeServerId) => + this.reportVisibility(visible, activeSessionId, activeServerId), dispose: () => this.removeSubscriber(id), } } @@ -477,13 +479,14 @@ export class OpenCodeEventStream { void this.subscribeToRemoteDirectories(directories) } - private reportVisibility(visible: boolean, activeSessionId?: string): void { + private reportVisibility(visible: boolean, activeSessionId?: string, activeServerId?: string): void { if (!this.clientId || !this.connected) return void this.transport.post('/api/sse/visibility', { clientId: this.clientId, visible, activeSessionId: activeSessionId ?? null, + activeServerId: activeServerId ?? LOCAL_OPENCODE_SERVER_ID, }) } } diff --git a/frontend/src/lib/opencode-event-stream/types.ts b/frontend/src/lib/opencode-event-stream/types.ts index 2d2620cca..44c625cda 100644 --- a/frontend/src/lib/opencode-event-stream/types.ts +++ b/frontend/src/lib/opencode-event-stream/types.ts @@ -29,7 +29,7 @@ export interface EventStreamTransport { export interface EventStreamSubscription { dispose(): void reconnect(): void - reportVisibility(visible: boolean, activeSessionId?: string): void + reportVisibility(visible: boolean, activeSessionId?: string, activeServerId?: string): void } export interface GlobalMonitorSubscription extends EventStreamSubscription { diff --git a/frontend/src/lib/terminal-target.ts b/frontend/src/lib/terminal-target.ts new file mode 100644 index 000000000..467ff8d93 --- /dev/null +++ b/frontend/src/lib/terminal-target.ts @@ -0,0 +1,25 @@ +export type TerminalTarget = + | { kind: 'repo'; repoId: number } + | { kind: 'server'; serverId: string } + +export function terminalTargetScope(target: TerminalTarget): number | string { + return target.kind === 'repo' ? target.repoId : target.serverId +} + +export function terminalTargetKey(target: TerminalTarget): string { + return target.kind === 'repo' ? `repo:${target.repoId}` : `server:${target.serverId}` +} + +export function terminalCollectionPath(target: TerminalTarget): string { + return target.kind === 'repo' + ? `/api/repos/${target.repoId}/terminals` + : `/api/opencode-servers/${encodeURIComponent(target.serverId)}/terminals` +} + +export function terminalItemPath(target: TerminalTarget, ptyID: string): string { + return `${terminalCollectionPath(target)}/${encodeURIComponent(ptyID)}` +} + +export function terminalConnectPath(target: TerminalTarget, ptyID: string): string { + return `${terminalItemPath(target, ptyID)}/connect` +} diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index 722649bf7..a4cc229c0 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -103,7 +103,7 @@ export function AssistantRedirect() { /> )} setSourceControlOpen(false)} currentBranch={repo?.currentBranch || repo?.branch || "main"} repoName="Assistant" /> - setTerminalOpen(false)} /> + setTerminalOpen(false)} /> setPreviewOpen(false)} directory={assistantDirectory} /> diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index 8c57bdef9..f41d5d7b9 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -1,7 +1,16 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { useAllSchedules, useAllScheduleRuns, useCancelRepoScheduleRun, useUnreadScheduleRuns } from '@/hooks/useSchedules' -import { useDeleteRepoSchedule, useRunRepoSchedule, useUpdateRepoSchedule, useCreateRepoSchedule } from '@/hooks/useSchedules' +import { + useAllSchedules, + useAllScheduleRuns, + useCancelScheduleTargetRun, + useCreateRepoSchedule, + useDeleteScheduleTarget, + useRunScheduleTarget, + useUnreadScheduleRuns, + useUpdateScheduleTarget, + type ScheduleTargetRef, +} from '@/hooks/useSchedules' import { ScheduleJobDialog, PromptsTab, ScheduleJobsTable, ScheduleListToolbar, ScheduleRunDrawer, ScheduleRunsTable, ScheduleRepoSwitcher } from '@/components/schedules' import type { CreateScheduleJobRequest } from '@opencode-manager/shared/types' import { DELETE_SCHEDULE_DESCRIPTION, matchesScheduleJobSearch, toUpdateScheduleRequest } from '@/components/schedules/schedule-utils' @@ -20,12 +29,26 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue' import type { ScheduleJobWithRepo, ScheduleRunWithContext } from '@/api/schedules' import { Combobox } from '@/components/ui/combobox' -import { getRepoPath } from '@/lib/navigation' +import { getRepoPath, getServerPath } from '@/lib/navigation' type StatusFilter = 'all' | 'enabled' | 'disabled' type ScheduleModeFilter = 'all' | 'cron' | 'interval' type SortOption = 'nextRun' | 'name' | 'repo' +function jobBucket(job: ScheduleJobWithRepo): string { + return job.repoId === null ? `server:${job.serverId ?? ''}:${job.repoPath}` : job.repoPath +} + +function targetForJob(job: ScheduleJobWithRepo): ScheduleTargetRef | null { + if (job.repoId !== null) return { kind: 'repo', repoId: job.repoId } + return job.serverId ? { kind: 'server', serverId: job.serverId } : null +} + +function targetForRun(run: ScheduleRunWithContext): ScheduleTargetRef | null { + if (run.repoId !== null) return { kind: 'repo', repoId: run.repoId } + return run.serverId ? { kind: 'server', serverId: run.serverId } : null +} + export function GlobalSchedules() { const navigate = useNavigate() const [selectedRepoId, setSelectedRepoId] = useState(undefined) @@ -46,7 +69,7 @@ export function GlobalSchedules() { const { scheduleTab, setScheduleTab, dialog, promptDialog, jobId, runId, templateId, openNewJob, openEditJob, openDeleteJob, openNewTemplate, openEditTemplate, openDeleteTemplate, openImportTemplate, closeDialog, closePromptDialog, selectRun } = useScheduleUrlState() - const cancelRunMutation = useCancelRepoScheduleRun() + const cancelRunMutation = useCancelScheduleTargetRun() const cancelRunPending = cancelRunMutation.isPending useEffect(() => { @@ -100,9 +123,9 @@ export function GlobalSchedules() { }, [nextUnreadRun, selectRun]) const createMutation = useCreateRepoSchedule() - const deleteMutation = useDeleteRepoSchedule() - const runMutation = useRunRepoSchedule() - const updateMutation = useUpdateRepoSchedule() + const deleteMutation = useDeleteScheduleTarget() + const runMutation = useRunScheduleTarget() + const updateMutation = useUpdateScheduleTarget() useEffect(() => { setRunOffset(0) @@ -152,14 +175,15 @@ export function GlobalSchedules() { }, [runsForDisplay, runSortOption]) const uniqueRepos = useMemo(() => { - const repoMap = new Map() + const repoMap = new Map() jobs.forEach((job) => { - repoMap.set(job.repoPath, { name: job.repoName, url: job.repoUrl }) + repoMap.set(jobBucket(job), { name: job.repoName, url: job.repoUrl, repoId: job.repoId }) }) - return Array.from(repoMap.entries()).map(([path, info]) => ({ - path, + return Array.from(repoMap.entries()).map(([bucket, info]) => ({ + path: bucket, name: info.name, url: info.url, + repoId: info.repoId, })) }, [jobs]) @@ -179,7 +203,7 @@ export function GlobalSchedules() { } if (repoFilter !== 'all') { - filtered = filtered.filter((job) => job.repoPath === repoFilter) + filtered = filtered.filter((job) => jobBucket(job) === repoFilter) } filtered = filtered.filter((job) => matchesScheduleJobSearch(job, searchQuery)) @@ -207,7 +231,7 @@ export function GlobalSchedules() { ...uniqueRepos.map((repo) => ({ value: repo.path, label: repo.name, - description: `${jobs.filter((j) => j.repoPath === repo.path).length} jobs`, + description: `${jobs.filter((j) => jobBucket(j) === repo.path).length} jobs`, })), ], [jobs, uniqueRepos]) @@ -251,38 +275,43 @@ export function GlobalSchedules() { const runRepoOptions = useMemo(() => [ { value: 'all', label: 'All Repos', description: '' }, - ...uniqueRepos.map((repo) => ({ - value: `${jobs.find((j) => j.repoPath === repo.path)?.repoId ?? 0}|${repo.path}`, + ...uniqueRepos.filter((repo) => repo.repoId !== null).map((repo) => ({ + value: `${repo.repoId}|${repo.path}`, label: repo.name, description: repo.path, })), - ], [uniqueRepos, jobs]) + ], [uniqueRepos]) const handleDelete = () => { - if (!deletingJob) { + const target = deletingJob ? targetForJob(deletingJob) : null + if (!deletingJob || !target) { return } deleteMutation.mutate( - { repoId: deletingJob.repoId, jobId: deletingJob.id }, + { target, jobId: deletingJob.id }, { onSuccess: () => closeDialog() } ) } - const handleCancelRun = (repoId: number, jobId: number, runId: number) => { - cancelRunMutation.mutate({ repoId, jobId, runId }) + const handleCancelRun = (target: ScheduleTargetRef, jobId: number, runId: number) => { + cancelRunMutation.mutate({ target, jobId, runId }) } const handleToggleEnabled = (job: ScheduleJobWithRepo) => { + const target = targetForJob(job) + if (!target) return updateMutation.mutate({ - repoId: job.repoId, + target, jobId: job.id, data: { enabled: !job.enabled }, }) } const handleRunNow = (job: ScheduleJobWithRepo) => { - runMutation.mutate({ repoId: job.repoId, jobId: job.id }) + const target = targetForJob(job) + if (!target) return + runMutation.mutate({ target, jobId: job.id }) } const handleEdit = (job: ScheduleJobWithRepo) => { @@ -303,10 +332,11 @@ export function GlobalSchedules() { } const handleUpdate = (data: CreateScheduleJobRequest) => { - if (!editingJob) return + const target = editingJob ? targetForJob(editingJob) : null + if (!editingJob || !target) return updateMutation.mutate( { - repoId: editingJob.repoId, + target, jobId: editingJob.id, data: toUpdateScheduleRequest(data), }, @@ -319,12 +349,20 @@ export function GlobalSchedules() { } const handleNavigateToRepo = (repoPath: string) => { - const repoId = jobs.find((j) => j.repoPath === repoPath)?.repoId - if (repoId === undefined) return - navigate(getRepoPath(repoId)) + const job = jobs.find((entry) => jobBucket(entry) === repoPath) + if (!job) return + if (job.repoId === null) { + if (job.serverId) navigate(getServerPath(job.serverId)) + return + } + navigate(getRepoPath(job.repoId)) } const handleOpenJobPage = (job: ScheduleJobWithRepo) => { + if (job.repoId === null) { + if (job.serverId) navigate(getServerPath(job.serverId)) + return + } navigate(`/repos/${job.repoId}/schedules`) } @@ -338,13 +376,15 @@ export function GlobalSchedules() { } const handleCancelJobRun = (job: ScheduleJobWithRepo) => { - if (!job.lastRun) return - handleCancelRun(job.repoId, job.id, job.lastRun.id) + const target = targetForJob(job) + if (!job.lastRun || !target) return + handleCancelRun(target, job.id, job.lastRun.id) } const handleCancelSelectedRun = () => { - if (!selectedRun) return - handleCancelRun(selectedRun.repoId, selectedRun.jobId, selectedRun.id) + const target = selectedRun ? targetForRun(selectedRun) : null + if (!selectedRun || !target) return + handleCancelRun(target, selectedRun.jobId, selectedRun.id) } const selectedRunIndex = runId !== null ? sortedRuns.findIndex((run) => run.id === runId) : -1 @@ -761,6 +801,9 @@ export function GlobalSchedules() { showRepoSelector repoId={selectedRepoId} onRepoChange={setSelectedRepoId} + serverTarget={editingJob && editingJob.repoId === null && editingJob.serverId && editingJob.directory + ? { serverId: editingJob.serverId, directory: editingJob.directory } + : undefined} /> : } New session + +
{expanded ? : null} diff --git a/frontend/src/pages/RepoDetail.tsx b/frontend/src/pages/RepoDetail.tsx index a26b79bf8..59a2cbe50 100644 --- a/frontend/src/pages/RepoDetail.tsx +++ b/frontend/src/pages/RepoDetail.tsx @@ -37,6 +37,7 @@ import { PendingActionsGroup } from "@/components/notifications/PendingActionsGr import { getRepoDisplayName } from "@/lib/utils"; import { notifyWorktreeSetup } from "@/lib/worktreeSetup"; import { getRepoDirectoryNameError, isWorktreeSibling } from "@opencode-manager/shared/utils"; +import type { MultiRunTarget } from "@opencode-manager/shared/schemas"; export function RepoDetail() { const { id } = useParams<{ id: string }>(); @@ -117,7 +118,8 @@ export function RepoDetail() { }, [baseDirectory, nonScheduleWorkspaceDirectories, activeScheduleDirectories]); const showWorktrees = activeTab === 'workspaces'; - const { data: multiRuns } = useMultiRuns(repoId, showWorktrees); + const multiRunTarget = useMemo(() => ({ kind: 'repo', repoId }), [repoId]); + const { data: multiRuns } = useMultiRuns(repoId > 0 ? multiRunTarget : undefined, showWorktrees); const sessionListDirectories = useMemo(() => { if (!showWorktrees) return baseDirectory ? [baseDirectory] : []; return Array.from(new Set([...nonScheduleWorkspaceDirectories, ...activeScheduleDirectories])); @@ -342,7 +344,7 @@ export function RepoDetail() { /> setTerminalOpen(false)} @@ -361,7 +363,7 @@ export function RepoDetail() { /> )} - {!isRemoteServer && } + )}
- {docked && !isRemoteServer && ( + {docked && ( )} - {!isRemoteServer && ( - setTerminalOpen(false)} - /> - )} + setTerminalOpen(false)} + /> {!isRemoteServer && ( ({ useAllSchedules: vi.fn(), useAllScheduleRuns: vi.fn(), useCreateRepoSchedule: vi.fn(), - useUpdateRepoSchedule: vi.fn(), - useDeleteRepoSchedule: vi.fn(), - useRunRepoSchedule: vi.fn(), - useCancelRepoScheduleRun: vi.fn(), + useUpdateScheduleTarget: vi.fn(), + useDeleteScheduleTarget: vi.fn(), + useRunScheduleTarget: vi.fn(), + useCancelScheduleTargetRun: vi.fn(), useUnreadScheduleRuns: vi.fn(), useScheduleUrlState: vi.fn(), ScheduleJobsTable: vi.fn(() => null), @@ -23,10 +23,10 @@ vi.mock('@/hooks/useSchedules', () => ({ useAllSchedules: mocks.useAllSchedules, useAllScheduleRuns: mocks.useAllScheduleRuns, useCreateRepoSchedule: mocks.useCreateRepoSchedule, - useUpdateRepoSchedule: mocks.useUpdateRepoSchedule, - useDeleteRepoSchedule: mocks.useDeleteRepoSchedule, - useRunRepoSchedule: mocks.useRunRepoSchedule, - useCancelRepoScheduleRun: mocks.useCancelRepoScheduleRun, + useUpdateScheduleTarget: mocks.useUpdateScheduleTarget, + useDeleteScheduleTarget: mocks.useDeleteScheduleTarget, + useRunScheduleTarget: mocks.useRunScheduleTarget, + useCancelScheduleTargetRun: mocks.useCancelScheduleTargetRun, useUnreadScheduleRuns: mocks.useUnreadScheduleRuns, })) @@ -182,10 +182,10 @@ describe('GlobalSchedules run history', () => { ) mocks.useAllSchedules.mockReturnValue({ data: [], isLoading: false, error: null }) mocks.useCreateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useUpdateRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useDeleteRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useRunRepoSchedule.mockReturnValue({ mutate: vi.fn(), isPending: false }) - mocks.useCancelRepoScheduleRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useUpdateScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useDeleteScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useRunScheduleTarget.mockReturnValue({ mutate: vi.fn(), isPending: false }) + mocks.useCancelScheduleTargetRun.mockReturnValue({ mutate: vi.fn(), isPending: false }) mocks.useUnreadScheduleRuns.mockReturnValue({ data: { runs: [], total: 0, failed: 0 } }) mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState()) }) diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index 1bd7e72a1..8373d5821 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -274,7 +274,6 @@ describe('SessionDetail remote server mode', () => { }) expect(mocks.SourceControlPanel).not.toHaveBeenCalled() - expect(mocks.TerminalPanel).not.toHaveBeenCalled() expect(mocks.PreviewPanel).not.toHaveBeenCalled() expect(mocks.FileBrowserSheet).not.toHaveBeenCalled() expect(mocks.ProjectActionsMenu).not.toHaveBeenCalled() @@ -283,8 +282,6 @@ describe('SessionDetail remote server mode', () => { expect(mocks.RepoSkillsDialog).not.toHaveBeenCalled() expect(mocks.RepoMcpDialog).not.toHaveBeenCalled() expect(mocks.ChangesWalkthroughSheet).not.toHaveBeenCalled() - expect(mocks.ToolSidePanel).not.toHaveBeenCalled() - expect(mocks.SessionGoalBar).not.toHaveBeenCalled() expect(mocks.SessionPickerDialog).not.toHaveBeenCalled() expect(mocks.SessionMoreButton).not.toHaveBeenCalled() expect(vi.mocked(PendingActionsGroup).mock.calls.at(-1)?.[0]).toEqual( @@ -292,6 +289,43 @@ describe('SessionDetail remote server mode', () => { ) }) + it('shows the session goal bar for a remote session', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SessionGoalBar).toHaveBeenCalled() + expect(mocks.SessionGoalBar.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ sessionID: 'ses_remote' }), + ) + }) + + it('exposes terminals for the linked server', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.TerminalPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + target: { kind: 'server', serverId: 'srv_kitchen' }, + directory: '/home/me/app', + isOpen: false, + }), + ) + expect(mocks.ToolSidePanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + repoId: undefined, + terminalTarget: { kind: 'server', serverId: 'srv_kitchen' }, + allowedPanelTools: ['terminal'], + directory: '/home/me/app', + }), + ) + }) + it('navigates child sessions within the linked server route', async () => { const { capturedPathname } = renderRemoteSession() diff --git a/shared/src/schemas/multi-runs.ts b/shared/src/schemas/multi-runs.ts index ef7d50dc1..419cbb853 100644 --- a/shared/src/schemas/multi-runs.ts +++ b/shared/src/schemas/multi-runs.ts @@ -62,9 +62,15 @@ export const MultiRunFusionSchema = z.object({ export type MultiRunFusion = z.infer; +export type MultiRunTarget = + | { kind: "repo"; repoId: number } + | { kind: "server"; serverId: string; directory: string }; + export const MultiRunSchema = z.object({ id: z.number().int(), - repoId: z.number().int(), + repoId: z.number().int().nullable(), + serverId: z.string(), + directory: z.string().nullable(), name: z.string(), prompt: z.string(), isolated: z.boolean(), @@ -76,19 +82,30 @@ export const MultiRunSchema = z.object({ export type MultiRun = z.infer; -export const LaunchMultiRunRequestSchema = z.object({ - repoId: z.number().int(), - name: z.string().trim().min(1).max(MULTI_RUN_NAME_MAX_LENGTH), - prompt: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), - models: z - .array(z.string().min(3)) - .min(1) - .max(MULTI_RUN_MAX_MODELS) - .refine((models) => new Set(models).size === models.length, { message: "Models must be unique" }), - isolate: z.boolean(), - baseRef: z.string().trim().min(1).optional(), - agent: z.string().optional(), -}); +export const LaunchMultiRunRequestSchema = z + .object({ + repoId: z.number().int().optional(), + serverId: z.string().trim().min(1).optional(), + directory: z.string().trim().min(1).optional(), + name: z.string().trim().min(1).max(MULTI_RUN_NAME_MAX_LENGTH), + prompt: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), + models: z + .array(z.string().min(3)) + .min(1) + .max(MULTI_RUN_MAX_MODELS) + .refine((models) => new Set(models).size === models.length, { message: "Models must be unique" }), + isolate: z.boolean(), + baseRef: z.string().trim().min(1).optional(), + agent: z.string().optional(), + }) + .refine((data) => (data.repoId === undefined) !== (data.serverId === undefined), { + message: "Provide either repoId or serverId", + path: ["repoId"], + }) + .refine((data) => (data.serverId === undefined) === (data.directory === undefined), { + message: "A server target needs a directory", + path: ["directory"], + }); export type LaunchMultiRunRequest = z.infer; diff --git a/shared/src/schemas/notifications.ts b/shared/src/schemas/notifications.ts index fcd21e710..f1bac5aa8 100644 --- a/shared/src/schemas/notifications.ts +++ b/shared/src/schemas/notifications.ts @@ -70,6 +70,8 @@ export const PushNotificationPayloadSchema = z.object({ directory: z.string().optional(), repoId: z.number().optional(), repoName: z.string().optional(), + serverId: z.string().optional(), + serverName: z.string().optional(), priority: z.enum(["normal", "high"]).optional(), }) .optional(), diff --git a/shared/src/schemas/schedule.ts b/shared/src/schemas/schedule.ts index e9bb5d3ed..315bbc238 100644 --- a/shared/src/schemas/schedule.ts +++ b/shared/src/schemas/schedule.ts @@ -13,8 +13,10 @@ export type ScheduleMode = z.infer * Where a scheduled run executes. `worktree` gives every run a fresh worktree that is * removed when the run ends, `kept-worktree` keeps each run's worktree afterwards, and * `shared-worktree` reuses one worktree and branch for every run of the schedule. + * `in-place` runs directly in the target directory and is the only mode available for a + * project on a linked remote OpenCode server, which has no local checkout to branch from. */ -export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree']) +export const ScheduleWorkspaceModeSchema = z.enum(['worktree', 'kept-worktree', 'shared-worktree', 'in-place']) export type ScheduleWorkspaceMode = z.infer /** @@ -171,7 +173,9 @@ export function buildSchedulePermissionRuleset( export const ScheduleJobSchema = z.object({ id: z.number(), - repoId: z.number(), + repoId: z.number().nullable(), + serverId: z.string().optional(), + directory: z.string().nullable().optional(), name: z.string(), description: z.string().nullable(), enabled: z.boolean(), @@ -197,7 +201,8 @@ export type ScheduleJob = z.infer export const ScheduleRunSchema = z.object({ id: z.number(), jobId: z.number(), - repoId: z.number(), + repoId: z.number().nullable(), + serverId: z.string().optional(), triggerSource: ScheduleRunTriggerSourceSchema, status: ScheduleRunStatusSchema, startedAt: z.number(), @@ -227,6 +232,7 @@ const ScheduleJobBaseRequestSchema = z.object({ mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), workspaceMode: ScheduleWorkspaceModeSchema.optional(), + directory: z.string().min(1).max(4096).optional(), }) export const CreateScheduleJobRequestSchema = z.discriminatedUnion('scheduleMode', [ @@ -258,6 +264,7 @@ export const UpdateScheduleJobRequestSchema = z.object({ mcpServers: ScheduleMcpServersSchema.optional(), branch: z.string().min(1).max(200).nullable().optional(), workspaceMode: ScheduleWorkspaceModeSchema.optional(), + directory: z.string().min(1).max(4096).nullable().optional(), }) export type UpdateScheduleJobRequest = z.infer diff --git a/shared/src/schemas/session-goals.ts b/shared/src/schemas/session-goals.ts index 2a6295864..51f96cb4a 100644 --- a/shared/src/schemas/session-goals.ts +++ b/shared/src/schemas/session-goals.ts @@ -29,6 +29,7 @@ export type SessionGoalTurnState = z.infer; export const SessionGoalSchema = z.object({ id: z.number().int(), sessionId: z.string(), + serverId: z.string(), directory: z.string(), objective: z.string(), status: SessionGoalStatusSchema, @@ -66,6 +67,7 @@ export function isTerminalSessionGoal(goal: SessionGoal | null | undefined): goa export const StartSessionGoalRequestSchema = z.object({ sessionId: z.string().min(1), + serverId: z.string().min(1).optional(), directory: z.string().min(1), objective: z.string().trim().min(1).max(SESSION_PROMPT_MAX_LENGTH), maxContinuations: z.number().int().min(GOAL_MAX_CONTINUATIONS_MIN).max(GOAL_MAX_CONTINUATIONS_MAX).optional(), diff --git a/shared/src/schemas/session-permissions.ts b/shared/src/schemas/session-permissions.ts index 0f0248929..514ce6f53 100644 --- a/shared/src/schemas/session-permissions.ts +++ b/shared/src/schemas/session-permissions.ts @@ -11,6 +11,7 @@ export type SessionLockReason = z.infer; export const SetSessionPermissionModeRequestSchema = z.object({ directory: z.string().min(1), mode: SessionPermissionModeSchema, + serverId: z.string().min(1).optional(), }); export type SetSessionPermissionModeRequest = z.infer; diff --git a/shared/src/schemas/sse.ts b/shared/src/schemas/sse.ts index 5f5296250..18c768baf 100644 --- a/shared/src/schemas/sse.ts +++ b/shared/src/schemas/sse.ts @@ -10,6 +10,7 @@ export const SSEVisibilitySchema = z.object({ clientId: z.string().min(1), visible: z.boolean(), activeSessionId: z.string().nullable().optional(), + activeServerId: z.string().min(1).optional(), }); export type SSESubscribeRequest = z.infer; diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index a12efff08..694a3b9b4 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -22,6 +22,10 @@ export function buildSessionPath( return `/repos/${repoId}/sessions/${sessionId}${search ? `?${search}` : ''}` } +export function buildServerSessionPath(serverId: string, sessionId: string): string { + return `/servers/${encodeURIComponent(serverId)}/sessions/${encodeURIComponent(sessionId)}` +} + function trimTrailingChar(value: string, char: string): string { let end = value.length while (end > 0 && value[end - 1] === char) end-- From 9ec2ed5903eb7952a4525ae7de0d53e13312cbf6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:01:53 -0400 Subject: [PATCH 02/15] fix(servers): address CodeRabbit review on linked-server automation Scope session permission modes and schedule locks by server, keep remote schedule cleanup working when a server is disabled or removed, harden the session server-scope migration and rollback, reject local server multi-run targets, widen schedule query invalidation, and update CLI fixtures for server-scoped schemas. --- .../202610181300-session-server-scope.ts | 101 ++++++++++++++-- backend/src/db/schedules.ts | 10 +- backend/src/db/session-permission-modes.ts | 8 +- backend/src/services/multi-runs.ts | 3 + backend/src/services/schedules.ts | 34 +++--- .../src/services/session-permission-modes.ts | 2 +- backend/test/db/schedules.test.ts | 27 +++++ .../db/session-server-scope-migration.test.ts | 96 +++++++++++++--- backend/test/services/multi-runs.test.ts | 26 ++++- .../test/services/schedules-remote.test.ts | 108 +++++++++++++++++- .../services/session-permission-modes.test.ts | 53 ++++++++- frontend/src/hooks/useSchedules.ts | 4 + ocm-cli/test/manager-api.test.ts | 3 + 13 files changed, 426 insertions(+), 49 deletions(-) diff --git a/backend/src/db/migrations/202610181300-session-server-scope.ts b/backend/src/db/migrations/202610181300-session-server-scope.ts index 56192a3c8..ea7431244 100644 --- a/backend/src/db/migrations/202610181300-session-server-scope.ts +++ b/backend/src/db/migrations/202610181300-session-server-scope.ts @@ -3,6 +3,13 @@ import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Migration } from '../migration-runner' const SERVER_ID_COLUMN = 'server_id' +const PERMISSION_MODES_TABLE = 'session_permission_modes' +const PERMISSION_MODES_REBUILD_TABLE = 'session_permission_modes_rebuild' + +interface ColumnInfo { + name: string + pk: number +} function tableExists(db: Database, table: string): boolean { return Boolean( @@ -10,9 +17,19 @@ function tableExists(db: Database, table: string): boolean { ) } +function tableColumns(db: Database, table: string): ColumnInfo[] { + return db.prepare(`PRAGMA table_info(${table})`).all() as ColumnInfo[] +} + function hasColumn(db: Database, table: string, column: string): boolean { - const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> - return columns.some((entry) => entry.name === column) + return tableColumns(db, table).some((entry) => entry.name === column) +} + +function primaryKeyColumns(db: Database, table: string): string[] { + return tableColumns(db, table) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name) } function addServerIdColumn(db: Database, table: string): void { @@ -29,22 +46,88 @@ function dropServerIdColumn(db: Database, table: string): void { db.run(`ALTER TABLE ${table} DROP COLUMN ${SERVER_ID_COLUMN}`) } +function deleteRemoteRows(db: Database, table: string): void { + if (!tableExists(db, table) || !hasColumn(db, table, SERVER_ID_COLUMN)) { + return + } + db.run(`DELETE FROM ${table} WHERE ${SERVER_ID_COLUMN} <> '${LOCAL_OPENCODE_SERVER_ID}'`) +} + +function rebuildPermissionModesForServers(db: Database): void { + if (!tableExists(db, PERMISSION_MODES_TABLE) || !hasColumn(db, PERMISSION_MODES_TABLE, SERVER_ID_COLUMN)) { + return + } + const primaryKey = primaryKeyColumns(db, PERMISSION_MODES_TABLE) + if (primaryKey.length === 2 && primaryKey[0] === SERVER_ID_COLUMN && primaryKey[1] === 'session_id') { + return + } + + db.run(`DROP TABLE IF EXISTS ${PERMISSION_MODES_REBUILD_TABLE}`) + db.run(` + CREATE TABLE ${PERMISSION_MODES_REBUILD_TABLE} ( + session_id TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT '${LOCAL_OPENCODE_SERVER_ID}', + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, session_id) + ) + `) + db.run(` + INSERT INTO ${PERMISSION_MODES_REBUILD_TABLE}(session_id, server_id, mode, updated_at) + SELECT session_id, ${SERVER_ID_COLUMN}, mode, updated_at FROM ${PERMISSION_MODES_TABLE} + `) + db.run(`DROP TABLE ${PERMISSION_MODES_TABLE}`) + db.run(`ALTER TABLE ${PERMISSION_MODES_REBUILD_TABLE} RENAME TO ${PERMISSION_MODES_TABLE}`) +} + +function rebuildPermissionModesForLocalSessions(db: Database): void { + if (!tableExists(db, PERMISSION_MODES_TABLE) || !hasColumn(db, PERMISSION_MODES_TABLE, SERVER_ID_COLUMN)) { + return + } + const primaryKey = primaryKeyColumns(db, PERMISSION_MODES_TABLE) + if (primaryKey.length === 1 && primaryKey[0] === 'session_id') { + return + } + + db.run(`DROP TABLE IF EXISTS ${PERMISSION_MODES_REBUILD_TABLE}`) + db.run(` + CREATE TABLE ${PERMISSION_MODES_REBUILD_TABLE} ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + db.run(` + INSERT INTO ${PERMISSION_MODES_REBUILD_TABLE}(session_id, mode, updated_at) + SELECT session_id, mode, updated_at FROM ${PERMISSION_MODES_TABLE} + WHERE ${SERVER_ID_COLUMN} = '${LOCAL_OPENCODE_SERVER_ID}' + `) + db.run(`DROP TABLE ${PERMISSION_MODES_TABLE}`) + db.run(`ALTER TABLE ${PERMISSION_MODES_REBUILD_TABLE} RENAME TO ${PERMISSION_MODES_TABLE}`) +} + const migration: Migration = { id: '202610181300-session-server-scope', up(db) { - addServerIdColumn(db, 'session_permission_modes') + addServerIdColumn(db, PERMISSION_MODES_TABLE) addServerIdColumn(db, 'session_goals') + rebuildPermissionModesForServers(db) db.run('DROP INDEX IF EXISTS idx_session_goals_open') - db.run(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open - ON session_goals(server_id, session_id) - WHERE status IN ('active','paused') - `) + if (tableExists(db, 'session_goals')) { + db.run(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_goals_open + ON session_goals(server_id, session_id) + WHERE status IN ('active','paused') + `) + } }, down(db) { + deleteRemoteRows(db, 'session_goals') + deleteRemoteRows(db, PERMISSION_MODES_TABLE) + db.run('DROP INDEX IF EXISTS idx_session_goals_open') if (tableExists(db, 'session_goals')) { db.run(` @@ -53,8 +136,8 @@ const migration: Migration = { WHERE status IN ('active','paused') `) } + rebuildPermissionModesForLocalSessions(db) dropServerIdColumn(db, 'session_goals') - dropServerIdColumn(db, 'session_permission_modes') }, } diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index 4d9e4e594..1e677d10f 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -536,9 +536,13 @@ export function getScheduleRunById(db: Database, repoId: number | null, jobId: n return row ? rowToScheduleRun(row) : null } -export function getScheduleRunBySessionId(db: Database, sessionId: string): ScheduleRun | null { - const stmt = db.prepare('SELECT * FROM schedule_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 1') - const row = stmt.get(sessionId) as ScheduleRunRow | undefined +export function getScheduleRunBySessionId( + db: Database, + sessionId: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, +): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE server_id = ? AND session_id = ? ORDER BY started_at DESC LIMIT 1') + const row = stmt.get(serverId, sessionId) as ScheduleRunRow | undefined return row ? rowToScheduleRun(row) : null } diff --git a/backend/src/db/session-permission-modes.ts b/backend/src/db/session-permission-modes.ts index 08a97c1eb..8d0302332 100644 --- a/backend/src/db/session-permission-modes.ts +++ b/backend/src/db/session-permission-modes.ts @@ -4,10 +4,11 @@ import { LOCAL_OPENCODE_SERVER_ID, type SessionPermissionMode } from '@opencode- export function ensureSessionPermissionModesTable(db: Database): void { db.run(` CREATE TABLE IF NOT EXISTS session_permission_modes ( - session_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, server_id TEXT NOT NULL DEFAULT 'local', mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + PRIMARY KEY (server_id, session_id) ) `) } @@ -32,8 +33,7 @@ export function setSessionPermissionMode( db.prepare(` INSERT INTO session_permission_modes(server_id, session_id, mode, updated_at) VALUES(?,?,?,?) - ON CONFLICT(session_id) DO UPDATE SET - server_id=excluded.server_id, + ON CONFLICT(server_id, session_id) DO UPDATE SET mode=excluded.mode, updated_at=excluded.updated_at `).run(serverId, sessionId, mode, Date.now()) diff --git a/backend/src/services/multi-runs.ts b/backend/src/services/multi-runs.ts index 76a24dde1..90890703d 100644 --- a/backend/src/services/multi-runs.ts +++ b/backend/src/services/multi-runs.ts @@ -88,6 +88,9 @@ function multiRunTargetFromRequest( request: Pick, ): MultiRunTarget { if (request.serverId !== undefined) { + if (request.serverId === LOCAL_OPENCODE_SERVER_ID) { + throw new MultiRunError('Local multi-runs must target a repository', 400) + } if (request.directory === undefined) { throw new MultiRunError('A server multi-run needs a directory', 400) } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 0d1f5d602..526c50a65 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -415,6 +415,13 @@ export class ScheduleService { } } + private resolveTargetPersistence(target: ScheduleJobTarget): { repo: Repo | null; repoId: number | null } { + if (target.kind === 'repo') { + return { repo: this.assertRepo(target.repoId), repoId: target.repoId } + } + return { repo: null, repoId: null } + } + private targetForJob(job: ScheduleJob): ScheduleJobTarget { if (job.repoId !== null) { return { kind: 'repo', repoId: job.repoId } @@ -570,7 +577,6 @@ export class ScheduleService { } listServerJobs(serverId: string): ScheduleJob[] { - this.assertServer(serverId) return listScheduleJobsByServer(this.db, serverId) } @@ -672,13 +678,13 @@ export class ScheduleService { private async deleteJobForTarget(target: ScheduleJobTarget, jobId: number): Promise { const job = this.loadJobForTarget(target, jobId) - const context = this.resolveTargetContext(target, job.directory) + const { repoId } = this.resolveTargetPersistence(target) if (ScheduleService.activeRuns.has(jobId)) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } - const runningRun = getRunningScheduleRunByJob(this.db, context.repoId, jobId) + const runningRun = getRunningScheduleRunByJob(this.db, repoId, jobId) if (runningRun) { throw new ScheduleServiceError('Cannot delete a schedule while it is running. Cancel the run first.', 409) } @@ -691,7 +697,7 @@ export class ScheduleService { await this.removeWorktrees(target.repoId, jobId) } - const deleted = deleteScheduleJob(this.db, context.repoId, jobId) + const deleted = deleteScheduleJob(this.db, repoId, jobId) if (!deleted) { throw new ScheduleServiceError('Schedule not found', 404) } @@ -781,17 +787,17 @@ export class ScheduleService { private async clearRunHistoryForTarget(target: ScheduleJobTarget, jobId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise<{ cleared: number }> { const job = this.loadJobForTarget(target, jobId) - const context = this.resolveTargetContext(target, job.directory) + const { repo, repoId } = this.resolveTargetPersistence(target) - const removable = listScheduleRunArtifactsByJob(this.db, context.repoId, jobId).filter((run) => run.status !== 'running') + const removable = listScheduleRunArtifactsByJob(this.db, repoId, jobId).filter((run) => run.status !== 'running') if (removable.length === 0) { return { cleared: 0 } } - if (context.repo) { - await this.worktreeManager.pruneRunArtifacts(context.repo, job, removable, worktrees) + if (repo) { + await this.worktreeManager.pruneRunArtifacts(repo, job, removable, worktrees) } - const cleared = deleteScheduleRunsByIds(this.db, context.repoId, jobId, removable.map((run) => run.id)) + const cleared = deleteScheduleRunsByIds(this.db, repoId, jobId, removable.map((run) => run.id)) return { cleared } } @@ -811,8 +817,8 @@ export class ScheduleService { private async deleteRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number, worktrees: ScheduleRunWorktreesMode = 'commit'): Promise { const job = this.loadJobForTarget(target, jobId) - const context = this.resolveTargetContext(target, job.directory) - const run = getScheduleRunById(this.db, context.repoId, jobId, runId) + const { repo, repoId } = this.resolveTargetPersistence(target) + const run = getScheduleRunById(this.db, repoId, jobId, runId) if (!run) { throw new ScheduleServiceError('Run not found', 404) @@ -821,10 +827,10 @@ export class ScheduleService { throw new ScheduleServiceError('Cannot delete a run while it is in progress. Cancel it first.', 409) } - if (context.repo) { - await this.worktreeManager.pruneRunArtifacts(context.repo, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) + if (repo) { + await this.worktreeManager.pruneRunArtifacts(repo, job, [{ runBranch: run.runBranch, worktreePath: run.worktreePath }], worktrees) } - const deleted = deleteScheduleRunById(this.db, context.repoId, jobId, runId) + const deleted = deleteScheduleRunById(this.db, repoId, jobId, runId) if (!deleted) { throw new ScheduleServiceError('Run not found', 404) } diff --git a/backend/src/services/session-permission-modes.ts b/backend/src/services/session-permission-modes.ts index fed1eb175..4514364dd 100644 --- a/backend/src/services/session-permission-modes.ts +++ b/backend/src/services/session-permission-modes.ts @@ -209,7 +209,7 @@ export class SessionPermissionModeService { rootSessionId: string, serverId: string, ): SessionLockReason | null { - if (serverId === LOCAL_OPENCODE_SERVER_ID && getScheduleRunBySessionId(this.db, rootSessionId)) { + if (getScheduleRunBySessionId(this.db, rootSessionId, serverId)) { return 'schedule' } diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index de497112f..7f8e51e6c 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -544,6 +544,33 @@ describe('schedule database queries', () => { expect(run).toBeNull() }) + it('scopes the run lookup by session id and server id', () => { + const stmt = { + get: vi.fn().mockReturnValue(makeRunRow({ server_id: 'srv_kitchen' })), + } + mockDb.prepare.mockReturnValue(stmt) + + const run = schedulesDb.getScheduleRunBySessionId(mockDb, 'ses-1', 'srv_kitchen') + + expect(mockDb.prepare).toHaveBeenCalledWith( + 'SELECT * FROM schedule_runs WHERE server_id = ? AND session_id = ? ORDER BY started_at DESC LIMIT 1', + ) + expect(stmt.get).toHaveBeenCalledWith('srv_kitchen', 'ses-1') + expect(run).toMatchObject({ id: 5, sessionId: 'ses-1' }) + }) + + it('defaults the run lookup to the local server', () => { + const stmt = { + get: vi.fn().mockReturnValue(undefined), + } + mockDb.prepare.mockReturnValue(stmt) + + const run = schedulesDb.getScheduleRunBySessionId(mockDb, 'ses-1') + + expect(stmt.get).toHaveBeenCalledWith('local', 'ses-1') + expect(run).toBeNull() + }) + it('lists all runs with context and no filters', () => { const row = { ...makeRunRow(), diff --git a/backend/test/db/session-server-scope-migration.test.ts b/backend/test/db/session-server-scope-migration.test.ts index 507c799d5..2025aed30 100644 --- a/backend/test/db/session-server-scope-migration.test.ts +++ b/backend/test/db/session-server-scope-migration.test.ts @@ -4,7 +4,7 @@ import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import migration from '../../src/db/migrations/202610181300-session-server-scope' import { getSessionGoalById, insertSessionGoal } from '../../src/db/session-goals' -import { getSessionPermissionMode } from '../../src/db/session-permission-modes' +import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' function createTestDb(): Database { const db = new Database(':memory:') @@ -53,12 +53,32 @@ function tableColumns(db: Database, table: string): string[] { return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((column) => column.name) } +function primaryKeyColumns(db: Database, table: string): string[] { + return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string; pk: number }>) + .filter((column) => column.pk > 0) + .sort((left, right) => left.pk - right.pk) + .map((column) => column.name) +} + function openGoalIndexColumns(db: Database): string[] { return (db.prepare("PRAGMA index_info('idx_session_goals_open')").all() as Array<{ name: string }>).map( (column) => column.name, ) } +function countRows(db: Database, table: string): number { + return (db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count +} + +const GOAL_INPUT = { + sessionId: 'ses_shared', + directory: '/repo', + objective: 'Ship it', + maxContinuations: 1, + tokenBudget: null, + tokensAtStart: 0, +} + describe('202610181300 session server scope migration', () => { let db: Database @@ -77,24 +97,70 @@ describe('202610181300 session server scope migration', () => { }) it('scopes the open goal index by server', () => { - const input = { - sessionId: 'ses_shared', - directory: '/repo', - objective: 'Ship it', - maxContinuations: 1, - tokenBudget: null, - tokensAtStart: 0, - } - - const local = insertSessionGoal(db, input) - const remote = insertSessionGoal(db, { ...input, serverId: 'srv_remote' }) - const duplicateRemote = insertSessionGoal(db, { ...input, serverId: 'srv_remote' }) + const local = insertSessionGoal(db, GOAL_INPUT) + const remote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) + const duplicateRemote = insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_remote' }) expect(local).not.toBeNull() expect(remote).not.toBeNull() expect(duplicateRemote).toBeNull() }) + it('keys permission modes by server and session', () => { + expect(primaryKeyColumns(db, 'session_permission_modes')).toEqual(['server_id', 'session_id']) + + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + + expect(getSessionPermissionMode(db, 'ses_shared')).toBe('auto') + expect(getSessionPermissionMode(db, 'ses_shared', 'srv_x')).toBe('ask') + expect(countRows(db, 'session_permission_modes')).toBe(2) + }) + + it('updates only the mode of the matching server and session', () => { + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + setSessionPermissionMode(db, 'ses_shared', 'ask') + + expect(getSessionPermissionMode(db, 'ses_shared')).toBe('ask') + expect(getSessionPermissionMode(db, 'ses_shared', 'srv_x')).toBe('ask') + expect(countRows(db, 'session_permission_modes')).toBe(2) + }) + + it('applies without the session_goals table', () => { + const standaloneDb = new Database(':memory:') + standaloneDb.run(` + CREATE TABLE session_permission_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL CHECK(mode IN ('ask','auto')), + updated_at INTEGER NOT NULL + ) + `) + + expect(() => migration.up(standaloneDb)).not.toThrow() + expect(primaryKeyColumns(standaloneDb, 'session_permission_modes')).toEqual(['server_id', 'session_id']) + + standaloneDb.close() + }) + + it('removes remote rows and restores session-scoped keys when rolled back', () => { + insertSessionGoal(db, GOAL_INPUT) + insertSessionGoal(db, { ...GOAL_INPUT, serverId: 'srv_x' }) + setSessionPermissionMode(db, 'ses_shared', 'auto') + setSessionPermissionMode(db, 'ses_shared', 'ask', 'srv_x') + + expect(() => migration.down(db)).not.toThrow() + + expect(openGoalIndexColumns(db)).toEqual(['session_id']) + expect(tableColumns(db, 'session_goals')).not.toContain('server_id') + expect(primaryKeyColumns(db, 'session_permission_modes')).toEqual(['session_id']) + expect(countRows(db, 'session_goals')).toBe(1) + expect(countRows(db, 'session_permission_modes')).toBe(1) + expect(db.prepare('SELECT session_id, mode FROM session_permission_modes').all()).toEqual([ + { session_id: 'ses_shared', mode: 'auto' }, + ]) + }) + it('upgrades legacy tables, keeps existing rows local and tolerates a second run', () => { const legacyDb = new Database(':memory:') createLegacyTables(legacyDb) @@ -110,12 +176,16 @@ describe('202610181300 session server scope migration', () => { const legacyGoalId = (legacyDb.prepare('SELECT id FROM session_goals LIMIT 1').get() as { id: number }).id migration.up(legacyDb) + setSessionPermissionMode(legacyDb, 'ses_root', 'ask', 'srv_x') migration.up(legacyDb) expect(tableColumns(legacyDb, 'session_permission_modes')).toContain('server_id') expect(tableColumns(legacyDb, 'session_goals')).toContain('server_id') expect(openGoalIndexColumns(legacyDb)).toEqual(['server_id', 'session_id']) + expect(primaryKeyColumns(legacyDb, 'session_permission_modes')).toEqual(['server_id', 'session_id']) expect(getSessionPermissionMode(legacyDb, 'ses_root')).toBe('auto') + expect(getSessionPermissionMode(legacyDb, 'ses_root', 'srv_x')).toBe('ask') + expect(countRows(legacyDb, 'session_permission_modes')).toBe(2) expect(getSessionGoalById(legacyDb, legacyGoalId)).toMatchObject({ serverId: 'local' }) legacyDb.close() diff --git a/backend/test/services/multi-runs.test.ts b/backend/test/services/multi-runs.test.ts index 53465799d..81483ff4b 100644 --- a/backend/test/services/multi-runs.test.ts +++ b/backend/test/services/multi-runs.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' -import { buildSchedulePermissionRuleset } from '@opencode-manager/shared/schemas' +import { buildSchedulePermissionRuleset, LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' import { createRepo, deleteRepo } from '../../src/db/queries' import { migrate } from '../../src/db/migration-runner' @@ -332,6 +332,30 @@ describe('MultiRunService', () => { expect(error).toMatchObject({ status: 404 }) }) + it('rejects a local server target because it stores no repository', async () => { + const repoId = readyRepo() + const { client, sessionCreate } = createClient() + const repoWorkspaces = createRepoWorkspaces() + const service = createService(client, repoWorkspaces.service) + + const error = await service + .launch({ + serverId: LOCAL_OPENCODE_SERVER_ID, + directory: REPO_DIR, + name: 'Sweep', + prompt: 'go', + models: ['openai/a'], + isolate: false, + }) + .catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(MultiRunError) + expect(error).toMatchObject({ status: 400 }) + expect(sessionCreate).not.toHaveBeenCalled() + expect(repoWorkspaces.create).not.toHaveBeenCalled() + expect(service.list({ kind: 'repo', repoId })).toHaveLength(0) + }) + it('removes the workspace when discarding an isolated entry', async () => { const repoId = readyRepo() const { client } = createClient() diff --git a/backend/test/services/schedules-remote.test.ts b/backend/test/services/schedules-remote.test.ts index 5ef18b753..81f6ed329 100644 --- a/backend/test/services/schedules-remote.test.ts +++ b/backend/test/services/schedules-remote.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' -import { getScheduleJobById, listScheduleRunsByJob } from '../../src/db/schedules' +import { getScheduleJobById, createScheduleRun, listScheduleRunsByJob } from '../../src/db/schedules' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' import { ScheduleService } from '../../src/services/schedules' import { createStubOpenCodeClient, stubLoadedModelCatalog } from '../helpers/stub-opencode-client' @@ -44,7 +44,7 @@ function createClient() { return { client: createStubOpenCodeClient({ api }), api: stub.api } } -function createService(db: Database, localClient: ReturnType, remoteClient: ReturnType, enabled = true) { +function createService(db: Database, localClient: ReturnType, remoteClient: ReturnType, enabled = true, registered = true) { const worktreeManager = { prepare: vi.fn().mockResolvedValue(null), finalize: vi.fn().mockResolvedValue({ commitHash: null }), @@ -53,7 +53,7 @@ function createService(db: Database, localClient: ReturnType ({ ...REMOTE_SERVER, enabled })), + get: vi.fn(() => (registered ? { ...REMOTE_SERVER, enabled } : undefined)), getClient: vi.fn(() => remoteClient), } const service = new ScheduleService(db, localClient, worktreeManager as never, registry as never) @@ -156,4 +156,106 @@ describe('remote schedule targets', () => { const runs = service.listAllRuns() expect(runs[0]).toMatchObject({ serverId: REMOTE_SERVER.id, serverName: REMOTE_SERVER.name }) }) + + it('lists remote jobs for a disabled server and for an unregistered server', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + const disabled = createService(db, localClient, client, false) + expect(disabled.service.listServerJobs(REMOTE_SERVER.id).map((entry) => entry.id)).toEqual([job.id]) + + const unregistered = createService(db, localClient, client, true, false) + expect(unregistered.service.listServerJobs(REMOTE_SERVER.id).map((entry) => entry.id)).toEqual([job.id]) + }) + + it('deletes a remote job while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await createService(db, localClient, client, false).service.deleteServerJob(REMOTE_SERVER.id, job.id) + + expect(getScheduleJobById(db, null, job.id)).toBeNull() + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote job when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await createService(db, localClient, client, true, false).service.deleteServerJob(REMOTE_SERVER.id, job.id) + + expect(getScheduleJobById(db, null, job.id)).toBeNull() + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('clears remote run history while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + seedCompletedRun(db, job.id) + + await expect(createService(db, localClient, client, false).service.clearServerRunHistory(REMOTE_SERVER.id, job.id)) + .resolves.toEqual({ cleared: 2 }) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('clears remote run history when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + seedCompletedRun(db, job.id) + + await expect(createService(db, localClient, client, true, false).service.clearServerRunHistory(REMOTE_SERVER.id, job.id)) + .resolves.toEqual({ cleared: 1 }) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote run while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const runId = seedCompletedRun(db, job.id) + + await createService(db, localClient, client, false).service.deleteServerRun(REMOTE_SERVER.id, job.id, runId) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) + + it('deletes a remote run when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const runId = seedCompletedRun(db, job.id) + + await createService(db, localClient, client, true, false).service.deleteServerRun(REMOTE_SERVER.id, job.id, runId) + + expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) + }) }) + +function seedCompletedRun(db: Database, jobId: number): number { + return createScheduleRun(db, { + jobId, + repoId: null, + serverId: REMOTE_SERVER.id, + triggerSource: 'schedule', + status: 'completed', + startedAt: Date.now(), + createdAt: Date.now(), + }).id +} diff --git a/backend/test/services/session-permission-modes.test.ts b/backend/test/services/session-permission-modes.test.ts index 589bca0a3..057f86023 100644 --- a/backend/test/services/session-permission-modes.test.ts +++ b/backend/test/services/session-permission-modes.test.ts @@ -3,7 +3,7 @@ import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' import { createRepo } from '../../src/db/queries' -import { createScheduleRun, updateScheduleRunMetadata } from '../../src/db/schedules' +import { createScheduleRun, getScheduleRunBySessionId, updateScheduleRunMetadata } from '../../src/db/schedules' import { getSessionPermissionMode, setSessionPermissionMode } from '../../src/db/session-permission-modes' import { SettingsService } from '../../src/services/settings' import { SessionPermissionModeService } from '../../src/services/session-permission-modes' @@ -670,6 +670,57 @@ describe('SessionPermissionModeService', () => { }) }) + it('reports the schedule lock for a remote scheduled session even with a stored auto mode', async () => { + const run = createScheduleRun(db, { + jobId: 1, + repoId: null, + serverId: 'srv_remote', + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, null, 1, run.id, { sessionId: 'ses_scheduled' }) + setSessionPermissionMode(db, 'ses_scheduled', 'auto', 'srv_remote') + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const remote = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: remote }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled', 'srv_remote')).resolves.toEqual({ + sessionId: 'ses_scheduled', + rootSessionId: 'ses_scheduled', + mode: 'ask', + lockedReason: 'schedule', + }) + await expect(service.setMode('ses_scheduled', 'auto', DIRECTORY, 'srv_remote')).rejects.toMatchObject({ status: 409 }) + }) + + it('does not lock a local session that matches only a remote schedule run', async () => { + const run = createScheduleRun(db, { + jobId: 1, + repoId: null, + serverId: 'srv_remote', + triggerSource: 'schedule', + status: 'running', + startedAt: Date.now(), + createdAt: Date.now(), + }) + updateScheduleRunMetadata(db, null, 1, run.id, { sessionId: 'ses_scheduled' }) + + expect(getScheduleRunBySessionId(db, 'ses_scheduled')).toBeNull() + + const local = createFakeSessionPermissionClient({ parents: { ses_scheduled: null } }) + const service = createMultiServerService(db, { local, srv_remote: createFakeSessionPermissionClient() }, new SettingsService(db)) + + await expect(service.getEffectiveMode('ses_scheduled')).resolves.toEqual({ + sessionId: 'ses_scheduled', + rootSessionId: 'ses_scheduled', + mode: 'ask', + lockedReason: null, + }) + }) + it('suppresses permission notifications for remote auto sessions', async () => { setSessionPermissionMode(db, 'ses_root', 'auto', 'srv_remote') const local = createFakeSessionPermissionClient() diff --git a/frontend/src/hooks/useSchedules.ts b/frontend/src/hooks/useSchedules.ts index 1a7765e5f..098255ab1 100644 --- a/frontend/src/hooks/useSchedules.ts +++ b/frontend/src/hooks/useSchedules.ts @@ -41,10 +41,14 @@ export type ScheduleTargetRef = function invalidateScheduleQueries(queryClient: ReturnType): void { queryClient.invalidateQueries({ queryKey: ['all-schedules'] }) queryClient.invalidateQueries({ queryKey: ['all-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedules'] }) queryClient.invalidateQueries({ queryKey: ['repo-schedule'] }) queryClient.invalidateQueries({ queryKey: ['repo-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['repo-schedule-run'] }) queryClient.invalidateQueries({ queryKey: ['server-schedule'] }) queryClient.invalidateQueries({ queryKey: ['server-schedule-runs'] }) + queryClient.invalidateQueries({ queryKey: ['server-schedule-run'] }) + queryClient.invalidateQueries({ queryKey: ['schedule-run-detail'] }) } export function useAllSchedules() { diff --git a/ocm-cli/test/manager-api.test.ts b/ocm-cli/test/manager-api.test.ts index 00668cebf..a47643608 100644 --- a/ocm-cli/test/manager-api.test.ts +++ b/ocm-cli/test/manager-api.test.ts @@ -7,6 +7,7 @@ const BASE_URL = 'http://localhost:5003' const goal: SessionGoal = { id: 7, sessionId: 'ses_1', + serverId: 'local', directory: '/repo', objective: 'fix the flaky test', status: 'active', @@ -27,6 +28,8 @@ const goal: SessionGoal = { const run: MultiRun = { id: 3, repoId: 1, + serverId: 'local', + directory: null, name: 'sweep', prompt: 'hello', isolated: true, From 7874afdab90b1ddd08ace7c054b603bc314564fd Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:46:14 -0400 Subject: [PATCH 03/15] feat(servers): bring linked-server sessions to parity with local Add a read-only Source Control panel for linked servers backed by the OpenCode VCS API, broadcast remote events to every client, include recent linked-server sessions in Activity with per-server sidebar indicators, and enable the session picker, tool shortcuts, mobile activity, remote Skills panel and remote permission prompt titles. --- backend/src/services/sse-aggregator.ts | 20 +- .../services/remote-server-events.test.ts | 15 +- .../services/sse-aggregator-remote.test.ts | 23 +- frontend/src/App.permission-dialog.test.tsx | 36 ++- frontend/src/App.tsx | 13 +- frontend/src/api/opencode.ts | 30 ++ .../activity/ActivityStateIndicator.tsx | 30 ++ .../activity/RepoActivityIndicator.tsx | 17 +- .../components/file-browser/FileDiffView.tsx | 133 +++++---- .../navigation/LinkedServersNav.test.tsx | 34 +++ .../navigation/LinkedServersNav.tsx | 90 +++--- .../components/navigation/MoreDrawer.test.tsx | 6 +- .../src/components/navigation/MoreDrawer.tsx | 7 +- .../navigation/RepoQuickSwitchSheet.test.tsx | 1 + .../navigation/ToolSidePanel.test.tsx | 38 ++- .../components/navigation/ToolSidePanel.tsx | 45 +-- .../navigation/moreDrawerItems.test.ts | 11 +- .../components/navigation/moreDrawerItems.ts | 2 +- .../src/components/repo/RepoSkillsDialog.tsx | 2 +- .../RemoteSourceControlContent.test.tsx | 114 ++++++++ .../RemoteSourceControlContent.tsx | 258 ++++++++++++++++++ .../source-control/SourceControlPanel.tsx | 36 ++- .../src/components/source-control/index.ts | 1 + frontend/src/contexts/EventContext.tsx | 15 +- frontend/src/hooks/useActiveWork.test.tsx | 71 ++++- frontend/src/hooks/useActiveWork.ts | 144 ++++++++-- frontend/src/hooks/useCommands.test.tsx | 6 +- .../hooks/useGlobalShortcutActions.test.tsx | 39 ++- .../src/hooks/useGlobalShortcutActions.ts | 38 ++- frontend/src/hooks/useOpenCode.test.tsx | 13 + frontend/src/hooks/useOpenCode.ts | 8 +- frontend/src/lib/activeWork.test.ts | 159 ++++++++++- frontend/src/lib/activeWork.ts | 190 ++++++++++--- frontend/src/lib/builtinCommands.ts | 4 +- frontend/src/lib/queryInvalidation.test.ts | 30 ++ frontend/src/lib/queryInvalidation.ts | 56 +++- frontend/src/pages/SessionDetail.tsx | 29 +- .../__tests__/SessionDetail.remote.test.tsx | 23 +- 38 files changed, 1507 insertions(+), 280 deletions(-) create mode 100644 frontend/src/components/activity/ActivityStateIndicator.tsx create mode 100644 frontend/src/components/source-control/RemoteSourceControlContent.test.tsx create mode 100644 frontend/src/components/source-control/RemoteSourceControlContent.tsx diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 41b33d1d4..298152da5 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -520,12 +520,17 @@ class SSEAggregator { this.notifyServerEvent(LOCAL_OPENCODE_SERVER_ID, directory, event) } - private writeEnvelopeToClients(clientIds: Iterable, directory: string | null, payloadJson: string): void { + private writeEnvelopeToClients( + clientIds: Iterable, + directory: string | null, + payloadJson: string, + serverId?: string, + ): void { let frame: Uint8Array | null = null for (const clientId of clientIds) { const client = this.clients.get(clientId) if (!client) continue - frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson)) + frame ??= encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) this.writeFrame(client, frame, 'Failed to send to client') } } @@ -543,16 +548,7 @@ class SSEAggregator { } deliverRemoteEnvelope(serverId: string, directory: string | null, payloadJson: string): void { - const clientIds = directory === null - ? this.clientIdsForServer(serverId) - : Array.from(this.directoryClients.get(buildServerDirectoryKey(serverId, directory)) ?? []) - if (clientIds.length === 0) return - const frame = encodeSSEFrame('message', serializeEnvelope(directory, payloadJson, serverId)) - for (const clientId of clientIds) { - const client = this.clients.get(clientId) - if (!client) continue - this.writeFrame(client, frame, 'Failed to send remote event to client') - } + this.writeEnvelopeToClients(this.clients.keys(), directory, payloadJson, serverId) } deliverRemoteEnvelopeToClient(clientId: string, serverId: string, directory: string, payloadJson: string): void { diff --git a/backend/test/services/remote-server-events.test.ts b/backend/test/services/remote-server-events.test.ts index 0fbb78035..67a279794 100644 --- a/backend/test/services/remote-server-events.test.ts +++ b/backend/test/services/remote-server-events.test.ts @@ -234,7 +234,7 @@ describe('RemoteServerEventHub event delivery', () => { expect(sources.sources[1]?.init).toBeUndefined() }) - it('delivers a located event only to clients subscribed to that server and directory, with a serverId envelope', () => { + it('broadcasts a located event to every client with a serverId envelope', () => { const remoteClient = createCapturingClient() const otherServerClient = createCapturingClient() const localClient = createCapturingClient() @@ -252,7 +252,7 @@ describe('RemoteServerEventHub event delivery', () => { source.message(remoteEvent('session.idle', { sessionID: 'ses_1' }, '/repo')) - expect(remoteClient.frames.map(parseFrame)).toEqual([{ + const envelope = { directory: '/repo', serverId: 'srv_a', payload: { @@ -262,9 +262,10 @@ describe('RemoteServerEventHub event delivery', () => { location: { directory: '/repo' }, data: { sessionID: 'ses_1' }, }, - }]) - expect(otherServerClient.frames).toHaveLength(0) - expect(localClient.frames).toHaveLength(0) + } + expect(remoteClient.frames.map(parseFrame)).toEqual([envelope]) + expect(otherServerClient.frames.map(parseFrame)).toEqual([envelope]) + expect(localClient.frames.map(parseFrame)).toEqual([envelope]) expect(listener).not.toHaveBeenCalled() expect(sseAggregator.getActiveSessions()).toEqual({}) }) @@ -321,7 +322,7 @@ describe('RemoteServerEventHub event delivery', () => { }]) }) - it('falls back to every client holding any key of the server when the directory is unknown', () => { + it('broadcasts to every client when the directory is unknown', () => { const subscribedA = createCapturingClient() const otherDirectoryA = createCapturingClient() const subscribedB = createCapturingClient() @@ -339,7 +340,7 @@ describe('RemoteServerEventHub event delivery', () => { } expect(subscribedA.frames.map(parseFrame)).toEqual([envelope]) expect(otherDirectoryA.frames.map(parseFrame)).toEqual([envelope]) - expect(subscribedB.frames).toHaveLength(0) + expect(subscribedB.frames.map(parseFrame)).toEqual([envelope]) }) it('keeps the raw upstream payload in the remote envelope and re-serializes multi-line payloads', () => { diff --git a/backend/test/services/sse-aggregator-remote.test.ts b/backend/test/services/sse-aggregator-remote.test.ts index 66cc60f5f..5e9fbbb61 100644 --- a/backend/test/services/sse-aggregator-remote.test.ts +++ b/backend/test/services/sse-aggregator-remote.test.ts @@ -75,24 +75,29 @@ describe('SSEAggregator remote envelope delivery', () => { sseAggregator.setScheduledSessionsResolver(() => []) }) - it('delivers a remote envelope only to clients subscribed to that server and directory', () => { + it('broadcasts a located remote envelope to every connected client whatever they are subscribed to', () => { const subscribed = createCapturingClient() const otherDirectory = createCapturingClient() + const otherServer = createCapturingClient() const local = createCapturingClient() + const unsubscribed = createCapturingClient() sseAggregator.addClient('remote-key', subscribed.callback, subscribed.writeFrame, ['srv_a|/repo']) sseAggregator.addClient('other-dir', otherDirectory.callback, otherDirectory.writeFrame, ['srv_a|/other']) + sseAggregator.addClient('other-server', otherServer.callback, otherServer.writeFrame, ['srv_b|/repo']) sseAggregator.addClient('local-dir', local.callback, local.writeFrame, ['/repo']) + sseAggregator.addClient('no-directories', unsubscribed.callback, unsubscribed.writeFrame, []) sseAggregator.deliverRemoteEnvelope('srv_a', '/repo', PAYLOAD) - expect(subscribed.frames).toEqual([ - `event: message\ndata: {"directory":"/repo","serverId":"srv_a","payload":${PAYLOAD}}\n\n`, - ]) - expect(otherDirectory.frames).toHaveLength(0) - expect(local.frames).toHaveLength(0) + const expected = { directory: '/repo', serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } + expect(subscribed.frames.map(parseFrame)).toEqual([expected]) + expect(otherDirectory.frames.map(parseFrame)).toEqual([expected]) + expect(otherServer.frames.map(parseFrame)).toEqual([expected]) + expect(local.frames.map(parseFrame)).toEqual([expected]) + expect(unsubscribed.frames.map(parseFrame)).toEqual([expected]) }) - it('broadcasts a directory-less remote envelope to every client holding any key of that server', () => { + it('broadcasts a directory-less remote envelope to every connected client', () => { const first = createCapturingClient() const second = createCapturingClient() const otherServer = createCapturingClient() @@ -107,8 +112,8 @@ describe('SSEAggregator remote envelope delivery', () => { const expected = { directory: null, serverId: 'srv_a', payload: JSON.parse(PAYLOAD) } expect(first.frames.map(parseFrame)).toEqual([expected]) expect(second.frames.map(parseFrame)).toEqual([expected]) - expect(otherServer.frames).toHaveLength(0) - expect(local.frames).toHaveLength(0) + expect(otherServer.frames.map(parseFrame)).toEqual([expected]) + expect(local.frames.map(parseFrame)).toEqual([expected]) }) it('skips a targeted remote envelope once the client unsubscribed', () => { diff --git a/frontend/src/App.permission-dialog.test.tsx b/frontend/src/App.permission-dialog.test.tsx index df89ba600..86a7edfa9 100644 --- a/frontend/src/App.permission-dialog.test.tsx +++ b/frontend/src/App.permission-dialog.test.tsx @@ -1,11 +1,12 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { PermissionRequest } from '@opencode-manager/shared/opencode' const mocks = vi.hoisted(() => ({ usePermissions: vi.fn(), getRepoIdForSession: vi.fn(), - useSession: vi.fn(), + sessionQueryOptions: vi.fn(), navigateToCurrent: vi.fn(), PermissionRequestDialog: vi.fn(), })) @@ -15,7 +16,7 @@ vi.mock('@/contexts/EventContext', () => ({ useEventContext: () => ({ getRepoIdForSession: mocks.getRepoIdForSession }), })) -vi.mock('@/hooks/useOpenCode', () => ({ useSession: mocks.useSession })) +vi.mock('@/hooks/useOpenCode', () => ({ sessionQueryOptions: mocks.sessionQueryOptions })) vi.mock('@/components/session/PermissionRequestDialog', () => ({ PermissionRequestDialog: mocks.PermissionRequestDialog, @@ -50,6 +51,7 @@ const localPermission: PendingPermission = { interface DialogProps { permission: PermissionRequest | null + sessionTitle?: string onOpenSession?: () => void } @@ -66,7 +68,12 @@ function renderWrapper(permission: PendingPermission) { currentIsFromDifferentSession: false, }) - render() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) return mocks.PermissionRequestDialog.mock.calls.at(-1)?.[0] as DialogProps } @@ -74,7 +81,11 @@ function renderWrapper(permission: PendingPermission) { describe('PermissionDialogWrapper', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useSession.mockReturnValue({ data: undefined }) + mocks.sessionQueryOptions.mockImplementation((sessionID: string | undefined, directory?: string, serverId?: string) => ({ + queryKey: ['session', sessionID ?? null, directory ?? null, serverId ?? null], + queryFn: async () => ({ title: 'Kitchen deploy' }), + enabled: !!sessionID, + })) mocks.PermissionRequestDialog.mockReturnValue(
) }) @@ -86,7 +97,18 @@ describe('PermissionDialogWrapper', () => { expect(props.permission).toEqual(remotePermission) expect(props.onOpenSession).toBe(mocks.navigateToCurrent) expect(mocks.getRepoIdForSession).not.toHaveBeenCalled() - expect(mocks.useSession).toHaveBeenCalledWith(undefined, '/srv/app') + expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_remote', '/srv/app', 'srv_kitchen') + }) + + it('resolves the remote session title from the permission server', async () => { + mocks.getRepoIdForSession.mockReturnValue(null) + + renderWrapper(remotePermission) + + await waitFor(() => { + const latest = mocks.PermissionRequestDialog.mock.calls.at(-1)?.[0] as DialogProps + expect(latest.sessionTitle).toBe('Kitchen deploy') + }) }) it('offers the session for a local permission resolved through its Manager repo', () => { @@ -96,7 +118,7 @@ describe('PermissionDialogWrapper', () => { expect(props.onOpenSession).toBe(mocks.navigateToCurrent) expect(mocks.getRepoIdForSession).toHaveBeenCalledWith('ses_local') - expect(mocks.useSession).toHaveBeenCalledWith('ses_local', '/srv/app') + expect(mocks.sessionQueryOptions).toHaveBeenCalledWith('ses_local', '/srv/app', 'local') }) it('hides the session action for a local permission without a Manager repo', () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f020528b6..a6a71151a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation, useParams, Navigate } from 'react-router-dom' import { useEffect, useRef, useCallback, type ReactNode } from 'react' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' @@ -40,7 +40,7 @@ import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions' -import { useSession } from '@/hooks/useOpenCode' +import { sessionQueryOptions } from '@/hooks/useOpenCode' import { RestartServerDialog } from '@/components/settings/RestartServerDialog' const queryClient = new QueryClient({ @@ -115,9 +115,12 @@ export function PermissionDialogWrapper() { } = usePermissions() const { getRepoIdForSession } = useEventContext() const isRemotePermission = currentPermission !== null && !isLocalOpenCodeServer(currentPermission.serverId) - const { data: session } = useSession( - isRemotePermission ? undefined : currentPermission?.sessionID, - currentDirectory ?? undefined, + const { data: session } = useQuery( + sessionQueryOptions( + currentPermission?.sessionID, + currentDirectory ?? undefined, + currentPermission?.serverId ?? LOCAL_OPENCODE_SERVER_ID, + ), ) const canOpenCurrentSession = currentPermission !== null diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index 84a10121d..e8ba22f22 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -37,6 +37,14 @@ export type OpenCodeLocationInfo = Awaited>[number] +export type VcsInfo = Awaited>['data'] + +export type VcsFileStatus = Awaited>['data'][number] + +export type VcsFileDiff = Awaited>['data'][number] + +export type VcsDiffMode = 'working' | 'branch' | 'committed' + export interface SessionPage { items: SessionInfo[] nextCursor?: string @@ -333,6 +341,28 @@ export async function listSkills(directory?: string, serverId?: string): Promise return data } +export async function getVcsInfo(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.vcs.get(openCodeLocation(directory)), serverId) + return data +} + +export async function listVcsStatus(directory: string, serverId?: string): Promise { + const { data } = await callOpenCode((api) => api.vcs.status(openCodeLocation(directory)), serverId) + return data +} + +export async function getVcsDiff( + directory: string, + mode: VcsDiffMode, + serverId?: string, +): Promise { + const { data } = await callOpenCode( + (api) => api.vcs.diff({ ...openCodeLocation(directory), mode }), + serverId, + ) + return data +} + export async function listPendingPermissions( directory: string, serverId?: string, diff --git a/frontend/src/components/activity/ActivityStateIndicator.tsx b/frontend/src/components/activity/ActivityStateIndicator.tsx new file mode 100644 index 000000000..f235a5c83 --- /dev/null +++ b/frontend/src/components/activity/ActivityStateIndicator.tsx @@ -0,0 +1,30 @@ +import { ActivitySpinner } from '@/components/ui/activity-spinner' +import { cn } from '@/lib/utils' + +/** + * Renders nothing unless a repo or linked server has an active session, then shows the braille + * spinner in the state colour (info while running, warning when a session needs input). + */ +export function ActivityStateIndicator({ + state, + className, +}: { + state: 'needs-input' | 'running' | null + className?: string +}) { + if (state === null) return null + + return ( + + + + ) +} diff --git a/frontend/src/components/activity/RepoActivityIndicator.tsx b/frontend/src/components/activity/RepoActivityIndicator.tsx index e1de6906a..e2fcae2a2 100644 --- a/frontend/src/components/activity/RepoActivityIndicator.tsx +++ b/frontend/src/components/activity/RepoActivityIndicator.tsx @@ -1,23 +1,12 @@ -import { ActivitySpinner } from '@/components/ui/activity-spinner' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { useRepoActivityState } from '@/hooks/useActiveWork' -import { cn } from '@/lib/utils' import type { Repo } from '@/api/types' /** - * Renders nothing unless a session in the repo is active, then shows the braille spinner in the - * state colour (info while running, warning when a session needs input). + * Shows the repo's activity spinner while a session in it needs input or is running. */ export function RepoActivityIndicator({ repo, className }: { repo: Repo; className?: string }) { const state = useRepoActivityState(repo) - if (state === null) return null - return ( - - - - ) + return } diff --git a/frontend/src/components/file-browser/FileDiffView.tsx b/frontend/src/components/file-browser/FileDiffView.tsx index c4193ba23..481b02cfc 100644 --- a/frontend/src/components/file-browser/FileDiffView.tsx +++ b/frontend/src/components/file-browser/FileDiffView.tsx @@ -28,6 +28,19 @@ interface FileDiffViewProps { isMobile?: boolean; } +export interface FileDiffContentProps { + filePath: string; + diff: string | null; + status: GitFileStatusType; + additions: number; + deletions: number; + isBinary?: boolean; + onBack?: () => void; + onClose?: () => void; + onOpenFile?: (path: string, lineNumber?: number) => void; + isMobile?: boolean; +} + const statusConfig: Record< GitFileStatusType, { icon: typeof FileText; color: string; bgColor: string; label: string } @@ -70,54 +83,24 @@ const statusConfig: Record< }, }; -export function FileDiffView({ - repoId, +export function FileDiffContent({ filePath, - includeStaged, - commitHash, + diff, + status, + additions, + deletions, + isBinary = false, onBack, onClose, onOpenFile, isMobile = false, -}: FileDiffViewProps) { - const workingDiff = useFileDiff(repoId, filePath, includeStaged); - const commitDiff = useCommitFileDiff(repoId, commitHash, filePath); - const { data: diffData, isLoading, error } = commitHash ? commitDiff : workingDiff; - +}: FileDiffContentProps) { const fileName = filePath.split("/").pop() || filePath; const dirPath = filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : ""; - if (isLoading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
- -

Failed to load diff

-

{error.message}

- {onBack && ( - - )} -
- ); - } - - if (!diffData) { - return null; - } - - const config = statusConfig[diffData.status]; + const config = statusConfig[status]; const Icon = config.icon; return ( @@ -178,15 +161,15 @@ export function FileDiffView({ > {config.label} - {!diffData.isBinary && ( + {!isBinary && ( <> - +{diffData.additions} - -{diffData.deletions} + +{additions} + -{deletions} )} - {diffData.diff && ( + {diff && (
- {diffData.isBinary ? ( + {isBinary ? (

Binary file - cannot display diff

- ) : !diffData.diff ? ( + ) : !diff ? (

No changes to display

) : (
); } + +export function FileDiffView({ + repoId, + filePath, + includeStaged, + commitHash, + onBack, + onClose, + onOpenFile, + isMobile = false, +}: FileDiffViewProps) { + const workingDiff = useFileDiff(repoId, filePath, includeStaged); + const commitDiff = useCommitFileDiff(repoId, commitHash, filePath); + const { data: diffData, isLoading, error } = commitHash ? commitDiff : workingDiff; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Failed to load diff

+

{error.message}

+ {onBack && ( + + )} +
+ ); + } + + if (!diffData) { + return null; + } + + return ( + + ); +} diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index a206b8be9..009e02f94 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -6,6 +6,7 @@ import { LinkedServersNav } from './LinkedServersNav' const mocks = vi.hoisted(() => ({ useOpenCodeServers: vi.fn(), useOpenCodeServerStatuses: vi.fn(), + useServerActivityState: vi.fn(), })) vi.mock('@/hooks/useOpenCodeServers', () => ({ @@ -13,6 +14,10 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, })) +vi.mock('@/hooks/useActiveWork', () => ({ + useServerActivityState: mocks.useServerActivityState, +})) + const kitchen = { id: 'srv_kitchen', name: 'Kitchen', @@ -46,6 +51,7 @@ describe('LinkedServersNav', () => { beforeEach(() => { vi.clearAllMocks() mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + mocks.useServerActivityState.mockReturnValue(null) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, @@ -117,4 +123,32 @@ describe('LinkedServersNav', () => { expect(onNavigate).toHaveBeenCalledWith('/servers/srv_kitchen') }) + + it('shows the activity indicator only for the server with an active session', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + mocks.useServerActivityState.mockImplementation((serverId: string) => + serverId === 'srv_kitchen' ? 'needs-input' : null, + ) + + renderNav() + + expect(mocks.useServerActivityState).toHaveBeenCalledWith('srv_kitchen') + expect(mocks.useServerActivityState).toHaveBeenCalledWith('srv_office') + expect(screen.getByRole('img', { name: 'Session needs input' })).toBeInTheDocument() + expect(screen.queryByRole('img', { name: 'Sessions running' })).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + }) + + it('shows the running indicator in the sheet variant', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useServerActivityState.mockReturnValue('running') + + render( + + + , + ) + + expect(screen.getByRole('img', { name: 'Sessions running' })).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 360b32ada..3e9e2d619 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -1,6 +1,9 @@ import { Link, useLocation } from 'react-router-dom' import { Server } from 'lucide-react' +import type { OpenCodeServer } from '@opencode-manager/shared/schemas' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' +import { useServerActivityState } from '@/hooks/useActiveWork' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { SidebarSection } from '@/components/ui/sidebar' import { getServerPath } from '@/lib/navigation' import { cn } from '@/lib/utils' @@ -31,6 +34,52 @@ function ServerConnectionDot({ connected }: { connected: boolean }) { ) } +interface LinkedServerRowProps { + server: OpenCodeServer + variant: 'sidebar' | 'sheet' + path: string + active: boolean + connected: boolean + onNavigate?: (path: string) => void +} + +function LinkedServerRow({ server, variant, path, active, connected, onNavigate }: LinkedServerRowProps) { + const activity = useServerActivityState(server.id) + const className = cn( + 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', + variant === 'sheet' && 'mx-2 p-2 text-left', + ) + const content = ( + <> + + {server.name} + + + + ) + + if (onNavigate) { + return ( + + ) + } + + return ( + + {content} + + ) +} + export function LinkedServersNav({ variant = 'sidebar', onNavigate }: LinkedServersNavProps) { const location = useLocation() const { servers, isLoading } = useOpenCodeServers() @@ -71,40 +120,17 @@ export function LinkedServersNav({ variant = 'sidebar', onNavigate }: LinkedServ const rows = enabledServers.map((server) => { const path = getServerPath(server.id) - const active = isActiveServerPath(location.pathname, path) - const connected = statusById[server.id]?.connected ?? false - const className = cn( - 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', - active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', - variant === 'sheet' && 'mx-2 p-2 text-left', - ) - const content = ( - <> - - {server.name} - - - ) - - if (onNavigate) { - return ( - - ) - } return ( - - {content} - + ) }) diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index 86556dac5..1f4032abc 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -416,14 +416,14 @@ describe('MoreDrawer', () => { expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() expect(screen.getByText('Terminal')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument() + expect(screen.getByText('Repos')).toBeInTheDocument() expect(screen.queryByText('Files')).not.toBeInTheDocument() - expect(screen.queryByText('Source Control')).not.toBeInTheDocument() + expect(screen.getByText('Source Control')).toBeInTheDocument() expect(screen.queryByText('Preview')).not.toBeInTheDocument() expect(screen.queryByText('Schedules')).not.toBeInTheDocument() expect(screen.queryByText('Reset Permissions')).not.toBeInTheDocument() - expect(screen.queryByText('Repos')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument() - expect(screen.queryByRole('heading', { name: 'Activity' })).not.toBeInTheDocument() }) it('shows unread reports and marks them all read', () => { diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 83582333c..2499d1229 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -14,7 +14,7 @@ import { useMarkAllScheduleRunsViewed, useUnreadScheduleRuns } from '@/hooks/use import { buildMoreItems, buildNavModel, isManagerOnlyNavItem, isSessionDetailPath, type MoreDrawerItem } from './moreDrawerItems' import { useSwipeBack } from '@/hooks/useMobile' import { cn, getRepoDisplayName } from '@/lib/utils' -import { getPathWithReturnTo, isAssistantPath } from '@/lib/navigation' +import { getPathWithReturnTo, isAssistantPath, parseServerSessionRoute } from '@/lib/navigation' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' @@ -77,6 +77,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { const { logout } = useAuth() const { data: health } = useServerHealth() const isSessionDetail = isSessionDetailPath(location.pathname) + const isRemoteSessionDetail = parseServerSessionRoute(location.pathname) !== null const isAssistantRoute = isAssistantPath(location.pathname) const isAssistantSession = isSessionDetail && searchParams.get('assistant') === '1' const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) @@ -205,11 +206,11 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { />
)} - {isOpen && !isRemoteRoute && ( + {isOpen && ( navigate(path)} /> )} - {isSessionDetail && !isRemoteRoute && ( + {(isSessionDetail || isRemoteSessionDetail) && ( openMobileSheet('repos')} trailing /> )} {assistantCta?.to && ( diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 2bbf856c3..86eb5b097 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -52,6 +52,7 @@ vi.mock('@/hooks/useActiveWork', () => ({ useActiveWork: activityMocks.useActiveWork, useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, useRepoActivityState: () => null, + useServerActivityState: () => null, })) vi.mock('@/api/repos') diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index eb4cd2a2e..32ec95848 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -27,6 +27,11 @@ vi.mock('@/components/file-browser/FileBrowser', () => ({ vi.mock('@/components/source-control', () => ({ SourceControlContent: () =>
source-control-tool
, })) +vi.mock('@/components/source-control/RemoteSourceControlContent', () => ({ + RemoteSourceControlContent: ({ serverId, directory }: { serverId: string; directory: string }) => ( +
remote-source-control-tool {serverId} {directory}
+ ), +})) vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalWorkspace: () =>
terminal-tool
, })) @@ -360,12 +365,43 @@ describe('ToolSidePanel', () => { within(rail) .getAllByRole('button') .map((button) => button.getAttribute('aria-label') ?? button.textContent?.trim()), - ).toEqual(['Activity', 'MCP', 'Skills', 'Terminal']) + ).toEqual(['Activity', 'MCP', 'Skills', 'Source Control', 'Terminal']) await user.click(screen.getByRole('button', { name: 'MCP' })) expect(await screen.findByText('mcp-tool')).toBeInTheDocument() }) + it('opens the remote source control panel for a linked-server session', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Source Control' })) + + expect( + await screen.findByText('remote-source-control-tool srv_kitchen /home/me/app'), + ).toBeInTheDocument() + expect(search().get('panel')).toBe('sourceControl') + expect(screen.queryByText('source-control-tool')).not.toBeInTheDocument() + }) + + it('opens the skills panel for a linked-server session', async () => { + const user = userEvent.setup() + render( + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Skills' })) + + expect(await screen.findByText('skills-tool')).toBeInTheDocument() + expect(search().get('panel')).toBe('skills') + }) + it('opens the Activity panel with rows and the window label when clicked', async () => { const user = userEvent.setup() setActivityGroups([createActivityItem('other', 'Other Session', 2, 'Beta')]) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index 97d28701d..ac629b71a 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import { FileBrowser } from '@/components/file-browser/FileBrowser' import { SourceControlContent } from '@/components/source-control' +import { RemoteSourceControlContent } from '@/components/source-control/RemoteSourceControlContent' import { TerminalWorkspace } from '@/components/terminal/TerminalPanel' import { ChangesWalkthroughCancel, @@ -38,7 +39,7 @@ const PANEL_WIDTH_CLASSES: Partial> = { activity: 'w-80', } -const REMOTE_TOOL_KEYS: ReadonlySet = new Set(['activity', 'mcp', 'skills', 'terminal']) +const REMOTE_TOOL_KEYS: ReadonlySet = new Set(['activity', 'mcp', 'skills', 'source-control', 'terminal']) function ActivityWindowLabel() { const minutes = useActivityWindowMinutes() @@ -163,7 +164,8 @@ export function ToolSidePanel({ const { activeTool, toggleTool, closePanel } = panel const location = useLocation() const openNavItem = useOpenNavItem() - const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) + const routeServerId = useRouteOpenCodeServerId() + const isRemoteRoute = !isLocalOpenCodeServer(routeServerId) const railRef = useRef(null) const previousActiveToolRef = useRef(activeTool) @@ -212,25 +214,34 @@ export function ToolSidePanel({ ? : null } + if (tool === 'skills') { + return sessionId ? ( + + ) : ( + + ) + } + if (tool === 'sourceControl') { + if (isRemoteRoute) { + return repoDirectory + ? + : null + } + return repoId === undefined + ? null + : + } if (repoId === undefined) return null switch (tool) { - case 'sourceControl': - return case 'actions': return - case 'skills': - return sessionId ? ( - - ) : ( - - ) case 'schedules': return } diff --git a/frontend/src/components/navigation/moreDrawerItems.test.ts b/frontend/src/components/navigation/moreDrawerItems.test.ts index 41afe8f64..18a2b1468 100644 --- a/frontend/src/components/navigation/moreDrawerItems.test.ts +++ b/frontend/src/components/navigation/moreDrawerItems.test.ts @@ -64,7 +64,15 @@ describe('buildMoreItems', () => { it('returns only the server tools a linked server can serve for /servers/:id/sessions/:sid', () => { const items = buildMoreItems('/servers/srv_kitchen/sessions/abc') - expect(items.map((item) => item.key)).toEqual(['home', 'mcp', 'skills', 'terminal', 'settings', 'logout']) + expect(items.map((item) => item.key)).toEqual([ + 'home', + 'mcp', + 'skills', + 'source-control', + 'terminal', + 'settings', + 'logout', + ]) }) it('omits the Actions item for an Assistant session', () => { @@ -174,6 +182,7 @@ describe('buildToolItems', () => { 'activity', 'mcp', 'skills', + 'source-control', 'terminal', ]) }) diff --git a/frontend/src/components/navigation/moreDrawerItems.ts b/frontend/src/components/navigation/moreDrawerItems.ts index 9280bdc64..8847f3f26 100644 --- a/frontend/src/components/navigation/moreDrawerItems.ts +++ b/frontend/src/components/navigation/moreDrawerItems.ts @@ -58,7 +58,6 @@ const NON_TOOL_ITEM_KEYS = new Set(['home', 'settings', 'logout']) const MANAGER_ONLY_ITEM_KEYS: ReadonlySet = new Set([ 'files', - 'source-control', 'preview', 'walkthrough', 'actions', @@ -92,6 +91,7 @@ function buildRouteNavModel(pathname: string): NavModel { items: [ { key: 'mcp', label: 'MCP', icon: Plug, dialog: 'mcp' }, { key: 'skills', label: 'Skills', icon: Sparkles, dialog: 'skills' }, + { key: 'source-control', label: 'Source Control', icon: GitCommitHorizontal, dialog: 'sourceControl' }, { key: 'terminal', label: 'Terminal', icon: SquareTerminal, dialog: 'terminal' }, ...baseItems, ], diff --git a/frontend/src/components/repo/RepoSkillsDialog.tsx b/frontend/src/components/repo/RepoSkillsDialog.tsx index 6307a8aa7..38aef9211 100644 --- a/frontend/src/components/repo/RepoSkillsDialog.tsx +++ b/frontend/src/components/repo/RepoSkillsDialog.tsx @@ -38,7 +38,7 @@ type RepoSkillsDialogProps = { type RepoSkillsContentProps = { open: boolean - repoId: number + repoId?: number onDone: () => void } & RepoSkillsSessionProps diff --git a/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx b/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx new file mode 100644 index 000000000..c742161a2 --- /dev/null +++ b/frontend/src/components/source-control/RemoteSourceControlContent.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RemoteSourceControlContent } from './RemoteSourceControlContent' + +const mocks = vi.hoisted(() => ({ + getVcsInfo: vi.fn(), + listVcsStatus: vi.fn(), + getVcsDiff: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + getVcsInfo: mocks.getVcsInfo, + listVcsStatus: mocks.listVcsStatus, + getVcsDiff: mocks.getVcsDiff, +})) + +const WORKING_FILES = [ + { file: 'src/app.ts', additions: 3, deletions: 1, status: 'modified' as const }, + { file: 'src/new.ts', additions: 5, deletions: 0, status: 'added' as const }, +] + +const WORKING_DIFF = [ + { + file: 'src/app.ts', + patch: '@@ -1,2 +1,2 @@\n-old line\n+new line', + additions: 1, + deletions: 1, + status: 'modified' as const, + }, +] + +const BRANCH_DIFF = [ + { + file: 'src/branch-only.ts', + patch: '@@ -1,1 +1,2 @@\n branch file\n+extra branch line', + additions: 1, + deletions: 0, + status: 'added' as const, + }, +] + +function renderContent() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + , + ) +} + +describe('RemoteSourceControlContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getVcsInfo.mockResolvedValue({ + provider: 'github', + branch: { current: 'feature/remote', default: 'main' }, + }) + mocks.listVcsStatus.mockResolvedValue(WORKING_FILES) + mocks.getVcsDiff.mockImplementation((_directory: string, mode: string) => + Promise.resolve(mode === 'branch' ? BRANCH_DIFF : WORKING_DIFF), + ) + }) + + it('lists the working-copy changes with the current branch', async () => { + renderContent() + + expect(await screen.findByText('app.ts')).toBeInTheDocument() + expect(screen.getByText('new.ts')).toBeInTheDocument() + expect(screen.getByText('feature/remote')).toBeInTheDocument() + expect(mocks.listVcsStatus).toHaveBeenCalledWith('/home/me/app', 'srv_kitchen') + }) + + it('switches to branch changes and lists the diff files', async () => { + const user = userEvent.setup() + renderContent() + + await user.click(await screen.findByRole('button', { name: 'Branch changes' })) + + expect(await screen.findByText('branch-only.ts')).toBeInTheDocument() + expect(screen.queryByText('new.ts')).not.toBeInTheDocument() + expect(mocks.getVcsDiff).toHaveBeenCalledWith('/home/me/app', 'branch', 'srv_kitchen') + }) + + it('shows the patch after selecting a changed file', async () => { + const user = userEvent.setup() + renderContent() + + await user.click(await screen.findByText('app.ts')) + + expect(await screen.findByText('new line')).toBeInTheDocument() + expect(mocks.getVcsDiff).toHaveBeenCalledWith('/home/me/app', 'working', 'srv_kitchen') + + await user.click(screen.getByRole('button', { name: 'Back to changed files' })) + + expect(await screen.findByText('new.ts')).toBeInTheDocument() + }) + + it('shows an empty state when the working copy has no changes', async () => { + mocks.listVcsStatus.mockResolvedValue([]) + renderContent() + + expect(await screen.findByText('No working changes')).toBeInTheDocument() + }) + + it('reports a failed status load', async () => { + mocks.listVcsStatus.mockRejectedValue(new Error('vcs unavailable')) + renderContent() + + expect(await screen.findByText('Failed to load changes')).toBeInTheDocument() + expect(screen.getByText('vcs unavailable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/source-control/RemoteSourceControlContent.tsx b/frontend/src/components/source-control/RemoteSourceControlContent.tsx new file mode 100644 index 000000000..645fa10c4 --- /dev/null +++ b/frontend/src/components/source-control/RemoteSourceControlContent.tsx @@ -0,0 +1,258 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft, FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react' +import { + getVcsDiff, + getVcsInfo, + listVcsStatus, + type VcsFileDiff, + type VcsFileStatus, +} from '@/api/opencode' +import { FileDiffContent } from '@/components/file-browser/FileDiffView' +import { GitFlatFileList } from './GitFlatFileList' +import { SourceControlDialog } from './SourceControlPanel' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { serverScopedKey } from '@/lib/serverScope' +import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' +import { useMobile } from '@/hooks/useMobile' +import { GIT_UI_COLORS } from '@/lib/git-status-styles' +import type { GitFileStatus } from '@/types/git' + +type VcsMode = 'working' | 'branch' + +const MODES: ReadonlyArray<{ id: VcsMode; label: string }> = [ + { id: 'working', label: 'Working changes' }, + { id: 'branch', label: 'Branch changes' }, +] + +interface RemoteSourceControlContentProps { + serverId: string + directory: string + compact?: boolean +} + +function toGitFileStatus(entry: VcsFileStatus | VcsFileDiff): GitFileStatus { + return { + path: entry.file, + status: entry.status, + staged: false, + additions: entry.additions, + deletions: entry.deletions, + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load source control' +} + +export function RemoteSourceControlContent({ + serverId, + directory, + compact = false, +}: RemoteSourceControlContentProps) { + const queryClient = useQueryClient() + const [mode, setMode] = useState('working') + const [selectedFile, setSelectedFile] = useState() + + const vcsKey = useMemo( + () => serverScopedKey(['opencode', 'vcs', directory], serverId), + [directory, serverId], + ) + + const infoQuery = useQuery({ + queryKey: [...vcsKey, 'info'], + queryFn: () => getVcsInfo(directory, serverId), + retry: false, + }) + + const statusQuery = useQuery({ + queryKey: [...vcsKey, 'status'], + queryFn: () => listVcsStatus(directory, serverId), + enabled: mode === 'working', + retry: false, + }) + + const diffQuery = useQuery({ + queryKey: [...vcsKey, 'diff', mode], + queryFn: () => getVcsDiff(directory, mode, serverId), + enabled: mode === 'branch' || selectedFile !== undefined, + retry: false, + }) + + const listQuery = mode === 'working' ? statusQuery : diffQuery + const files = useMemo( + () => + mode === 'working' + ? (statusQuery.data ?? []).map(toGitFileStatus) + : (diffQuery.data ?? []).map(toGitFileStatus), + [mode, statusQuery.data, diffQuery.data], + ) + + const selectedDiff = + selectedFile === undefined + ? undefined + : diffQuery.data?.find((entry) => entry.file === selectedFile) + + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: vcsKey, refetchType: 'none' }) + void infoQuery.refetch({ cancelRefetch: false }) + void (mode === 'working' ? statusQuery : diffQuery).refetch({ cancelRefetch: false }) + if (mode === 'working' && selectedFile !== undefined) { + void diffQuery.refetch({ cancelRefetch: false }) + } + } + + useRefreshOnOpen(true, refresh) + + const branch = infoQuery.data?.branch.current ?? infoQuery.data?.branch.default ?? 'unknown' + const isRefreshing = infoQuery.isFetching || listQuery.isFetching || diffQuery.isFetching + + const selectMode = (next: VcsMode) => { + if (next === mode) return + setSelectedFile(undefined) + setMode(next) + } + + if (selectedFile !== undefined) { + return ( +
+
+ + + {selectedFile} + +
+
+ {diffQuery.isLoading ? ( +
+ +
+ ) : diffQuery.error ? ( +
+ +

Failed to load diff

+

{errorMessage(diffQuery.error)}

+
+ ) : ( + setSelectedFile(undefined)} + isMobile={compact} + /> + )} +
+
+ ) + } + + return ( +
+
+
+ + {branch} +
+
+ +
+
+ +
+ {MODES.map((entry) => ( + + ))} +
+ +
+ {listQuery.isLoading ? ( +
+ +
+ ) : listQuery.error ? ( +
+ +
+

Failed to load changes

+

{errorMessage(listQuery.error)}

+
+ +
+ ) : files.length === 0 ? ( +
+

+ {mode === 'working' ? 'No working changes' : 'No branch changes'} +

+
+ ) : ( + setSelectedFile(path)} + readOnly + /> + )} +
+
+ ) +} + +interface RemoteSourceControlPanelProps { + serverId: string + directory: string + isOpen: boolean + onClose: () => void +} + +export function RemoteSourceControlPanel({ + serverId, + directory, + isOpen, + onClose, +}: RemoteSourceControlPanelProps) { + const isMobile = useMobile() + + return ( + + + + ) +} diff --git a/frontend/src/components/source-control/SourceControlPanel.tsx b/frontend/src/components/source-control/SourceControlPanel.tsx index df27ce523..6871f594c 100644 --- a/frontend/src/components/source-control/SourceControlPanel.tsx +++ b/frontend/src/components/source-control/SourceControlPanel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import type { ReactNode } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { FetchError } from '@opencode-manager/shared' import { useGitStatus, getApiErrorMessage } from '@/api/git' @@ -287,6 +288,32 @@ export function SourceControlPanel({ }: SourceControlPanelProps) { const isMobile = useMobile() + return ( + + + + ) +} + +interface SourceControlDialogProps { + isOpen: boolean + onClose: () => void + title: string + children: ReactNode +} + +export function SourceControlDialog({ isOpen, onClose, title, children }: SourceControlDialogProps) { + const isMobile = useMobile() + return ( !open && onClose()}> - {isMobile && repoName ? repoName : 'Source Control'} + {title} {isMobile && ( diff --git a/frontend/src/components/source-control/index.ts b/frontend/src/components/source-control/index.ts index 9611420a6..78e99ee8b 100644 --- a/frontend/src/components/source-control/index.ts +++ b/frontend/src/components/source-control/index.ts @@ -1,4 +1,5 @@ export { SourceControlPanel, SourceControlContent } from './SourceControlPanel' +export { RemoteSourceControlPanel } from './RemoteSourceControlContent' export { ChangesTab } from './ChangesTab' export { CommitsTab } from './CommitsTab' export { BranchesTab } from './BranchesTab' diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 390cad16b..1f07ef465 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -817,6 +817,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } case 'session.idle': { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) + rememberSessionDirectory(event.data.sessionID, event.directory, serverId) useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) break } @@ -831,7 +832,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'succeeded') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } @@ -839,7 +840,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'failed') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } @@ -847,18 +848,18 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) useSessionStatus.getState().setOutcome(event.data.sessionID, 'interrupted') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) queryClient.invalidateQueries({ queryKey: changeWalkthroughQueryKey(event.data.sessionID) }) break } case 'session.deleted': { useSessionStatus.getState().forgetSession(event.data.sessionID) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) break } case 'session.created': { useSessionStatus.getState().setSessionServer(event.data.sessionID, serverId) - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) if (typeof event.data.parentID === 'string') { useSessionStatus.getState().setSessionParent(event.data.sessionID, event.data.parentID) } @@ -868,7 +869,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { case 'session.renamed': case 'session.moved': case 'session.metadata.updated': - invalidateSessionListCachesDebounced(queryClient, event.directory) + invalidateSessionListCachesDebounced(queryClient, event.directory, serverId) break case 'shell.created': { const info = event.data.info @@ -923,6 +924,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { setSSHHostKeyRequest(event.properties) break case 'vcs.branch.updated': { + if (!isLocalOpenCodeServer(serverId)) break const repo = reposRef.current?.find((candidate) => repoMatchesDirectory(candidate, event.directory)) if (!repo) { queryClient.invalidateQueries({ queryKey: ['repo', 'siblings'] }) @@ -966,6 +968,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { ) void reconcilePendingActionsForDirectories(directories) void syncRunningShells(directories) + invalidateSessionListCachesDebounced(queryClient, undefined, serverId) invalidateQueryKeysDebounced( queryClient, LOCATION_CATALOG_QUERY_KEYS.map((key) => serverScopedKey(key, serverId)), diff --git a/frontend/src/hooks/useActiveWork.test.tsx b/frontend/src/hooks/useActiveWork.test.tsx index 3337459b7..1b933c928 100644 --- a/frontend/src/hooks/useActiveWork.test.tsx +++ b/frontend/src/hooks/useActiveWork.test.tsx @@ -13,6 +13,7 @@ const WINDOW_MS = 60 * MS_PER_MINUTE const mocks = vi.hoisted(() => ({ listSessionPage: vi.fn(), + listRemoteSessionPage: vi.fn(), getSession: vi.fn(), getRepoSiblings: vi.fn(), useNavigableRepos: vi.fn(), @@ -23,7 +24,10 @@ vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - listSessionPage: mocks.listSessionPage, + listSessionPage: (input: Parameters[0], serverId?: string) => + serverId === undefined || serverId === 'local' + ? mocks.listSessionPage(input, serverId) + : mocks.listRemoteSessionPage(input, serverId), getSession: mocks.getSession, } }) @@ -103,6 +107,8 @@ const recentIDs = (groups: { recent: Array<{ session: SessionInfo }> }): string[ describe('useActiveWork', () => { beforeEach(() => { mocks.listSessionPage.mockReset() + mocks.listRemoteSessionPage.mockReset() + mocks.listRemoteSessionPage.mockResolvedValue({ items: [] }) mocks.getSession.mockReset() mocks.getRepoSiblings.mockReset() mocks.getRepoSiblings.mockResolvedValue([]) @@ -308,6 +314,69 @@ describe('useActiveWork', () => { expect(row.path).toBe('/servers/srv_kitchen/sessions/ses_remote') }) + it('includes recent sessions from an enabled linked server with its server label and path', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [] }) + mocks.listRemoteSessionPage.mockResolvedValue({ + items: [session('ses_remote_recent', now - 500, null, '/home/me/app')], + }) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(recentIDs(result.current.groups)).toContain('ses_remote_recent')) + + const row = result.current.groups.recent[0] + expect(row.serverId).toBe('srv_kitchen') + expect(row.serverName).toBe('Kitchen') + expect(row.repo).toBeNull() + expect(row.path).toBe('/servers/srv_kitchen/sessions/ses_remote_recent') + expect(mocks.listRemoteSessionPage).toHaveBeenCalledWith( + { limit: 50, order: 'desc', includeChildren: true }, + 'srv_kitchen', + ) + expect(result.current.isError).toBe(false) + }) + + it('rolls a listed remote child up to a root fetched from the same server', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [] }) + mocks.listRemoteSessionPage.mockResolvedValue({ + items: [session('ses_remote_child', now - 500, 'ses_remote_root', '/home/me/app')], + }) + mocks.getSession.mockImplementation(async (id: string, serverId?: string) => { + if (serverId !== 'srv_kitchen') throw new Error('local lookup for a remote session') + if (id === 'ses_remote_root') return session('ses_remote_root', now - 1500, null, '/home/me/app') + throw new Error(`unexpected session ${id}`) + }) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(result.current.groups.recent.map((item) => item.session.id)).toEqual(['ses_remote_root'])) + + expect(result.current.groups.recent[0].serverId).toBe('srv_kitchen') + expect(mocks.getSession).toHaveBeenCalledWith('ses_remote_root', 'srv_kitchen') + expect(mocks.getSession).not.toHaveBeenCalledWith('ses_remote_root', 'local') + }) + + it('keeps the local list when a linked server fails to load', async () => { + const now = Date.now() + mocks.listSessionPage.mockResolvedValue({ items: [session('local_recent', now - 500)] }) + mocks.listRemoteSessionPage.mockRejectedValue(new Error('server unreachable')) + + const { result } = renderHook(() => useActiveWork(WINDOW_MS), { + wrapper: createWrapper(createQueryClient()), + }) + + await waitFor(() => expect(recentIDs(result.current.groups)).toContain('local_recent')) + + expect(result.current.isError).toBe(false) + expect(recentIDs(result.current.groups)).not.toContain('ses_remote_recent') + }) + it('renders a running remote session row and resolves its ancestry on the owning server', async () => { const now = Date.now() mocks.listSessionPage.mockResolvedValue({ items: [] }) diff --git a/frontend/src/hooks/useActiveWork.ts b/frontend/src/hooks/useActiveWork.ts index 0e4a8dcd0..174278ec9 100644 --- a/frontend/src/hooks/useActiveWork.ts +++ b/frontend/src/hooks/useActiveWork.ts @@ -1,13 +1,15 @@ import { useEffect, useMemo, useState } from 'react' import { useQueries, useQueryClient, type QueryClient } from '@tanstack/react-query' -import { LOCAL_OPENCODE_SERVER_ID, parseServerDirectoryKey } from '@opencode-manager/shared/schemas' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { SessionInfo } from '@opencode-manager/shared/opencode' +import { listSessionPage } from '@/api/opencode' import { sessionQueryOptions, useSessionsAcrossDirectories } from '@/hooks/useOpenCode' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' import { usePendingInputSessionIDs, useRepoWorktreeList } from '@/contexts/RepoWorktreesContext' import { useSessionStatus } from '@/stores/sessionStatusStore' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' import { serverScopedKey } from '@/lib/serverScope' +import { activeWorkSessionsQueryKey } from '@/lib/queryInvalidation' import { useSettings } from '@/hooks/useSettings' import { DEFAULT_ACTIVITY_WINDOW_MINUTES } from '@/api/types/settings' import { useRepoWorktrees } from '@/hooks/useRepoWorktrees' @@ -15,16 +17,20 @@ import { MS_PER_MINUTE, buildActiveWork, buildRepoActivityIndex, + buildServerActivityIndex, collectActiveRoots, needsWorktreeLookup, repoActivityFromIndex, rootSessionID, + serverActivityFromIndex, + sessionScopeOfDirectory, type ActiveWorkGroups, } from '@/lib/activeWork' import { combineQuerySessions, mergeExtraSessions } from '@/components/navigation/sidebar-session-tree' import type { Repo } from '@/api/types' const ACTIVE_WORK_SESSION_LIMIT = 50 +const ACTIVE_WORK_SESSIONS_STALE_MS = 10000 /** * The active-work time window in minutes, derived from the user's configured @@ -80,6 +86,28 @@ export function useRepoActivityState(repo: Repo): 'needs-input' | 'running' | nu ) } +/** + * Whether a linked server has any active session, returned as a primitive so rows only re-render + * when it changes. Reads only the session-status store and the pending queues, with no network. + */ +export function useServerActivityState(serverId: string): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => + serverActivityFromIndex( + serverId, + buildServerActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + sessionServers: state.sessionServers, + pendingInputSessionIDs, + }), + ), + ) +} + interface SessionScope { serverId: string directory: string | undefined @@ -94,13 +122,8 @@ function sessionScopeOf( directories: ReadonlyMap, sessionServers: ReadonlyMap, ): SessionScope { - const directoryKey = directories.get(sessionID) - const parsed = directoryKey ? parseServerDirectoryKey(directoryKey) : null - const serverId = - parsed && parsed.serverId !== LOCAL_OPENCODE_SERVER_ID - ? parsed.serverId - : sessionServers.get(sessionID) ?? LOCAL_OPENCODE_SERVER_ID - return { serverId, directory: parsed?.directory } + const scope = sessionScopeOfDirectory(directories.get(sessionID), sessionServers.get(sessionID)) + return { serverId: scope.serverId, directory: scope.directory ?? undefined } } /** @@ -140,6 +163,25 @@ const combineAncestorLinks = ( results: Array<{ data?: Array<[string, string | null]> }>, ): Array<[string, string | null]> => results.flatMap((result) => result.data ?? []) +interface ServerSessionPage { + serverId: string + sessions: SessionInfo[] +} + +/** + * Pairs each server's active-work page with its server id, dropping pages that are empty or failed + * so one unreachable server never removes the others from the list. + */ +function combineServerSessionPages( + results: Array<{ data?: SessionInfo[] }>, + serverIds: ReadonlyArray, +): ServerSessionPage[] { + return results.flatMap((result, index) => { + if (!result.data || result.data.length === 0) return [] + return [{ serverId: serverIds[index], sessions: result.data }] + }) +} + /** * The full active-work list. Performs network reads, so it must only be used by components that * are mounted while visible (the session picker fetches only while open). @@ -198,16 +240,70 @@ export function useActiveWork(windowMs: number): { const sessionServers = useSessionStatus((state) => state.sessionServers) const pendingInputSessionIDs = usePendingInputSessionIDs() + const { servers } = useOpenCodeServers() + const remoteServerIds = useMemo( + () => servers.filter((server) => server.enabled).map((server) => server.id), + [servers], + ) + + const serverSessionPages = useQueries({ + queries: remoteServerIds.map((serverId) => ({ + queryKey: activeWorkSessionsQueryKey(serverId), + queryFn: async (): Promise => { + const page = await listSessionPage( + { limit: ACTIVE_WORK_SESSION_LIMIT, order: 'desc', includeChildren: true }, + serverId, + ) + return page.items + }, + staleTime: ACTIVE_WORK_SESSIONS_STALE_MS, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + retry: 1, + })), + combine: (results) => combineServerSessionPages(results, remoteServerIds), + }) + + const serverSessions = useMemo( + () => serverSessionPages.flatMap((page) => page.sessions), + [serverSessionPages], + ) + + const serverSessionServers = useMemo(() => { + const owners = new Map() + for (const page of serverSessionPages) { + for (const session of page.sessions) { + owners.set(session.id, page.serverId) + if (session.parentID) owners.set(session.parentID, page.serverId) + } + } + return owners + }, [serverSessionPages]) + + const allSessions = useMemo( + () => (serverSessions.length === 0 ? sessions : [...sessions, ...serverSessions]), + [sessions, serverSessions], + ) + + const sessionOwners = useMemo(() => { + if (serverSessionServers.size === 0) return sessionServers + const merged = new Map(sessionServers) + for (const [sessionID, serverId] of serverSessionServers) { + if (!merged.has(sessionID)) merged.set(sessionID, serverId) + } + return merged + }, [sessionServers, serverSessionServers]) + const parents = useMemo(() => { const merged = new Map() - for (const session of sessions) { + for (const session of allSessions) { merged.set(session.id, session.parentID ?? null) } for (const [sessionID, parentID] of storeParents) { if (!merged.has(sessionID)) merged.set(sessionID, parentID) } return merged - }, [sessions, storeParents]) + }, [allSessions, storeParents]) const roots = useMemo( () => collectActiveRoots({ statuses, parents, runningShells, pendingInputSessionIDs }), @@ -215,14 +311,14 @@ export function useActiveWork(windowMs: number): { ) const listedSessionIDs = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions], + () => new Set(allSessions.map((session) => session.id)), + [allSessions], ) const missingRootIDs = useMemo(() => { const ids = new Set() const windowStart = now - windowMs - for (const session of sessions) { + for (const session of allSessions) { if (session.time.updated < windowStart) continue const rootID = rootSessionID(session.id, parents) if (!listedSessionIDs.has(rootID)) ids.add(rootID) @@ -234,14 +330,14 @@ export function useActiveWork(windowMs: number): { if (!listedSessionIDs.has(sessionID)) ids.add(sessionID) } return [...ids] - }, [sessions, parents, listedSessionIDs, roots, windowMs, now]) + }, [allSessions, parents, listedSessionIDs, roots, windowMs, now]) const ancestorLinks = useQueries({ queries: missingRootIDs.map((sessionID) => { - const { serverId } = sessionScopeOf(sessionID, sessionDirectories, sessionServers) + const { serverId } = sessionScopeOf(sessionID, sessionDirectories, sessionOwners) return { queryKey: serverScopedKey(['opencode', 'session-ancestry', sessionID], serverId), - queryFn: () => fetchAncestorLinks(queryClient, sessionID, sessionDirectories, sessionServers), + queryFn: () => fetchAncestorLinks(queryClient, sessionID, sessionDirectories, sessionOwners), staleTime: Infinity, } }), @@ -262,8 +358,8 @@ export function useActiveWork(windowMs: number): { ) const resolvedSessionServers = useMemo(() => { - const merged = new Map(sessionServers) - for (const [sessionID, serverId] of sessionServers) { + const merged = new Map(sessionOwners) + for (const [sessionID, serverId] of sessionOwners) { const visited = new Set() let currentID: string | null | undefined = sessionID while (currentID && !visited.has(currentID)) { @@ -273,7 +369,7 @@ export function useActiveWork(windowMs: number): { } } return merged - }, [sessionServers, resolvedParents]) + }, [sessionOwners, resolvedParents]) const hasRemoteRoots = useMemo( () => @@ -283,12 +379,12 @@ export function useActiveWork(windowMs: number): { [resolvedRoots, resolvedSessionServers], ) - const { servers } = useOpenCodeServers({ enabled: hasRemoteRoots }) + const hasServerSessions = hasRemoteRoots || serverSessionPages.length > 0 const serverNames = useMemo(() => { - if (!hasRemoteRoots || servers.length === 0) return undefined + if (!hasServerSessions || servers.length === 0) return undefined return new Map(servers.map((server) => [server.id, server.name])) - }, [hasRemoteRoots, servers]) + }, [hasServerSessions, servers]) const backfillRootIDs = useMemo(() => { const ids = new Set() @@ -308,8 +404,8 @@ export function useActiveWork(windowMs: number): { }) const mergedSessions = useMemo( - () => mergeExtraSessions(sessions, fetchedSessions), - [sessions, fetchedSessions], + () => mergeExtraSessions(allSessions, fetchedSessions), + [allSessions, fetchedSessions], ) const needsLookup = useMemo( diff --git a/frontend/src/hooks/useCommands.test.tsx b/frontend/src/hooks/useCommands.test.tsx index c7c2a9070..84b88ef66 100644 --- a/frontend/src/hooks/useCommands.test.tsx +++ b/frontend/src/hooks/useCommands.test.tsx @@ -168,9 +168,9 @@ describe('useCommands', () => { const names = result.current.searchCommands('').map(({ item }) => item.name) - expect(names).not.toContain('sessions') - expect(names).not.toContain('resume') - expect(names).not.toContain('continue') + expect(names).toContain('sessions') + expect(names).toContain('resume') + expect(names).toContain('continue') expect(names).not.toContain('walkthrough') expect(names).toContain('mcp') expect(names).toContain('skills') diff --git a/frontend/src/hooks/useGlobalShortcutActions.test.tsx b/frontend/src/hooks/useGlobalShortcutActions.test.tsx index 75390fdf8..4d3c75552 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.test.tsx +++ b/frontend/src/hooks/useGlobalShortcutActions.test.tsx @@ -7,6 +7,7 @@ import { useGlobalShortcutActions } from './useGlobalShortcutActions' const mocks = vi.hoisted(() => ({ getRepo: vi.fn(), + getSession: vi.fn(), createSession: vi.fn(), useMobile: vi.fn(), })) @@ -27,6 +28,7 @@ vi.mock('@/api/repos', () => ({ vi.mock('@/api/opencode', () => ({ createSession: mocks.createSession, + getSession: mocks.getSession, })) function Harness() { @@ -60,6 +62,7 @@ describe('useGlobalShortcutActions', () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) mocks.useMobile.mockReturnValue(false) mocks.getRepo.mockResolvedValue({ id: 5, fullPath: '/repo/five' }) + mocks.getSession.mockResolvedValue({ id: 'ses_1', location: { directory: '/srv/kitchen' } }) mocks.createSession.mockResolvedValue({ id: 'session-new' }) }) @@ -129,10 +132,20 @@ describe('useGlobalShortcutActions', () => { await waitFor(() => expect(location()).toBe('/repos/5/sessions/session-new')) }) - it('ignores the terminal and source-control shortcuts on a linked-server route', async () => { + it('toggles a linked-server route tool without falling back to a Manager host', async () => { const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') pressLeaderFollowUp('t') + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1?panel=terminal')) + + pressLeaderFollowUp('t') + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1')) + expect(mocks.getRepo).not.toHaveBeenCalled() + }) + + it('does not fall back to a Manager host for a tool the linked-server route lacks', async () => { + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + pressLeaderFollowUp('b') await act(async () => { @@ -143,6 +156,30 @@ describe('useGlobalShortcutActions', () => { expect(mocks.getRepo).not.toHaveBeenCalled() }) + it('toggles the terminal dialog on a linked-server route on mobile', async () => { + mocks.useMobile.mockReturnValue(true) + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + + pressLeaderFollowUp('t') + + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/ses_1?dialog=terminal')) + }) + + it('creates a session in the linked server directory and navigates to the server session path', async () => { + const { location } = renderHarness('/servers/srv_kitchen/sessions/ses_1') + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledWith('ses_1', 'srv_kitchen')) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + pressKey({ key: 'o', ctrlKey: true }) + pressKey({ key: 'n' }) + + await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/kitchen' }, 'srv_kitchen')) + await waitFor(() => expect(location()).toBe('/servers/srv_kitchen/sessions/session-new')) + }) + it('does not swallow the follow-up key when the route has no new-session host', async () => { renderHarness('/') diff --git a/frontend/src/hooks/useGlobalShortcutActions.ts b/frontend/src/hooks/useGlobalShortcutActions.ts index 989d7ef96..a4c2ebb71 100644 --- a/frontend/src/hooks/useGlobalShortcutActions.ts +++ b/frontend/src/hooks/useGlobalShortcutActions.ts @@ -6,17 +6,25 @@ import { getRepo } from '@/api/repos' import { buildToolItems, toolKeyOf } from '@/components/navigation/moreDrawerItems' import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' import { dialogSearch } from '@/hooks/useDialogParam' -import { useCreateSession } from '@/hooks/useOpenCode' +import { sessionQueryOptions, useCreateSession } from '@/hooks/useOpenCode' import { useMobile } from '@/hooks/useMobile' import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useUrlParams } from '@/hooks/useUrlParams' import { toggleToolDialogParams, toggleToolPanelParams, type PanelTool } from '@/hooks/useToolPanel' import { isLocalOpenCodeServer, useRouteOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { getAssistantPath, getRepoPath, getSessionPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { + getAssistantPath, + getRepoPath, + getServerSessionPath, + getSessionPath, + isAssistantPath, + parseRepoRoute, + parseServerSessionRoute, +} from '@/lib/navigation' /** - * Registers the app-wide shortcut actions: the settings dialog, the route tool panels, and repo-scoped session - * creation. Page layers can override any of these by registering the same action name. + * Registers the app-wide shortcut actions: the settings dialog, the route tool panels, and session creation on the + * route's OpenCode server. Page layers can override any of these by registering the same action name. */ export function useGlobalShortcutActions(): void { const { pathname } = useLocation() @@ -24,7 +32,8 @@ export function useGlobalShortcutActions(): void { const { updateParams } = useUrlParams() const { open: openSettings } = useSettingsDialog() const isMobile = useMobile() - const isRemoteRoute = !isLocalOpenCodeServer(useRouteOpenCodeServerId()) + const serverId = useRouteOpenCodeServerId() + const isRemoteRoute = !isLocalOpenCodeServer(serverId) const repoId = isAssistantPath(pathname) ? ASSISTANT_REPO_ID : parseRepoRoute(pathname).repoId @@ -34,16 +43,25 @@ export function useGlobalShortcutActions(): void { enabled: repoId !== null, }) - const createSession = useCreateSession(repo?.fullPath, (session) => { + const remoteSessionRoute = isRemoteRoute ? parseServerSessionRoute(pathname) : null + const { data: remoteSession } = useQuery( + sessionQueryOptions(remoteSessionRoute?.sessionId, undefined, serverId), + ) + const sessionDirectory = isRemoteRoute ? remoteSession?.location?.directory : repo?.fullPath + + const createSession = useCreateSession(sessionDirectory, (session) => { + if (isRemoteRoute) { + navigate(getServerSessionPath(serverId, session.id)) + return + } if (repoId === null) return navigate(getSessionPath(repoId, session.id)) - }) + }, serverId) const toolItems = useMemo(() => buildToolItems(pathname), [pathname]) const hasFilesTool = toolItems.some((item) => toolKeyOf(item) === 'files') const toggleRouteTool = useCallback((tool: PanelTool) => { - if (isRemoteRoute) return if (toolItems.some((item) => toolKeyOf(item) === tool)) { updateParams((params) => { if (isMobile) toggleToolDialogParams(params, tool) @@ -51,12 +69,12 @@ export function useGlobalShortcutActions(): void { }, 'push') return } - if (tool === 'files') return + if (isRemoteRoute || tool === 'files') return const host = repoId !== null ? getRepoPath(repoId) : getAssistantPath() navigate(`${host}${dialogSearch(tool)}`) }, [toolItems, isMobile, updateParams, navigate, repoId, isRemoteRoute]) - const newSession = repoId !== null && repo?.fullPath && !createSession.isPending + const newSession = sessionDirectory && !createSession.isPending ? () => createSession.mutate({}) : undefined diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index b9739e676..4a611dd66 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -466,6 +466,19 @@ describe('useCreateSession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/w/a', agent: undefined }, 'local') }) + + it('creates a session on an explicit server for a caller outside the server scope', async () => { + const queryClient = createQueryClient() + const { result } = renderHook(() => useCreateSession('/srv/kitchen', undefined, 'srv_kitchen'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ agent: undefined }) + }) + + expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/kitchen', agent: undefined }, 'srv_kitchen') + }) }) describe('useChildSessionReconciliation', () => { diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index aa71cd2d0..63a03aecd 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -246,11 +246,17 @@ export const useChildSessionReconciliation = (sessionID: string | undefined) => }); }; +/** + * Creates a session in `directory` on the route's server, or on `serverIdOverride` for callers rendered + * outside the server scope that act on a linked server. + */ export const useCreateSession = ( directory?: string, onSuccess?: (session: { id: string }) => void, + serverIdOverride?: string, ) => { - const serverId = useOpenCodeServerId(); + const routeServerId = useOpenCodeServerId(); + const serverId = serverIdOverride ?? routeServerId; const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/lib/activeWork.test.ts b/frontend/src/lib/activeWork.test.ts index 179b692e2..c3ca14d4f 100644 --- a/frontend/src/lib/activeWork.test.ts +++ b/frontend/src/lib/activeWork.test.ts @@ -6,6 +6,7 @@ import type { RepoSibling, RepoWorktreeSource } from '@opencode-manager/shared/u import { buildActiveWork, buildRepoActivityIndex, + buildServerActivityIndex, buildWorktreeActivity, collectActiveRoots, formatActivityEmptyMessage, @@ -15,6 +16,7 @@ import { repoActivityFromIndex, resolveSessionBranch, rootSessionID, + serverActivityFromIndex, type ActiveWorktree, } from './activeWork' @@ -268,6 +270,127 @@ describe('buildRepoActivityIndex and repoActivityFromIndex', () => { }) }) +describe('buildServerActivityIndex and serverActivityFromIndex', () => { + const noShells = new Map() + + const stateFor = ( + serverId: string, + input: Parameters[0], + ): 'needs-input' | 'running' | null => + serverActivityFromIndex(serverId, buildServerActivityIndex(input)) + + it('ignores local sessions', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', '/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + }) + + expect(state).toBeNull() + }) + + it('marks the server named by a server-keyed directory', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('marks the server named by the session-server index', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', '/home/me/app']]), + sessionServers: new Map([['session', 'srv_kitchen']]), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('rolls a remote needs-input child up to its root on that server', () => { + const input: Parameters[0] = { + statuses: statuses({}), + parents: new Map([['child', 'root'], ['root', null]]), + runningShells: noShells, + directories: new Map([['root', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: ['child'], + } + + expect(stateFor('srv_kitchen', input)).toBe('needs-input') + expect(stateFor('srv_office', input)).toBeNull() + }) + + it('keeps servers apart when the same directory is active on both', () => { + const runningInput: Parameters[0] = { + statuses: statuses({ kitchen: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['kitchen', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + } + + expect(stateFor('srv_kitchen', runningInput)).toBe('running') + expect(stateFor('srv_office', runningInput)).toBeNull() + }) + + it('prefers needs-input over running', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({ busy: { type: 'busy' }, waiting: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([ + ['busy', 'srv_kitchen|/home/me/app'], + ['waiting', 'srv_kitchen|/home/me/other'], + ]), + sessionServers: new Map(), + pendingInputSessionIDs: ['waiting'], + }) + + expect(state).toBe('needs-input') + }) + + it('marks a running shell on its server', () => { + const state = stateFor('srv_kitchen', { + statuses: statuses({}), + parents: new Map(), + runningShells: new Map([ + ['shell-1', { sessionID: 'orphan', directory: 'srv_kitchen|/home/me/app' }], + ]), + directories: new Map(), + sessionServers: new Map([['orphan', 'srv_kitchen']]), + pendingInputSessionIDs: [], + }) + + expect(state).toBe('running') + }) + + it('reuses the index for the same snapshot references', () => { + const input: Parameters[0] = { + statuses: statuses({ session: { type: 'busy' } }), + parents: new Map(), + runningShells: noShells, + directories: new Map([['session', 'srv_kitchen|/home/me/app']]), + sessionServers: new Map(), + pendingInputSessionIDs: [], + } + + expect(buildServerActivityIndex(input)).toBe(buildServerActivityIndex(input)) + }) +}) + describe('repoActivityFromIndex with worktrees', () => { const noShells = new Map() const main = createRepo({ id: 1, fullPath: '/repos/a' }) @@ -557,7 +680,7 @@ describe('buildActiveWork', () => { expect(groups.recent).toEqual([]) }) - it('never emits a recent row for a remote session', () => { + it('emits a recent remote root with its server label and server session path', () => { const sessions = [createSession('remote', NOW - 1000, '/home/me/app')] const groups = buildActiveWork({ @@ -569,9 +692,41 @@ describe('buildActiveWork', () => { roots: emptyRoots, outcomes: new Map(), sessionServers: new Map([['remote', 'srv_kitchen']]), + serverNames: new Map([['srv_kitchen', 'Kitchen']]), }) - expect(groups).toEqual({ needsInput: [], running: [], recent: [] }) + expect(groups.recent).toHaveLength(1) + expect(groups.recent[0].repo).toBeNull() + expect(groups.recent[0].serverId).toBe('srv_kitchen') + expect(groups.recent[0].serverName).toBe('Kitchen') + expect(groups.recent[0].state).toBe('recent') + expect(groups.recent[0].path).toBe('/servers/srv_kitchen/sessions/remote') + }) + + it('rolls a remote child up to its remote root and keeps only the root row', () => { + const parents = new Map([ + ['remote-root', null], + ['remote-child', 'remote-root'], + ]) + const sessions = [ + createSession('remote-root', NOW - 4000, '/home/me/app'), + createSession('remote-child', NOW - 1000, '/home/me/app', 'remote-root'), + ] + + const groups = buildActiveWork({ + now: NOW, + windowMs: WINDOW_MS, + repos: [repoA], + sessions, + parents, + roots: emptyRoots, + outcomes: new Map(), + sessionServers: new Map([['remote-root', 'srv_kitchen']]), + serverNames: new Map([['srv_kitchen', 'Kitchen']]), + }) + + expect(groups.recent.map((item) => item.session.id)).toEqual(['remote-root']) + expect(groups.recent[0].updatedAt).toBe(NOW - 1000) }) }) diff --git a/frontend/src/lib/activeWork.ts b/frontend/src/lib/activeWork.ts index fce74ea19..84d2c5062 100644 --- a/frontend/src/lib/activeWork.ts +++ b/frontend/src/lib/activeWork.ts @@ -115,7 +115,7 @@ export function collectActiveRoots(input: { return { needsInput, running } } -interface RepoActivityIndexInput { +interface ActivityIndexInput { statuses: ReadonlyMap parents: ReadonlyMap runningShells: ReadonlyMap @@ -123,67 +123,171 @@ interface RepoActivityIndexInput { pendingInputSessionIDs: Iterable } -let cachedRepoActivityIndex: (RepoActivityIndexInput & { - index: Map -}) | null = null +interface ServerActivityIndexInput extends ActivityIndexInput { + sessionServers: ReadonlyMap +} -function computeRepoActivityIndex(input: RepoActivityIndexInput): Map { - const { statuses, parents, runningShells, directories, pendingInputSessionIDs } = input - const index = new Map() +type ActivityIndex = Map + +/** Caches one built value per input snapshot, matching the input fields the value depends on. */ +function cacheByInput( + compute: (input: I) => V, + sameInput: (left: I, right: I) => boolean, +): (input: I) => V { + let cached: { input: I; value: V } | null = null - const markNeedsInput = (directory?: string | null): void => { - const normalized = normalizeDirectory(localDirectoryOf(directory)) - if (normalized) index.set(normalized, 'needs-input') + return (input) => { + if (cached && sameInput(cached.input, input)) return cached.value + const value = compute(input) + cached = { input, value } + return value } +} + +function sameActivityInput(left: I, right: I): boolean { + return ( + left.statuses === right.statuses && + left.parents === right.parents && + left.runningShells === right.runningShells && + left.directories === right.directories && + left.pendingInputSessionIDs === right.pendingInputSessionIDs + ) +} - const markRunning = (directory?: string | null): void => { - const normalized = normalizeDirectory(localDirectoryOf(directory)) - if (!normalized || index.get(normalized) === 'needs-input') return - index.set(normalized, 'running') +function sameServerActivityInput( + left: ServerActivityIndexInput, + right: ServerActivityIndexInput, +): boolean { + return sameActivityInput(left, right) && left.sessionServers === right.sessionServers +} + +/** + * Rolls every active or pending session onto the index key its directory resolves to. Pending + * input marks its own and its root's key as needs-input; non-idle sessions and running shells mark + * running unless needs-input already claimed the key. + */ +function computeActivityIndex( + input: ActivityIndexInput, + keyOf: (sessionID: string, directory: string | null | undefined) => string | null, +): ActivityIndex { + const { statuses, parents, runningShells, directories, pendingInputSessionIDs } = input + const index: ActivityIndex = new Map() + + const mark = ( + state: 'needs-input' | 'running', + sessionID: string, + directory?: string | null, + ): void => { + const key = keyOf(sessionID, directory) + if (!key) return + if (state === 'running' && index.get(key) === 'needs-input') return + index.set(key, state) + } + + const markSession = (state: 'needs-input' | 'running', sessionID: string): void => { + mark(state, sessionID, directories.get(sessionID)) + mark(state, sessionID, directories.get(rootSessionID(sessionID, parents))) } for (const sessionID of pendingInputSessionIDs) { - markNeedsInput(directories.get(sessionID)) - markNeedsInput(directories.get(rootSessionID(sessionID, parents))) + markSession('needs-input', sessionID) } for (const [sessionID, status] of statuses) { if (status.type === 'idle') continue - markRunning(directories.get(sessionID)) - markRunning(directories.get(rootSessionID(sessionID, parents))) + markSession('running', sessionID) } for (const shell of runningShells.values()) { - markRunning(shell.directory) - markRunning(directories.get(shell.sessionID)) - markRunning(directories.get(rootSessionID(shell.sessionID, parents))) + mark('running', shell.sessionID, shell.directory) + markSession('running', shell.sessionID) } return index } +function localActivityKey(_sessionID: string, directory?: string | null): string | null { + return normalizeDirectory(localDirectoryOf(directory)) +} + +const repoActivityIndex = cacheByInput( + (input) => computeActivityIndex(input, localActivityKey), + sameActivityInput, +) + /** * Indexes active sessions by normalized directory for repo badges. Pending input marks its own and * its root's directory as needs-input; non-idle sessions and running shells mark running unless * needs-input already claimed the directory. Shared across rows per store snapshot by reference. */ -export function buildRepoActivityIndex( - input: RepoActivityIndexInput, -): Map { - if ( - cachedRepoActivityIndex && - cachedRepoActivityIndex.statuses === input.statuses && - cachedRepoActivityIndex.parents === input.parents && - cachedRepoActivityIndex.runningShells === input.runningShells && - cachedRepoActivityIndex.directories === input.directories && - cachedRepoActivityIndex.pendingInputSessionIDs === input.pendingInputSessionIDs - ) { - return cachedRepoActivityIndex.index +export function buildRepoActivityIndex(input: ActivityIndexInput): ActivityIndex { + return repoActivityIndex(input) +} + +/** The server and plain directory named by a session-status directory value. */ +export interface SessionDirectoryScope { + serverId: string + directory: string | null +} + +/** + * Resolves a session-status directory value to its owning server and plain directory. A + * server-keyed value names its owner, otherwise the session-server entry does; everything else + * belongs to the local server. + */ +export function sessionScopeOfDirectory( + directory: string | null | undefined, + sessionServerId?: string, +): SessionDirectoryScope { + const parsed = directory ? parseServerDirectoryKey(directory) : null + const serverId = + parsed && parsed.serverId !== LOCAL_OPENCODE_SERVER_ID + ? parsed.serverId + : sessionServerId ?? LOCAL_OPENCODE_SERVER_ID + return { serverId, directory: parsed?.directory ?? null } +} + +function serverIndexKey(serverId: string, directory?: string | null): string { + return `${serverId}|${normalizeDirectory(directory) ?? ''}` +} + +const serverActivityIndex = cacheByInput( + (input) => + computeActivityIndex(input, (sessionID, directory) => { + const scope = sessionScopeOfDirectory(directory, input.sessionServers.get(sessionID)) + if (scope.serverId === LOCAL_OPENCODE_SERVER_ID) return null + return serverIndexKey(scope.serverId, scope.directory) + }), + sameServerActivityInput, +) + +/** + * Indexes active sessions by owning linked server and directory for server badges. A session + * belongs to the server named by its server-keyed directory, otherwise to its session-server entry; + * local sessions are never indexed. Keys are `|`, with an empty directory when + * the session's directory is unknown. + */ +export function buildServerActivityIndex(input: ServerActivityIndexInput): ActivityIndex { + return serverActivityIndex(input) +} + +/** + * A linked server's activity from a prebuilt server index. Needs-input outranks running. + */ +export function serverActivityFromIndex( + serverId: string, + index: ReadonlyMap, +): 'needs-input' | 'running' | null { + const prefix = `${serverId}|` + let running = false + + for (const [key, state] of index) { + if (!key.startsWith(prefix)) continue + if (state === 'needs-input') return 'needs-input' + if (state === 'running') running = true } - const index = computeRepoActivityIndex(input) - cachedRepoActivityIndex = { ...input, index } - return index + return running ? 'running' : null } /** @@ -322,11 +426,11 @@ export function needsWorktreeLookup(input: { /** * Groups the given sessions into needs-input, running, and recent roots. A session appears in * exactly one group (needs-input over running over recent) and only when it resolves to a local - * repo or to an owning linked server. A session owned by a linked server is only emitted while it - * needs input or is running, so remote work never fills the recent window. A recent session in a - * schedule worktree is omitted so schedules only surface while they need attention. The input may - * mix roots and children; children never become items, but their latest update time rolls up into - * the root's `updatedAt` and decides the recent window and ordering. + * repo or to an owning linked server. A session owned by a linked server keeps its own row in any + * state, opening on that server instead of a local repo. A recent session in a schedule worktree is + * omitted so schedules only surface while they need attention. The input may mix roots and children; + * children never become items, but their latest update time rolls up into the root's `updatedAt` and + * decides the recent window and ordering. */ export function buildActiveWork(input: { now: number @@ -380,7 +484,6 @@ export function buildActiveWork(input: { const serverId = sessionServers?.get(session.id) ?? null if (serverId) { - if (state === 'recent') continue seen.add(session.id) const item: ActiveWorkItem = { key: getSessionKey(session), @@ -395,7 +498,8 @@ export function buildActiveWork(input: { path: getServerSessionPath(serverId, session.id), } if (state === 'needs-input') needsInput.push(item) - else running.push(item) + else if (state === 'running') running.push(item) + else recent.push(item) continue } diff --git a/frontend/src/lib/builtinCommands.ts b/frontend/src/lib/builtinCommands.ts index 923172fb1..4c25db67d 100644 --- a/frontend/src/lib/builtinCommands.ts +++ b/frontend/src/lib/builtinCommands.ts @@ -69,13 +69,13 @@ export function isBuiltinCommand(command: CommandInfo): command is BuiltinComman } const MANAGER_ONLY_COMMAND_ACTIONS: ReadonlySet = new Set([ - 'showSessions', 'showWalkthrough', ]) /** * Built-in composer commands for a server scope. Commands backed by Manager-only features are omitted for - * linked OpenCode servers; MCP and skills stay because their views work against the linked server. + * linked OpenCode servers; MCP, skills, and the session picker stay because their views work against the + * linked server. */ export function getBuiltinCommands(isLocalServer: boolean): readonly BuiltinCommand[] { if (isLocalServer) return BUILTIN_COMMANDS diff --git a/frontend/src/lib/queryInvalidation.test.ts b/frontend/src/lib/queryInvalidation.test.ts index 672d500a2..2ef7c355a 100644 --- a/frontend/src/lib/queryInvalidation.test.ts +++ b/frontend/src/lib/queryInvalidation.test.ts @@ -1,6 +1,7 @@ import { QueryClient } from '@tanstack/react-query' import { afterEach, describe, expect, it, vi } from 'vitest' import { + activeWorkSessionsQueryKey, invalidateConfigCaches, invalidateProviderCaches, invalidateProviderCachesDebounced, @@ -206,6 +207,35 @@ describe('invalidateSessionListCachesDebounced', () => { expect(queryClient.getQueryState(sessionListKey('/a'))?.isInvalidated).toBe(true) expect(queryClient.getQueryState(sessionListKey('/b'))?.isInvalidated).toBe(true) }) + + it('keeps a remote event directory from invalidating the local list with the same path', () => { + vi.useFakeTimers() + const queryClient = new QueryClient() + const remoteListKey = ['opencode', 'sessions', '/repo', { allDirectories: true }, 'srv_a'] + queryClient.setQueryData(sessionListKey('/repo'), { pages: [], pageParams: [] }) + queryClient.setQueryData(remoteListKey, { pages: [], pageParams: [] }) + + invalidateSessionListCachesDebounced(queryClient, '/repo', 'srv_a') + vi.advanceTimersByTime(200) + + expect(queryClient.getQueryState(remoteListKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(sessionListKey('/repo'))?.isInvalidated).toBe(false) + }) + + it('invalidates the active-work list of the event server only', () => { + vi.useFakeTimers() + const queryClient = new QueryClient() + const localActiveWorkKey = activeWorkSessionsQueryKey('local') + const remoteActiveWorkKey = activeWorkSessionsQueryKey('srv_a') + queryClient.setQueryData(localActiveWorkKey, []) + queryClient.setQueryData(remoteActiveWorkKey, []) + + invalidateSessionListCachesDebounced(queryClient, '/repo', 'srv_a') + vi.advanceTimersByTime(200) + + expect(queryClient.getQueryState(remoteActiveWorkKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(localActiveWorkKey)?.isInvalidated).toBe(false) + }) }) describe('invalidateQueryKeysDebounced', () => { diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index b3bec0759..501666c5d 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -1,4 +1,5 @@ import type { QueryClient } from '@tanstack/react-query' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { GitStatusResponse } from '@/types/git' export function sessionTranscriptQueryKey(sessionID: string | null | undefined) { @@ -182,23 +183,49 @@ export function invalidateSessionCaches(queryClient: QueryClient) { }) } +const SESSION_LIST_QUERY_GROUPS = new Set(['sessions', 'active-work-sessions']) + +/** + * Query key of one server's active-work session list: flat, newest-first, across all directories. + * It is distinct from the paged session-list keys so the two data shapes never share a cache entry. + */ +export function activeWorkSessionsQueryKey(serverId: string): readonly unknown[] { + return ['opencode', 'active-work-sessions', serverId] +} + export function invalidateSessionListCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ predicate: (query) => - query.queryKey[0] === 'opencode' && query.queryKey[1] === 'sessions', + query.queryKey[0] === 'opencode' && SESSION_LIST_QUERY_GROUPS.has(String(query.queryKey[1])), }) } +/** + * The server a session-list query key belongs to: an active-work key carries it third, a paged + * session-list key appends it after its options, and an unscoped key is a local-server key. + */ +function sessionListQueryServerId(queryKey: readonly unknown[]): string { + if (queryKey[1] === 'active-work-sessions') return String(queryKey[2]) + return typeof queryKey[4] === 'string' ? queryKey[4] : LOCAL_OPENCODE_SERVER_ID +} + interface SessionListInvalidationState { timer: ReturnType | null directories: Set invalidateAll: boolean } -const sessionListInvalidationStates = new WeakMap() +const sessionListInvalidationStates = new WeakMap>() -function sessionListQueryMatchesDirectories(queryKey: readonly unknown[], directories: Set) { - if (queryKey[0] !== 'opencode' || queryKey[1] !== 'sessions') return false +function sessionListQueryMatchesDirectories( + queryKey: readonly unknown[], + directories: Set, + serverId: string, +) { + if (queryKey[0] !== 'opencode') return false + if (!SESSION_LIST_QUERY_GROUPS.has(String(queryKey[1]))) return false + if (sessionListQueryServerId(queryKey) !== serverId) return false + if (queryKey[1] === 'active-work-sessions') return true const directoryKey = queryKey[2] if (typeof directoryKey !== 'string') return false return directoryKey.split('|').some((directory) => directories.has(directory)) @@ -207,22 +234,30 @@ function sessionListQueryMatchesDirectories(queryKey: readonly unknown[], direct function flushSessionListInvalidation( queryClient: QueryClient, state: SessionListInvalidationState, + serverId: string, ) { if (state.invalidateAll) { - invalidateSessionListCaches(queryClient) + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'opencode' && + SESSION_LIST_QUERY_GROUPS.has(String(query.queryKey[1])) && + sessionListQueryServerId(query.queryKey) === serverId, + }) return } queryClient.invalidateQueries({ - predicate: (query) => sessionListQueryMatchesDirectories(query.queryKey, state.directories), + predicate: (query) => sessionListQueryMatchesDirectories(query.queryKey, state.directories, serverId), }) } export function invalidateSessionListCachesDebounced( queryClient: QueryClient, directory?: string, + serverId: string = LOCAL_OPENCODE_SERVER_ID, delayMs = 200, ) { - const state = sessionListInvalidationStates.get(queryClient) ?? { + const states = sessionListInvalidationStates.get(queryClient) ?? new Map() + const state = states.get(serverId) ?? { timer: null, directories: new Set(), invalidateAll: false, @@ -233,10 +268,11 @@ export function invalidateSessionListCachesDebounced( } else { state.invalidateAll = true } - sessionListInvalidationStates.set(queryClient, state) + states.set(serverId, state) + sessionListInvalidationStates.set(queryClient, states) state.timer = setTimeout(() => { - sessionListInvalidationStates.delete(queryClient) - flushSessionListInvalidation(queryClient, state) + states.delete(serverId) + flushSessionListInvalidation(queryClient, state, serverId) }, delayMs) } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 7582eb4aa..c1cddc992 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -61,7 +61,7 @@ import { formatOpenCodeModelRef } from "@opencode-manager/shared/opencode"; import { FormPrompt } from "@/components/session/FormPrompt"; import { MinimizedFormIndicator } from "@/components/session/MinimizedFormIndicator"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; -import { SourceControlPanel } from "@/components/source-control"; +import { SourceControlPanel, RemoteSourceControlPanel } from "@/components/source-control"; import { TerminalPanel } from "@/components/terminal/TerminalPanel"; import { PreviewPanel } from "@/components/preview/PreviewPanel"; import { SessionSendErrorBanner } from "@/components/session/SessionSendErrorBanner"; @@ -1060,15 +1060,24 @@ export function SessionDetail() { /> )} - {!isRemoteServer && ( - setSourceControlOpen(false)} - currentBranch={repo?.currentBranch || repo?.branch || "main"} - repoName={workspaceDisplayName} - /> - )} + {isRemoteServer + ? sessionDirectory && ( + setSourceControlOpen(false)} + /> + ) + : ( + setSourceControlOpen(false)} + currentBranch={repo?.currentBranch || repo?.branch || "main"} + repoName={workspaceDisplayName} + /> + )} ({ getRepo: vi.fn(), useSessionStatus: vi.fn(), SourceControlPanel: vi.fn(() => null), + RemoteSourceControlPanel: vi.fn(() => null), TerminalPanel: vi.fn(() => null), PreviewPanel: vi.fn(() => null), FileBrowserSheet: vi.fn(() => null), @@ -145,7 +146,10 @@ vi.mock('@/components/notifications/PendingActionsGroup', () => ({ vi.mock('@/components/navigation/SessionMoreButton', () => ({ SessionMoreButton: mocks.SessionMoreButton })) vi.mock('@/components/navigation/ToolSidePanel', () => ({ ToolSidePanel: mocks.ToolSidePanel })) vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ FileBrowserSheet: mocks.FileBrowserSheet })) -vi.mock('@/components/source-control', () => ({ SourceControlPanel: mocks.SourceControlPanel })) +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: mocks.SourceControlPanel, + RemoteSourceControlPanel: mocks.RemoteSourceControlPanel, +})) vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalPanel: mocks.TerminalPanel })) vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewPanel: mocks.PreviewPanel })) vi.mock('@/components/repo/ProjectActionsMenu', () => ({ ProjectActionsMenu: mocks.ProjectActionsMenu })) @@ -349,6 +353,23 @@ describe('SessionDetail remote server mode', () => { ) }) + it('opens the remote source control panel instead of the Manager one', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SourceControlPanel).not.toHaveBeenCalled() + expect(mocks.RemoteSourceControlPanel.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + serverId: 'srv_kitchen', + directory: '/home/me/app', + isOpen: false, + }), + ) + }) + it('treats the stream as disconnected when the remote server status is disconnected', async () => { mocks.useOpenCodeServerStatuses.mockReturnValue({ statusById: { From e4a4afbad46ef721a025edb7493050b870520497 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:59:15 -0400 Subject: [PATCH 04/15] fix(schedules): recover runs across reconnects and cancel on unavailable servers Re-check monitored schedule sessions when their server's event stream reconnects, finalize cancellation without contacting a disabled or removed linked server, and tighten the disabled-server goal recovery test. --- backend/src/services/schedules.ts | 61 ++++++++++- .../test/services/schedules-remote.test.ts | 100 +++++++++++++++++- .../services/schedules.permission.test.ts | 3 + backend/test/services/schedules.test.ts | 79 ++++++++++++++ backend/test/services/session-goals.test.ts | 1 + 5 files changed, 238 insertions(+), 6 deletions(-) diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 526c50a65..f1e9ca729 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -75,6 +75,8 @@ class ScheduleServiceError extends Error { } } +class ServerUnavailableError extends ScheduleServiceError {} + const SESSION_STOPPED_ERROR = 'The session stopped without producing an assistant response. This usually means OpenCode restarted mid-run or the session was interrupted. Open the linked session to inspect any partial output and rerun if needed.' const DELETION_RESERVATION_RUN_ID = -1 @@ -93,6 +95,7 @@ interface SessionMonitor { interface ActiveRun { runId: number abort: AbortController + sessionMonitor?: SessionMonitor } type CancelDecision = 'cancelled' | 'settled' | 'resume' @@ -217,6 +220,12 @@ function createSessionMonitor(directory: string, sessionId: string, serverId: st handleEvent(event) }) + const unsubscribeConnected = sseAggregator.onServerConnected((connectedServerId) => { + if (connectedServerId === serverId) { + push({ errorText: null, disposed: false }) + } + }) + return { markSubmitted: () => { queued.length = 0 @@ -237,6 +246,7 @@ function createSessionMonitor(directory: string, sessionId: string, serverId: st } disposed = true unsubscribe() + unsubscribeConnected() push({ errorText: null, disposed: true }) }, } @@ -335,6 +345,22 @@ export class ScheduleService { } } + private static attachSessionMonitor(jobId: number, runId: number, sessionMonitor: SessionMonitor): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId === runId) { + active.sessionMonitor = sessionMonitor + } + } + + private static disposeActiveRunMonitor(jobId: number, runId: number): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId !== runId || !active.sessionMonitor) { + return + } + active.sessionMonitor.dispose() + active.sessionMonitor = undefined + } + getActiveRunSessions(): ScheduledSessionRef[] { const refs: ScheduledSessionRef[] = [] @@ -362,10 +388,10 @@ export class ScheduleService { private assertServer(serverId: string) { const server = this.serverRegistry?.get(serverId) if (!server) { - throw new ScheduleServiceError('OpenCode server not found', 404) + throw new ServerUnavailableError('OpenCode server not found', 404) } if (!server.enabled) { - throw new ScheduleServiceError(`OpenCode server "${server.name}" is disabled`, 400) + throw new ServerUnavailableError(`OpenCode server "${server.name}" is disabled`, 400) } return server } @@ -950,6 +976,7 @@ export class ScheduleService { } const sessionMonitor = createSessionMonitor(runDirectory, session.id, context.serverId) + ScheduleService.attachSessionMonitor(jobId, run.id, sessionMonitor) void this.submitPromptAndMonitor({ repoId, @@ -1029,7 +1056,17 @@ export class ScheduleService { private async cancelRunForTarget(target: ScheduleJobTarget, jobId: number, runId: number): Promise { const job = this.loadJobForTarget(target, jobId) - const context = this.resolveTargetContext(target, job.directory) + let context: ScheduleTargetContext + + try { + context = this.resolveTargetContext(target, job.directory) + } catch (error) { + if (target.kind !== 'server' || !(error instanceof ServerUnavailableError)) { + throw error + } + return this.cancelUnavailableServerRun(job, jobId, runId) + } + const run = getScheduleRunById(this.db, context.repoId, jobId, runId) if (!run) { @@ -1078,6 +1115,23 @@ export class ScheduleService { } } + private async cancelUnavailableServerRun(job: ScheduleJob, jobId: number, runId: number): Promise { + const run = getScheduleRunById(this.db, null, jobId, runId) + + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + + if (run.status !== 'running') { + throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) + } + + ScheduleService.requestCancel(jobId, runId) + ScheduleService.abortActiveRun(jobId, runId) + ScheduleService.disposeActiveRunMonitor(jobId, runId) + return this.finalizeCancelledRun(null, job, run, null, jobId, runId) + } + private getRunById(repoId: number | null, jobId: number, runId: number): ScheduleRun { const run = getScheduleRunById(this.db, repoId, jobId, runId) if (!run) { @@ -1394,6 +1448,7 @@ export class ScheduleService { if (outcome.kind === 'busy') { const sessionMonitor = createSessionMonitor(runDirectory, run.sessionId, context.serverId) + ScheduleService.attachSessionMonitor(job.id, run.id, sessionMonitor) void this.monitorRunCompletion({ sessionMonitor, repoId: run.repoId, diff --git a/backend/test/services/schedules-remote.test.ts b/backend/test/services/schedules-remote.test.ts index 81f6ed329..14c42621a 100644 --- a/backend/test/services/schedules-remote.test.ts +++ b/backend/test/services/schedules-remote.test.ts @@ -2,11 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Database } from 'bun:sqlite' import { migrate } from '../../src/db/migration-runner' import { allMigrations } from '../../src/db/migrations' -import { getScheduleJobById, createScheduleRun, listScheduleRunsByJob } from '../../src/db/schedules' +import { getScheduleJobById, createScheduleRun, getScheduleRunById, listScheduleRunsByJob } from '../../src/db/schedules' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' import { ScheduleService } from '../../src/services/schedules' +import { sseAggregator } from '../../src/services/sse-aggregator' import { createStubOpenCodeClient, stubLoadedModelCatalog } from '../helpers/stub-opencode-client' -import { createStubScheduleApi } from '../helpers/stub-schedule-api' +import { assistantMessage, createStubScheduleApi } from '../helpers/stub-schedule-api' vi.mock('../../src/utils/logger', () => ({ logger: { @@ -41,7 +42,20 @@ function createDatabase(): Database { function createClient() { const stub = createStubScheduleApi() const api = { ...stub.api, ...stubLoadedModelCatalog() } as unknown as OpenCodeApi - return { client: createStubOpenCodeClient({ api }), api: stub.api } + return { client: createStubOpenCodeClient({ api }), api: stub.api, state: stub.state } +} + +function activeRunMonitor(jobId: number): { dispose(): void } | undefined { + const activeRuns = (ScheduleService as unknown as { activeRuns: Map }).activeRuns + return activeRuns.get(jobId)?.sessionMonitor +} + +function hasCancelInProgress(jobId: number): boolean { + return (ScheduleService as unknown as { pendingCancels: Map }).pendingCancels.has(jobId) +} + +function hasActiveRun(jobId: number): boolean { + return (ScheduleService as unknown as { activeRuns: Map }).activeRuns.has(jobId) } function createService(db: Database, localClient: ReturnType, remoteClient: ReturnType, enabled = true, registered = true) { @@ -122,6 +136,50 @@ describe('remote schedule targets', () => { expect(worktreeManager.finalize).not.toHaveBeenCalled() }) + it('completes a remote run after a server reconnect when the settle event was missed', async () => { + const localClient = createClient().client + const { client, api, state } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + state.active = { 'ses-run-1': { type: 'running' } } + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + await vi.waitFor(() => expect(api.session.active).toHaveBeenCalled()) + + state.active = {} + state.messages = [assistantMessage('Remote audit complete.', { completed: true })] + sseAggregator.notifyServerConnected(REMOTE_SERVER.id) + + await vi.waitFor(() => { + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ + status: 'completed', + responseText: 'Remote audit complete.', + }) + }) + }) + + it('keeps waiting after a remote server reconnect while the session is still busy', async () => { + const localClient = createClient().client + const { client, api, state } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + + state.active = { 'ses-run-1': { type: 'running' } } + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + await vi.waitFor(() => expect(api.session.active).toHaveBeenCalled()) + const activeChecks = vi.mocked(api.session.active).mock.calls.length + + sseAggregator.notifyServerConnected(REMOTE_SERVER.id) + + await vi.waitFor(() => { + expect(vi.mocked(api.session.active).mock.calls.length).toBeGreaterThan(activeChecks) + }) + expect(getScheduleRunById(db, null, job.id, run.id)?.status).toBe('running') + + activeRunMonitor(job.id)?.dispose() + await vi.waitFor(() => expect(hasActiveRun(job.id)).toBe(false)) + }) + it('records a failed run when the target server is disabled', async () => { const localClient = createClient().client const { client } = createClient() @@ -246,6 +304,42 @@ describe('remote schedule targets', () => { expect(listScheduleRunsByJob(db, null, job.id)).toEqual([]) }) + + it('cancels a running remote run while the target server is disabled', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + const disabled = createService(db, localClient, client, false) + + const cancelled = await disabled.service.cancelServerRun(REMOTE_SERVER.id, job.id, run.id) + + expect(cancelled).toMatchObject({ id: run.id, status: 'cancelled', errorText: 'Run cancelled by user.' }) + expect(hasActiveRun(job.id)).toBe(false) + + await vi.waitFor(() => expect(hasCancelInProgress(job.id)).toBe(false)) + + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ status: 'cancelled' }) + }) + + it('cancels a running remote run when the target server is missing from the registry', async () => { + const localClient = createClient().client + const { client } = createClient() + const { service } = createService(db, localClient, client) + const job = await service.createServerJob(REMOTE_SERVER.id, '/srv/projects/audit', baseRequest) + const run = await service.runServerJob(REMOTE_SERVER.id, job.id, 'manual') + const unregistered = createService(db, localClient, client, true, false) + + const cancelled = await unregistered.service.cancelServerRun(REMOTE_SERVER.id, job.id, run.id) + + expect(cancelled).toMatchObject({ id: run.id, status: 'cancelled', errorText: 'Run cancelled by user.' }) + expect(hasActiveRun(job.id)).toBe(false) + + await vi.waitFor(() => expect(hasCancelInProgress(job.id)).toBe(false)) + + expect(getScheduleRunById(db, null, job.id, run.id)).toMatchObject({ status: 'cancelled' }) + }) }) function seedCompletedRun(db: Database, jobId: number): number { diff --git a/backend/test/services/schedules.permission.test.ts b/backend/test/services/schedules.permission.test.ts index 12342e5b9..014017487 100644 --- a/backend/test/services/schedules.permission.test.ts +++ b/backend/test/services/schedules.permission.test.ts @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenCodeModel: vi.fn(), onEvent: vi.fn(), + onServerConnected: vi.fn(), loggerError: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { @@ -78,6 +79,7 @@ vi.mock('../../src/services/opencode-models', () => ({ vi.mock('../../src/services/sse-aggregator', () => ({ sseAggregator: { onEvent: mocks.onEvent, + onServerConnected: mocks.onServerConnected, }, })) @@ -161,6 +163,7 @@ describe('ScheduleService permission ruleset in session creation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) }) it('sends the default permission ruleset when job.permissionConfig is null', async () => { diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 6448b592d..4a463b508 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -3,6 +3,7 @@ import path from 'path' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' import { getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' const mocks = vi.hoisted(() => ({ getRepoById: vi.fn(), @@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => ({ resolveOpenCodeModel: vi.fn(), onEvent: vi.fn(), + onServerConnected: vi.fn(), loggerError: vi.fn(), updateScheduleRunWorktree: vi.fn(), stubWorktreeManager: { @@ -86,6 +88,7 @@ vi.mock('../../src/services/opencode-models', () => ({ vi.mock('../../src/services/sse-aggregator', () => ({ sseAggregator: { onEvent: mocks.onEvent, + onServerConnected: mocks.onServerConnected, }, })) @@ -130,6 +133,15 @@ function captureEventListener(): (directory: string, event: unknown) => void { return mocks.onEvent.mock.calls[0]?.[0] as (directory: string, event: unknown) => void } +function captureServerConnectedListener(): (serverId: string) => void { + return mocks.onServerConnected.mock.calls[0]?.[0] as (serverId: string) => void +} + +function activeRunMonitor(jobId: number): { dispose(): void } | undefined { + const activeRuns = Reflect.get(ScheduleService, 'activeRuns') as Map + return activeRuns.get(jobId)?.sessionMonitor +} + const repo = { id: 42, fullPath: '/workspace/repos/sample-project', @@ -195,6 +207,7 @@ describe('ScheduleService', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.getScheduleRunById.mockReturnValue({ ...baseRun, sessionId: 'ses-run-1', @@ -253,6 +266,70 @@ describe('ScheduleService', () => { ) }) + it('completes a run after the server reconnects when the settle event was missed', async () => { + const stub = createStubScheduleApi({ + active: { 'ses-run-1': { type: 'running' } }, + messages: [assistantMessage('Recovered summary.', { completed: true })], + }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + stub.state.active = {} + + captureServerConnectedListener()(LOCAL_OPENCODE_SERVER_ID) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Recovered summary.', + }), + ) + }) + }) + + it('keeps waiting after the server reconnects while the session is still busy', async () => { + const stub = createStubScheduleApi({ active: { 'ses-run-1': { type: 'running' } } }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + const activeChecks = vi.mocked(stub.api.session.active).mock.calls.length + + captureServerConnectedListener()(LOCAL_OPENCODE_SERVER_ID) + + await vi.waitFor(() => { + expect(vi.mocked(stub.api.session.active).mock.calls.length).toBeGreaterThan(activeChecks) + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + activeRunMonitor(7)?.dispose() + await vi.waitFor(() => { + expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false) + }) + }) + it('strips thinking blocks from the persisted assistant response while joining text items', async () => { const stub = createStubScheduleApi({ sessionID: 'ses-think', @@ -1263,6 +1340,7 @@ describe('ScheduleService startup cancellation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.stubWorktreeManager.prepare.mockResolvedValue(null) mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) }) @@ -1525,6 +1603,7 @@ describe('ScheduleService monitor cancellation', () => { mocks.createScheduleRun.mockReturnValue(baseRun) mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) mocks.onEvent.mockReturnValue(vi.fn()) + mocks.onServerConnected.mockReturnValue(vi.fn()) mocks.stubWorktreeManager.prepare.mockResolvedValue(null) mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) }) diff --git a/backend/test/services/session-goals.test.ts b/backend/test/services/session-goals.test.ts index 57273be28..22b12262d 100644 --- a/backend/test/services/session-goals.test.ts +++ b/backend/test/services/session-goals.test.ts @@ -1250,6 +1250,7 @@ describe('SessionGoalService remote servers', () => { await vi.runAllTimersAsync() expect(restarted.getLatest('ses_remote', REMOTE_SERVER_ID)).toMatchObject({ status: 'active' }) + expect(local.auditorCalls).toHaveLength(0) expect(remote.auditorCalls).toHaveLength(0) }) }) From cb9b7645e67999a233f0104e69d36a9c8860b4ad Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:16:32 -0400 Subject: [PATCH 05/15] fix(opencode-import): skip caches and worktrees when importing host state First-run state import copied the entire host OpenCode data directory, including snapshots, repos, binaries, logs and worktrees, which could reach tens of gigabytes, block startup, and recurse into the manager's own checkout. Skip those top-level entries and any entry containing the destination. --- backend/src/services/opencode-import.ts | 42 +++++++++++- backend/test/services/opencode-import.test.ts | 66 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts index 2cb7e44d6..4231dd9ef 100644 --- a/backend/src/services/opencode-import.ts +++ b/backend/src/services/opencode-import.ts @@ -23,6 +23,18 @@ import type { SettingsService } from './settings' const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) +export const OPENCODE_STATE_IMPORT_EXCLUDED_ENTRIES: ReadonlySet = new Set([ + 'worktree', + 'snapshot', + 'repos', + 'bin', + 'log', + 'tool-output', + 'shell', +]) + +const OPENCODE_STATE_IMPORT_EXCLUDED_SUFFIXES = ['.log'] + export interface OpenCodeImportStatus { configSourcePath: string | null configSourcePaths: string[] @@ -93,7 +105,29 @@ function escapeSqliteValue(value: string): string { return value.replace(/'/g, "''") } -async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise { +function getTopLevelEntryContainingPath(sourcePath: string, targetPath: string): string | null { + const relativeTargetPath = path.relative(path.resolve(sourcePath), path.resolve(targetPath)) + + if (relativeTargetPath === '' || relativeTargetPath.startsWith('..') || path.isAbsolute(relativeTargetPath)) { + return null + } + + return relativeTargetPath.split(path.sep)[0] ?? null +} + +export function shouldImportOpenCodeStateEntry(entryName: string, sourcePath: string, targetPath: string): boolean { + if (OPENCODE_STATE_IMPORT_EXCLUDED_ENTRIES.has(entryName)) { + return false + } + + if (OPENCODE_STATE_IMPORT_EXCLUDED_SUFFIXES.some(suffix => entryName.endsWith(suffix))) { + return false + } + + return entryName !== getTopLevelEntryContainingPath(sourcePath, targetPath) +} + +async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string, destinationPath: string): Promise { const entries = await readdir(sourcePath, { withFileTypes: true }) for (const entry of entries) { @@ -101,6 +135,10 @@ async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): P continue } + if (!shouldImportOpenCodeStateEntry(entry.name, sourcePath, destinationPath)) { + continue + } + await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), { recursive: true, force: true, @@ -139,7 +177,7 @@ export async function importOpenCodeStateDirectory(sourcePath: string, targetPat const stagedTargetPath = await mkdtemp(path.join(targetParentPath, `${targetDirectoryName}-import-`)) try { - await copyOpenCodeStateFiles(resolvedSourcePath, stagedTargetPath) + await copyOpenCodeStateFiles(resolvedSourcePath, stagedTargetPath, resolvedTargetPath) snapshotOpenCodeDatabase(sourceDbPath, path.join(stagedTargetPath, 'opencode.db')) await rm(resolvedTargetPath, { recursive: true, force: true }) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts index a36e9ff8b..756d805d9 100644 --- a/backend/test/services/opencode-import.test.ts +++ b/backend/test/services/opencode-import.test.ts @@ -455,6 +455,72 @@ describe('opencode-import service', () => { expect(mockRename).toHaveBeenCalledWith(stagedPath, targetPath) }) + it('imports only durable host state entries and keeps nested directories named like excluded caches', async () => { + const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') + const actualFs = await vi.importActual('fs/promises') + const rootPath = await actualFs.mkdtemp(path.join(os.tmpdir(), 'opencode-import-test-')) + const sourcePath = path.join(rootPath, 'source') + const targetPath = path.join(rootPath, 'workspace', '.opencode', 'state', 'opencode') + + const writeSourceFile = async (relativePath: string) => { + const filePath = path.join(sourcePath, relativePath) + await actualFs.mkdir(path.dirname(filePath), { recursive: true }) + await actualFs.writeFile(filePath, relativePath) + } + const mockRm = vi.mocked(rm) + + try { + await actualFs.mkdir(path.dirname(targetPath), { recursive: true }) + for (const relativePath of [ + 'opencode.db', + 'auth.json', + 'storage/x', + 'storage/log/y', + 'project/project.json', + 'plans/plan.json', + 'forge/forge.json', + 'forge.db', + 'worktree/checkout/file', + 'snapshot/snapshot', + 'repos/repo', + 'bin/binary', + 'log/server', + 'tool-output/output', + 'shell/command', + 'debug.log', + ]) { + await writeSourceFile(relativePath) + } + + mockFileExists.mockImplementation(async (candidate: string) => candidate === path.join(sourcePath, 'opencode.db')) + mockEnsureDirectoryExists.mockResolvedValue(undefined) + mockReaddir.mockImplementation(actualFs.readdir) + mockMkdtemp.mockImplementation(actualFs.mkdtemp) + mockRename.mockImplementation(actualFs.rename) + mockRm.mockImplementation(actualFs.rm) + vi.mocked(cp).mockImplementation(actualFs.cp) + + const result = await importOpenCodeStateDirectory(sourcePath, targetPath) + + expect(result).toBe(true) + expect((await actualFs.readdir(targetPath)).sort()).toEqual( + ['auth.json', 'forge', 'forge.db', 'plans', 'project', 'storage'].sort() + ) + expect(await actualFs.readFile(path.join(targetPath, 'storage', 'log', 'y'), 'utf8')).toBe('storage/log/y') + expect(MockSQLiteDatabase).toHaveBeenCalledWith(path.join(sourcePath, 'opencode.db')) + } finally { + await actualFs.rm(rootPath, { recursive: true, force: true }) + } + }) + + it('skips the top-level entry that contains the import destination', async () => { + const { shouldImportOpenCodeStateEntry } = await import('../../src/services/opencode-import') + + expect(shouldImportOpenCodeStateEntry('.opencode', '/host/opencode', '/host/opencode/.opencode/state/opencode')).toBe(false) + expect(shouldImportOpenCodeStateEntry('storage', '/host/opencode', '/host/opencode/.opencode/state/opencode')).toBe(true) + expect(shouldImportOpenCodeStateEntry('storage', '/host/opencode', '/elsewhere/.opencode/state/opencode')).toBe(true) + }) + it('cleans up the staged import directory when snapshotting fails', async () => { const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') From 36b8c1b2001f0be14dbd82cb99db47cf689f895d Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:16:32 -0400 Subject: [PATCH 06/15] feat(servers): switch the sidebar listing between local and linked servers The Servers section becomes a Local/server switcher shared by the desktop sidebar, collapsed rail and mobile repo sheet. Selecting a linked server lists its projects and sessions with the same navigation as local repos, and a remote project page mirrors the local repo page. --- frontend/src/App.tsx | 10 + .../navigation/DesktopSessionTree.test.tsx | 9 + .../navigation/DesktopSessionTree.tsx | 129 ++++-- .../components/navigation/DesktopSidebar.tsx | 13 +- .../navigation/LinkedServersNav.test.tsx | 117 ++++- .../navigation/LinkedServersNav.tsx | 159 ++++--- .../navigation/RemoteProjectNav.test.tsx | 310 +++++++++++++ .../navigation/RemoteProjectNav.tsx | 435 ++++++++++++++++++ .../navigation/RepoQuickSwitchSheet.test.tsx | 64 ++- .../navigation/RepoQuickSwitchSheet.tsx | 61 ++- .../components/navigation/RepoSessionNav.tsx | 42 +- .../components/navigation/ToolSidePanel.tsx | 2 +- .../navigation/moreDrawerItems.test.ts | 24 + .../components/navigation/moreDrawerItems.ts | 3 +- .../navigation/sidebar-session-tree.ts | 8 +- .../src/components/repo/RepoSkillsDialog.tsx | 6 +- .../servers/LinkedServerStateCard.tsx | 36 ++ frontend/src/hooks/useActiveWork.ts | 52 +++ frontend/src/hooks/useSessionSearch.ts | 8 +- .../hooks/useSidebarServerSelection.test.tsx | 147 ++++++ .../src/hooks/useSidebarServerSelection.ts | 44 ++ frontend/src/lib/activeWork.ts | 11 + frontend/src/lib/navigation.test.ts | 30 +- frontend/src/lib/navigation.ts | 20 + frontend/src/pages/RemoteProject.tsx | 221 +++++++++ frontend/src/pages/RemoteServer.tsx | 57 ++- .../pages/__tests__/RemoteProject.test.tsx | 208 +++++++++ .../src/pages/__tests__/RemoteServer.test.tsx | 12 +- frontend/src/stores/sidebarServerStore.ts | 33 ++ 29 files changed, 2075 insertions(+), 196 deletions(-) create mode 100644 frontend/src/components/navigation/RemoteProjectNav.test.tsx create mode 100644 frontend/src/components/navigation/RemoteProjectNav.tsx create mode 100644 frontend/src/components/servers/LinkedServerStateCard.tsx create mode 100644 frontend/src/hooks/useSidebarServerSelection.test.tsx create mode 100644 frontend/src/hooks/useSidebarServerSelection.ts create mode 100644 frontend/src/pages/RemoteProject.tsx create mode 100644 frontend/src/pages/__tests__/RemoteProject.test.tsx create mode 100644 frontend/src/stores/sidebarServerStore.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6a71151a..fa1cbbe21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' import { RemoteServer } from './pages/RemoteServer' +import { RemoteProject } from './pages/RemoteProject' import { SessionDetail } from './pages/SessionDetail' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' @@ -321,6 +322,15 @@ const router = createBrowserRouter([ ), loader: protectedLoader, }, + { + path: '/servers/:serverId/projects', + element: ( + + + + ), + loader: protectedLoader, + }, { path: '/servers/:serverId/sessions/:sessionId', element: ( diff --git a/frontend/src/components/navigation/DesktopSessionTree.test.tsx b/frontend/src/components/navigation/DesktopSessionTree.test.tsx index 9a37fcf38..186e4eba6 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.test.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.test.tsx @@ -5,7 +5,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter, useLocation } from 'react-router-dom' import type { ReactNode } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { DesktopSessionTree } from './DesktopSessionTree' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' import type { Repo } from '@/api/types' type SessionFixture = { @@ -90,6 +92,11 @@ vi.mock('@/hooks/useSessionPins', () => ({ useSessionPins: () => ({ data: sessionPinsData.current }), })) +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: () => ({ servers: [], isLoading: false }), + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ hasForSession: (sessionID: string) => permissionSessions.current.has(sessionID), @@ -156,6 +163,8 @@ const fetchedDirectories = () => new Set(sessionsHookCalls.flatMap((call) => cal describe('DesktopSessionTree', () => { beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) listReposMock.mockReset() listReposMock.mockResolvedValue([repoA, repoB, repoC]) getRepoSiblingsMock.mockReset() diff --git a/frontend/src/components/navigation/DesktopSessionTree.tsx b/frontend/src/components/navigation/DesktopSessionTree.tsx index e7395b4e7..a31d6bf94 100644 --- a/frontend/src/components/navigation/DesktopSessionTree.tsx +++ b/frontend/src/components/navigation/DesktopSessionTree.tsx @@ -1,11 +1,15 @@ -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { Search } from 'lucide-react' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import type { Repo, Session } from '@/api/types' import { Input } from '@/components/ui/input' +import { RepoActivityIndicator } from '@/components/activity/RepoActivityIndicator' import { useNavigableRepos, useSidebarRepoGroups } from '@/hooks/useSidebarRepoGroups' import { useSessionSearch } from '@/hooks/useSessionSearch' import { useSessionPins } from '@/hooks/useSessionPins' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { NewSessionButton, RepoNavGroup, @@ -21,7 +25,7 @@ import { isCurrentSessionItem, isRepoReady, } from '@/components/navigation/sidebar-session-tree' -import { getRepoPath } from '@/lib/navigation' +import { getRepoPath, getSessionPath } from '@/lib/navigation' import { getRepoDisplayName } from '@/lib/utils' import { buildPinnedSessionKeys } from '@/lib/sessionKey' @@ -52,6 +56,28 @@ function SessionSearchInput({ value, onChange }: { value: string; onChange: (val ) } +function SessionTreeLayout({ + draft, + onDraftChange, + children, +}: { + draft: string + onDraftChange: (value: string) => void + children: ReactNode +}) { + return ( +
+
+ +
+ +
+ {children} +
+
+ ) +} + interface SessionSearchResultsProps { nameMatchedRepos: Repo[] otherRepos: Repo[] @@ -90,14 +116,21 @@ function SessionSearchResults({ {matchingGroups.map((group) => ( navigate(getRepoPath(group.repo.id))} - actions={} + indicator={} + actions={ + getSessionPath(group.repo.id, sessionId)} + onOpenSession={navigate} + /> + } > {group.items.map((item) => ( -
- -
+ + {isLoading ? ( + Loading repos... + ) : trimmedDraft ? ( + + ) : ( + navigate(getRepoPath(repoId))} + onSelectSession={navigate} + renderActions={(repo) => ( + getSessionPath(repo.id, sessionId)} + onOpenSession={navigate} + /> + )} + /> + )} + + ) +} -
- {isLoading ? ( - Loading repos... - ) : trimmedDraft ? ( - - ) : ( - navigate(getRepoPath(repoId))} - onSelectSession={navigate} - renderActions={(repo) => } - /> - )} -
-
+function RemoteSessionTree({ serverId }: { serverId: string }) { + const navigate = useNavigate() + const [draft, setDraft] = useState('') + + return ( + + + ) } + +/** + * The sidebar session navigator. Follows the selected server: local repos for this machine, or the + * selected linked server's projects with the same rows and search behaviour. + */ +export function DesktopSessionTree() { + const [selectedServerId] = useSidebarServerSelection() + + if (selectedServerId !== LOCAL_OPENCODE_SERVER_ID) { + return + } + + return +} diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index db4b95fe8..24ca5b6f1 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -6,6 +6,9 @@ import { useSidebarWidth } from '@/hooks/useSidebarWidth' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { useOpenNavItem } from '@/hooks/useOpenNavItem' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' @@ -28,6 +31,12 @@ export function DesktopSidebar() { const [sidebarWidth, setSidebarWidth] = useSidebarWidth() const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() + const [selectedServerId] = useSidebarServerSelection() + const { servers } = useOpenCodeServers() + const selectedServerName = + selectedServerId === LOCAL_OPENCODE_SERVER_ID + ? null + : servers.find((server) => server.id === selectedServerId)?.name ?? null const isDesktop = useDesktop() @@ -109,7 +118,9 @@ export function DesktopSidebar() {
-

Sessions

+

+ Sessions{selectedServerName ? ` · ${selectedServerName}` : ''} +

diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index 009e02f94..5ad9af90e 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -1,12 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MemoryRouter, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { LinkedServersNav } from './LinkedServersNav' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' const mocks = vi.hoisted(() => ({ useOpenCodeServers: vi.fn(), useOpenCodeServerStatuses: vi.fn(), useServerActivityState: vi.fn(), + useLocalActivityState: vi.fn(), })) vi.mock('@/hooks/useOpenCodeServers', () => ({ @@ -16,6 +19,7 @@ vi.mock('@/hooks/useOpenCodeServers', () => ({ vi.mock('@/hooks/useActiveWork', () => ({ useServerActivityState: mocks.useServerActivityState, + useLocalActivityState: mocks.useLocalActivityState, })) const kitchen = { @@ -31,10 +35,16 @@ const kitchen = { const office = { ...kitchen, id: 'srv_office', name: 'Office' } +function LocationDisplay() { + const location = useLocation() + return
{location.pathname}
+} + function renderNav(pathname = '/') { return render( + , ) } @@ -43,6 +53,7 @@ function renderRail(pathname = '/') { return render( + , ) } @@ -50,8 +61,11 @@ function renderRail(pathname = '/') { describe('LinkedServersNav', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) mocks.useServerActivityState.mockReturnValue(null) + mocks.useLocalActivityState.mockReturnValue(null) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, @@ -60,20 +74,21 @@ describe('LinkedServersNav', () => { }) it('renders nothing when no linked servers exist', () => { - const { container } = renderNav() + renderNav() - expect(container).toBeEmptyDOMElement() expect(screen.queryByText('Servers')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Local/ })).not.toBeInTheDocument() }) - it('lists enabled servers with a link and connection dot', () => { + it('lists the local machine and enabled servers as toggle buttons with connection dots', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) renderNav() expect(screen.getByText('Servers')).toBeInTheDocument() - expect(screen.getByRole('link', { name: /Kitchen/ })).toHaveAttribute('href', '/servers/srv_kitchen') - expect(screen.getByRole('link', { name: /Office/ })).toHaveAttribute('href', '/servers/srv_office') + expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-pressed', 'false') expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() expect(screen.getByRole('img', { name: 'Disconnected' })).toBeInTheDocument() }) @@ -86,42 +101,93 @@ describe('LinkedServersNav', () => { renderNav() - expect(screen.getByRole('link', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('link', { name: /Office/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Office/ })).not.toBeInTheDocument() }) - it('renders enabled servers as icon links in the collapsed rail', () => { + it('selects a server without navigating', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + + renderNav() + + fireEvent.click(screen.getByRole('button', { name: /Office/ })) + + expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('selects the local machine without navigating', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) - renderRail('/servers/srv_kitchen/sessions/abc') + renderNav() + + fireEvent.click(screen.getByRole('button', { name: /Local/ })) + + expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('follows a linked server route', async () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) - expect(screen.getByRole('link', { name: 'Kitchen' })).toHaveAttribute( - 'aria-current', - 'page', + renderNav('/servers/srv_kitchen/sessions/abc') + + await waitFor(() => + expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'true'), ) + expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'false') + }) + + it('falls back to local when the persisted server is disabled', async () => { + useSidebarServerStore.setState({ serverId: 'srv_office' }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, { ...office, enabled: false }], + isLoading: false, + }) + + renderNav() + + await waitFor(() => + expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true'), + ) + }) + + it('renders the local machine and enabled servers as icon toggles in the collapsed rail', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + + renderRail('/servers/srv_kitchen/sessions/abc') + + expect(screen.getByRole('button', { name: 'Local' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Kitchen' })).toHaveAttribute('aria-pressed', 'true') expect(screen.queryByText('Servers')).not.toBeInTheDocument() }) it('renders nothing in the collapsed rail when no linked servers exist', () => { - const { container } = renderRail() + renderRail() - expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('button', { name: 'Local' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Kitchen' })).not.toBeInTheDocument() }) - it('hands navigation to the caller in the sheet variant', () => { + it('switches the selected server in the sheet variant without navigating', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) - const onNavigate = vi.fn() render( - - + + + , ) expect(screen.getByRole('heading', { name: 'Servers' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /Kitchen/ })) - expect(onNavigate).toHaveBeenCalledWith('/servers/srv_kitchen') + expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByTestId('location')).toHaveTextContent('/') }) it('shows the activity indicator only for the server with an active session', () => { @@ -139,6 +205,15 @@ describe('LinkedServersNav', () => { expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() }) + it('shows local activity on the local row', () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useLocalActivityState.mockReturnValue('running') + + renderNav() + + expect(screen.getByRole('img', { name: 'Sessions running' })).toBeInTheDocument() + }) + it('shows the running indicator in the sheet variant', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) mocks.useServerActivityState.mockReturnValue('running') diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 3e9e2d619..062e1b6e5 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -1,11 +1,10 @@ -import { Link, useLocation } from 'react-router-dom' -import { Server } from 'lucide-react' -import type { OpenCodeServer } from '@opencode-manager/shared/schemas' +import { Laptop, Server } from 'lucide-react' +import { LOCAL_OPENCODE_SERVER_ID, type OpenCodeServer } from '@opencode-manager/shared/schemas' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' -import { useServerActivityState } from '@/hooks/useActiveWork' +import { useLocalActivityState, useServerActivityState } from '@/hooks/useActiveWork' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { SidebarSection } from '@/components/ui/sidebar' -import { getServerPath } from '@/lib/navigation' import { cn } from '@/lib/utils' export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' @@ -13,12 +12,6 @@ export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' interface LinkedServersNavProps { /** `sidebar` renders the labelled section, `rail` the collapsed icon column, `sheet` the mobile sheet group. */ variant?: LinkedServersVariant - /** When provided, each row is a button that hands navigation to the caller instead of rendering a link. */ - onNavigate?: (path: string) => void -} - -function isActiveServerPath(pathname: string, path: string) { - return pathname === path || pathname.startsWith(`${path}/`) } function ServerConnectionDot({ connected }: { connected: boolean }) { @@ -34,57 +27,84 @@ function ServerConnectionDot({ connected }: { connected: boolean }) { ) } +function rowClassName(active: boolean, variant: 'sidebar' | 'sheet') { + return cn( + 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', + variant === 'sheet' && 'mx-2 p-2 text-left', + ) +} + +function railClassName(active: boolean) { + return cn( + 'flex items-center justify-center rounded-md p-2.5 transition-colors duration-150', + 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', + active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', + ) +} + +interface LocalServerRowProps { + variant: 'sidebar' | 'sheet' + active: boolean + onSelect: () => void +} + +function LocalServerRow({ variant, active, onSelect }: LocalServerRowProps) { + const activity = useLocalActivityState() + + return ( + + ) +} + interface LinkedServerRowProps { server: OpenCodeServer variant: 'sidebar' | 'sheet' - path: string active: boolean connected: boolean - onNavigate?: (path: string) => void + onSelect: () => void } -function LinkedServerRow({ server, variant, path, active, connected, onNavigate }: LinkedServerRowProps) { +function LinkedServerRow({ server, variant, active, connected, onSelect }: LinkedServerRowProps) { const activity = useServerActivityState(server.id) - const className = cn( - 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', - active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', - variant === 'sheet' && 'mx-2 p-2 text-left', - ) - const content = ( - <> + + return ( + - ) - } - - return ( - - {content} - + ) } -export function LinkedServersNav({ variant = 'sidebar', onNavigate }: LinkedServersNavProps) { - const location = useLocation() +/** + * The server switcher. Selecting a row changes which server's projects and sessions the sidebar + * lists; it never navigates. Hidden when no linked server is enabled. + */ +export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) { const { servers, isLoading } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() + const [selectedServerId, selectServer] = useSidebarServerSelection() const enabledServers = servers.filter((server) => server.enabled) + const localActive = selectedServerId === LOCAL_OPENCODE_SERVER_ID if (isLoading || enabledServers.length === 0) { return null @@ -93,46 +113,57 @@ export function LinkedServersNav({ variant = 'sidebar', onNavigate }: LinkedServ if (variant === 'rail') { return (
+ {enabledServers.map((server) => { - const path = getServerPath(server.id) - const active = isActiveServerPath(location.pathname, path) + const active = selectedServerId === server.id return ( - selectServer(server.id)} title={server.name} - aria-current={active ? 'page' : undefined} - className={cn( - 'flex items-center justify-center rounded-md p-2.5 transition-colors duration-150', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', - active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', - )} + aria-pressed={active} + aria-current={active ? 'true' : undefined} + className={railClassName(active)} > {server.name} - + ) })}
) } - const rows = enabledServers.map((server) => { - const path = getServerPath(server.id) - - return ( + const rows = [ + selectServer(LOCAL_OPENCODE_SERVER_ID)} + />, + ...enabledServers.map((server) => ( selectServer(server.id)} /> - ) - }) + )), + ] if (variant === 'sheet') { return ( diff --git a/frontend/src/components/navigation/RemoteProjectNav.test.tsx b/frontend/src/components/navigation/RemoteProjectNav.test.tsx new file mode 100644 index 000000000..33b0181b2 --- /dev/null +++ b/frontend/src/components/navigation/RemoteProjectNav.test.tsx @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { OpenCodeProject } from '@/api/opencode' +import { RemoteProjectNavList } from './RemoteProjectNav' + +type SessionFixture = { + id: string + projectID: string + title: string + time: { created: number; updated: number; archived?: number } + location: { directory: string } + cost: number + tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } +} + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), + useSessionsAcrossDirectories: vi.fn(), + useCreateSession: vi.fn(), + sessionsCalls: [] as Array<{ directories: string[]; options?: { serverId?: string } }>, + createCalls: [] as Array<{ directory?: string; serverId?: string }>, + sessions: [] as SessionFixture[], + hasNextPage: { current: false }, +})) + +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: mocks.listProjects } +}) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) + +vi.mock('@/hooks/useOpenCode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSessionsAcrossDirectories: ( + directories: string[], + options?: { serverId?: string }, + ) => { + mocks.sessionsCalls.push({ directories, options }) + return { + data: mocks.sessions.filter((session) => directories.includes(session.location.directory)), + isLoading: false, + isError: false, + hasNextPage: mocks.hasNextPage.current, + } + }, + useCreateSession: ( + directory: string, + onSuccess?: (session: { id: string }) => void, + serverId?: string, + ) => ({ + mutate: () => { + mocks.createCalls.push({ directory, serverId }) + onSuccess?.({ id: 'new-session' }) + }, + isPending: false, + }), + } +}) + +vi.mock('@/hooks/useActiveWork', () => ({ + useProjectActivityState: () => null, +})) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ hasForSession: () => false }), + useForms: () => ({ hasForSession: () => false }), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +function createProject(id: string, name: string | undefined, canonical: string): OpenCodeProject { + return { + id, + canonical, + name, + time: { created: 1, updated: 2 }, + sandboxes: [], + } as OpenCodeProject +} + +function createSession(id: string, directory: string, updated = Date.now()): SessionFixture { + return { + id, + projectID: 'proj-1', + title: `Session ${id}`, + time: { created: updated - 10000, updated }, + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +const alpha = createProject('p1', 'Alpha', '/projects/alpha') +const beta = createProject('p2', undefined, '/projects/beta') + +function LocationSpy() { + const { pathname, search } = useLocation() + return
{`${pathname}${search}`}
+} + +function createWrapper(initialEntry: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + } + /> + + + + ) +} + +const toggleFor = (name: string) => screen.getByRole('button', { name: `Show sessions in ${name}` }) + +function RemoteListHarness({ serverId }: { serverId: string }) { + const navigate = useNavigate() + return ( + + ) +} + +describe('RemoteProjectNavList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.sessionsCalls.splice(0, mocks.sessionsCalls.length) + mocks.createCalls.splice(0, mocks.createCalls.length) + mocks.sessions = [createSession('a1', '/projects/alpha', Date.now())] + mocks.hasNextPage.current = false + mocks.listProjects.mockResolvedValue([alpha, beta]) + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, + isLoading: false, + }) + }) + + it('renders project names with their directory and activity indicator slot', async () => { + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('beta')).toBeInTheDocument() + expect(screen.getByText('/projects/alpha')).toBeInTheDocument() + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + + it('expands the project of the current project route and loads its sessions', async () => { + render(, { + wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), + }) + + await waitFor(() => expect(toggleFor('Alpha')).toHaveAttribute('aria-expanded', 'true')) + expect(await screen.findByRole('button', { name: /Session a1/ })).toBeInTheDocument() + expect(mocks.sessionsCalls.some((call) => call.directories.includes('/projects/alpha'))).toBe(true) + expect(mocks.sessionsCalls[0]?.options?.serverId).toBe('srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: /Session a1/ })) + expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen/sessions/a1') + }) + + it('opens a project when its name is clicked', async () => { + render(, { wrapper: createWrapper('/') }) + + fireEvent.click(await screen.findByRole('button', { name: /^Alpha/ })) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) + }) + + it('expands another project and shows its sessions', async () => { + mocks.sessions = [ + createSession('a1', '/projects/alpha', Date.now()), + createSession('b1', '/projects/beta', Date.now() - 1000), + ] + + render(, { wrapper: createWrapper('/') }) + + await screen.findByText('beta') + fireEvent.click(toggleFor('beta')) + + expect(await screen.findByRole('button', { name: /Session b1/ })).toBeInTheDocument() + expect(toggleFor('beta')).toHaveAttribute('aria-expanded', 'true') + }) + + it('creates a session on the selected server and opens it', async () => { + render(, { wrapper: createWrapper('/') }) + + fireEvent.click(await screen.findByRole('button', { name: 'New session in beta' })) + + expect(mocks.createCalls).toContainEqual({ directory: '/projects/beta', serverId: 'srv_kitchen' }) + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/sessions/new-session', + ) + }) + + it('offers All sessions when the project has another page', async () => { + mocks.hasNextPage.current = true + + render(, { + wrapper: createWrapper('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha'), + }) + + fireEvent.click(await screen.findByText('All sessions')) + + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) + }) + + it('shows a disabled state for a disabled server', () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...kitchen, enabled: false }], + isLoading: false, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(screen.getByText('Server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('reports a failed project load', async () => { + mocks.listProjects.mockRejectedValue(new Error('offline')) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Could not load projects')).toBeInTheDocument() + }) + + it('reports an unreachable server on a failed project load', async () => { + mocks.listProjects.mockRejectedValue(new Error('offline')) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: false, version: null, error: 'boom' } }, + isLoading: false, + }) + + render( + , + { wrapper: createWrapper('/') }, + ) + + expect(await screen.findByText('Server unreachable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/navigation/RemoteProjectNav.tsx b/frontend/src/components/navigation/RemoteProjectNav.tsx new file mode 100644 index 000000000..d3b733911 --- /dev/null +++ b/frontend/src/components/navigation/RemoteProjectNav.tsx @@ -0,0 +1,435 @@ +import { useEffect, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { listProjects, type OpenCodeProject } from '@/api/opencode' +import type { Session } from '@/api/types' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' +import { useSessionsAcrossDirectories, sessionQueryOptions } from '@/hooks/useOpenCode' +import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' +import { useProjectActivityState } from '@/hooks/useActiveWork' +import { useSessionSearch } from '@/hooks/useSessionSearch' +import { serverScopedKey } from '@/lib/serverScope' +import { + getServerProjectPath, + getServerSessionPath, + parseServerProjectRoute, + parseServerSessionRoute, +} from '@/lib/navigation' +import { getDirectoryName } from '@/lib/utils' +import { getSessionKey } from '@/lib/sessionKey' +import { + NewSessionButton, + RepoNavGroup, + SessionNavLink, + SessionNavRow, + SessionNavStatus, +} from '@/components/navigation/RepoSessionNav' +import { + SIDEBAR_SESSIONS_PER_REPO, + isCurrentSessionItem, + normalizeDirectory, + type SidebarSessionItem, +} from '@/components/navigation/sidebar-session-tree' + +function projectName(project: OpenCodeProject): string { + return project.name?.trim() || getDirectoryName(project.canonical) +} + +function buildRemoteItem(session: Session, serverId: string, projectLabel: string): SidebarSessionItem { + return { + key: getSessionKey(session), + session, + repoId: 0, + serverId, + repoLabel: projectLabel, + branchLabel: null, + isWorktree: false, + isPinned: false, + path: getServerSessionPath(serverId, session.id), + } +} + +function RemoteProjectActivityIndicator({ serverId, directory }: { serverId: string; directory: string }) { + const state = useProjectActivityState(serverId, directory) + + return +} + +function useActiveRemoteDirectory(serverId: string): string | null { + const { pathname, search } = useLocation() + const projectRoute = parseServerProjectRoute(pathname, search) + const sessionRoute = parseServerSessionRoute(pathname) + const sessionID = sessionRoute?.serverId === serverId ? sessionRoute.sessionId : undefined + const { data: session } = useQuery({ + ...sessionQueryOptions(sessionID, undefined, serverId), + enabled: Boolean(sessionID), + }) + + if (projectRoute?.serverId === serverId) return projectRoute.directory + return session?.location.directory ?? null +} + +interface RemoteSessionRowsProps { + serverId: string + projectLabel: string + sessions: Session[] + isLoading: boolean + isError: boolean + hasMore?: boolean + allSessionsPath?: string + onSelectSession: (path: string) => void +} + +function RemoteSessionRows({ + serverId, + projectLabel, + sessions, + isLoading, + isError, + hasMore, + allSessionsPath, + onSelectSession, +}: RemoteSessionRowsProps) { + const location = useLocation() + const visibleSessions = useMemo( + () => sessions.filter((session) => !session.time.archived), + [sessions], + ) + + if (isLoading) return Loading sessions... + if (isError) return Failed to load sessions + if (visibleSessions.length === 0) return No sessions + + return ( + <> + {visibleSessions.map((session) => { + const item = buildRemoteItem(session, serverId, projectLabel) + return ( + + ) + })} + {hasMore && allSessionsPath && ( + onSelectSession(allSessionsPath)}>All sessions + )} + + ) +} + +interface RemoteProjectSessionsProps { + serverId: string + project: OpenCodeProject + onSelectSession: (path: string) => void +} + +function RemoteProjectSessions({ serverId, project, onSelectSession }: RemoteProjectSessionsProps) { + const { data, isLoading, isError, hasNextPage } = useSessionsAcrossDirectories([project.canonical], { + serverId, + limit: SIDEBAR_SESSIONS_PER_REPO, + }) + + return ( + + ) +} + +interface RemoteProjectGroupProps { + serverId: string + project: OpenCodeProject + isOpen: boolean + isCurrent: boolean + onToggle?: () => void + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void + children: React.ReactNode +} + +function RemoteProjectGroup({ + serverId, + project, + isOpen, + isCurrent, + onToggle, + onOpenProject, + onSelectSession, + children, +}: RemoteProjectGroupProps) { + const name = projectName(project) + + return ( + onOpenProject(getServerProjectPath(serverId, project.canonical))} + onToggle={onToggle} + indicator={} + actions={ + getServerSessionPath(serverId, sessionId)} + onOpenSession={onSelectSession} + /> + } + > + {children} + + ) +} + +interface RemoteProjectGroupsProps { + serverId: string + projects: OpenCodeProject[] + activeDirectory: string | null + isVisible: boolean + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectGroups({ + serverId, + projects, + activeDirectory, + isVisible, + onOpenProject, + onSelectSession, +}: RemoteProjectGroupsProps) { + const [expandedProjectId, setExpandedProjectId] = useState(null) + const normalizedActiveDirectory = normalizeDirectory(activeDirectory) + + useEffect(() => { + if (!isVisible || !normalizedActiveDirectory) return + const match = projects.find( + (project) => normalizeDirectory(project.canonical) === normalizedActiveDirectory, + ) + if (match) setExpandedProjectId(match.id) + }, [isVisible, normalizedActiveDirectory, projects]) + + return ( + <> + {projects.map((project) => { + const isExpanded = expandedProjectId === project.id + return ( + + setExpandedProjectId((current) => (current === project.id ? null : project.id)) + } + onOpenProject={onOpenProject} + onSelectSession={onSelectSession} + > + + + ) + })} + + ) +} + +interface RemoteProjectSearchResultsProps { + serverId: string + projects: OpenCodeProject[] + activeDirectory: string | null + search: string + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +function RemoteProjectSearchResults({ + serverId, + projects, + activeDirectory, + search, + onOpenProject, + onSelectSession, +}: RemoteProjectSearchResultsProps) { + const directories = useMemo(() => projects.map((project) => project.canonical), [projects]) + const { + setQuery, + filteredSessions, + isLoading, + isError, + } = useSessionSearch(directories, { + limit: SIDEBAR_SESSIONS_PER_REPO, + serverId, + initialQuery: search, + }) + + useEffect(() => { + setQuery(search) + }, [search, setQuery]) + + const needle = search.trim().toLowerCase() + const normalizedActiveDirectory = normalizeDirectory(activeDirectory) + + const nameMatchedProjects = useMemo( + () => + projects.filter( + (project) => + projectName(project).toLowerCase().includes(needle) || + project.canonical.toLowerCase().includes(needle), + ), + [projects, needle], + ) + + const sessionsByProjectId = useMemo(() => { + const byDirectory = new Map() + for (const session of filteredSessions) { + const directory = normalizeDirectory(session.location.directory) + if (!directory) continue + const list = byDirectory.get(directory) + if (list) list.push(session) + else byDirectory.set(directory, [session]) + } + + const byProjectId = new Map() + for (const project of projects) { + const sessions = byDirectory.get(normalizeDirectory(project.canonical) ?? '') + if (sessions) byProjectId.set(project.id, sessions) + } + return byProjectId + }, [projects, filteredSessions]) + + const visibleProjects = useMemo(() => { + const nameMatchedIds = new Set(nameMatchedProjects.map((project) => project.id)) + return projects.filter( + (project) => nameMatchedIds.has(project.id) || sessionsByProjectId.has(project.id), + ) + }, [projects, nameMatchedProjects, sessionsByProjectId]) + + if (isLoading) return Loading sessions... + if (isError) return Failed to load sessions + if (visibleProjects.length === 0) return No sessions found + + return ( + <> + {visibleProjects.map((project) => { + const matchedSessions = sessionsByProjectId.get(project.id) + return ( + + {matchedSessions ? ( + + ) : ( + + )} + + ) + })} + + ) +} + +interface RemoteProjectNavListProps { + serverId: string + isVisible: boolean + search?: string + onOpenProject: (path: string) => void + onSelectSession: (path: string) => void +} + +/** + * The linked server's projects in the sidebar, styled like the local repo groups and expanded to + * the current project or session. Searching filters project names and that server's sessions. + */ +export function RemoteProjectNavList({ + serverId, + isVisible, + search = '', + onOpenProject, + onSelectSession, +}: RemoteProjectNavListProps) { + const { servers, isLoading: isServersLoading } = useOpenCodeServers() + const { statusById } = useOpenCodeServerStatuses() + const server = servers.find((candidate) => candidate.id === serverId) + const activeDirectory = useActiveRemoteDirectory(serverId) + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + const trimmedSearch = search.trim() + + if (isServersLoading) return Loading projects... + if (!server) return Server not found + if (!server.enabled) return Server is disabled + if (projectsQuery.isLoading) return Loading projects... + if (projectsQuery.isError) { + return ( + + {statusById[serverId]?.connected === false ? 'Server unreachable' : 'Could not load projects'} + + ) + } + if (projects.length === 0) return No projects + + if (trimmedSearch) { + return ( + + ) + } + + return ( + + ) +} diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 86eb5b097..88784f243 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -4,8 +4,11 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' import { getRepoSiblings, listRepos } from '@/api/repos' +import { listProjects, type OpenCodeProject } from '@/api/opencode' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' import type { ActiveWorkItem } from '@/lib/activeWork' type SessionFixture = { @@ -53,10 +56,17 @@ vi.mock('@/hooks/useActiveWork', () => ({ useActivityWindowMinutes: activityMocks.useActivityWindowMinutes, useRepoActivityState: () => null, useServerActivityState: () => null, + useLocalActivityState: () => null, + useProjectActivityState: () => null, })) vi.mock('@/api/repos') +vi.mock('@/api/opencode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, listProjects: vi.fn() } +}) + vi.mock('@/contexts/EventContext', () => ({ usePermissions: () => ({ hasForSession: (id: string) => permissionSessions.current.has(id), @@ -156,6 +166,16 @@ function createLinkedServer(id: string, name: string, enabled = true) { } } +function createProject(id: string, name: string, canonical: string): OpenCodeProject { + return { + id, + canonical, + name, + time: { created: 1, updated: 2 }, + sandboxes: [], + } as OpenCodeProject +} + function setLinkedServers(servers: Array>) { serverMocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) serverMocks.useOpenCodeServerStatuses.mockReturnValue({ @@ -205,7 +225,10 @@ function MobileSheetHarness() { describe('RepoQuickSwitchSheet', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) vi.mocked(getRepoSiblings).mockResolvedValue([]) + vi.mocked(listProjects).mockResolvedValue([]) sessionsData.splice(0, sessionsData.length) sessionsHookCalls.splice(0, sessionsHookCalls.length) permissionSessions.current.clear() @@ -994,8 +1017,42 @@ describe('RepoQuickSwitchSheet', () => { expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull() }) - it('navigates to a linked server and closes the sheet', async () => { + it('switches to a linked server without navigating or closing the sheet', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) + setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) + const handleClose = vi.fn() + + render( + + + + + + + + } + /> + + + , + ) + + fireEvent.click(await screen.findByRole('button', { name: /Kitchen/ })) + + expect(await screen.findByText('Alpha')).toBeInTheDocument() + expect(screen.queryByText('repo1')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Add repository/ })).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') + expect(handleClose).not.toHaveBeenCalled() + }) + + it('opens a remote project from the sheet and closes it', async () => { vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) const handleClose = vi.fn() @@ -1018,8 +1075,11 @@ describe('RepoQuickSwitchSheet', () => { ) fireEvent.click(await screen.findByRole('button', { name: /Kitchen/ })) + fireEvent.click(await screen.findByRole('button', { name: /^Alpha/ })) - expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen') + expect(screen.getByTestId('location')).toHaveTextContent( + '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', + ) expect(handleClose).toHaveBeenCalled() }) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index 709122ff0..933700968 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' import { Input } from '@/components/ui/input' import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' @@ -9,11 +10,13 @@ import { getRepoDisplayName } from '@/lib/utils' import { AddRepoDialog } from '@/components/repo/AddRepoDialog' import { FolderGit2, Plus } from 'lucide-react' import { useUrlParams } from '@/hooks/useUrlParams' -import { getAssistantPath, getRepoPath } from '@/lib/navigation' +import { getAssistantPath, getRepoPath, getSessionPath } from '@/lib/navigation' import { NewSessionButton, RepoSessionNavList, SearchClearButton } from '@/components/navigation/RepoSessionNav' import { LinkedServersNav } from '@/components/navigation/LinkedServersNav' +import { RemoteProjectNavList } from '@/components/navigation/RemoteProjectNav' import { getActiveRepoId } from '@/components/navigation/sidebar-session-tree' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' interface RepoQuickSwitchSheetProps { isOpen: boolean @@ -26,6 +29,8 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const { searchParams } = useUrlParams() const [searchQuery, setSearchQuery] = useState('') const [addRepoOpen, setAddRepoOpen] = useState(false) + const [selectedServerId] = useSidebarServerSelection() + const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID const activeRepoId = getActiveRepoId(location.pathname) const { repos, isLoading, refetch } = useNavigableRepos(isOpen) @@ -81,32 +86,37 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr /> {searchQuery.length > 0 && setSearchQuery('')} />}
- + {!isRemote && ( + + )}
- {isOpen && ( - navigateAndClose(path, { replace: true })} - /> - )} + {isOpen && } {isOpen && !searchQuery.trim() && ( navigateAndClose(path)} /> )} - {isLoading ? ( + {isRemote ? ( + navigateAndClose(path, { replace: true })} + onSelectSession={(path) => navigateAndClose(path)} + /> + ) : isLoading ? (
{Array.from({ length: 5 }).map((_, i) => (
@@ -124,7 +134,14 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr isVisible={isOpen} onOpenRepo={handleClick} onSelectSession={(path) => navigateAndClose(path)} - renderActions={(repo) => navigateAndClose(path)} />} + renderActions={(repo) => ( + getSessionPath(repo.id, sessionId)} + onOpenSession={(path) => navigateAndClose(path)} + /> + )} /> )} } -function SessionNavLink({ children, onClick }: { children: ReactNode; onClick: () => void }) { +export function SessionNavLink({ children, onClick }: { children: ReactNode; onClick: () => void }) { return ( + + ) +} diff --git a/frontend/src/hooks/useActiveWork.ts b/frontend/src/hooks/useActiveWork.ts index 174278ec9..fe3b0325b 100644 --- a/frontend/src/hooks/useActiveWork.ts +++ b/frontend/src/hooks/useActiveWork.ts @@ -20,6 +20,7 @@ import { buildServerActivityIndex, collectActiveRoots, needsWorktreeLookup, + projectActivityFromIndex, repoActivityFromIndex, rootSessionID, serverActivityFromIndex, @@ -108,6 +109,57 @@ export function useServerActivityState(serverId: string): 'needs-input' | 'runni ) } +/** + * Whether any local session is active, returned as a primitive so the local switcher row only + * re-renders when it changes. Reads only the session-status store and the pending queues. + */ +export function useLocalActivityState(): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => { + const index = buildRepoActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + pendingInputSessionIDs, + }) + + let running = false + for (const activity of index.values()) { + if (activity === 'needs-input') return 'needs-input' + running = true + } + return running ? 'running' : null + }) +} + +/** + * Whether a project on a linked server has any active session, returned as a primitive so rows + * only re-render when it changes. + */ +export function useProjectActivityState( + serverId: string, + directory: string, +): 'needs-input' | 'running' | null { + const pendingInputSessionIDs = usePendingInputSessionIDs() + + return useSessionStatus((state) => + projectActivityFromIndex( + serverId, + directory, + buildServerActivityIndex({ + statuses: state.statuses, + parents: state.parents, + runningShells: state.runningShells, + directories: state.directories, + sessionServers: state.sessionServers, + pendingInputSessionIDs, + }), + ), + ) +} + interface SessionScope { serverId: string directory: string | undefined diff --git a/frontend/src/hooks/useSessionSearch.ts b/frontend/src/hooks/useSessionSearch.ts index 07b222619..f95d495b6 100644 --- a/frontend/src/hooks/useSessionSearch.ts +++ b/frontend/src/hooks/useSessionSearch.ts @@ -10,6 +10,10 @@ export interface UseSessionSearchOptions { limit?: number allDirectories?: boolean project?: { id: string; subpath: string } + /** Searches a linked server instead of the route's server. */ + serverId?: string + /** Seeds the search box, for callers that mount the hook after a query is already entered. */ + initialQuery?: string } /** @@ -20,7 +24,8 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc const limit = options.limit ?? DEFAULT_LIMIT const allDirectories = options.allDirectories ?? false const project = options.project - const [query, setQuery] = useState('') + const serverId = options.serverId + const [query, setQuery] = useState(options.initialQuery ?? '') const trimmedQuery = query.trim() const debouncedQuery = useDebouncedValue(trimmedQuery, 150) const search = trimmedQuery ? debouncedQuery : '' @@ -39,6 +44,7 @@ export function useSessionSearch(directories: string[], options: UseSessionSearc keepPreviousResults: true, allDirectories, project, + serverId, }) const directorySet = useMemo(() => new Set(directories), [directories]) diff --git a/frontend/src/hooks/useSidebarServerSelection.test.tsx b/frontend/src/hooks/useSidebarServerSelection.test.tsx new file mode 100644 index 000000000..17d458bd8 --- /dev/null +++ b/frontend/src/hooks/useSidebarServerSelection.test.tsx @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, useNavigate } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useSidebarServerSelection } from './useSidebarServerSelection' +import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/sidebarServerStore' + +const mocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: () => ({ statuses: [], statusById: {}, isLoading: false }), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const office = { ...kitchen, id: 'srv_office', name: 'Office' } + +function SelectionHarness() { + const [serverId, selectServer] = useSidebarServerSelection() + const navigate = useNavigate() + + return ( + <> +
{serverId}
+ + + + + ) +} + +function renderHarness(initialEntry = '/') { + return render( + + + , + ) +} + +describe('useSidebarServerSelection', () => { + beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + }) + + it('defaults to the local server', () => { + renderHarness() + + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID) + }) + + it('selects the server named by a linked server route', async () => { + renderHarness('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha') + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + }) + + it('selects local on a local repo route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderHarness('/repos/1') + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('keeps a manual selection while the local route stays unchanged', async () => { + renderHarness('/repos/1') + + fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + + expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen') + }) + + it('follows a navigation to another linked server', async () => { + renderHarness('/servers/srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: 'Go to office' })) + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_office')) + }) + + it('follows a navigation back to a local route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + renderHarness('/servers/srv_kitchen') + + fireEvent.click(screen.getByRole('button', { name: 'Go to repo' })) + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('falls back to local when the selected server is disabled', async () => { + useSidebarServerStore.setState({ serverId: 'srv_office' }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, { ...office, enabled: false }], + isLoading: false, + }) + + renderHarness() + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('falls back to local when the selected server no longer exists', async () => { + useSidebarServerStore.setState({ serverId: 'srv_gone' }) + + renderHarness() + + await waitFor(() => + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), + ) + }) + + it('persists the selection', () => { + renderHarness() + + fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( + 'srv_kitchen', + ) + }) +}) diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts new file mode 100644 index 000000000..ba0370f3c --- /dev/null +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import { useLocation, useMatch } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' + +function isLocalRoute(pathname: string): boolean { + return parseRepoRoute(pathname).repoId !== null || isAssistantPath(pathname) +} + +/** + * The linked server selected in the sidebar, defaulting to the local server. The route selects a + * server when the location changes, selecting a row never navigates, and a selected server that is + * missing or disabled falls back to local. + */ +export function useSidebarServerSelection(): [string, (serverId: string) => void] { + const { pathname } = useLocation() + const routeServerId = useMatch('/servers/:serverId/*')?.params.serverId + const { servers, isLoading } = useOpenCodeServers() + const serverId = useSidebarServerStore((state) => state.serverId) + const selectServer = useSidebarServerStore((state) => state.selectServer) + + useEffect(() => { + const current = useSidebarServerStore.getState().serverId + if (routeServerId) { + if (current !== routeServerId) selectServer(routeServerId) + return + } + if (isLocalRoute(pathname) && current !== LOCAL_OPENCODE_SERVER_ID) { + selectServer(LOCAL_OPENCODE_SERVER_ID) + } + }, [pathname, routeServerId, selectServer]) + + useEffect(() => { + if (isLoading || serverId === LOCAL_OPENCODE_SERVER_ID) return + const server = servers.find((candidate) => candidate.id === serverId) + if (!server || !server.enabled) { + selectServer(LOCAL_OPENCODE_SERVER_ID) + } + }, [isLoading, servers, serverId, selectServer]) + + return [serverId, selectServer] +} diff --git a/frontend/src/lib/activeWork.ts b/frontend/src/lib/activeWork.ts index 84d2c5062..92b7be70f 100644 --- a/frontend/src/lib/activeWork.ts +++ b/frontend/src/lib/activeWork.ts @@ -290,6 +290,17 @@ export function serverActivityFromIndex( return running ? 'running' : null } +/** + * A linked server project's activity from a prebuilt server index, keyed by server and directory. + */ +export function projectActivityFromIndex( + serverId: string, + directory: string, + index: ReadonlyMap, +): 'needs-input' | 'running' | null { + return index.get(serverIndexKey(serverId, directory)) ?? null +} + /** * The repo's activity from a prebuilt index, checking each of its own paths and the directories of * the worktrees it owns. Needs-input outranks running. diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index 4e5ec0c68..07c1e304f 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerSessionRoute } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerPath, getServerProjectPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; describe('getAssistantPath', () => { it('returns /assistant', () => { @@ -141,6 +141,28 @@ describe('parseRepoRoute', () => { }); }); +describe('linked server project paths', () => { + it('builds a project path with encoded server id and directory', () => { + expect(getServerProjectPath('srv_kitchen', '/home/me/app')).toBe( + '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp' + ); + expect(getServerProjectPath('srv a/b', '/srv/my app')).toBe( + '/servers/srv%20a%2Fb/projects?directory=%2Fsrv%2Fmy%20app' + ); + }); + + it('parses the server id and decoded directory from a project route', () => { + expect( + parseServerProjectRoute('/servers/srv_kitchen/projects', '?directory=%2Fhome%2Fme%2Fapp') + ).toEqual({ serverId: 'srv_kitchen', directory: '/home/me/app' }); + }); + + it('returns null without a directory or for other routes', () => { + expect(parseServerProjectRoute('/servers/srv_kitchen/projects', '')).toBeNull(); + expect(parseServerProjectRoute('/servers/srv_kitchen', '?directory=%2Fhome%2Fme')).toBeNull(); + }); +}); + describe('parseServerSessionRoute', () => { it('parses a linked-server session route', () => { expect(parseServerSessionRoute('/servers/srv_kitchen/sessions/ses_1')).toEqual({ @@ -217,6 +239,12 @@ describe('getSwipeBackTarget', () => { it('returns root for a server page', () => { expect(getSwipeBackTarget('/servers/srv_kitchen', '')).toBe('/'); }); + + it('returns the server page for a remote project page', () => { + expect( + getSwipeBackTarget('/servers/srv_kitchen/projects', '?directory=%2Fhome%2Fme%2Fapp') + ).toBe('/servers/srv_kitchen'); + }); }); describe('repo route', () => { diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index b22e3d3ce..7475dc8ba 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -36,6 +36,21 @@ export function getServerSessionPath(serverId: string, sessionId: string): strin return buildServerSessionPath(serverId, sessionId); } +export function getServerProjectPath(serverId: string, directory: string): string { + return `/servers/${encodeURIComponent(serverId)}/projects?directory=${encodeURIComponent(directory)}`; +} + +export function parseServerProjectRoute( + pathname: string, + search: string, +): { serverId: string; directory: string } | null { + const match = /^\/servers\/([^/]+)\/projects$/.exec(pathname); + if (!match) return null; + const directory = new URLSearchParams(search).get('directory'); + if (!directory) return null; + return { serverId: decodeURIComponent(match[1]), directory }; +} + export function parseServerSessionRoute(pathname: string): { serverId: string; sessionId: string } | null { const match = /^\/servers\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname); if (!match) return null; @@ -90,6 +105,11 @@ export function getReturnToPath(search: string, fallback: string): string { } export function getSwipeBackTarget(pathname: string, search = ''): string | null { + const serverProject = parseServerProjectRoute(pathname, search); + if (serverProject) { + return getServerPath(serverProject.serverId); + } + const serverSession = parseServerSessionRoute(pathname); if (serverSession) { return getServerPath(serverSession.serverId); diff --git a/frontend/src/pages/RemoteProject.tsx b/frontend/src/pages/RemoteProject.tsx new file mode 100644 index 000000000..044cb79be --- /dev/null +++ b/frontend/src/pages/RemoteProject.tsx @@ -0,0 +1,221 @@ +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { FolderGit2, GitBranch, Loader2, Plus, Server } from 'lucide-react' +import { getVcsInfo, listProjects } from '@/api/opencode' +import { useCreateSession } from '@/hooks/useOpenCode' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSSE } from '@/hooks/useSSE' +import { useMobile } from '@/hooks/useMobile' +import { useToolPanel } from '@/hooks/useToolPanel' +import { useDialogParam } from '@/hooks/useDialogParam' +import { useTerminalDialogParam, useTerminalDirectoryParam } from '@/hooks/useOpenTerminal' +import { serverScopedKey } from '@/lib/serverScope' +import { getServerPath, getServerSessionPath } from '@/lib/navigation' +import { getDirectoryName } from '@/lib/utils' +import { Header } from '@/components/ui/header' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { SessionList } from '@/components/session/SessionList' +import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { RemoteSchedulesButton } from '@/components/schedules/RemoteSchedulesButton' +import { RemoteMultiRunButton } from '@/components/repo/RemoteMultiRunButton' +import { RepoMcpDialog } from '@/components/repo/RepoMcpDialog' +import { RepoSkillsDialog } from '@/components/repo/RepoSkillsDialog' +import { RemoteSourceControlPanel } from '@/components/source-control/RemoteSourceControlContent' +import { TerminalPanel } from '@/components/terminal/TerminalPanel' +import { ToolSidePanel } from '@/components/navigation/ToolSidePanel' + +function RemoteProjectContent({ serverId, directory }: { serverId: string; directory?: string }) { + const navigate = useNavigate() + const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { setActiveTab } = useSettingsDialog() + const [mcpDialogOpen, setMcpDialogOpen] = useDialogParam('mcp') + const [skillsDialogOpen, setSkillsDialogOpen] = useDialogParam('skills') + const [sourceControlOpen, setSourceControlOpen] = useDialogParam('sourceControl') + const [terminalOpen, setTerminalOpen] = useTerminalDialogParam() + const terminalDirectory = useTerminalDirectoryParam() + const docked = !useMobile() + const toolPanel = useToolPanel(docked) + + const server = servers.find((candidate) => candidate.id === serverId) + const isDisabled = server !== undefined && !server.enabled + const isReady = server?.enabled === true && Boolean(directory) + const createSession = useCreateSession(directory) + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const vcsQuery = useQuery({ + queryKey: [...serverScopedKey(['opencode', 'vcs', directory ?? ''], serverId), 'info'], + queryFn: () => getVcsInfo(directory ?? '', serverId), + enabled: isReady, + retry: false, + }) + + useSSE(directory) + + const project = (projectsQuery.data ?? []).find((entry) => entry.canonical === directory) + const branch = vcsQuery.data?.branch.current ?? vcsQuery.data?.branch.default + const title = project?.name?.trim() || (directory ? getDirectoryName(directory) : 'Project') + + const openSettings = () => setActiveTab('servers') + + const handleCreateSession = () => { + createSession.mutate({}, { + onSuccess: (session) => navigate(getServerSessionPath(serverId, session.id)), + }) + } + + return ( +
+
+
+
+ + {title} + + + {server?.name ?? 'Linked server'} + + {branch ? ( + + + {branch} + + ) : null} +
+ + {directory ? ( + <> + + + + + + ) : null} + +
+ +
+ {!serversLoading && !server ? ( +
+ +
+ ) : isDisabled ? ( +
+ +
+ ) : !directory ? ( +
+ navigate(getServerPath(serverId))} + /> +
+ ) : isReady ? ( + navigate(getServerSessionPath(serverId, sessionId))} + /> + ) : null} +
+
+ + {docked && directory ? ( + + ) : null} + + {directory ? ( + <> + + + setSourceControlOpen(false)} + /> + setTerminalOpen(false)} + /> + + ) : null} +
+ ) +} + +export function RemoteProject() { + const { serverId } = useParams<{ serverId: string }>() + const [searchParams] = useSearchParams() + const directory = searchParams.get('directory') ?? undefined + + if (!serverId) { + return null + } + + return +} diff --git a/frontend/src/pages/RemoteServer.tsx b/frontend/src/pages/RemoteServer.tsx index 0786853a9..d872cf636 100644 --- a/frontend/src/pages/RemoteServer.tsx +++ b/frontend/src/pages/RemoteServer.tsx @@ -18,7 +18,7 @@ import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCo import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useSSE } from '@/hooks/useSSE' import { serverScopedKey } from '@/lib/serverScope' -import { getServerSessionPath } from '@/lib/navigation' +import { getServerProjectPath, getServerSessionPath } from '@/lib/navigation' import { getDirectoryName } from '@/lib/utils' import { Header } from '@/components/ui/header' import { Badge } from '@/components/ui/badge' @@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' import { RemoteSchedulesButton } from '@/components/schedules/RemoteSchedulesButton' import { RemoteMultiRunButton } from '@/components/repo/RemoteMultiRunButton' @@ -123,15 +124,21 @@ function ProjectRow({ project, serverId }: { project: OpenCodeProject; serverId: type="button" onClick={() => setExpanded((current) => !current)} aria-expanded={expanded} - className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1 text-left transition-colors hover:bg-accent/50" + aria-label={`${expanded ? 'Collapse' : 'Expand'} ${name}`} + className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent/50" > {expanded ? ( - + ) : ( - + )} + + - + {name} {project.canonical} @@ -140,7 +147,7 @@ function ProjectRow({ project, serverId }: { project: OpenCodeProject; serverId: {project.vcs} ) : null} - + - + ) : null} {isDisabled ? ( - - -
-

This server is disabled

-

- Enable it in Settings → Servers to browse its projects and sessions. -

-
- -
+ ) : null} {server && !isDisabled && projectsQuery.isError ? ( diff --git a/frontend/src/pages/__tests__/RemoteProject.test.tsx b/frontend/src/pages/__tests__/RemoteProject.test.tsx new file mode 100644 index 000000000..2f09202e2 --- /dev/null +++ b/frontend/src/pages/__tests__/RemoteProject.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { RemoteProject } from '../RemoteProject' + +interface SessionListCapture { + directory?: string + createDirectory?: string + onSelectSession?: (sessionId: string) => void +} + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + getVcsInfo: vi.fn(), + useCreateSession: vi.fn(), + useOpenCodeServers: vi.fn(), + useSettingsDialog: vi.fn(), + useSSE: vi.fn(), + createSession: vi.fn(), + lastSessionListProps: { current: undefined as SessionListCapture | undefined }, + setActiveTab: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + listProjects: mocks.listProjects, + getVcsInfo: mocks.getVcsInfo, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useCreateSession: mocks.useCreateSession, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: mocks.useSettingsDialog, +})) + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: mocks.useSSE, +})) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: (props: SessionListCapture) => { + mocks.lastSessionListProps.current = props + return
{props.directory}
+ }, +})) + +vi.mock('@/components/navigation/ToolSidePanel', () => ({ + ToolSidePanel: () =>
, +})) + +const server = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const project = { + id: 'prj_1', + canonical: '/home/me/app', + name: 'App', + time: { created: 1, updated: 2, active: 2 }, + sandboxes: [], +} + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function renderProject(path = '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') { + return render( + + + + } /> + } /> + } /> + + + , + ) +} + +describe('RemoteProject', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.lastSessionListProps.current = undefined + mocks.listProjects.mockResolvedValue([project]) + mocks.getVcsInfo.mockResolvedValue({ branch: { current: 'main', default: 'main' } }) + mocks.useCreateSession.mockReturnValue({ mutate: mocks.createSession, isPending: false }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) + mocks.useSettingsDialog.mockReturnValue({ setActiveTab: mocks.setActiveTab }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + }) + + it('renders the project name, server name and branch from the linked server', async () => { + renderProject() + + expect(await screen.findByText('App')).toBeInTheDocument() + expect(screen.getByText('Kitchen')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('main')).toBeInTheDocument() + }) + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + expect(mocks.getVcsInfo).toHaveBeenCalledWith('/home/me/app', 'srv_kitchen') + }) + + it('falls back to the directory name when the server does not list the project', async () => { + mocks.listProjects.mockResolvedValue([]) + renderProject() + + expect(await screen.findByText('app')).toBeInTheDocument() + }) + + it('renders the session list for the project directory and subscribes to its events', () => { + renderProject() + + expect(screen.getByTestId('session-list')).toHaveTextContent('/home/me/app') + expect(mocks.lastSessionListProps.current?.createDirectory).toBe('/home/me/app') + expect(mocks.useSSE).toHaveBeenCalledWith('/home/me/app') + }) + + it('opens a session from the session list on the remote session route', () => { + renderProject() + + act(() => { + mocks.lastSessionListProps.current?.onSelectSession?.('ses_42') + }) + + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + + it('creates a session in the project directory and navigates to it', async () => { + mocks.useCreateSession.mockReturnValue({ + mutate: (_input: unknown, options?: { onSuccess?: (session: { id: string }) => void }) => { + options?.onSuccess?.({ id: 'ses_new' }) + }, + isPending: false, + }) + renderProject() + + fireEvent.click(screen.getAllByRole('button', { name: 'New Session' })[0]) + + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + expect(mocks.useCreateSession).toHaveBeenCalledWith('/home/me/app') + }) + + it('shows a missing-directory state without rendering the session list', async () => { + renderProject('/servers/srv_kitchen/projects') + + expect(await screen.findByText('No project selected')).toBeInTheDocument() + expect(screen.queryByTestId('session-list')).not.toBeInTheDocument() + }) + + it('shows the disabled state without loading projects', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProject() + + expect(await screen.findByText('This server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + expect(screen.queryByTestId('session-list')).not.toBeInTheDocument() + }) + + it('shows the missing-server state', async () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + + renderProject() + + expect(await screen.findByText('Server not found')).toBeInTheDocument() + }) + + it('opens server settings from the disabled state', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProject() + + fireEvent.click(await screen.findByRole('button', { name: 'Open server settings' })) + + expect(mocks.setActiveTab).toHaveBeenCalledWith('servers') + }) + + it('goes back to the linked server page', async () => { + renderProject() + + fireEvent.click(await screen.findByRole('button', { name: 'Go back' })) + + expect(screen.getByTestId('server-route')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/RemoteServer.test.tsx b/frontend/src/pages/__tests__/RemoteServer.test.tsx index 3689be291..0114ff197 100644 --- a/frontend/src/pages/__tests__/RemoteServer.test.tsx +++ b/frontend/src/pages/__tests__/RemoteServer.test.tsx @@ -126,10 +126,20 @@ describe('RemoteServer', () => { expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') }) + it('links the project name to the remote project page', async () => { + renderServer() + + const link = await screen.findByRole('link', { name: /App/ }) + expect(link).toHaveAttribute( + 'href', + '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp', + ) + }) + it('links each expanded project session to the remote session route', async () => { renderServer() - fireEvent.click(await screen.findByRole('button', { name: /App/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Expand App' })) const link = await screen.findByRole('link', { name: /Fix the bug/ }) expect(link).toHaveAttribute('href', '/servers/srv_kitchen/sessions/ses_1') diff --git a/frontend/src/stores/sidebarServerStore.ts b/frontend/src/stores/sidebarServerStore.ts new file mode 100644 index 000000000..b69cdd3df --- /dev/null +++ b/frontend/src/stores/sidebarServerStore.ts @@ -0,0 +1,33 @@ +import { create } from 'zustand' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { readStoredValue, writeStoredValue } from '@/lib/persistentStorage' + +export const SIDEBAR_SERVER_STORAGE_KEY = 'oc:sidebar:server' + +function readStoredServerId(): string { + const stored = readStoredValue(SIDEBAR_SERVER_STORAGE_KEY) + if (stored === null) return LOCAL_OPENCODE_SERVER_ID + try { + const parsed = JSON.parse(stored) + return typeof parsed === 'string' && parsed.length > 0 ? parsed : LOCAL_OPENCODE_SERVER_ID + } catch { + return LOCAL_OPENCODE_SERVER_ID + } +} + +interface SidebarServerStore { + serverId: string + selectServer: (serverId: string) => void +} + +/** + * The server whose projects and sessions the sidebar lists. Shared by the desktop sidebar, its + * collapsed switcher, and the mobile repo sheet, and persisted across reloads. + */ +export const useSidebarServerStore = create((set) => ({ + serverId: readStoredServerId(), + selectServer: (serverId) => { + writeStoredValue(SIDEBAR_SERVER_STORAGE_KEY, serverId) + set({ serverId }) + }, +})) From 8277ec253ec219f6e1f45121e4f986f83b4df963 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:36:43 -0400 Subject: [PATCH 07/15] fix(terminal): remove terminals that exit cleanly A shell that exits with code 0 is now deleted and its tab removed, and the terminal panel closes when it was the last one. Non-zero exits keep their tab and output. --- .../navigation/ToolSidePanel.test.tsx | 17 +++- .../components/navigation/ToolSidePanel.tsx | 2 +- .../terminal/TerminalPanel.test.tsx | 91 +++++++++++++++++-- .../src/components/terminal/TerminalPanel.tsx | 75 +++++++++++---- 4 files changed, 160 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index 32ec95848..221c86ac1 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -33,7 +33,12 @@ vi.mock('@/components/source-control/RemoteSourceControlContent', () => ({ ), })) vi.mock('@/components/terminal/TerminalPanel', () => ({ - TerminalWorkspace: () =>
terminal-tool
, + TerminalWorkspace: ({ onLastTerminalExited }: { onLastTerminalExited?: () => void }) => ( +
+ terminal-tool + +
+ ), })) vi.mock('@/components/preview/PreviewPanel', () => ({ PreviewWorkspace: () =>
preview-tool
})) vi.mock('@/components/schedules/RepoSchedulesContent', () => ({ @@ -322,6 +327,16 @@ describe('ToolSidePanel', () => { expect(search().has('terminal')).toBe(false) }) + it('closes the docked terminal panel when the last terminal exits', async () => { + const user = userEvent.setup() + renderAt('?panel=terminal&terminal=pty-1') + + await user.click(await screen.findByRole('button', { name: 'Exit last terminal' })) + + await waitFor(() => expect(search().has('panel')).toBe(false)) + expect(search().has('terminal')).toBe(false) + }) + it('falls back to the dialog when not docked', async () => { renderAt('?panel=sourceControl', false) diff --git a/frontend/src/components/navigation/ToolSidePanel.tsx b/frontend/src/components/navigation/ToolSidePanel.tsx index 13bd9ba4f..930571d4b 100644 --- a/frontend/src/components/navigation/ToolSidePanel.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.tsx @@ -211,7 +211,7 @@ export function ToolSidePanel({ if (tool === 'activity') return if (tool === 'terminal') { return terminalWorkspaceTarget - ? + ? : null } if (tool === 'skills') { diff --git a/frontend/src/components/terminal/TerminalPanel.test.tsx b/frontend/src/components/terminal/TerminalPanel.test.tsx index 962ab58a4..b226c96b0 100644 --- a/frontend/src/components/terminal/TerminalPanel.test.tsx +++ b/frontend/src/components/terminal/TerminalPanel.test.tsx @@ -22,15 +22,23 @@ vi.mock('./TerminalView', async () => { const React = await import('react') return { TerminalView: React.forwardRef(function MockTerminalView( - props: { ptyID: string; active: boolean }, + props: { ptyID: string; active: boolean; onExited: () => void }, ref: React.Ref, ) { React.useImperativeHandle(ref, () => ({ send: vi.fn() })) - return React.createElement('div', { - 'data-testid': 'terminal-view', - 'data-pty-id': props.ptyID, - 'data-active': String(props.active), - }) + return React.createElement( + 'div', + { + 'data-testid': 'terminal-view', + 'data-pty-id': props.ptyID, + 'data-active': String(props.active), + }, + React.createElement( + 'button', + { type: 'button', 'aria-label': `exit ${props.ptyID}`, onClick: props.onExited }, + 'exit', + ), + ) }), } }) @@ -88,6 +96,10 @@ function renderPanel(initialEntry = '/repos/1?dialog=terminal', onClose = vi.fn( describe('TerminalPanel', () => { beforeEach(() => { vi.clearAllMocks() + createMutate.mockReset() + removeMutate.mockReset() + refetch.mockReset() + refetch.mockResolvedValue({ data: [] }) vi.mocked(useMobile).mockReturnValue(false) mockTerminalHooks([]) }) @@ -267,6 +279,73 @@ describe('TerminalPanel', () => { expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't2', directory: '/repo' }, expect.any(Object)) }) + it('removes a cleanly exited terminal, closes the panel when it was the last, and does not respawn', async () => { + mockTerminalHooks([runningTerminal]) + refetch.mockResolvedValue({ data: [{ ...runningTerminal, status: 'exited', exitCode: 0 }] }) + removeMutate.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + const onClose = vi.fn() + renderPanel('/repos/1?dialog=terminal&terminal=t1', onClose) + await screen.findAllByTestId('terminal-view') + + fireEvent.click(screen.getByLabelText('exit t1')) + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)) + expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't1', directory: '/repo' }, expect.any(Object)) + expect(screen.queryByLabelText('Close Terminal')).not.toBeInTheDocument() + expect(createMutate).not.toHaveBeenCalled() + }) + + it('keeps a terminal that exited with a non-zero code so the failure stays inspectable', async () => { + mockTerminalHooks([runningTerminal]) + refetch.mockResolvedValue({ data: [{ ...runningTerminal, status: 'exited', exitCode: 2 }] }) + const onClose = vi.fn() + const { rerender } = renderPanel('/repos/1?dialog=terminal&terminal=t1', onClose) + await screen.findAllByTestId('terminal-view') + + fireEvent.click(screen.getByLabelText('exit t1')) + + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)) + expect(removeMutate).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByLabelText('Close Terminal')).toBeInTheDocument() + + mockTerminalHooks([{ ...runningTerminal, status: 'exited', exitCode: 2 }]) + rerender( + + + , + ) + + expect(await screen.findByText('exited (2)')).toBeInTheDocument() + }) + + it('keeps the panel open and selects another running terminal when one exits cleanly', async () => { + const otherTerminal: TerminalInfo = { ...runningTerminal, id: 't3', title: 'Shell' } + mockTerminalHooks([runningTerminal, otherTerminal]) + refetch.mockResolvedValue({ + data: [runningTerminal, { ...otherTerminal, status: 'exited', exitCode: 0 }], + }) + removeMutate.mockImplementation((_variables: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + const onClose = vi.fn() + renderPanel('/repos/1?dialog=terminal&terminal=t3', onClose) + + const views = await screen.findAllByTestId('terminal-view') + expect(views.find((view) => view.getAttribute('data-pty-id') === 't3')).toHaveAttribute('data-active', 'true') + + fireEvent.click(screen.getByLabelText('exit t3')) + + await waitFor(() => expect(screen.queryByLabelText('Close Shell')).not.toBeInTheDocument()) + expect(removeMutate).toHaveBeenCalledWith({ ptyID: 't3', directory: '/repo' }, expect.any(Object)) + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByTestId('terminal-view')).toHaveAttribute('data-pty-id', 't1') + expect(screen.getByTestId('terminal-view')).toHaveAttribute('data-active', 'true') + expect(createMutate).not.toHaveBeenCalled() + }) + it('renders the key bar only on mobile', async () => { mockTerminalHooks([runningTerminal]) diff --git a/frontend/src/components/terminal/TerminalPanel.tsx b/frontend/src/components/terminal/TerminalPanel.tsx index d1a3fef81..00dbcc15e 100644 --- a/frontend/src/components/terminal/TerminalPanel.tsx +++ b/frontend/src/components/terminal/TerminalPanel.tsx @@ -29,9 +29,10 @@ interface TerminalWorkspaceProps { target: TerminalTarget directory: string | undefined isOpen: boolean + onLastTerminalExited?: () => void } -export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspaceProps) { +export function TerminalWorkspace({ target, directory, isOpen, onLastTerminalExited }: TerminalWorkspaceProps) { const isMobile = useMobile() const { searchParams, updateParams } = useUrlParams() const openPreview = useOpenPreview() @@ -44,10 +45,14 @@ export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspa const [ctrlArmed, setCtrlArmed] = useState(false) const [pendingClose, setPendingClose] = useState(null) + const [dismissedTerminalIds, setDismissedTerminalIds] = useState>(() => new Set()) const viewHandlesRef = useRef(new Map()) const autoCreateCheckedDirectoryRef = useRef(null) - const terminals = useMemo(() => data ?? [], [data]) + const terminals = useMemo( + () => (data ?? []).filter((terminal) => !dismissedTerminalIds.has(terminal.id)), + [data, dismissedTerminalIds], + ) const selectTerminal = useCallback((id: string) => { updateParams((params) => { @@ -83,27 +88,63 @@ export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspa ) }, [createTerminal, directory, selectTerminal]) - const performRemove = useCallback((terminal: TerminalInfo) => { - removeTerminal( - { ptyID: terminal.id, directory }, - { - onSuccess: () => { - updateParams((params) => { - if (params.get('terminal') === terminal.id) params.delete('terminal') - }, 'replace') + const performRemove = useCallback( + ( + terminalId: string, + options: { onRemoved?: () => void; onError?: () => void } = {}, + ) => { + removeTerminal( + { ptyID: terminalId, directory }, + { + onSuccess: () => { + updateParams((params) => { + if (params.get('terminal') === terminalId) params.delete('terminal') + }, 'replace') + options.onRemoved?.() + }, + onError: options.onError, }, - }, - ) - }, [removeTerminal, directory, updateParams]) + ) + }, + [removeTerminal, directory, updateParams], + ) const handleCloseTab = useCallback((terminal: TerminalInfo) => { if (terminal.status === 'running') { setPendingClose(terminal) return } - performRemove(terminal) + performRemove(terminal.id) }, [performRemove]) + const handleTerminalExited = useCallback((terminalId: string) => { + const settleExit = async () => { + let latestTerminals: TerminalInfo[] + try { + const result = await refetch() + latestTerminals = result.data ?? [] + } catch { + return + } + const exitCode = latestTerminals.find((terminal) => terminal.id === terminalId)?.exitCode + if (exitCode !== undefined && exitCode !== 0) return + setDismissedTerminalIds((ids) => new Set(ids).add(terminalId)) + performRemove(terminalId, { + onRemoved: () => { + if (latestTerminals.every((terminal) => terminal.id === terminalId)) onLastTerminalExited?.() + }, + onError: () => { + setDismissedTerminalIds((ids) => { + const next = new Set(ids) + next.delete(terminalId) + return next + }) + }, + }) + } + void settleExit() + }, [refetch, performRemove, onLastTerminalExited]) + const handleKeySend = useCallback((input: string) => { if (!activeTerminalId) return viewHandlesRef.current.get(activeTerminalId)?.send(input) @@ -190,7 +231,7 @@ export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspa ctrlArmed={ctrlArmed} onCtrlConsumed={handleCtrlConsumed} onOpenLink={handleOpenLink} - onExited={() => { void refetch() }} + onExited={() => handleTerminalExited(terminal.id)} /> ))} @@ -205,7 +246,7 @@ export function TerminalWorkspace({ target, directory, isOpen }: TerminalWorkspa open={pendingClose !== null} onOpenChange={(open) => { if (!open) setPendingClose(null) }} onConfirm={() => { - if (pendingClose) performRemove(pendingClose) + if (pendingClose) performRemove(pendingClose.id) setPendingClose(null) }} onCancel={() => setPendingClose(null)} @@ -251,7 +292,7 @@ export function TerminalPanel({ target, directory, isOpen, onClose }: TerminalPa )} - + ) From 96e22dfa6634d4fc4c7dc85dd36eb45e6f34725c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:36:43 -0400 Subject: [PATCH 08/15] feat(servers): compact server switcher and mobile navigation for linked servers Replace the Servers list with a single dropdown switcher in the sidebar, collapsed rail and mobile repo sheet, flagging activity on other servers. Show the More button on remote sessions, the tab bar on server and project pages, and the server name in a distinct color in the session header. --- .../navigation/DesktopSidebar.test.tsx | 4 +- .../components/navigation/DesktopSidebar.tsx | 15 +- .../navigation/LinkedServersNav.test.tsx | 226 +++++++------- .../navigation/LinkedServersNav.tsx | 277 +++++++++++------- .../navigation/MobileTabBar.test.tsx | 91 ++++++ .../components/navigation/MobileTabBar.tsx | 77 +++-- .../navigation/RepoQuickSwitchSheet.test.tsx | 29 +- .../navigation/RepoQuickSwitchSheet.tsx | 6 +- frontend/src/pages/SessionDetail.tsx | 11 +- .../__tests__/SessionDetail.remote.test.tsx | 15 +- 10 files changed, 495 insertions(+), 256 deletions(-) diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index d5d17fb2a..a52b6d721 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -250,7 +250,7 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Logout')).toBeInTheDocument() }) - it('hides the Servers group when no linked servers exist', () => { + it('hides the server switcher when no linked servers exist', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -261,7 +261,7 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/']) }) - expect(screen.queryByText('Servers')).toBeNull() + expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() }) it('renders home, repos and account items and no session tree when collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 24ca5b6f1..2303c5e34 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -6,9 +6,6 @@ import { useSidebarWidth } from '@/hooks/useSidebarWidth' import { useAuth } from '@/hooks/useAuth' import { useUrlParams } from '@/hooks/useUrlParams' import { useOpenNavItem } from '@/hooks/useOpenNavItem' -import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' -import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' -import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { buildNavModel, type MoreDrawerItem, type NavPrimaryCta } from '@/components/navigation/moreDrawerItems' import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' import { DesktopSessionTree } from '@/components/navigation/DesktopSessionTree' @@ -31,12 +28,6 @@ export function DesktopSidebar() { const [sidebarWidth, setSidebarWidth] = useSidebarWidth() const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() - const [selectedServerId] = useSidebarServerSelection() - const { servers } = useOpenCodeServers() - const selectedServerName = - selectedServerId === LOCAL_OPENCODE_SERVER_ID - ? null - : servers.find((server) => server.id === selectedServerId)?.name ?? null const isDesktop = useDesktop() @@ -115,11 +106,13 @@ export function DesktopSidebar() { ) : ( <> - +
+ +

- Sessions{selectedServerName ? ` · ${selectedServerName}` : ''} + Sessions

diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index 5ad9af90e..16573d3e3 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { MemoryRouter, useLocation } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { LinkedServersNav } from './LinkedServersNav' @@ -37,25 +38,24 @@ const office = { ...kitchen, id: 'srv_office', name: 'Office' } function LocationDisplay() { const location = useLocation() - return
{location.pathname}
+ return
{`${location.pathname}${location.search}`}
} -function renderNav(pathname = '/') { +function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { return render( - + , ) } -function renderRail(pathname = '/') { - return render( - - - - , - ) +function setServers(servers: Array) { + mocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) +} + +function triggerName(name: string) { + return `Switch server, current: ${name}` } describe('LinkedServersNav', () => { @@ -63,12 +63,15 @@ describe('LinkedServersNav', () => { vi.clearAllMocks() localStorage.clear() useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) - mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + setServers([]) mocks.useServerActivityState.mockReturnValue(null) mocks.useLocalActivityState.mockReturnValue(null) mocks.useOpenCodeServerStatuses.mockReturnValue({ statuses: [], - statusById: { srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null } }, + statusById: { + srv_kitchen: { serverId: 'srv_kitchen', connected: true, version: null, error: null }, + srv_office: { serverId: 'srv_office', connected: false, version: null, error: null }, + }, isLoading: false, }) }) @@ -76,154 +79,179 @@ describe('LinkedServersNav', () => { it('renders nothing when no linked servers exist', () => { renderNav() - expect(screen.queryByText('Servers')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Local/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() }) - it('lists the local machine and enabled servers as toggle buttons with connection dots', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + it('renders nothing for disabled linked servers', () => { + setServers([{ ...kitchen, enabled: false }]) renderNav() - expect(screen.getByText('Servers')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Disconnected' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() }) - it('hides disabled servers', () => { - mocks.useOpenCodeServers.mockReturnValue({ - servers: [kitchen, { ...office, enabled: false }], - isLoading: false, - }) + it('shows the local machine on the trigger by default', () => { + setServers([kitchen]) + + renderNav() + + expect(screen.getByRole('button', { name: triggerName('Local') })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() + }) + + it('shows the selected server name and connection dot on the trigger', async () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) renderNav() - expect(screen.getByRole('button', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Office/ })).not.toBeInTheDocument() + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() }) - it('selects a server without navigating', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + it('lists the local machine and enabled servers in the menu', async () => { + const user = userEvent.setup() + setServers([kitchen, { ...office, enabled: false }]) renderNav() + await user.click(screen.getByRole('button', { name: triggerName('Local') })) + + expect(await screen.findByRole('menuitem', { name: /Local/ })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() + }) + + it('selects a server without navigating', async () => { + const user = userEvent.setup() + setServers([kitchen, office]) - fireEvent.click(screen.getByRole('button', { name: /Office/ })) + renderNav() + await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(await screen.findByRole('menuitem', { name: /Office/ })) - expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: /Office/ })).toHaveAttribute('aria-current', 'true') - expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'false') + expect(await screen.findByRole('button', { name: triggerName('Office') })).toBeInTheDocument() + expect(useSidebarServerStore.getState().serverId).toBe('srv_office') expect(screen.getByTestId('location')).toHaveTextContent('/') }) - it('selects the local machine without navigating', () => { + it('selects the local machine without navigating', async () => { + const user = userEvent.setup() + setServers([kitchen]) useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) renderNav() + await user.click(screen.getByRole('button', { name: triggerName('Kitchen') })) + await user.click(await screen.findByRole('menuitem', { name: /Local/ })) - fireEvent.click(screen.getByRole('button', { name: /Local/ })) - - expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true') + expect(await screen.findByRole('button', { name: triggerName('Local') })).toBeInTheDocument() expect(screen.getByTestId('location')).toHaveTextContent('/') }) it('follows a linked server route', async () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + setServers([kitchen]) renderNav('/servers/srv_kitchen/sessions/abc') - await waitFor(() => - expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'true'), - ) - expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'false') + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() }) it('falls back to local when the persisted server is disabled', async () => { useSidebarServerStore.setState({ serverId: 'srv_office' }) - mocks.useOpenCodeServers.mockReturnValue({ - servers: [kitchen, { ...office, enabled: false }], - isLoading: false, - }) + setServers([kitchen, { ...office, enabled: false }]) renderNav() - await waitFor(() => - expect(screen.getByRole('button', { name: /Local/ })).toHaveAttribute('aria-pressed', 'true'), - ) + expect(await screen.findByRole('button', { name: triggerName('Local') })).toBeInTheDocument() }) - it('renders the local machine and enabled servers as icon toggles in the collapsed rail', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + it('shows an attention dot for another server that needs input', async () => { + setServers([kitchen, office]) + mocks.useServerActivityState.mockImplementation((serverId: string) => + serverId === 'srv_office' ? 'needs-input' : null, + ) - renderRail('/servers/srv_kitchen/sessions/abc') + renderNav() - expect(screen.getByRole('button', { name: 'Local' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Kitchen' })).toHaveAttribute('aria-pressed', 'true') - expect(screen.queryByText('Servers')).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Office needs input' })).toBeInTheDocument() + expect(screen.queryByRole('img', { name: 'Office is running' })).not.toBeInTheDocument() }) - it('renders nothing in the collapsed rail when no linked servers exist', () => { - renderRail() + it('shows a running attention dot for another running server', () => { + setServers([kitchen]) + mocks.useServerActivityState.mockReturnValue('running') - expect(screen.queryByRole('button', { name: 'Local' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Kitchen' })).not.toBeInTheDocument() - }) + renderNav() - it('switches the selected server in the sheet variant without navigating', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) + expect(screen.getByRole('img', { name: 'Kitchen is running' })).toBeInTheDocument() + }) - render( - - - - , - ) + it('shows local attention while a remote server is selected', () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useLocalActivityState.mockReturnValue('needs-input') - expect(screen.getByRole('heading', { name: 'Servers' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /Kitchen/ })) + renderNav() - expect(screen.getByRole('button', { name: /Kitchen/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByTestId('location')).toHaveTextContent('/') + expect(screen.getByRole('img', { name: 'Local needs input' })).toBeInTheDocument() }) - it('shows the activity indicator only for the server with an active session', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) - mocks.useServerActivityState.mockImplementation((serverId: string) => - serverId === 'srv_kitchen' ? 'needs-input' : null, - ) + it('does not show an attention dot for the selected server', async () => { + setServers([kitchen]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useServerActivityState.mockReturnValue('needs-input') renderNav() - expect(mocks.useServerActivityState).toHaveBeenCalledWith('srv_kitchen') - expect(mocks.useServerActivityState).toHaveBeenCalledWith('srv_office') + await waitFor(() => + expect(screen.getByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument(), + ) + expect(screen.queryByRole('img', { name: 'Kitchen needs input' })).not.toBeInTheDocument() expect(screen.getByRole('img', { name: 'Session needs input' })).toBeInTheDocument() - expect(screen.queryByRole('img', { name: 'Sessions running' })).not.toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() }) - it('shows local activity on the local row', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) - mocks.useLocalActivityState.mockReturnValue('running') + it('opens settings on the servers tab from the menu', async () => { + const user = userEvent.setup() + setServers([kitchen]) renderNav() + await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(await screen.findByRole('menuitem', { name: /Manage servers/ })) - expect(screen.getByRole('img', { name: 'Sessions running' })).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/?settings=open&settingsTab=servers') }) - it('shows the running indicator in the sheet variant', () => { - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen], isLoading: false }) - mocks.useServerActivityState.mockReturnValue('running') + it('renders a single icon trigger in the collapsed rail', async () => { + const user = userEvent.setup() + setServers([kitchen]) - render( - - - , - ) + renderNav('/', 'rail') - expect(screen.getByRole('img', { name: 'Sessions running' })).toBeInTheDocument() + const trigger = screen.getByRole('button', { name: triggerName('Local') }) + expect(trigger).toBeInTheDocument() + expect(screen.queryByText('Local')).not.toBeInTheDocument() + + await user.click(trigger) + expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + }) + + it('renders nothing in the collapsed rail when no linked servers exist', () => { + renderNav('/', 'rail') + + expect(screen.queryByRole('button', { name: /Switch server/ })).not.toBeInTheDocument() + }) + + it('renders a thin trigger row in the sheet variant', async () => { + const user = userEvent.setup() + setServers([kitchen]) + + renderNav('/', 'sheet') + + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) + + expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') }) }) diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index 062e1b6e5..d546cc2d8 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -1,19 +1,32 @@ -import { Laptop, Server } from 'lucide-react' -import { LOCAL_OPENCODE_SERVER_ID, type OpenCodeServer } from '@opencode-manager/shared/schemas' +import { Check, ChevronDown, Laptop, Server, Settings2 } from 'lucide-react' +import { + LOCAL_OPENCODE_SERVER_ID, + type OpenCodeServer, + type OpenCodeServerStatus, +} from '@opencode-manager/shared/schemas' import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' import { useLocalActivityState, useServerActivityState } from '@/hooks/useActiveWork' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' -import { SidebarSection } from '@/components/ui/sidebar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' interface LinkedServersNavProps { - /** `sidebar` renders the labelled section, `rail` the collapsed icon column, `sheet` the mobile sheet group. */ + /** `sidebar` renders the full-width trigger row, `rail` the collapsed icon button, `sheet` the mobile sheet row. */ variant?: LinkedServersVariant } +type ActivityState = 'needs-input' | 'running' | null + function ServerConnectionDot({ connected }: { connected: boolean }) { const label = connected ? 'Connected' : 'Disconnected' @@ -27,158 +40,206 @@ function ServerConnectionDot({ connected }: { connected: boolean }) { ) } -function rowClassName(active: boolean, variant: 'sidebar' | 'sheet') { - return cn( - 'flex items-center gap-3 rounded-md p-2.5 text-sm transition-colors duration-150', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', - active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', - variant === 'sheet' && 'mx-2 p-2 text-left', - ) +function LocalActivityIndicator() { + const activity = useLocalActivityState() + + return } -function railClassName(active: boolean) { - return cn( - 'flex items-center justify-center rounded-md p-2.5 transition-colors duration-150', - 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', - active ? 'bg-accent text-accent-foreground' : 'text-foreground hover:bg-accent/50', +function ServerActivityIndicator({ serverId }: { serverId: string }) { + const activity = useServerActivityState(serverId) + + return +} + +function AttentionDot({ activity, label }: { activity: ActivityState; label: string }) { + if (activity === null) return null + + const needsInput = activity === 'needs-input' + + return ( + ) } -interface LocalServerRowProps { - variant: 'sidebar' | 'sheet' - active: boolean - onSelect: () => void +function LocalAttentionDot() { + const activity = useLocalActivityState() + + return +} + +function ServerAttentionDot({ server }: { server: OpenCodeServer }) { + const activity = useServerActivityState(server.id) + + return +} + +function OtherServersAttention({ + servers, + selectedServerId, +}: { + servers: OpenCodeServer[] + selectedServerId: string +}) { + const otherServers = servers.filter((server) => server.id !== selectedServerId) + const showLocal = selectedServerId !== LOCAL_OPENCODE_SERVER_ID + + if (!showLocal && otherServers.length === 0) return null + + return ( + + {showLocal && } + {otherServers.map((server) => ( + + ))} + + ) } -function LocalServerRow({ variant, active, onSelect }: LocalServerRowProps) { +function LocalServerMenuItem({ active, onSelect }: { active: boolean; onSelect: () => void }) { const activity = useLocalActivityState() return ( - + {active && } + ) } -interface LinkedServerRowProps { +function LinkedServerMenuItem({ + server, + active, + connected, + onSelect, +}: { server: OpenCodeServer - variant: 'sidebar' | 'sheet' active: boolean connected: boolean onSelect: () => void -} - -function LinkedServerRow({ server, variant, active, connected, onSelect }: LinkedServerRowProps) { +}) { const activity = useServerActivityState(server.id) return ( - + {active && } + + ) +} + +function LinkedServersMenuContent({ + servers, + selectedServerId, + statusById, + onSelect, +}: { + servers: OpenCodeServer[] + selectedServerId: string + statusById: Record + onSelect: (serverId: string) => void +}) { + const { setActiveTab } = useSettingsDialog() + + return ( + + onSelect(LOCAL_OPENCODE_SERVER_ID)} + /> + {servers.map((server) => ( + onSelect(server.id)} + /> + ))} + + setActiveTab('servers')} className="gap-2"> + + Manage servers… + + ) } /** - * The server switcher. Selecting a row changes which server's projects and sessions the sidebar - * lists; it never navigates. Hidden when no linked server is enabled. + * The server switcher. The trigger names the selected server and opens a menu that changes which + * server's projects and sessions the sidebar lists; selecting never navigates. Hidden when no + * linked server is enabled. */ export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) { const { servers, isLoading } = useOpenCodeServers() const { statusById } = useOpenCodeServerStatuses() const [selectedServerId, selectServer] = useSidebarServerSelection() const enabledServers = servers.filter((server) => server.enabled) - const localActive = selectedServerId === LOCAL_OPENCODE_SERVER_ID if (isLoading || enabledServers.length === 0) { return null } - if (variant === 'rail') { - return ( -
+ const selectedServer = enabledServers.find((server) => server.id === selectedServerId) + const localActive = selectedServerId === LOCAL_OPENCODE_SERVER_ID + const selectedName = localActive ? 'Local' : selectedServer?.name ?? 'Local' + const isRail = variant === 'rail' + const connectionConnected = statusById[selectedServerId]?.connected ?? false + + return ( + + - {enabledServers.map((server) => { - const active = selectedServerId === server.id - - return ( - - ) - })} -
- ) - } - - const rows = [ - selectServer(LOCAL_OPENCODE_SERVER_ID)} - />, - ...enabledServers.map((server) => ( - selectServer(server.id)} + + - )), - ] - - if (variant === 'sheet') { - return ( -
-

- Servers -

- {rows} -
- ) - } - - return ( - - {rows} - + ) } diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index 22d249fc9..03f2c10fc 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -194,6 +194,97 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('/assistant') }) + it('renders global tabs on a linked server page without the local Files tab', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + const labels = screen.getAllByRole('button').map((button) => button.textContent) + expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) + expect(screen.queryByText('Files')).not.toBeInTheDocument() + expect(screen.queryByText('Git')).not.toBeInTheDocument() + }) + + it('renders global tabs with Git on a linked server project page', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + const labels = screen.getAllByRole('button').map((button) => button.textContent) + expect(labels).toEqual(['Repos', 'Git', 'Assistant', 'More']) + expect(screen.queryByText('Files')).not.toBeInTheDocument() + expect(screen.queryByText('Schedules')).not.toBeInTheDocument() + }) + + it('opens the remote source control dialog from the Git tab on a linked server project page', async () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const user = userEvent.setup() + + render( + + + + + + + } /> + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Git' })) + expect(screen.getByTestId('location')).toHaveTextContent('/servers/srv_kitchen/projects') + expect(screen.getByTestId('location')).toHaveTextContent('directory=%2Fhome%2Fme%2Fapp') + expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') + }) + + it('opens the repos sheet from the Repos tab on a linked server page', async () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const user = userEvent.setup() + + render( + + + + + + + } /> + + + , + ) + + await user.click(screen.getByRole('button', { name: 'Repos' })) + expect(screen.getByTestId('location')).toHaveTextContent('mobileTab=repos') + }) + + it('does not render tab bar on a linked server session path', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const { container } = render( + + + + + , + ) + expect(container.firstChild).toBeNull() + }) + it('renders schedule tabs on /repos/:id/schedules path', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 3267ddfb8..2176aacc9 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -7,7 +7,12 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useUrlParams } from '@/hooks/useUrlParams' import { useScheduleUrlState, type ScheduleTab } from '@/hooks/useScheduleUrlState' import { useUnreadScheduleRuns } from '@/hooks/useSchedules' -import { getAssistantPath, isAssistantPath, parseRepoRoute } from '@/lib/navigation' +import { + getAssistantPath, + isAssistantPath, + parseRepoRoute, + parseServerProjectRoute, +} from '@/lib/navigation' import { openDialogParam } from '@/hooks/useDialogParam' interface TabDef { @@ -27,50 +32,75 @@ interface GlobalTabsArgs { navigate: ReturnType isInsideRepo: boolean repoId: number | null + serverScope: ServerRouteScope updateParams: ReturnType['updateParams'] unreadScheduleCount: number } type TabBarMode = 'hidden' | 'global' | 'schedule' +type ServerRouteScope = 'server' | 'project' | null + interface MobileTabRouteState { mode: TabBarMode isInsideRepo: boolean repoId: number | null + serverScope: ServerRouteScope } -function getMobileTabRouteState(pathname: string): MobileTabRouteState { - if (isAssistantPath(pathname)) { - return { mode: 'global', isInsideRepo: false, repoId: null } +const SERVER_PAGE_PATH = /^\/servers\/[^/]+$/ +const SERVER_PROJECT_PAGE_PATH = /^\/servers\/[^/]+\/projects$/ + +function getServerRouteScope(pathname: string, search: string): ServerRouteScope { + if (SERVER_PROJECT_PAGE_PATH.test(pathname)) { + return parseServerProjectRoute(pathname, search) ? 'project' : 'server' } + if (SERVER_PAGE_PATH.test(pathname)) { + return 'server' + } + return null +} - const { repoId, section: repoSection } = parseRepoRoute(pathname) +function getMobileTabRouteState(pathname: string, search: string): MobileTabRouteState { + if (isAssistantPath(pathname)) { + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope: null } + } if (pathname === '/' || pathname === '/schedules') { - return { mode: 'global', isInsideRepo: false, repoId: null } + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope: null } } + const serverScope = getServerRouteScope(pathname, search) + if (serverScope === 'server' || serverScope === 'project') { + return { mode: 'global', isInsideRepo: false, repoId: null, serverScope } + } + + const { repoId, section: repoSection } = parseRepoRoute(pathname) + if (repoId === null) { - return { mode: 'hidden', isInsideRepo: false, repoId: null } + return { mode: 'hidden', isInsideRepo: false, repoId: null, serverScope: null } } switch (repoSection) { case null: - return { mode: 'global', isInsideRepo: true, repoId } + return { mode: 'global', isInsideRepo: true, repoId, serverScope: null } case 'schedules': - return { mode: 'schedule', isInsideRepo: true, repoId } + return { mode: 'schedule', isInsideRepo: true, repoId, serverScope: null } default: - return { mode: 'hidden', isInsideRepo: false, repoId } + return { mode: 'hidden', isInsideRepo: false, repoId, serverScope: null } } } -function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, updateParams, unreadScheduleCount }: GlobalTabsArgs): TabDef[] { +function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, serverScope, updateParams, unreadScheduleCount }: GlobalTabsArgs): TabDef[] { const openRepoDialog = (dialog: string) => { openDialogParam(updateParams, dialog) } const inRepo = isInsideRepo && repoId !== null + const inServerRoute = serverScope !== null const filesAreScoped = inRepo || isAssistantPath(pathname) + const showsGit = inRepo || serverScope === 'project' + const showsSchedules = !inRepo && serverScope !== 'project' const handleFilesClick = () => { if (filesAreScoped) { @@ -93,14 +123,16 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: () => open('repos'), active: openSheet === 'repos' || (pathname === '/' && !openSheet), }, - { - key: 'files', - label: 'Files', - icon: FolderOpen, - onClick: handleFilesClick, - active: openSheet === 'files', - }, - ...(inRepo + ...(!inServerRoute + ? [{ + key: 'files', + label: 'Files', + icon: FolderOpen, + onClick: handleFilesClick, + active: openSheet === 'files', + }] + : []), + ...(showsGit ? [{ key: 'source-control', label: 'Git', @@ -116,7 +148,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: handleAssistantClick, active: isAssistantPath(pathname) && !openSheet, }, - ...(!inRepo + ...(showsSchedules ? [{ key: 'schedules', label: 'Schedules', @@ -198,14 +230,14 @@ const TabBarRow = memo(function TabBarRow({ tabs }: TabBarRowProps) { }) export const MobileTabBar = memo(function MobileTabBar() { - const { pathname } = useLocation() + const { pathname, search } = useLocation() const navigate = useNavigate() const { openSheet, open, close } = useMobileTabBar() const { updateParams } = useUrlParams() const { scheduleTab, setScheduleTab } = useScheduleUrlState() const { data: unreadRuns } = useUnreadScheduleRuns() const isMobile = useMobile() - const routeState = useMemo(() => getMobileTabRouteState(pathname), [pathname]) + const routeState = useMemo(() => getMobileTabRouteState(pathname, search), [pathname, search]) const unreadScheduleCount = unreadRuns?.total ?? 0 const tabs = useMemo( @@ -219,6 +251,7 @@ export const MobileTabBar = memo(function MobileTabBar() { navigate, isInsideRepo: routeState.isInsideRepo, repoId: routeState.repoId, + serverScope: routeState.serverScope, updateParams, unreadScheduleCount, })), diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 88784f243..bdc22f7d3 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { beforeEach, describe, it, expect, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -990,7 +991,8 @@ describe('RepoQuickSwitchSheet', () => { expect(handleClose).toHaveBeenCalled() }) - it('renders the Servers group with enabled linked servers', async () => { + it('renders the server switcher when enabled linked servers exist', async () => { + const user = userEvent.setup() vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) setLinkedServers([ createLinkedServer('srv_kitchen', 'Kitchen'), @@ -999,13 +1001,18 @@ describe('RepoQuickSwitchSheet', () => { render(, { wrapper: createWrapper() }) - expect(await screen.findByRole('heading', { name: 'Servers' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Kitchen/ })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Office/ })).not.toBeInTheDocument() + const trigger = await screen.findByRole('button', { name: 'Switch server, current: Local' }) + expect(trigger).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() + + await user.click(trigger) + + expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() }) - it('hides the Servers group when no linked servers exist', async () => { + it('hides the server switcher when no linked servers exist', async () => { vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) render(, { wrapper: createWrapper() }) @@ -1014,10 +1021,11 @@ describe('RepoQuickSwitchSheet', () => { expect(screen.getByText('repo1')).toBeInTheDocument() }) - expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull() + expect(screen.queryByRole('button', { name: /Switch server/ })).toBeNull() }) it('switches to a linked server without navigating or closing the sheet', async () => { + const user = userEvent.setup() vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) @@ -1041,7 +1049,8 @@ describe('RepoQuickSwitchSheet', () => { , ) - fireEvent.click(await screen.findByRole('button', { name: /Kitchen/ })) + await user.click(await screen.findByRole('button', { name: 'Switch server, current: Local' })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) expect(await screen.findByText('Alpha')).toBeInTheDocument() expect(screen.queryByText('repo1')).not.toBeInTheDocument() @@ -1051,6 +1060,7 @@ describe('RepoQuickSwitchSheet', () => { }) it('opens a remote project from the sheet and closes it', async () => { + const user = userEvent.setup() vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) setLinkedServers([createLinkedServer('srv_kitchen', 'Kitchen')]) @@ -1074,8 +1084,9 @@ describe('RepoQuickSwitchSheet', () => { , ) - fireEvent.click(await screen.findByRole('button', { name: /Kitchen/ })) - fireEvent.click(await screen.findByRole('button', { name: /^Alpha/ })) + await user.click(await screen.findByRole('button', { name: 'Switch server, current: Local' })) + await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) + await user.click(await screen.findByRole('button', { name: /^Alpha/ })) expect(screen.getByTestId('location')).toHaveTextContent( '/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha', diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index 933700968..e1b586625 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -104,7 +104,11 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr
- {isOpen && } + {isOpen && ( +
+ +
+ )} {isOpen && !searchQuery.trim() && ( navigateAndClose(path)} /> )} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index c1cddc992..ef3eba09a 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -792,7 +792,14 @@ export function SessionDetail() { onChange={handleRenameSession} subtitle={ - {workspaceDisplayName} + {isRemoteServer ? ( + <> + {linkedServer?.name ?? 'Linked server'} + {sessionDirectory && ` · ${getDirectoryName(sessionDirectory)}`} + + ) : ( + workspaceDisplayName + )} {sessionBranch && · {sessionBranch}} } @@ -823,7 +830,7 @@ export function SessionDetail() { isReconnecting={streamReconnecting} revertMessageID={revertMessageID} /> - {!isRemoteServer && } + diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index 533741485..d0ccd5ffe 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -266,8 +266,9 @@ describe('SessionDetail remote server mode', () => { renderRemoteSession() await waitFor(() => { - expect(screen.getByText('Kitchen · app')).toBeInTheDocument() + expect(screen.getByText('Kitchen')).toHaveClass('text-info') }) + expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') }) it('uses the session directory as the only directory source', async () => { @@ -293,12 +294,22 @@ describe('SessionDetail remote server mode', () => { expect(mocks.RepoActionsDialog).not.toHaveBeenCalled() expect(mocks.ResetPermissionsDialog).not.toHaveBeenCalled() expect(mocks.ChangesWalkthroughSheet).not.toHaveBeenCalled() - expect(mocks.SessionMoreButton).not.toHaveBeenCalled() + expect(mocks.SessionMoreButton).toHaveBeenCalled() expect(vi.mocked(PendingActionsGroup).mock.calls.at(-1)?.[0]).toEqual( expect.objectContaining({ showScheduleReports: false }), ) }) + it('renders the More button so the drawer stays reachable on a remote session', async () => { + renderRemoteSession() + + await waitFor(() => { + expect(screen.getByTestId('message-thread')).toBeInTheDocument() + }) + + expect(mocks.SessionMoreButton).toHaveBeenCalled() + }) + it('shows the session goal bar for a remote session', async () => { renderRemoteSession() From 42dd733378508b15acbf485722ccf1e3d748a204 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:37:42 -0400 Subject: [PATCH 09/15] fix(servers): open settings tabs with history and keep server selection on load errors External settings openers push a history entry and close the mobile sheet. The sidebar server selection only falls back to Local after the server list loads successfully. --- .../navigation/LinkedServersNav.test.tsx | 2 +- .../navigation/LinkedServersNav.tsx | 4 +- frontend/src/hooks/useOpenCodeFailureToast.ts | 6 +- frontend/src/hooks/useOpenCodeServers.ts | 1 + frontend/src/hooks/useSettingsDialog.test.tsx | 56 ++++++++++++++++++- frontend/src/hooks/useSettingsDialog.ts | 14 ++++- .../hooks/useSidebarServerSelection.test.tsx | 25 ++++++++- .../src/hooks/useSidebarServerSelection.ts | 8 +-- frontend/src/pages/RemoteProject.tsx | 4 +- frontend/src/pages/RemoteServer.tsx | 4 +- frontend/src/pages/SessionDetail.tsx | 4 +- .../pages/__tests__/RemoteProject.test.tsx | 6 +- .../src/pages/__tests__/RemoteServer.test.tsx | 6 +- .../__tests__/SessionDetail.commands.test.tsx | 6 +- .../__tests__/SessionDetail.remote.test.tsx | 2 +- 15 files changed, 118 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index 16573d3e3..baefe439b 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -51,7 +51,7 @@ function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { } function setServers(servers: Array) { - mocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false }) + mocks.useOpenCodeServers.mockReturnValue({ servers, isLoading: false, isSuccess: true }) } function triggerName(name: string) { diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index d546cc2d8..e088aa899 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -159,7 +159,7 @@ function LinkedServersMenuContent({ statusById: Record onSelect: (serverId: string) => void }) { - const { setActiveTab } = useSettingsDialog() + const { openTab } = useSettingsDialog() return ( @@ -177,7 +177,7 @@ function LinkedServersMenuContent({ /> ))} - setActiveTab('servers')} className="gap-2"> + openTab('servers')} className="gap-2"> Manage servers… diff --git a/frontend/src/hooks/useOpenCodeFailureToast.ts b/frontend/src/hooks/useOpenCodeFailureToast.ts index 3c23bf728..fb797eec5 100644 --- a/frontend/src/hooks/useOpenCodeFailureToast.ts +++ b/frontend/src/hooks/useOpenCodeFailureToast.ts @@ -12,7 +12,7 @@ export const OPENCODE_FAILURE_TOAST_ID = 'opencode-server-failure' */ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) { const { data: health } = useServerHealth(enabled) - const { setActiveTab } = useSettingsDialog() + const { openTab } = useSettingsDialog() const notifiedFailureRef = useRef(null) const issue = getOpenCodeServerIssue(health) @@ -27,7 +27,7 @@ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) id: OPENCODE_FAILURE_TOAST_ID, duration: Infinity, description: failureMessage, - action: { label: 'View logs', onClick: () => setActiveTab('logs') }, + action: { label: 'View logs', onClick: () => openTab('logs') }, cancel: { label: 'Restart', onClick: onRestart }, }) return @@ -38,5 +38,5 @@ export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) showToast.dismiss(OPENCODE_FAILURE_TOAST_ID) showToast.success('OpenCode server is back online') } - }, [failureMessage, isHealthy, setActiveTab, onRestart]) + }, [failureMessage, isHealthy, openTab, onRestart]) } diff --git a/frontend/src/hooks/useOpenCodeServers.ts b/frontend/src/hooks/useOpenCodeServers.ts index b0da8cd78..750101083 100644 --- a/frontend/src/hooks/useOpenCodeServers.ts +++ b/frontend/src/hooks/useOpenCodeServers.ts @@ -58,6 +58,7 @@ export function useOpenCodeServers(options: { enabled?: boolean } = {}) { return { servers: serversQuery.data ?? [], isLoading: serversQuery.isLoading, + isSuccess: serversQuery.isSuccess, error: serversQuery.error, refetch: serversQuery.refetch, createAsync: createMutation.mutateAsync, diff --git a/frontend/src/hooks/useSettingsDialog.test.tsx b/frontend/src/hooks/useSettingsDialog.test.tsx index 60644de28..ca81008f9 100644 --- a/frontend/src/hooks/useSettingsDialog.test.tsx +++ b/frontend/src/hooks/useSettingsDialog.test.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { act, render, screen } from '@testing-library/react' -import { MemoryRouter, useNavigate } from 'react-router-dom' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { describe, it, expect } from 'vitest' import { useSettingsDialog } from './useSettingsDialog' import { renderHookWithRouter } from '@/test/test-utils' @@ -164,6 +164,60 @@ describe('useSettingsDialog', () => { expect(screen.getByTestId('dialog-state').textContent).toBe('closed') }) + it('openTab pushes a settings entry, selects the tab, and drops the mobile sheet', () => { + function Harness() { + const { isOpen, activeTab, openTab } = useSettingsDialog() + const navigate = useNavigate() + const location = useLocation() + const [step, setStep] = useState<'start' | 'opened' | 'back'>('start') + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + if (step === 'opened') { + handled.current = true + openTab('servers') + } else if (step === 'back') { + handled.current = true + navigate(-1) + } + }, [step, openTab, navigate]) + + return ( +
+ {isOpen ? 'open' : 'closed'} + {activeTab} + {location.search} + + +
+ ) + } + + render( + + + , + ) + + expect(screen.getByTestId('dialog-state').textContent).toBe('closed') + + act(() => { screen.getByText('open').click() }) + + expect(screen.getByTestId('search').textContent).toBe('?settings=open&settingsTab=servers') + expect(screen.getByTestId('dialog-state').textContent).toBe('open') + expect(screen.getByTestId('active-tab').textContent).toBe('servers') + + act(() => { screen.getByText('back').click() }) + + expect(screen.getByTestId('search').textContent).toBe('?mobileTab=repos') + expect(screen.getByTestId('dialog-state').textContent).toBe('closed') + }) + it('setActiveTab replaces so navigate(-1) does not step through old tab', () => { function Harness() { const { isOpen, activeTab, open, setActiveTab } = useSettingsDialog() diff --git a/frontend/src/hooks/useSettingsDialog.ts b/frontend/src/hooks/useSettingsDialog.ts index f6f4f8c4e..ea1e0b60c 100644 --- a/frontend/src/hooks/useSettingsDialog.ts +++ b/frontend/src/hooks/useSettingsDialog.ts @@ -12,6 +12,11 @@ export function isSettingsContentTab(value: string | null): value is SettingsCon interface UseSettingsDialogReturn { isOpen: boolean open: () => void + /** + * Opens the settings dialog on the given tab by pushing a new history entry, so Back closes + * settings instead of leaving the page, and clears the mobile sheet selection behind the dialog. + */ + openTab: (tab: SettingsContentTab) => void close: () => void toggle: () => void activeTab: SettingsContentTab @@ -30,14 +35,18 @@ export function useSettingsDialog(): UseSettingsDialogReturn { const activeTabRef = useRef(activeTab) activeTabRef.current = activeTab - const open = useCallback(() => { + const openTab = useCallback((tab: SettingsContentTab) => { updateParams((p) => { p.set('settings', 'open') - p.set('settingsTab', activeTabRef.current) + p.set('settingsTab', tab) p.delete('mobileTab') }, 'push') }, [updateParams]) + const open = useCallback(() => { + openTab(activeTabRef.current) + }, [openTab]) + const close = useCallback(() => { updateParams((p) => { p.delete('settings') @@ -64,6 +73,7 @@ export function useSettingsDialog(): UseSettingsDialogReturn { return { isOpen, open, + openTab, close, toggle, activeTab, diff --git a/frontend/src/hooks/useSidebarServerSelection.test.tsx b/frontend/src/hooks/useSidebarServerSelection.test.tsx index 17d458bd8..a84a999c0 100644 --- a/frontend/src/hooks/useSidebarServerSelection.test.tsx +++ b/frontend/src/hooks/useSidebarServerSelection.test.tsx @@ -59,7 +59,11 @@ describe('useSidebarServerSelection', () => { beforeEach(() => { localStorage.clear() useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) - mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, office], isLoading: false }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, office], + isLoading: false, + isSuccess: true, + }) }) it('defaults to the local server', () => { @@ -116,6 +120,7 @@ describe('useSidebarServerSelection', () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [kitchen, { ...office, enabled: false }], isLoading: false, + isSuccess: true, }) renderHarness() @@ -125,6 +130,21 @@ describe('useSidebarServerSelection', () => { ) }) + it('keeps the persisted server when the server list fails to load', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [], + isLoading: false, + isSuccess: false, + error: new Error('offline'), + }) + + renderHarness() + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + expect(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY)).toBeNull() + }) + it('falls back to local when the selected server no longer exists', async () => { useSidebarServerStore.setState({ serverId: 'srv_gone' }) @@ -133,6 +153,9 @@ describe('useSidebarServerSelection', () => { await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID), ) + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( + LOCAL_OPENCODE_SERVER_ID, + ) }) it('persists the selection', () => { diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts index ba0370f3c..5f37b5542 100644 --- a/frontend/src/hooks/useSidebarServerSelection.ts +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -12,12 +12,12 @@ function isLocalRoute(pathname: string): boolean { /** * The linked server selected in the sidebar, defaulting to the local server. The route selects a * server when the location changes, selecting a row never navigates, and a selected server that is - * missing or disabled falls back to local. + * missing or disabled in a successfully loaded list falls back to local. */ export function useSidebarServerSelection(): [string, (serverId: string) => void] { const { pathname } = useLocation() const routeServerId = useMatch('/servers/:serverId/*')?.params.serverId - const { servers, isLoading } = useOpenCodeServers() + const { servers, isSuccess } = useOpenCodeServers() const serverId = useSidebarServerStore((state) => state.serverId) const selectServer = useSidebarServerStore((state) => state.selectServer) @@ -33,12 +33,12 @@ export function useSidebarServerSelection(): [string, (serverId: string) => void }, [pathname, routeServerId, selectServer]) useEffect(() => { - if (isLoading || serverId === LOCAL_OPENCODE_SERVER_ID) return + if (!isSuccess || serverId === LOCAL_OPENCODE_SERVER_ID) return const server = servers.find((candidate) => candidate.id === serverId) if (!server || !server.enabled) { selectServer(LOCAL_OPENCODE_SERVER_ID) } - }, [isLoading, servers, serverId, selectServer]) + }, [isSuccess, servers, serverId, selectServer]) return [serverId, selectServer] } diff --git a/frontend/src/pages/RemoteProject.tsx b/frontend/src/pages/RemoteProject.tsx index 044cb79be..d83fb177a 100644 --- a/frontend/src/pages/RemoteProject.tsx +++ b/frontend/src/pages/RemoteProject.tsx @@ -29,7 +29,7 @@ import { ToolSidePanel } from '@/components/navigation/ToolSidePanel' function RemoteProjectContent({ serverId, directory }: { serverId: string; directory?: string }) { const navigate = useNavigate() const { servers, isLoading: serversLoading } = useOpenCodeServers() - const { setActiveTab } = useSettingsDialog() + const { openTab } = useSettingsDialog() const [mcpDialogOpen, setMcpDialogOpen] = useDialogParam('mcp') const [skillsDialogOpen, setSkillsDialogOpen] = useDialogParam('skills') const [sourceControlOpen, setSourceControlOpen] = useDialogParam('sourceControl') @@ -63,7 +63,7 @@ function RemoteProjectContent({ serverId, directory }: { serverId: string; direc const branch = vcsQuery.data?.branch.current ?? vcsQuery.data?.branch.default const title = project?.name?.trim() || (directory ? getDirectoryName(directory) : 'Project') - const openSettings = () => setActiveTab('servers') + const openSettings = () => openTab('servers') const handleCreateSession = () => { createSession.mutate({}, { diff --git a/frontend/src/pages/RemoteServer.tsx b/frontend/src/pages/RemoteServer.tsx index d872cf636..1910ff46f 100644 --- a/frontend/src/pages/RemoteServer.tsx +++ b/frontend/src/pages/RemoteServer.tsx @@ -170,7 +170,7 @@ function RemoteServerContent({ serverId }: { serverId: string }) { const navigate = useNavigate() const { servers, isLoading: serversLoading } = useOpenCodeServers() const { statusById, isLoading: statusLoading } = useOpenCodeServerStatuses() - const { setActiveTab } = useSettingsDialog() + const { openTab } = useSettingsDialog() const createSession = useCreateSession() const [openDirectory, setOpenDirectory] = useState('') @@ -185,7 +185,7 @@ function RemoteServerContent({ serverId }: { serverId: string }) { retry: false, }) - const openSettings = () => setActiveTab('servers') + const openSettings = () => openTab('servers') const handleOpenDirectory = async (event: FormEvent) => { event.preventDefault() diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index ef3eba09a..3b8a4ecc0 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -131,7 +131,7 @@ export function SessionDetail() { const repoId = Number(id) || 0; const isAssistantSession = new URLSearchParams(location.search).get('assistant') === '1'; const { preferences, updateSettings } = useSettings(); - const { open: openSettings, setActiveTab: setSettingsTab } = useSettingsDialog(); + const { open: openSettings, openTab: openSettingsTab } = useSettingsDialog(); const messageContainerRef = useRef(null); const promptInputRef = useRef(null); const [sessionsDialogOpen, setSessionsDialogOpen] = useState(false); @@ -354,7 +354,7 @@ export function SessionDetail() { ? requestedWalkthroughSource : undefined; const handleSkillLoaded = useCallback((skill: SkillFileInfo) => showToast.success(`Loaded skill: ${skill.name}`), []); - const handleConnectProvider = useCallback(() => setSettingsTab('providers'), [setSettingsTab]); + const handleConnectProvider = useCallback(() => openSettingsTab('providers'), [openSettingsTab]); useAutoOpenWalkthrough({ sessionId, diff --git a/frontend/src/pages/__tests__/RemoteProject.test.tsx b/frontend/src/pages/__tests__/RemoteProject.test.tsx index 2f09202e2..ecdcd2da7 100644 --- a/frontend/src/pages/__tests__/RemoteProject.test.tsx +++ b/frontend/src/pages/__tests__/RemoteProject.test.tsx @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => ({ useSSE: vi.fn(), createSession: vi.fn(), lastSessionListProps: { current: undefined as SessionListCapture | undefined }, - setActiveTab: vi.fn(), + openTab: vi.fn(), })) vi.mock('@/api/opencode', () => ({ @@ -99,7 +99,7 @@ describe('RemoteProject', () => { mocks.getVcsInfo.mockResolvedValue({ branch: { current: 'main', default: 'main' } }) mocks.useCreateSession.mockReturnValue({ mutate: mocks.createSession, isPending: false }) mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) - mocks.useSettingsDialog.mockReturnValue({ setActiveTab: mocks.setActiveTab }) + mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) }) @@ -195,7 +195,7 @@ describe('RemoteProject', () => { fireEvent.click(await screen.findByRole('button', { name: 'Open server settings' })) - expect(mocks.setActiveTab).toHaveBeenCalledWith('servers') + expect(mocks.openTab).toHaveBeenCalledWith('servers') }) it('goes back to the linked server page', async () => { diff --git a/frontend/src/pages/__tests__/RemoteServer.test.tsx b/frontend/src/pages/__tests__/RemoteServer.test.tsx index 0114ff197..115490f3c 100644 --- a/frontend/src/pages/__tests__/RemoteServer.test.tsx +++ b/frontend/src/pages/__tests__/RemoteServer.test.tsx @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ useOpenCodeServerStatuses: vi.fn(), useSettingsDialog: vi.fn(), useSSE: vi.fn(), - setActiveTab: vi.fn(), + openTab: vi.fn(), createSession: vi.fn(), })) @@ -101,7 +101,7 @@ describe('RemoteServer', () => { }, isLoading: false, }) - mocks.useSettingsDialog.mockReturnValue({ setActiveTab: mocks.setActiveTab }) + mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) }) @@ -223,6 +223,6 @@ describe('RemoteServer', () => { fireEvent.click(screen.getByRole('button', { name: /Manage servers/ })) - expect(mocks.setActiveTab).toHaveBeenCalledWith('servers') + expect(mocks.openTab).toHaveBeenCalledWith('servers') }) }) diff --git a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx index caf887a6a..d05406b6c 100644 --- a/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.commands.test.tsx @@ -20,7 +20,7 @@ const mocks = vi.hoisted(() => ({ useAutoScroll: vi.fn(), dialogSetters: {} as Record>, openSettings: vi.fn(), - setSettingsTab: vi.fn(), + openSettingsTab: vi.fn(), useSessionStatusForSession: vi.fn(), compactSession: vi.fn(), listSessionMessages: vi.fn(), @@ -118,7 +118,7 @@ vi.mock('@/hooks/useSettings', () => ({ })) vi.mock('@/hooks/useSettingsDialog', () => ({ - useSettingsDialog: vi.fn(() => ({ open: mocks.openSettings, setActiveTab: mocks.setSettingsTab })), + useSettingsDialog: vi.fn(() => ({ open: mocks.openSettings, openTab: mocks.openSettingsTab })), })) vi.mock('@/hooks/useMobile', () => ({ @@ -733,7 +733,7 @@ describe('SessionDetail command actions', () => { await connectProvider() }) - expect(mocks.setSettingsTab).toHaveBeenCalledWith('providers') + expect(mocks.openSettingsTab).toHaveBeenCalledWith('providers') }) it('exports complete history across pages in oldest-first order without duplicates', async () => { diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index d0ccd5ffe..e6b5cdf81 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -235,7 +235,7 @@ describe('SessionDetail remote server mode', () => { preferences: { expandToolCalls: false }, updateSettings: vi.fn(), }) - mocks.useSettingsDialog.mockReturnValue({ open: vi.fn(), setActiveTab: vi.fn() }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn(), openTab: vi.fn() }) mocks.useMobile.mockReturnValue(false) mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn(), scrollByUser: vi.fn() }) From a926991637c324fb7aca8f7a05f0f2fea9d56899 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:42:47 -0400 Subject: [PATCH 10/15] fix(navigation): ignore malformed server ids on remote project routes --- frontend/src/lib/navigation.test.ts | 1 + frontend/src/lib/navigation.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index b14df9d07..37e8c2873 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -266,6 +266,7 @@ describe('getSwipeBackTarget', () => { expect(getSwipeBackTarget('/servers/%E0%A4%A/sessions/ses_1', '')).toBeNull(); expect(() => getSwipeBackTarget('/servers/srv_kitchen/sessions/%E0%A4%A', '')).not.toThrow(); expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/%E0%A4%A', '')).toBeNull(); + expect(() => getSwipeBackTarget('/servers/%E0%A4%A/projects', '?directory=%2Fa')).not.toThrow(); }); }); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 8869885a5..f6626619f 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -47,8 +47,9 @@ export function parseServerProjectRoute( const match = /^\/servers\/([^/]+)\/projects$/.exec(pathname); if (!match) return null; const directory = new URLSearchParams(search).get('directory'); - if (!directory) return null; - return { serverId: decodeURIComponent(match[1]), directory }; + const serverId = decodePathSegment(match[1]); + if (!directory || !serverId) return null; + return { serverId, directory }; } function decodePathSegment(segment: string): string | null { From 0a520babc4a8e762c0e8bb25732b9f746477f63f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:27:59 -0400 Subject: [PATCH 11/15] feat(models): share favorites, recents and variants across linked servers Model preferences are per user, as in the OpenCode TUI, so linked servers read and write the same model state as the local server. The picker still only lists models in the current server's catalog, and a failed preference load no longer blocks model selection. --- frontend/src/hooks/useModelSelection.test.tsx | 80 +++++++++++++++---- frontend/src/hooks/useModelSelection.ts | 21 ++--- frontend/src/hooks/useVariants.test.tsx | 21 +++-- frontend/src/hooks/useVariants.ts | 7 +- 4 files changed, 90 insertions(+), 39 deletions(-) diff --git a/frontend/src/hooks/useModelSelection.test.tsx b/frontend/src/hooks/useModelSelection.test.tsx index eebcc9cf8..531537077 100644 --- a/frontend/src/hooks/useModelSelection.test.tsx +++ b/frontend/src/hooks/useModelSelection.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useModelSelection, useOpenCodeDefaultModel } from './useModelSelection' +import { modelStateQueryKey, useModelSelection, useOpenCodeDefaultModel } from './useModelSelection' import { useProviders } from './useProviders' import { locationAgentKey, useModelStore, type ModelSelection } from '@/stores/modelStore' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' @@ -493,8 +493,7 @@ describe('useModelSelection', () => { models: [modelInfo('anthropic', 'claude-sonnet-4'), modelInfo('openai', 'gpt-4o')], } - const renderRemote = (directory = '/test') => { - const queryClient = createTestQueryClient() + const renderRemote = (directory = '/test', queryClient = createTestQueryClient()) => { return renderHook( () => useModelSelection(directory), { @@ -531,42 +530,95 @@ describe('useModelSelection', () => { ).toBeUndefined() }) - it('does not read or write the local model state', async () => { + it('reads the shared model state and writes favorites and recents on a remote server', async () => { + mockGetOpenCodeModelState.mockResolvedValue({ + recent: [ + { providerID: 'openai', modelID: 'gpt-4o' }, + { providerID: 'ghost', modelID: 'gone' }, + ], + favorite: [ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + { providerID: 'ghost', modelID: 'gone' }, + ], + variant: {}, + }) + const { result } = renderRemote() await waitFor(() => { expect(result.current.isModelReady).toBe(true) }) + expect(mockGetOpenCodeModelState).toHaveBeenCalledTimes(1) + expect(result.current.modelState).toBeDefined() + await waitFor(() => { + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) + }) + await waitFor(() => { + expect(result.current.favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) + }) + act(() => { result.current.setModel({ providerID: 'openai', modelID: 'gpt-4o' }) result.current.toggleFavorite({ providerID: 'openai', modelID: 'gpt-4o' }) result.current.removeRecentModel({ providerID: 'openai', modelID: 'gpt-4o' }) }) + await waitFor(() => { + expect(mockAddOpenCodeRecentModel).toHaveBeenCalledTimes(1) + expect(mockToggleOpenCodeFavoriteModel).toHaveBeenCalledTimes(1) + expect(mockRemoveOpenCodeRecentModel).toHaveBeenCalledTimes(1) + }) + expect(mockAddOpenCodeRecentModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + expect(mockToggleOpenCodeFavoriteModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + expect(mockRemoveOpenCodeRecentModel.mock.calls[0][0]).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) + }) + + it('reads the model state a local server populated from the same cache entry', async () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(modelStateQueryKey(), { + recent: [{ providerID: 'openai', modelID: 'gpt-4o' }], + favorite: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }], + variant: {}, + }) + + const { result } = renderRemote('/test', queryClient) + + await waitFor(() => { + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) + }) + expect(result.current.favoriteModels).toEqual([ + { providerID: 'anthropic', modelID: 'claude-sonnet-4' }, + ]) expect(mockGetOpenCodeModelState).not.toHaveBeenCalled() - expect(mockAddOpenCodeRecentModel).not.toHaveBeenCalled() - expect(mockToggleOpenCodeFavoriteModel).not.toHaveBeenCalled() - expect(mockRemoveOpenCodeRecentModel).not.toHaveBeenCalled() - expect(result.current.recentModels).toEqual([]) + }) + + it('stays ready with empty preferences when the model state fails to load', async () => { + mockGetOpenCodeModelState.mockRejectedValue(new Error('offline')) + const { result } = renderRemote() + + await waitFor(() => { + expect(result.current.isModelReady).toBe(true) + }) expect(result.current.favoriteModels).toEqual([]) - expect(result.current.modelState).toBeUndefined() + expect(result.current.model).not.toBeNull() }) - it('ignores local recents in the selection order', async () => { - mockGetOpenCodeConfiguredModel.mockResolvedValue('anthropic/claude-sonnet-4') + it('uses the shared recents in the selection order', async () => { mockGetOpenCodeModelState.mockResolvedValue({ recent: [{ providerID: 'openai', modelID: 'gpt-4o' }], favorite: [], - variant: { 'openai/gpt-4o': 'high' }, + variant: {}, }) const { result } = renderRemote() await waitFor(() => { - expect(result.current.model).toEqual({ providerID: 'anthropic', modelID: 'claude-sonnet-4' }) + expect(result.current.model).toEqual({ providerID: 'openai', modelID: 'gpt-4o' }) }) - expect(result.current.recentModels).toEqual([]) + expect(result.current.recentModels).toEqual([{ providerID: 'openai', modelID: 'gpt-4o' }]) }) it('keeps the agent model ahead of the configured model', async () => { diff --git a/frontend/src/hooks/useModelSelection.ts b/frontend/src/hooks/useModelSelection.ts index 9575dd98e..95b554ed7 100644 --- a/frontend/src/hooks/useModelSelection.ts +++ b/frontend/src/hooks/useModelSelection.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { locationAgentKey, useModelStore, type ActiveAgent, type ModelPick, type ModelSelection } from '@/stores/modelStore' -import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' +import { useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { serverScopedKey } from '@/lib/serverScope' import { buildServerDirectoryKey } from '@opencode-manager/shared/schemas' import { useProviders } from './useProviders' @@ -62,13 +62,12 @@ export function modelStateQueryKey() { } export function useOpenCodeModelState(_directory?: string, enabled = true) { - const serverId = useOpenCodeServerId() return useQuery({ - queryKey: serverScopedKey(modelStateQueryKey(), serverId), + queryKey: modelStateQueryKey(), queryFn: () => getOpenCodeModelState(), staleTime: 30000, placeholderData: keepPreviousData, - enabled: enabled && isLocalOpenCodeServer(serverId), + enabled, }) } @@ -123,8 +122,7 @@ export function useModelStateMutation( errorMessage: string, ) { const queryClient = useQueryClient() - const serverId = useOpenCodeServerId() - const queryKey = serverScopedKey(modelStateQueryKey(), serverId) + const queryKey = modelStateQueryKey() return useMutation({ mutationFn: fetcher, @@ -222,8 +220,6 @@ export function useModelSelection( ): UseModelSelectionResult { const { data: catalog, isPlaceholderData: catalogIsPlaceholder } = useProviders(directory) const serverId = useOpenCodeServerId() - const isLocalServer = isLocalOpenCodeServer(serverId) - const activeAgent = useModelStore((state) => state.activeAgent) const newSessionPicks = useModelStore((state) => state.newSessionPicks) const sessionPicks = useModelStore((state) => state.sessionPicks) @@ -231,14 +227,13 @@ export function useModelSelection( const setSessionPick = useModelStore((state) => state.setSessionPick) const setActiveAgent = useModelStore((state) => state.setActiveAgent) - const { data: localModelState } = useOpenCodeModelState() - const modelState = isLocalServer ? localModelState : undefined + const { data: modelState, isError: modelStateFailed } = useOpenCodeModelState() const { data: configModelString } = useOpenCodeConfiguredModel(directory) const isModelReady = Boolean( catalog && !catalogIsPlaceholder && - (!isLocalServer || modelState !== undefined) && + (modelState !== undefined || modelStateFailed) && configModelString !== undefined, ) @@ -353,16 +348,14 @@ export function useModelSelection( } else { setNewSessionPick(key, nextModel) } - if (isLocalServer) updateRecentModel.mutate(nextModel) + updateRecentModel.mutate(nextModel) } const toggleFavorite = (nextModel: ModelSelection) => { - if (!isLocalServer) return updateFavoriteModel.mutate(nextModel) } const removeRecent = (nextModel: ModelSelection) => { - if (!isLocalServer) return removeRecentMutation.mutate(nextModel) } diff --git a/frontend/src/hooks/useVariants.test.tsx b/frontend/src/hooks/useVariants.test.tsx index d1ef27a9f..290608c9b 100644 --- a/frontend/src/hooks/useVariants.test.tsx +++ b/frontend/src/hooks/useVariants.test.tsx @@ -334,7 +334,7 @@ describe('useVariants', () => { ), }) - it('keeps a remote session variant in the session pick without persisting it', async () => { + it('persists a remote session variant in the session pick and via saveOpenCodeModelVariant', async () => { selectAgentModel() const { result } = renderRemoteVariants(createQueryClient(), { id: 's1', @@ -350,11 +350,15 @@ describe('useVariants', () => { expect(useModelStore.getState().sessionPicks.s1[AGENT_ID].variant).toBe('high') await waitFor(() => expect(result.current.currentVariant).toBe('high')) - expect(mockGetOpenCodeModelState).not.toHaveBeenCalled() - expect(mockSaveOpenCodeModelVariant).not.toHaveBeenCalled() + await waitFor(() => { + expect(mockSaveOpenCodeModelVariant).toHaveBeenCalledWith( + { providerID: PROVIDER_ID, modelID: MODEL_ID }, + 'high', + ) + }) }) - it('does not persist a remote variant outside a session', async () => { + it('persists a remote variant outside a session and drives the selection from the preference', async () => { selectAgentModel() const { result } = renderRemoteVariants(createQueryClient()) @@ -364,9 +368,14 @@ describe('useVariants', () => { result.current.setVariant('high') }) - expect(mockSaveOpenCodeModelVariant).not.toHaveBeenCalled() + await waitFor(() => expect(result.current.currentVariant).toBe('high')) expect(useModelStore.getState().newSessionPicks).toEqual({}) - expect(result.current.currentVariant).toBeUndefined() + await waitFor(() => { + expect(mockSaveOpenCodeModelVariant).toHaveBeenCalledWith( + { providerID: PROVIDER_ID, modelID: MODEL_ID }, + 'high', + ) + }) }) }) }) diff --git a/frontend/src/hooks/useVariants.ts b/frontend/src/hooks/useVariants.ts index eb4d7ad0a..be58d1447 100644 --- a/frontend/src/hooks/useVariants.ts +++ b/frontend/src/hooks/useVariants.ts @@ -1,7 +1,6 @@ import { useCallback, useMemo } from 'react' import { useModelSelection, useModelStateMutation, type ModelSelectionSession } from './useModelSelection' import { useModelStore, type ModelSelection } from '@/stores/modelStore' -import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { saveOpenCodeModelVariant } from '@/api/providers' import { cycleModelVariant, @@ -28,8 +27,6 @@ export function useVariants( session?: ModelSelectionSession, ): UseVariantsResult { const { model, selection, info } = useModelSelection(directory, session) - const serverId = useOpenCodeServerId() - const isLocalServer = isLocalOpenCodeServer(serverId) const activeAgent = useModelStore((state) => state.activeAgent) const setSessionPick = useModelStore((state) => state.setSessionPick) @@ -52,9 +49,9 @@ export function useVariants( if (session?.id && activeAgent?.id) { setSessionPick(session.id, activeAgent.id, { ...model, variant: normalizeModelVariant(variant) }) } - if (isLocalServer) saveVariant.mutate({ model, value: variant }) + saveVariant.mutate({ model, value: variant }) }, - [activeAgent?.id, isLocalServer, model, saveVariant, session?.id, setSessionPick], + [activeAgent?.id, model, saveVariant, session?.id, setSessionPick], ) const cycleVariant = useCallback( From c1e09f34a5bf9856f944297858913c8b4d1045d1 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:58:17 -0400 Subject: [PATCH 12/15] feat(servers): name the local server OC Manager in the switcher --- .../navigation/LinkedServersNav.test.tsx | 24 +++++++++---------- .../navigation/LinkedServersNav.tsx | 8 ++++--- .../navigation/RepoQuickSwitchSheet.test.tsx | 6 ++--- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index baefe439b..1c418c435 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -95,7 +95,7 @@ describe('LinkedServersNav', () => { renderNav() - expect(screen.getByRole('button', { name: triggerName('Local') })).toBeInTheDocument() + expect(screen.getByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() }) @@ -114,9 +114,9 @@ describe('LinkedServersNav', () => { setServers([kitchen, { ...office, enabled: false }]) renderNav() - await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) - expect(await screen.findByRole('menuitem', { name: /Local/ })).toHaveAttribute('aria-current', 'true') + expect(await screen.findByRole('menuitem', { name: /OC Manager/ })).toHaveAttribute('aria-current', 'true') expect(screen.getByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() expect(screen.getByRole('img', { name: 'Connected' })).toBeInTheDocument() expect(screen.queryByRole('menuitem', { name: /Office/ })).not.toBeInTheDocument() @@ -127,7 +127,7 @@ describe('LinkedServersNav', () => { setServers([kitchen, office]) renderNav() - await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) await user.click(await screen.findByRole('menuitem', { name: /Office/ })) expect(await screen.findByRole('button', { name: triggerName('Office') })).toBeInTheDocument() @@ -142,9 +142,9 @@ describe('LinkedServersNav', () => { renderNav() await user.click(screen.getByRole('button', { name: triggerName('Kitchen') })) - await user.click(await screen.findByRole('menuitem', { name: /Local/ })) + await user.click(await screen.findByRole('menuitem', { name: /OC Manager/ })) - expect(await screen.findByRole('button', { name: triggerName('Local') })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() expect(screen.getByTestId('location')).toHaveTextContent('/') }) @@ -162,7 +162,7 @@ describe('LinkedServersNav', () => { renderNav() - expect(await screen.findByRole('button', { name: triggerName('Local') })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: triggerName('OC Manager') })).toBeInTheDocument() }) it('shows an attention dot for another server that needs input', async () => { @@ -193,7 +193,7 @@ describe('LinkedServersNav', () => { renderNav() - expect(screen.getByRole('img', { name: 'Local needs input' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'OC Manager needs input' })).toBeInTheDocument() }) it('does not show an attention dot for the selected server', async () => { @@ -215,7 +215,7 @@ describe('LinkedServersNav', () => { setServers([kitchen]) renderNav() - await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) await user.click(await screen.findByRole('menuitem', { name: /Manage servers/ })) expect(screen.getByTestId('location')).toHaveTextContent('/?settings=open&settingsTab=servers') @@ -227,9 +227,9 @@ describe('LinkedServersNav', () => { renderNav('/', 'rail') - const trigger = screen.getByRole('button', { name: triggerName('Local') }) + const trigger = screen.getByRole('button', { name: triggerName('OC Manager') }) expect(trigger).toBeInTheDocument() - expect(screen.queryByText('Local')).not.toBeInTheDocument() + expect(screen.queryByText('OC Manager')).not.toBeInTheDocument() await user.click(trigger) expect(await screen.findByRole('menuitem', { name: /Kitchen/ })).toBeInTheDocument() @@ -248,7 +248,7 @@ describe('LinkedServersNav', () => { renderNav('/', 'sheet') expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() - await user.click(screen.getByRole('button', { name: triggerName('Local') })) + await user.click(screen.getByRole('button', { name: triggerName('OC Manager') })) await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) expect(await screen.findByRole('button', { name: triggerName('Kitchen') })).toBeInTheDocument() diff --git a/frontend/src/components/navigation/LinkedServersNav.tsx b/frontend/src/components/navigation/LinkedServersNav.tsx index e088aa899..e0db4a056 100644 --- a/frontend/src/components/navigation/LinkedServersNav.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.tsx @@ -18,6 +18,8 @@ import { } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' +const LOCAL_SERVER_LABEL = 'OC Manager' + export type LinkedServersVariant = 'sidebar' | 'rail' | 'sheet' interface LinkedServersNavProps { @@ -72,7 +74,7 @@ function AttentionDot({ activity, label }: { activity: ActivityState; label: str function LocalAttentionDot() { const activity = useLocalActivityState() - return + return } function ServerAttentionDot({ server }: { server: OpenCodeServer }) { @@ -113,7 +115,7 @@ function LocalServerMenuItem({ active, onSelect }: { active: boolean; onSelect: className="gap-2" > - Local + {LOCAL_SERVER_LABEL} {active && }
@@ -202,7 +204,7 @@ export function LinkedServersNav({ variant = 'sidebar' }: LinkedServersNavProps) const selectedServer = enabledServers.find((server) => server.id === selectedServerId) const localActive = selectedServerId === LOCAL_OPENCODE_SERVER_ID - const selectedName = localActive ? 'Local' : selectedServer?.name ?? 'Local' + const selectedName = localActive ? LOCAL_SERVER_LABEL : selectedServer?.name ?? LOCAL_SERVER_LABEL const isRail = variant === 'rail' const connectionConnected = statusById[selectedServerId]?.connected ?? false diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index bdc22f7d3..021aaf119 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -1001,7 +1001,7 @@ describe('RepoQuickSwitchSheet', () => { render(, { wrapper: createWrapper() }) - const trigger = await screen.findByRole('button', { name: 'Switch server, current: Local' }) + const trigger = await screen.findByRole('button', { name: 'Switch server, current: OC Manager' }) expect(trigger).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Servers' })).not.toBeInTheDocument() @@ -1049,7 +1049,7 @@ describe('RepoQuickSwitchSheet', () => { , ) - await user.click(await screen.findByRole('button', { name: 'Switch server, current: Local' })) + await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) expect(await screen.findByText('Alpha')).toBeInTheDocument() @@ -1084,7 +1084,7 @@ describe('RepoQuickSwitchSheet', () => { , ) - await user.click(await screen.findByRole('button', { name: 'Switch server, current: Local' })) + await user.click(await screen.findByRole('button', { name: 'Switch server, current: OC Manager' })) await user.click(await screen.findByRole('menuitem', { name: /Kitchen/ })) await user.click(await screen.findByRole('button', { name: /^Alpha/ })) From c18c3584ef0091c67107a22f5844ee5c01ab7271 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:01:48 -0400 Subject: [PATCH 13/15] feat(servers): show linked server projects on the home page and remove the server page The home page follows the sidebar server selection and lists a linked server's projects with the same cards, search and row actions as local repositories. /servers/:id now selects that server and redirects home. Back from a remote session goes to its project page and back from a project goes home, and the selection follows the route app-wide so mobile keeps the linked server. --- frontend/src/App.tsx | 15 +- .../navigation/LinkedServersNav.test.tsx | 7 + .../navigation/MobileTabBar.test.tsx | 33 +- .../components/navigation/MobileTabBar.tsx | 27 +- .../components/repo/RemoteMultiRunButton.tsx | 24 +- frontend/src/components/repo/RepoCard.tsx | 144 ++++---- .../src/components/repo/RepoCardShell.tsx | 66 ++++ .../src/components/repo/RepoListControls.tsx | 16 +- .../src/components/repo/RepoSearchInput.tsx | 30 ++ .../schedules/RemoteSchedulesButton.tsx | 23 +- .../components/servers/ConnectionStatus.tsx | 43 +++ .../components/servers/RemoteProjectList.tsx | 243 +++++++++++++ .../servers/RemoteProjectRowActions.tsx | 160 +++++++++ .../__tests__/RemoteProjectList.test.tsx | 300 ++++++++++++++++ .../contexts/SwipeNavigationContext.test.tsx | 20 +- .../src/contexts/SwipeNavigationContext.tsx | 9 + .../hooks/useSidebarServerSelection.test.tsx | 106 ++++-- .../src/hooks/useSidebarServerSelection.ts | 18 +- frontend/src/lib/navigation.test.ts | 11 +- frontend/src/lib/navigation.ts | 8 +- frontend/src/pages/GlobalSchedules.tsx | 10 +- frontend/src/pages/LinkedServerRedirect.tsx | 18 + frontend/src/pages/RemoteProject.tsx | 8 +- frontend/src/pages/RemoteServer.tsx | 330 ------------------ frontend/src/pages/Repos.tsx | 87 +++-- frontend/src/pages/SessionDetail.tsx | 29 +- .../pages/__tests__/GlobalSchedules.test.tsx | 33 +- .../__tests__/LinkedServerRedirect.test.tsx | 32 ++ .../pages/__tests__/RemoteProject.test.tsx | 14 +- .../src/pages/__tests__/RemoteServer.test.tsx | 228 ------------ frontend/src/pages/__tests__/Repos.test.tsx | 190 ++++++++++ .../__tests__/SessionDetail.remote.test.tsx | 77 +++- 32 files changed, 1573 insertions(+), 786 deletions(-) create mode 100644 frontend/src/components/repo/RepoCardShell.tsx create mode 100644 frontend/src/components/repo/RepoSearchInput.tsx create mode 100644 frontend/src/components/servers/ConnectionStatus.tsx create mode 100644 frontend/src/components/servers/RemoteProjectList.tsx create mode 100644 frontend/src/components/servers/RemoteProjectRowActions.tsx create mode 100644 frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx create mode 100644 frontend/src/pages/LinkedServerRedirect.tsx delete mode 100644 frontend/src/pages/RemoteServer.tsx create mode 100644 frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx delete mode 100644 frontend/src/pages/__tests__/RemoteServer.test.tsx create mode 100644 frontend/src/pages/__tests__/Repos.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa1cbbe21..071b656d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,7 @@ import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' -import { RemoteServer } from './pages/RemoteServer' +import { LinkedServerRedirect } from './pages/LinkedServerRedirect' import { RemoteProject } from './pages/RemoteProject' import { SessionDetail } from './pages/SessionDetail' import { Schedules } from './pages/Schedules' @@ -41,6 +41,7 @@ import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions' +import { useSidebarServerRouteSync } from '@/hooks/useSidebarServerSelection' import { sessionQueryOptions } from '@/hooks/useOpenCode' import { RestartServerDialog } from '@/components/settings/RestartServerDialog' @@ -164,11 +165,13 @@ function AppShell() { const rootRef = useRef(null) const { openSheet, open } = useMobileTabBar() + useSidebarServerRouteSync() + const swipeNav = useSwipeNavigation() const getRouteSwipeBackTarget = useCallback( - () => getSwipeBackTarget(location.pathname, location.search), - [location.pathname, location.search] + () => swipeNav?.backTarget() ?? getSwipeBackTarget(location.pathname, location.search), + [swipeNav, location.pathname, location.search] ) const canSwipeBack = useCallback( @@ -315,11 +318,7 @@ const router = createBrowserRouter([ }, { path: '/servers/:serverId', - element: ( - - - - ), + element: , loader: protectedLoader, }, { diff --git a/frontend/src/components/navigation/LinkedServersNav.test.tsx b/frontend/src/components/navigation/LinkedServersNav.test.tsx index 1c418c435..27d4cc4d3 100644 --- a/frontend/src/components/navigation/LinkedServersNav.test.tsx +++ b/frontend/src/components/navigation/LinkedServersNav.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter, useLocation } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { LinkedServersNav } from './LinkedServersNav' +import { useSidebarServerRouteSync } from '@/hooks/useSidebarServerSelection' import { useSidebarServerStore } from '@/stores/sidebarServerStore' const mocks = vi.hoisted(() => ({ @@ -41,9 +42,15 @@ function LocationDisplay() { return
{`${location.pathname}${location.search}`}
} +function SidebarServerRouteSync() { + useSidebarServerRouteSync() + return null +} + function renderNav(pathname = '/', variant?: 'sidebar' | 'rail' | 'sheet') { return render( + , diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index 03f2c10fc..018e19b0c 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -13,8 +13,10 @@ import userEvent from '@testing-library/user-event' import { describe, it, expect, beforeEach } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { MobileTabBar } from './MobileTabBar' import { useMobile } from '@/hooks/useMobile' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' function LocationSpy() { const { pathname, search } = useLocation() @@ -24,6 +26,8 @@ function LocationSpy() { describe('MobileTabBar', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) }) it('renders nothing when useMobile returns false', () => { @@ -194,20 +198,17 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('/assistant') }) - it('renders global tabs on a linked server page without the local Files tab', () => { + it('hides the tab bar on a linked server page path', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() - render( + const { container } = render( , ) - const labels = screen.getAllByRole('button').map((button) => button.textContent) - expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) - expect(screen.queryByText('Files')).not.toBeInTheDocument() - expect(screen.queryByText('Git')).not.toBeInTheDocument() + expect(container.firstChild).toBeNull() }) it('renders global tabs with Git on a linked server project page', () => { @@ -250,14 +251,14 @@ describe('MobileTabBar', () => { expect(screen.getByTestId('location')).toHaveTextContent('dialog=sourceControl') }) - it('opens the repos sheet from the Repos tab on a linked server page', async () => { + it('opens the repos sheet from the Repos tab on a linked server project page', async () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() const user = userEvent.setup() render( - + @@ -319,6 +320,22 @@ describe('MobileTabBar', () => { expect(screen.queryByText('Git')).not.toBeInTheDocument() }) + it('hides the Files tab on the home page when a linked server is selected', () => { + vi.mocked(useMobile).mockReturnValue(true) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + const queryClient = new QueryClient() + render( + + + + + , + ) + const labels = screen.getAllByRole('button').map((button) => button.textContent) + expect(labels).toEqual(['Repos', 'Assistant', 'Schedules', 'More']) + expect(screen.queryByText('Files')).not.toBeInTheDocument() + }) + it('renders tab bar on /schedules path', () => { vi.mocked(useMobile).mockReturnValue(true) const queryClient = new QueryClient() diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index 2176aacc9..ea5816147 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -7,6 +7,8 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { useUrlParams } from '@/hooks/useUrlParams' import { useScheduleUrlState, type ScheduleTab } from '@/hooks/useScheduleUrlState' import { useUnreadScheduleRuns } from '@/hooks/useSchedules' +import { useSidebarServerSelection } from '@/hooks/useSidebarServerSelection' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { getAssistantPath, isAssistantPath, @@ -35,11 +37,12 @@ interface GlobalTabsArgs { serverScope: ServerRouteScope updateParams: ReturnType['updateParams'] unreadScheduleCount: number + localServerSelected: boolean } type TabBarMode = 'hidden' | 'global' | 'schedule' -type ServerRouteScope = 'server' | 'project' | null +type ServerRouteScope = 'project' | null interface MobileTabRouteState { mode: TabBarMode @@ -48,17 +51,8 @@ interface MobileTabRouteState { serverScope: ServerRouteScope } -const SERVER_PAGE_PATH = /^\/servers\/[^/]+$/ -const SERVER_PROJECT_PAGE_PATH = /^\/servers\/[^/]+\/projects$/ - function getServerRouteScope(pathname: string, search: string): ServerRouteScope { - if (SERVER_PROJECT_PAGE_PATH.test(pathname)) { - return parseServerProjectRoute(pathname, search) ? 'project' : 'server' - } - if (SERVER_PAGE_PATH.test(pathname)) { - return 'server' - } - return null + return parseServerProjectRoute(pathname, search) ? 'project' : null } function getMobileTabRouteState(pathname: string, search: string): MobileTabRouteState { @@ -71,7 +65,7 @@ function getMobileTabRouteState(pathname: string, search: string): MobileTabRout } const serverScope = getServerRouteScope(pathname, search) - if (serverScope === 'server' || serverScope === 'project') { + if (serverScope !== null) { return { mode: 'global', isInsideRepo: false, repoId: null, serverScope } } @@ -91,7 +85,7 @@ function getMobileTabRouteState(pathname: string, search: string): MobileTabRout } } -function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, serverScope, updateParams, unreadScheduleCount }: GlobalTabsArgs): TabDef[] { +function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideRepo, repoId, serverScope, updateParams, unreadScheduleCount, localServerSelected }: GlobalTabsArgs): TabDef[] { const openRepoDialog = (dialog: string) => { openDialogParam(updateParams, dialog) } @@ -99,6 +93,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR const inRepo = isInsideRepo && repoId !== null const inServerRoute = serverScope !== null const filesAreScoped = inRepo || isAssistantPath(pathname) + const showsFiles = !inServerRoute && (filesAreScoped || localServerSelected) const showsGit = inRepo || serverScope === 'project' const showsSchedules = !inRepo && serverScope !== 'project' @@ -123,7 +118,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR onClick: () => open('repos'), active: openSheet === 'repos' || (pathname === '/' && !openSheet), }, - ...(!inServerRoute + ...(showsFiles ? [{ key: 'files', label: 'Files', @@ -237,6 +232,8 @@ export const MobileTabBar = memo(function MobileTabBar() { const { scheduleTab, setScheduleTab } = useScheduleUrlState() const { data: unreadRuns } = useUnreadScheduleRuns() const isMobile = useMobile() + const [selectedServerId] = useSidebarServerSelection() + const localServerSelected = selectedServerId === LOCAL_OPENCODE_SERVER_ID const routeState = useMemo(() => getMobileTabRouteState(pathname, search), [pathname, search]) const unreadScheduleCount = unreadRuns?.total ?? 0 @@ -254,6 +251,7 @@ export const MobileTabBar = memo(function MobileTabBar() { serverScope: routeState.serverScope, updateParams, unreadScheduleCount, + localServerSelected, })), [ routeState, @@ -266,6 +264,7 @@ export const MobileTabBar = memo(function MobileTabBar() { navigate, updateParams, unreadScheduleCount, + localServerSelected, ], ) diff --git a/frontend/src/components/repo/RemoteMultiRunButton.tsx b/frontend/src/components/repo/RemoteMultiRunButton.tsx index 6396a4149..2ba6e834c 100644 --- a/frontend/src/components/repo/RemoteMultiRunButton.tsx +++ b/frontend/src/components/repo/RemoteMultiRunButton.tsx @@ -3,12 +3,28 @@ import { Layers } from 'lucide-react' import { Button } from '@/components/ui/button' import { MultiRunSheet } from '@/components/repo/MultiRunSheet' -interface RemoteMultiRunButtonProps { +interface RemoteMultiRunProps { serverId: string directory: string + open: boolean + onOpenChange: (open: boolean) => void } -export function RemoteMultiRunButton({ serverId, directory }: RemoteMultiRunButtonProps) { +/** + * The multi-run sheet for one project on a linked OpenCode server, controlled by its caller. + */ +export function RemoteMultiRun({ serverId, directory, open, onOpenChange }: RemoteMultiRunProps) { + return ( + + ) +} + +export function RemoteMultiRunButton({ serverId, directory }: { serverId: string; directory: string }) { const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) @@ -24,8 +40,8 @@ export function RemoteMultiRunButton({ serverId, directory }: RemoteMultiRunButt Multi-run {mounted ? ( - + + Cloning... +
+ ) : ( + <> + + + {branchToDisplay || "main"} + + {isDirty && ( + + + + {unstagedCount > 0 && unstagedCount} + {unstagedCount > 0 && stagedCount > 0 && "/"} + {stagedCount > 0 && `${stagedCount}s`} + + + )} + {(ahead > 0 || behind > 0) && ( + + + {ahead > 0 && `↑${ahead}`} + {behind > 0 && `↓${behind}`} + + + )} + {repo.isLocal && ( + + + + )} + + ); + return ( -
} + actions={ + !manageMode && !isSelected && !hasSelectedRepos ? ( + + ) : undefined + } + meta={meta} + activityLabel={activityLabel} + isInteractive={isReady} + isSelected={isSelected} onClick={handleCardClick} - className={`relative border rounded-xl overflow-hidden transition-all duration-200 w-full ${ - isReady ? "cursor-pointer active:scale-[0.98] hover:border-primary/50 hover:bg-accent/50 hover:shadow-md" : "cursor-default" - } ${ - isSelected - ? "border-primary bg-primary/5" - : "border-border bg-card" - }`} - > -
-
-
-
-

- {repoName} -

- {isReady && ( -
- )} - -
- - {!manageMode && !isSelected && !hasSelectedRepos && ( - - )} -
- -
-
- {isCloning ? ( - - - Cloning... - - ) : ( - <> - - - {branchToDisplay || "main"} - - {isDirty && ( - - - - {unstagedCount > 0 && unstagedCount} - {unstagedCount > 0 && stagedCount > 0 && "/"} - {stagedCount > 0 && `${stagedCount}s`} - - - )} - {(ahead > 0 || behind > 0) && ( - - - {ahead > 0 && `↑${ahead}`} - {behind > 0 && `↓${behind}`} - - - )} - {repo.isLocal && ( - - - - )} - - )} -
- - {activityLabel && ( - - {activityLabel} - - )} -
-
-
-
+ /> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/repo/RepoCardShell.tsx b/frontend/src/components/repo/RepoCardShell.tsx new file mode 100644 index 000000000..8a5086f40 --- /dev/null +++ b/frontend/src/components/repo/RepoCardShell.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +interface RepoCardShellProps { + title: string; + meta: ReactNode; + statusDotClassName?: string; + activity?: ReactNode; + actions?: ReactNode; + activityLabel?: string; + isInteractive?: boolean; + isSelected?: boolean; + onClick?: () => void; +} + +export function RepoCardShell({ + title, + meta, + statusDotClassName, + activity, + actions, + activityLabel, + isInteractive = false, + isSelected = false, + onClick, +}: RepoCardShellProps) { + return ( +
+
+
+
+
+

+ {title} +

+ {statusDotClassName && ( +
+ )} + {activity} +
+ + {actions} +
+ +
+
+ {meta} +
+ + {activityLabel && ( + + {activityLabel} + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/repo/RepoListControls.tsx b/frontend/src/components/repo/RepoListControls.tsx index e780c04c7..ba24ca882 100644 --- a/frontend/src/components/repo/RepoListControls.tsx +++ b/frontend/src/components/repo/RepoListControls.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { Search, SlidersHorizontal, Trash2, X } from "lucide-react"; +import { SlidersHorizontal, Trash2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { RepoSearchInput } from "./RepoSearchInput"; import { DropdownMenu, DropdownMenuContent, @@ -120,17 +120,7 @@ export function RepoListControls({ return (
-
- - onSearchChange(e.target.value)} - placeholder="Search repositories..." - className="pl-9 h-9" - autoComplete="off" - name="repo-search" - /> -
+ {isMobile ? ( diff --git a/frontend/src/components/repo/RepoSearchInput.tsx b/frontend/src/components/repo/RepoSearchInput.tsx new file mode 100644 index 000000000..61e9a0844 --- /dev/null +++ b/frontend/src/components/repo/RepoSearchInput.tsx @@ -0,0 +1,30 @@ +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; + +interface RepoSearchInputProps { + value: string; + onValueChange: (query: string) => void; + placeholder?: string; + name?: string; +} + +export function RepoSearchInput({ + value, + onValueChange, + placeholder = "Search repositories...", + name = "repo-search", +}: RepoSearchInputProps) { + return ( +
+ + onValueChange(event.target.value)} + placeholder={placeholder} + className="pl-9 h-9" + autoComplete="off" + name={name} + /> +
+ ); +} diff --git a/frontend/src/components/schedules/RemoteSchedulesButton.tsx b/frontend/src/components/schedules/RemoteSchedulesButton.tsx index 775db89f6..6335ea276 100644 --- a/frontend/src/components/schedules/RemoteSchedulesButton.tsx +++ b/frontend/src/components/schedules/RemoteSchedulesButton.tsx @@ -4,17 +4,27 @@ import { Button } from '@/components/ui/button' import type { ScheduleServerTarget } from '@/api/schedules' import { ServerSchedulesDialog } from './ServerSchedulesDialog' -interface RemoteSchedulesButtonProps { +interface RemoteSchedulesDialogProps { serverId: string directory: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * The schedule list for one project on a linked OpenCode server, controlled by its caller. + */ +export function RemoteSchedulesDialog({ serverId, directory, open, onOpenChange }: RemoteSchedulesDialogProps) { + const target: ScheduleServerTarget = { serverId, directory } + + return } /** * Opens the schedule list for one project on a linked OpenCode server. */ -export function RemoteSchedulesButton({ serverId, directory }: RemoteSchedulesButtonProps) { +export function RemoteSchedulesButton({ serverId, directory }: { serverId: string; directory: string }) { const [open, setOpen] = useState(false) - const target: ScheduleServerTarget = { serverId, directory } return ( <> @@ -28,7 +38,12 @@ export function RemoteSchedulesButton({ serverId, directory }: RemoteSchedulesBu Schedules - + ) } diff --git a/frontend/src/components/servers/ConnectionStatus.tsx b/frontend/src/components/servers/ConnectionStatus.tsx new file mode 100644 index 000000000..80c867622 --- /dev/null +++ b/frontend/src/components/servers/ConnectionStatus.tsx @@ -0,0 +1,43 @@ +import type { OpenCodeServerStatus } from '@opencode-manager/shared/schemas' + +interface ConnectionStatusProps { + status?: OpenCodeServerStatus + isLoading: boolean +} + +/** + * Connection indicator for a linked server: checking, unknown, unreachable with its error, or + * connected with the server version. + */ +export function ConnectionStatus({ status, isLoading }: ConnectionStatusProps) { + if (isLoading) { + return Checking connection... + } + + if (!status) { + return ( + + + Unknown + + ) + } + + if (!status.connected) { + return ( + + + Unreachable + {status.error ? · {status.error} : null} + + ) + } + + return ( + + + Connected + {status.version ? · v{status.version} : null} + + ) +} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx new file mode 100644 index 000000000..0ea7b65be --- /dev/null +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -0,0 +1,243 @@ +import { useMemo, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Folder, FolderGit2, GitBranch, Loader2, Plus, Search, Server } from 'lucide-react' +import { listProjects, type OpenCodeProject } from '@/api/opencode' +import { useCreateSession } from '@/hooks/useOpenCode' +import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useProjectActivityState } from '@/hooks/useActiveWork' +import { serverScopedKey } from '@/lib/serverScope' +import { getServerProjectPath, getServerSessionPath } from '@/lib/navigation' +import { formatShortRelativeTime, getDirectoryName } from '@/lib/utils' +import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' +import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { RemoteProjectRowActions } from '@/components/servers/RemoteProjectRowActions' +import { RepoCardShell } from '@/components/repo/RepoCardShell' +import { RepoCardSkeleton } from '@/components/repo/RepoCardSkeleton' +import { RepoSearchInput } from '@/components/repo/RepoSearchInput' + +function projectName(project: OpenCodeProject): string { + return project.name?.trim() || getDirectoryName(project.canonical) +} + +function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; serverId: string }) { + const navigate = useNavigate() + const activityState = useProjectActivityState(serverId, project.canonical) + const updatedAt = project.time.updated + + return ( + } + actions={} + meta={ + + {project.vcs ? ( + + ) : ( + + )} + {project.canonical} + + } + activityLabel={updatedAt > 0 ? formatShortRelativeTime(new Date(updatedAt)) : undefined} + isInteractive + onClick={() => navigate(getServerProjectPath(serverId, project.canonical))} + /> + ) +} + +function RemoteProjectListLoading() { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+
+
+ ) +} + +function RemoteProjectListContent({ serverId }: { serverId: string }) { + const navigate = useNavigate() + const { servers, isLoading: serversLoading } = useOpenCodeServers() + const { openTab } = useSettingsDialog() + const createSession = useCreateSession() + const [openDirectory, setOpenDirectory] = useState('') + const [searchQuery, setSearchQuery] = useState('') + + const server = servers.find((candidate) => candidate.id === serverId) + const isDisabled = server !== undefined && !server.enabled + + const projectsQuery = useQuery({ + queryKey: serverScopedKey(['opencode', 'projects'], serverId), + queryFn: () => listProjects(serverId), + enabled: server?.enabled === true, + retry: false, + }) + + const projects = useMemo(() => projectsQuery.data ?? [], [projectsQuery.data]) + + const filteredProjects = useMemo(() => { + const query = searchQuery.trim().toLowerCase() + if (!query) return projects + + return projects.filter((project) => + projectName(project).toLowerCase().includes(query) || + project.canonical.toLowerCase().includes(query), + ) + }, [projects, searchQuery]) + + const openSettings = () => openTab('servers') + + const handleOpenDirectory = async (event: FormEvent) => { + event.preventDefault() + const directory = openDirectory.trim() + if (!directory) return + try { + const session = await createSession.mutateAsync({ directory }) + navigate(getServerSessionPath(serverId, session.id)) + } catch { + return + } + } + + return ( +
+ {!serversLoading && !server ? ( + + ) : null} + + {isDisabled ? ( + + ) : null} + + {server && !isDisabled && projectsQuery.isError ? ( + + +
+

Could not load projects

+

+ {projectsQuery.error instanceof Error + ? projectsQuery.error.message + : 'The server did not respond.'} +

+
+ +
+ ) : null} + + {server && !isDisabled && !projectsQuery.isError ? ( + projectsQuery.isLoading ? ( + + ) : projects.length === 0 ? ( +
+ +

No projects on this server yet

+

+ Start a session in a directory below to create one. +

+
+ ) : ( + <> +
+ +
+
+
+ {filteredProjects.length === 0 ? ( +
+ +

+ No projects found matching "{searchQuery}" +

+
+ ) : ( +
+ {filteredProjects.map((project) => ( + + ))} +
+ )} +
+
+ + ) + ) : null} + + {server && !isDisabled ? ( +
+

Open directory

+
+ setOpenDirectory(event.target.value)} + placeholder="/absolute/path/on/the/server" + aria-label="Directory path" + autoComplete="off" + name="remote-directory" + className="font-mono text-sm" + /> + +
+
+ ) : null} +
+ ) +} + +/** + * The projects of one linked server with their sessions and project actions, scoped to that server + * so every hook inside it targets the linked server rather than the local one. + */ +export function RemoteProjectList({ serverId }: { serverId: string }) { + return ( + + + + ) +} diff --git a/frontend/src/components/servers/RemoteProjectRowActions.tsx b/frontend/src/components/servers/RemoteProjectRowActions.tsx new file mode 100644 index 000000000..3b58840e3 --- /dev/null +++ b/frontend/src/components/servers/RemoteProjectRowActions.tsx @@ -0,0 +1,160 @@ +import { useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { CalendarClock, Layers, Loader2, MoreVertical, Plus } from 'lucide-react' +import { useCreateSession } from '@/hooks/useOpenCode' +import { useMobile } from '@/hooks/useMobile' +import { getServerSessionPath } from '@/lib/navigation' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { RemoteSchedulesDialog } from '@/components/schedules/RemoteSchedulesButton' +import { RemoteMultiRun } from '@/components/repo/RemoteMultiRunButton' + +interface RemoteProjectRowActionsProps { + serverId: string + directory: string +} + +interface ProjectActionButtonProps { + label: string + onClick: () => void + disabled?: boolean + children: ReactNode +} + +function ProjectActionButton({ label, onClick, disabled, children }: ProjectActionButtonProps) { + return ( + + + + + {label} + + ) +} + +/** + * The project actions for one remote project: new session, schedules, and multi-run. Desktop shows + * inline icon buttons and mobile shows a menu, matching local repository rows. + */ +export function RemoteProjectRowActions({ serverId, directory }: RemoteProjectRowActionsProps) { + const navigate = useNavigate() + const isMobile = useMobile() + const createSession = useCreateSession(directory) + const [schedulesOpen, setSchedulesOpen] = useState(false) + const [multiRunMounted, setMultiRunMounted] = useState(false) + const [multiRunOpen, setMultiRunOpen] = useState(false) + + const handleNewSession = async () => { + try { + const session = await createSession.mutateAsync({ agent: undefined }) + navigate(getServerSessionPath(serverId, session.id)) + } catch { + return + } + } + + const handleMultiRunOpen = () => { + setMultiRunMounted(true) + setMultiRunOpen(true) + } + + return ( +
event.stopPropagation()}> + {isMobile ? ( + + + + + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + { + void handleNewSession() + }} + disabled={createSession.isPending} + > + {createSession.isPending ? ( + + ) : ( + + )} + New session + + setSchedulesOpen(true)}> + + Schedules + + + + Multi-run + + + + ) : ( + +
+ { + void handleNewSession() + }} + disabled={createSession.isPending} + > + {createSession.isPending ? : } + + setSchedulesOpen(true)}> + + + + + +
+
+ )} + + + {multiRunMounted ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx new file mode 100644 index 000000000..8fd227f77 --- /dev/null +++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { RemoteProjectList } from '../RemoteProjectList' + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), + useCreateSession: vi.fn(), + useOpenCodeServers: vi.fn(), + useSettingsDialog: vi.fn(), + openTab: vi.fn(), + createSession: vi.fn(), + isMobile: true, +})) + +vi.mock('@/api/opencode', () => ({ + listProjects: mocks.listProjects, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useCreateSession: mocks.useCreateSession, +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: mocks.useSettingsDialog, +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: () => mocks.isMobile, +})) + +vi.mock('@/hooks/useActiveWork', () => ({ + useProjectActivityState: () => null, +})) + +vi.mock('@/components/schedules/ServerSchedulesDialog', () => ({ + ServerSchedulesDialog: ({ + open, + target, + }: { + open: boolean + target: { serverId: string; directory: string } | null + }) => + open ? ( +
+ ) : null, +})) + +vi.mock('@/components/repo/MultiRunSheet', () => ({ + MultiRunSheet: ({ + open, + target, + directory, + }: { + open: boolean + target: { kind: string; serverId?: string; directory?: string } + directory?: string + }) => ( +
+ ), +})) + +const server = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +const project = { + id: 'prj_1', + canonical: '/home/me/app', + name: 'App', + vcs: 'git', + time: { created: Date.now() - 120_000, updated: Date.now() - 60_000, active: 2 }, + sandboxes: [], +} + +const otherProject = { + id: 'prj_2', + canonical: '/srv/other', + name: 'Other', + time: { created: Date.now() - 120_000, updated: Date.now() - 60_000, active: 2 }, + sandboxes: [], +} + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function renderProjectList(serverId = 'srv_kitchen') { + return render( + + + + } /> + } /> + } /> + + + , + ) +} + +describe('RemoteProjectList', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.isMobile = true + mocks.listProjects.mockResolvedValue([project]) + mocks.useCreateSession.mockReturnValue({ mutateAsync: mocks.createSession, isPending: false }) + mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) + mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) + }) + + it('lists projects from the remote server', async () => { + renderProjectList() + + await waitFor(() => { + expect(screen.getByText('App')).toBeInTheDocument() + }) + expect(screen.getByText('/home/me/app')).toBeInTheDocument() + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + + it('opens the remote project page when a project card is clicked', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByText('App')) + + await waitFor(() => { + expect(screen.getByTestId('project-route')).toBeInTheDocument() + }) + }) + + it('creates a session in a project directory from the actions menu and navigates to it', async () => { + const user = userEvent.setup() + mocks.createSession.mockResolvedValue({ id: 'ses_new' }) + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /New session/ })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) + }) + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + }) + + it('opens the project schedules from the actions menu', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /Schedules/ })) + + const dialog = await screen.findByTestId('schedules-dialog') + expect(dialog).toHaveAttribute('data-server', 'srv_kitchen') + expect(dialog).toHaveAttribute('data-directory', '/home/me/app') + + await user.click(dialog) + expect(screen.queryByTestId('project-route')).not.toBeInTheDocument() + expect(screen.getByTestId('schedules-dialog')).toBeInTheDocument() + }) + + it('opens multi-run for the project from the actions menu', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Project actions' })) + await user.click(await screen.findByRole('menuitem', { name: /Multi-run/ })) + + const sheet = await screen.findByTestId('multi-run-sheet') + expect(sheet).toHaveAttribute('data-open', 'true') + expect(sheet).toHaveAttribute('data-kind', 'server') + expect(sheet).toHaveAttribute('data-server', 'srv_kitchen') + expect(sheet).toHaveAttribute('data-directory', '/home/me/app') + }) + + it('shows inline project actions on desktop without opening the project', async () => { + const user = userEvent.setup() + mocks.isMobile = false + renderProjectList() + + await screen.findByText('App') + expect(screen.queryByRole('button', { name: 'Project actions' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'New session' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Multi-run' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Schedules' })) + + expect(await screen.findByTestId('schedules-dialog')).toHaveAttribute('data-directory', '/home/me/app') + expect(screen.queryByTestId('project-route')).not.toBeInTheDocument() + }) + + it('opens a session in an arbitrary directory', async () => { + const user = userEvent.setup() + mocks.createSession.mockResolvedValue({ id: 'ses_other' }) + renderProjectList() + + await user.type(screen.getByLabelText('Directory path'), '/srv/other') + await user.click(screen.getByRole('button', { name: /Start session/ })) + + await waitFor(() => { + expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/other' }) + }) + await waitFor(() => { + expect(screen.getByTestId('session-route')).toBeInTheDocument() + }) + }) + + it('filters projects by name and path', async () => { + const user = userEvent.setup() + mocks.listProjects.mockResolvedValue([project, otherProject]) + renderProjectList() + + await screen.findByText('App') + const search = screen.getByPlaceholderText('Search projects...') + + await user.type(search, '/srv/other') + expect(screen.queryByText('App')).not.toBeInTheDocument() + expect(screen.getByText('Other')).toBeInTheDocument() + + await user.clear(search) + await user.type(search, 'app') + expect(screen.getByText('App')).toBeInTheDocument() + expect(screen.queryByText('Other')).not.toBeInTheDocument() + }) + + it('shows an empty search result state', async () => { + const user = userEvent.setup() + renderProjectList() + + await screen.findByText('App') + await user.type(screen.getByPlaceholderText('Search projects...'), 'missing') + + expect(screen.getByText('No projects found matching "missing"')).toBeInTheDocument() + }) + + it('shows the disabled state without loading projects', async () => { + mocks.useOpenCodeServers.mockReturnValue({ + servers: [{ ...server, enabled: false }], + isLoading: false, + }) + + renderProjectList() + + expect(screen.getByText('This server is disabled')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('shows a missing server state when the server is no longer configured', async () => { + mocks.useOpenCodeServers.mockReturnValue({ servers: [], isLoading: false }) + + renderProjectList('srv_gone') + + expect(screen.getByText('Server not found')).toBeInTheDocument() + expect(mocks.listProjects).not.toHaveBeenCalled() + }) + + it('shows an empty state when the server has no projects', async () => { + mocks.listProjects.mockResolvedValue([]) + + renderProjectList() + + await waitFor(() => { + expect(screen.getByText('No projects on this server yet')).toBeInTheDocument() + }) + }) + + it('scopes the list to the given server rather than the route', async () => { + renderProjectList('srv_kitchen') + + await waitFor(() => { + expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') + }) + }) +}) diff --git a/frontend/src/contexts/SwipeNavigationContext.test.tsx b/frontend/src/contexts/SwipeNavigationContext.test.tsx index 7033519c2..fa6435b22 100644 --- a/frontend/src/contexts/SwipeNavigationContext.test.tsx +++ b/frontend/src/contexts/SwipeNavigationContext.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { renderHook } from '@testing-library/react' +import { renderHook, act } from '@testing-library/react' import { SwipeNavigationProvider, useSwipeNavigation } from './SwipeNavigationContext' describe('SwipeNavigationContext', () => { @@ -47,6 +47,24 @@ describe('SwipeNavigationContext', () => { expect(current?.isSuspended()).toBe(false) }) + it('round-trips a page back target', () => { + const { result } = renderHook(() => useSwipeNavigation(), { + wrapper: SwipeNavigationProvider, + }) + + expect(result.current?.backTarget()).toBeNull() + + act(() => { + result.current?.setBackTarget('/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') + }) + expect(result.current?.backTarget()).toBe('/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp') + + act(() => { + result.current?.setBackTarget(null) + }) + expect(result.current?.backTarget()).toBeNull() + }) + it('clamps resume below zero to zero', () => { let current: ReturnType diff --git a/frontend/src/contexts/SwipeNavigationContext.tsx b/frontend/src/contexts/SwipeNavigationContext.tsx index e51af5a3f..154e31404 100644 --- a/frontend/src/contexts/SwipeNavigationContext.tsx +++ b/frontend/src/contexts/SwipeNavigationContext.tsx @@ -5,12 +5,17 @@ interface SwipeNavigationValue { suspend: () => void resume: () => void isSuspended: () => boolean + /** Overrides the URL-derived swipe-back destination for the current page; pass null to clear it. */ + setBackTarget: (path: string | null) => void + /** Returns the page-registered swipe-back destination, or null when the URL should decide. */ + backTarget: () => string | null } export const SwipeNavigationContext = createContext(null) function SwipeNavigationProvider({ children }: { children: ReactNode }) { const countRef = useRef(0) + const backTargetRef = useRef(null) const value = useMemo( () => ({ suspend: () => { @@ -20,6 +25,10 @@ function SwipeNavigationProvider({ children }: { children: ReactNode }) { countRef.current = Math.max(0, countRef.current - 1) }, isSuspended: () => countRef.current > 0, + setBackTarget: (path) => { + backTargetRef.current = path + }, + backTarget: () => backTargetRef.current, }), [] ) diff --git a/frontend/src/hooks/useSidebarServerSelection.test.tsx b/frontend/src/hooks/useSidebarServerSelection.test.tsx index a84a999c0..7d0ce93ee 100644 --- a/frontend/src/hooks/useSidebarServerSelection.test.tsx +++ b/frontend/src/hooks/useSidebarServerSelection.test.tsx @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter, useNavigate } from 'react-router-dom' +import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' -import { useSidebarServerSelection } from './useSidebarServerSelection' +import { useSidebarServerRouteSync, useSidebarServerSelection } from './useSidebarServerSelection' import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/sidebarServerStore' const mocks = vi.hoisted(() => ({ @@ -27,12 +27,18 @@ const kitchen = { const office = { ...kitchen, id: 'srv_office', name: 'Office' } -function SelectionHarness() { +function RouteSync() { + useSidebarServerRouteSync() + return null +} + +function SelectionHarness({ syncRoute = true }: { syncRoute?: boolean }) { const [serverId, selectServer] = useSidebarServerSelection() const navigate = useNavigate() return ( <> + {syncRoute ? : null}
{serverId}
+ + + ) +} + +function renderHarness(initialEntry = '/', options?: { syncRoute?: boolean }) { return render( - + , ) } -describe('useSidebarServerSelection', () => { - beforeEach(() => { - localStorage.clear() - useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) - mocks.useOpenCodeServers.mockReturnValue({ - servers: [kitchen, office], - isLoading: false, - isSuccess: true, - }) +function resetSelection() { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen, office], + isLoading: false, + isSuccess: true, }) +} + +describe('useSidebarServerSelection', () => { + beforeEach(resetSelection) it('defaults to the local server', () => { - renderHarness() + renderHarness('/', { syncRoute: false }) expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID) }) + it('persists the selection', () => { + renderHarness('/', { syncRoute: false }) + + fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( + 'srv_kitchen', + ) + }) + + it('does not follow the route without the route sync', () => { + renderHarness('/servers/srv_kitchen', { syncRoute: false }) + + expect(screen.getByTestId('selected')).toHaveTextContent(LOCAL_OPENCODE_SERVER_ID) + }) +}) + +describe('useSidebarServerRouteSync', () => { + beforeEach(resetSelection) + it('selects the server named by a linked server route', async () => { renderHarness('/servers/srv_kitchen/projects?directory=%2Fprojects%2Falpha') @@ -88,6 +137,14 @@ describe('useSidebarServerSelection', () => { ) }) + it('keeps a persisted linked selection on the home route', async () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderHarness('/') + + await waitFor(() => expect(screen.getByTestId('selected')).toHaveTextContent('srv_kitchen')) + }) + it('keeps a manual selection while the local route stays unchanged', async () => { renderHarness('/repos/1') @@ -158,13 +215,20 @@ describe('useSidebarServerSelection', () => { ) }) - it('persists the selection', () => { - renderHarness() + it('follows a linked server route with no sidebar consumer mounted', async () => { + render( + + + , + ) - fireEvent.click(screen.getByRole('button', { name: 'Select kitchen' })) + fireEvent.click(screen.getByRole('button', { name: 'Open remote project' })) - expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe( - 'srv_kitchen', - ) + await waitFor(() => expect(useSidebarServerStore.getState().serverId).toBe('srv_kitchen')) + + fireEvent.click(screen.getByRole('button', { name: 'Go home' })) + + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/')) + expect(useSidebarServerStore.getState().serverId).toBe('srv_kitchen') }) }) diff --git a/frontend/src/hooks/useSidebarServerSelection.ts b/frontend/src/hooks/useSidebarServerSelection.ts index 5f37b5542..e0e18e8ac 100644 --- a/frontend/src/hooks/useSidebarServerSelection.ts +++ b/frontend/src/hooks/useSidebarServerSelection.ts @@ -10,11 +10,12 @@ function isLocalRoute(pathname: string): boolean { } /** - * The linked server selected in the sidebar, defaulting to the local server. The route selects a - * server when the location changes, selecting a row never navigates, and a selected server that is - * missing or disabled in a successfully loaded list falls back to local. + * Keeps the sidebar server selection in step with the location and the linked server list: a linked + * server route selects that server, a local repo or assistant route selects local, and a selected + * server that is missing or disabled in a successfully loaded list falls back to local. Mounted once + * by the app shell so the selection follows the route even when no sidebar consumer is rendered. */ -export function useSidebarServerSelection(): [string, (serverId: string) => void] { +export function useSidebarServerRouteSync(): void { const { pathname } = useLocation() const routeServerId = useMatch('/servers/:serverId/*')?.params.serverId const { servers, isSuccess } = useOpenCodeServers() @@ -39,6 +40,15 @@ export function useSidebarServerSelection(): [string, (serverId: string) => void selectServer(LOCAL_OPENCODE_SERVER_ID) } }, [isSuccess, servers, serverId, selectServer]) +} + +/** + * The linked server selected in the sidebar, defaulting to the local server. Selecting a server + * never navigates; `useSidebarServerRouteSync` keeps the selection aligned with the route. + */ +export function useSidebarServerSelection(): [string, (serverId: string) => void] { + const serverId = useSidebarServerStore((state) => state.serverId) + const selectServer = useSidebarServerStore((state) => state.selectServer) return [serverId, selectServer] } diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index 37e8c2873..d30c2a35b 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerPath, getServerProjectPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath, getPathWithReturnTo, getReturnToPath, getRepoPath, getServerProjectPath, getServerSessionPath, getSessionPath, parseRepoRoute, parseServerProjectRoute, parseServerSessionRoute } from './navigation'; describe('getAssistantPath', () => { it('returns /assistant', () => { @@ -97,7 +97,6 @@ describe('getSessionPath', () => { describe('linked server paths', () => { it('builds server and server session paths with encoded segments', () => { - expect(getServerPath('srv_kitchen')).toBe('/servers/srv_kitchen'); expect(getServerSessionPath('srv_kitchen', 'ses_1')).toBe('/servers/srv_kitchen/sessions/ses_1'); expect(getServerSessionPath('srv a/b', 'ses 1')).toBe('/servers/srv%20a%2Fb/sessions/ses%201'); }); @@ -247,18 +246,18 @@ describe('getSwipeBackTarget', () => { }); describe('linked server routes', () => { - it('returns the server page for a remote session detail', () => { - expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/ses_1', '')).toBe('/servers/srv_kitchen'); + it('returns root for a remote session detail', () => { + expect(getSwipeBackTarget('/servers/srv_kitchen/sessions/ses_1', '')).toBe('/'); }); it('returns root for a server page', () => { expect(getSwipeBackTarget('/servers/srv_kitchen', '')).toBe('/'); }); - it('returns the server page for a remote project page', () => { + it('returns root for a remote project page', () => { expect( getSwipeBackTarget('/servers/srv_kitchen/projects', '?directory=%2Fhome%2Fme%2Fapp') - ).toBe('/servers/srv_kitchen'); + ).toBe('/'); }); it('does not throw for a malformed server session path', () => { diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index f6626619f..e90bc0119 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -28,10 +28,6 @@ export function getSessionPath(repoId: number | string, sessionId: string, optio return buildSessionPath(repoId, sessionId, options); } -export function getServerPath(serverId: string): string { - return `/servers/${encodeURIComponent(serverId)}`; -} - export function getServerSessionPath(serverId: string, sessionId: string): string { return buildServerSessionPath(serverId, sessionId); } @@ -119,12 +115,12 @@ export function getReturnToPath(search: string, fallback: string): string { export function getSwipeBackTarget(pathname: string, search = ''): string | null { const serverProject = parseServerProjectRoute(pathname, search); if (serverProject) { - return getServerPath(serverProject.serverId); + return '/'; } const serverSession = parseServerSessionRoute(pathname); if (serverSession) { - return getServerPath(serverSession.serverId); + return '/'; } if (/^\/servers\/[^/]+$/.test(pathname)) { diff --git a/frontend/src/pages/GlobalSchedules.tsx b/frontend/src/pages/GlobalSchedules.tsx index f41d5d7b9..02f26a4e6 100644 --- a/frontend/src/pages/GlobalSchedules.tsx +++ b/frontend/src/pages/GlobalSchedules.tsx @@ -29,7 +29,7 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue' import type { ScheduleJobWithRepo, ScheduleRunWithContext } from '@/api/schedules' import { Combobox } from '@/components/ui/combobox' -import { getRepoPath, getServerPath } from '@/lib/navigation' +import { getRepoPath, getServerProjectPath } from '@/lib/navigation' type StatusFilter = 'all' | 'enabled' | 'disabled' type ScheduleModeFilter = 'all' | 'cron' | 'interval' @@ -49,6 +49,10 @@ function targetForRun(run: ScheduleRunWithContext): ScheduleTargetRef | null { return run.serverId ? { kind: 'server', serverId: run.serverId } : null } +function getLinkedServerJobPath(job: ScheduleJobWithRepo): string { + return job.serverId && job.directory ? getServerProjectPath(job.serverId, job.directory) : '/' +} + export function GlobalSchedules() { const navigate = useNavigate() const [selectedRepoId, setSelectedRepoId] = useState(undefined) @@ -352,7 +356,7 @@ export function GlobalSchedules() { const job = jobs.find((entry) => jobBucket(entry) === repoPath) if (!job) return if (job.repoId === null) { - if (job.serverId) navigate(getServerPath(job.serverId)) + if (job.serverId) navigate(getLinkedServerJobPath(job)) return } navigate(getRepoPath(job.repoId)) @@ -360,7 +364,7 @@ export function GlobalSchedules() { const handleOpenJobPage = (job: ScheduleJobWithRepo) => { if (job.repoId === null) { - if (job.serverId) navigate(getServerPath(job.serverId)) + if (job.serverId) navigate(getLinkedServerJobPath(job)) return } navigate(`/repos/${job.repoId}/schedules`) diff --git a/frontend/src/pages/LinkedServerRedirect.tsx b/frontend/src/pages/LinkedServerRedirect.tsx new file mode 100644 index 000000000..1fbbaedcc --- /dev/null +++ b/frontend/src/pages/LinkedServerRedirect.tsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { Navigate, useParams } from 'react-router-dom' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' + +/** + * Legacy `/servers/:serverId` entry point: selects that server in the sidebar and returns home, + * where the center section lists the selected server's projects. + */ +export function LinkedServerRedirect() { + const { serverId } = useParams<{ serverId: string }>() + const selectServer = useSidebarServerStore((state) => state.selectServer) + + useEffect(() => { + if (serverId) selectServer(serverId) + }, [serverId, selectServer]) + + return +} diff --git a/frontend/src/pages/RemoteProject.tsx b/frontend/src/pages/RemoteProject.tsx index d83fb177a..3d4563ca2 100644 --- a/frontend/src/pages/RemoteProject.tsx +++ b/frontend/src/pages/RemoteProject.tsx @@ -11,7 +11,7 @@ import { useToolPanel } from '@/hooks/useToolPanel' import { useDialogParam } from '@/hooks/useDialogParam' import { useTerminalDialogParam, useTerminalDirectoryParam } from '@/hooks/useOpenTerminal' import { serverScopedKey } from '@/lib/serverScope' -import { getServerPath, getServerSessionPath } from '@/lib/navigation' +import { getServerSessionPath } from '@/lib/navigation' import { getDirectoryName } from '@/lib/utils' import { Header } from '@/components/ui/header' import { Badge } from '@/components/ui/badge' @@ -76,7 +76,7 @@ function RemoteProjectContent({ serverId, directory }: { serverId: string; direc
- + {title} @@ -152,8 +152,8 @@ function RemoteProjectContent({ serverId, directory }: { serverId: string; direc icon={FolderGit2} title="No project selected" description="This project link is missing its directory on the server." - actionLabel="Back to server" - onAction={() => navigate(getServerPath(serverId))} + actionLabel="Back to projects" + onAction={() => navigate('/')} />
) : isReady ? ( diff --git a/frontend/src/pages/RemoteServer.tsx b/frontend/src/pages/RemoteServer.tsx deleted file mode 100644 index 1910ff46f..000000000 --- a/frontend/src/pages/RemoteServer.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import { useState, type FormEvent } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { formatDistanceToNow } from 'date-fns' -import { - ChevronDown, - ChevronRight, - FolderGit2, - Loader2, - Plus, - Server, - Settings, -} from 'lucide-react' -import type { OpenCodeServerStatus } from '@opencode-manager/shared/schemas' -import { listProjects, type OpenCodeProject } from '@/api/opencode' -import { useCreateSession, useSessionsAcrossDirectories } from '@/hooks/useOpenCode' -import { useOpenCodeServers, useOpenCodeServerStatuses } from '@/hooks/useOpenCodeServers' -import { useSettingsDialog } from '@/hooks/useSettingsDialog' -import { useSSE } from '@/hooks/useSSE' -import { serverScopedKey } from '@/lib/serverScope' -import { getServerProjectPath, getServerSessionPath } from '@/lib/navigation' -import { getDirectoryName } from '@/lib/utils' -import { Header } from '@/components/ui/header' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' -import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' -import { RemoteSchedulesButton } from '@/components/schedules/RemoteSchedulesButton' -import { RemoteMultiRunButton } from '@/components/repo/RemoteMultiRunButton' - -function ConnectionStatus({ status, isLoading }: { status?: OpenCodeServerStatus; isLoading: boolean }) { - if (isLoading) { - return Checking connection... - } - - if (!status) { - return ( - - - Unknown - - ) - } - - if (!status.connected) { - return ( - - - Unreachable - {status.error ? · {status.error} : null} - - ) - } - - return ( - - - Connected - {status.version ? · v{status.version} : null} - - ) -} - -function ProjectSessions({ serverId, directory }: { serverId: string; directory: string }) { - useSSE(directory) - const { data: sessions, isLoading } = useSessionsAcrossDirectories([directory]) - - if (isLoading) { - return ( -
- - Loading sessions... -
- ) - } - - if (sessions.length === 0) { - return

No sessions in this project yet.

- } - - return ( -
    - {sessions.map((session) => ( -
  • - - - - {session.title || 'Untitled Session'} - - - {formatDistanceToNow(new Date(session.time.updated), { addSuffix: true })} - - -
  • - ))} -
- ) -} - -function ProjectRow({ project, serverId }: { project: OpenCodeProject; serverId: string }) { - const navigate = useNavigate() - const [expanded, setExpanded] = useState(false) - const createSession = useCreateSession(project.canonical) - const name = project.name?.trim() || getDirectoryName(project.canonical) - - const handleNewSession = async () => { - try { - const session = await createSession.mutateAsync({ agent: undefined }) - navigate(getServerSessionPath(serverId, session.id)) - } catch { - return - } - } - - return ( - -
- - - - - {name} - {project.canonical} - - {project.vcs ? ( - - {project.vcs} - - ) : null} - - - - -
- {expanded ? : null} -
- ) -} - -function RemoteServerContent({ serverId }: { serverId: string }) { - const navigate = useNavigate() - const { servers, isLoading: serversLoading } = useOpenCodeServers() - const { statusById, isLoading: statusLoading } = useOpenCodeServerStatuses() - const { openTab } = useSettingsDialog() - const createSession = useCreateSession() - const [openDirectory, setOpenDirectory] = useState('') - - const server = servers.find((candidate) => candidate.id === serverId) - const status = statusById[serverId] - const isDisabled = server !== undefined && !server.enabled - - const projectsQuery = useQuery({ - queryKey: serverScopedKey(['opencode', 'projects'], serverId), - queryFn: () => listProjects(serverId), - enabled: server?.enabled === true, - retry: false, - }) - - const openSettings = () => openTab('servers') - - const handleOpenDirectory = async (event: FormEvent) => { - event.preventDefault() - const directory = openDirectory.trim() - if (!directory) return - try { - const session = await createSession.mutateAsync({ directory }) - navigate(getServerSessionPath(serverId, session.id)) - } catch { - return - } - } - - return ( -
-
-
- - {server?.name ?? 'Linked server'} - - {server?.baseUrl} - -
- - - - -
- -
- {!serversLoading && !server ? ( - - ) : null} - - {isDisabled ? ( - - ) : null} - - {server && !isDisabled && projectsQuery.isError ? ( - - -
-

Could not load projects

-

- {projectsQuery.error instanceof Error - ? projectsQuery.error.message - : 'The server did not respond.'} -

-
- -
- ) : null} - - {server && !isDisabled && !projectsQuery.isError ? ( -
-
-

Projects

- {projectsQuery.isFetching ? ( - - - Refreshing - - ) : null} -
- - {projectsQuery.isLoading ? ( - - - Loading projects... - - ) : (projectsQuery.data ?? []).length === 0 ? ( - - -

No projects on this server yet

-

- Start a session in a directory below to create one. -

-
- ) : ( - (projectsQuery.data ?? []).map((project) => ( - - )) - )} -
- ) : null} - - {server && !isDisabled ? ( -
-

Open directory

-
- setOpenDirectory(event.target.value)} - placeholder="/absolute/path/on/the/server" - aria-label="Directory path" - autoComplete="off" - name="remote-directory" - className="font-mono text-sm" - /> - -
-
- ) : null} -
-
- ) -} - -export function RemoteServer() { - const { serverId } = useParams<{ serverId: string }>() - - if (!serverId) { - return null - } - - return -} diff --git a/frontend/src/pages/Repos.tsx b/frontend/src/pages/Repos.tsx index 6850f6f00..222c317e1 100644 --- a/frontend/src/pages/Repos.tsx +++ b/frontend/src/pages/Repos.tsx @@ -1,16 +1,40 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { Plus, FolderOpen, CalendarClock, Settings } from "lucide-react"; +import { LOCAL_OPENCODE_SERVER_ID } from "@opencode-manager/shared/schemas"; import { RepoList } from "@/components/repo/RepoList"; import { AddRepoDialog } from "@/components/repo/AddRepoDialog"; import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; +import { RemoteProjectList } from "@/components/servers/RemoteProjectList"; +import { ConnectionStatus } from "@/components/servers/ConnectionStatus"; import { Header } from "@/components/ui/header"; import { Button } from "@/components/ui/button"; -import { Plus, FolderOpen, CalendarClock } from "lucide-react"; import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup"; +import { ToolSidePanel } from "@/components/navigation/ToolSidePanel"; import { useDialogParam } from "@/hooks/useDialogParam"; import { useMobile } from "@/hooks/useMobile"; +import { useOpenCodeServerStatuses } from "@/hooks/useOpenCodeServers"; +import { useSettingsDialog } from "@/hooks/useSettingsDialog"; +import { useSidebarServerSelection } from "@/hooks/useSidebarServerSelection"; import { useToolPanel } from "@/hooks/useToolPanel"; -import { ToolSidePanel } from "@/components/navigation/ToolSidePanel"; + +function LinkedServerHeaderActions({ serverId }: { serverId: string }) { + const { statusById, isLoading } = useOpenCodeServerStatuses(); + const { openTab } = useSettingsDialog(); + const status = statusById[serverId]; + + return ( + <> + + + + + + ); +} export function Repos() { const navigate = useNavigate(); @@ -18,6 +42,8 @@ export function Repos() { const [fileBrowserOpen, setFileBrowserOpen] = useDialogParam('files'); const docked = !useMobile(); const toolPanel = useToolPanel(docked); + const [selectedServerId] = useSidebarServerSelection(); + const isLocalServer = selectedServerId === LOCAL_OPENCODE_SERVER_ID; const handleCloseFileBrowser = () => { setFileBrowserOpen(false); @@ -34,15 +60,19 @@ export function Repos() {
- + {isLocalServer ? ( + + ) : ( + + )} - + {isLocalServer ? ( + + ) : null}
- - + {isLocalServer ? : }
- - + {isLocalServer ? ( + + ) : null} + {isLocalServer ? ( + + ) : null}
- {docked && } + {isLocalServer && docked ? : null}
); } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 3b8a4ecc0..7720333d5 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -10,7 +10,7 @@ import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; import { Badge } from "@/components/ui/badge"; import { SessionPickerDialog } from "@/components/session/SessionPickerDialog"; -import { getRepoPath, getServerPath, getServerSessionPath, getSessionListPath, getSessionPath } from '@/lib/navigation' +import { getRepoPath, getServerProjectPath, getServerSessionPath, getSessionListPath, getSessionPath } from '@/lib/navigation' import { FetchError } from '@/api/fetchWrapper' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; @@ -49,6 +49,7 @@ import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog import { RepoSkillsDialog } from "@/components/repo/RepoSkillsDialog"; import { compactSession, forkSession, listProjects, listSessionMessages } from "@/api/opencode"; import { useOpenCodeServerId, isLocalOpenCodeServer } from "@/contexts/OpenCodeServerContext"; +import { useSwipeNavigation } from "@/contexts/SwipeNavigationContext"; import { useOpenCodeServers, useOpenCodeServerStatuses } from "@/hooks/useOpenCodeServers"; import { serverScopedKey } from "@/lib/serverScope"; import { useSessionStatus } from "@/stores/sessionStatusStore"; @@ -128,6 +129,7 @@ export function SessionDetail() { const { id, sessionId } = useParams<{ id: string; sessionId: string }>(); const navigate = useNavigate(); const location = useLocation(); + const swipeNav = useSwipeNavigation(); const repoId = Number(id) || 0; const isAssistantSession = new URLSearchParams(location.search).get('assistant') === '1'; const { preferences, updateSettings } = useSettings(); @@ -198,6 +200,17 @@ export function SessionDetail() { : undefined ); const sessionDirectory = isRemoteServer ? resolvedDirectory : resolvedDirectory ?? repoDirectory; + const remoteProjectBackPath = isRemoteServer && sessionDirectory + ? getServerProjectPath(serverId, sessionDirectory) + : '/'; + + useEffect(() => { + if (!isRemoteServer) return; + swipeNav?.setBackTarget(remoteProjectBackPath); + return () => { + swipeNav?.setBackTarget(null); + }; + }, [isRemoteServer, remoteProjectBackPath, swipeNav]); const { data: repoSiblings } = useRepoSiblings(!isAssistantSession && !isRemoteServer && repo && !repo.isWorktree ? repoId : undefined); @@ -501,12 +514,12 @@ export function SessionDetail() { const handleCloseSession = useCallback(() => { if (isRemoteServer) { - navigate(getServerPath(serverId)) + navigate(remoteProjectBackPath) return } const tab = new URLSearchParams(location.search).get('repoTab') ?? undefined; navigate(getSessionListPath(repoId, isAssistantSession, tab)) - }, [navigate, repoId, isAssistantSession, isRemoteServer, serverId, location.search]) + }, [navigate, repoId, isAssistantSession, isRemoteServer, remoteProjectBackPath, location.search]) const handleOpenModelDialog = useCallback(() => { promptInputRef.current?.openModelPicker(); @@ -716,8 +729,8 @@ export function SessionDetail() { return ( ); } @@ -727,8 +740,8 @@ export function SessionDetail() { return ( ); } @@ -755,7 +768,7 @@ export function SessionDetail() { : getRepoDisplayName(repo); const tabFromUrl = new URLSearchParams(location.search).get('repoTab') ?? undefined; const sessionBackPath = isRemoteServer - ? getServerPath(serverId) + ? remoteProjectBackPath : getSessionListPath(repoId, isAssistantSession, tabFromUrl); return ( diff --git a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx index b3b8ff803..190dd0894 100644 --- a/frontend/src/pages/__tests__/GlobalSchedules.test.tsx +++ b/frontend/src/pages/__tests__/GlobalSchedules.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor, act } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { LocationCatcher } from '@/test/test-utils' import { GlobalSchedules } from '../GlobalSchedules' const mocks = vi.hoisted(() => ({ @@ -153,6 +154,19 @@ const renderGlobalSchedules = () => { ) } +const renderGlobalSchedulesWithLocation = () => { + const capturedPathname = { current: '' } + const capturedSearch = { current: '' } + render( + + + + , + { wrapper: createWrapper() } + ) + return { capturedPathname, capturedSearch } +} + const runsTableProps = () => mocks.ScheduleRunsTable.mock.calls.at(-1)?.[0] as { runs: Array<{ id: number }> selectedRunId: number | null @@ -333,6 +347,23 @@ describe('GlobalSchedules run history', () => { expect(selectRun).toHaveBeenCalledWith(5) }) + it('opens a linked-server job on its project page', async () => { + const job = makeJob({ repoId: null, serverId: 'srv_kitchen', directory: '/home/me/app', lastRun: null }) + mocks.useAllSchedules.mockReturnValue({ data: [job], isLoading: false, error: null }) + mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'jobs', runId: null })) + + const { capturedPathname, capturedSearch } = renderGlobalSchedulesWithLocation() + + act(() => { + jobsTableProps().onOpen(job) + }) + + await waitFor(() => { + expect(capturedPathname.current).toBe('/servers/srv_kitchen/projects') + }) + expect(capturedSearch.current).toBe('?directory=%2Fhome%2Fme%2Fapp') + }) + it('keeps the jobs tab visible with the empty state when there are no jobs', () => { mocks.useScheduleUrlState.mockReturnValue(createMockScheduleUrlState({ scheduleTab: 'jobs', runId: null })) diff --git a/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx b/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx new file mode 100644 index 000000000..6869841b7 --- /dev/null +++ b/frontend/src/pages/__tests__/LinkedServerRedirect.test.tsx @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { LinkedServerRedirect } from '../LinkedServerRedirect' +import { SIDEBAR_SERVER_STORAGE_KEY, useSidebarServerStore } from '@/stores/sidebarServerStore' + +function renderRedirect(serverId = 'srv_x') { + return render( + + + } /> + home
} /> + + , + ) +} + +describe('LinkedServerRedirect', () => { + beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + }) + + it('lands on home with the server selected', async () => { + renderRedirect('srv_x') + + await waitFor(() => expect(screen.getByText('home')).toBeInTheDocument()) + expect(useSidebarServerStore.getState().serverId).toBe('srv_x') + expect(JSON.parse(localStorage.getItem(SIDEBAR_SERVER_STORAGE_KEY) ?? 'null')).toBe('srv_x') + }) +}) diff --git a/frontend/src/pages/__tests__/RemoteProject.test.tsx b/frontend/src/pages/__tests__/RemoteProject.test.tsx index ecdcd2da7..1c4053a2f 100644 --- a/frontend/src/pages/__tests__/RemoteProject.test.tsx +++ b/frontend/src/pages/__tests__/RemoteProject.test.tsx @@ -82,8 +82,8 @@ function renderProject(path = '/servers/srv_kitchen/projects?directory=%2Fhome%2 + } /> } /> - } /> } /> @@ -164,6 +164,14 @@ describe('RemoteProject', () => { expect(screen.queryByTestId('session-list')).not.toBeInTheDocument() }) + it('goes back to the projects list from the missing-directory state', async () => { + renderProject('/servers/srv_kitchen/projects') + + fireEvent.click(await screen.findByRole('button', { name: 'Back to projects' })) + + expect(screen.getByTestId('home-route')).toBeInTheDocument() + }) + it('shows the disabled state without loading projects', async () => { mocks.useOpenCodeServers.mockReturnValue({ servers: [{ ...server, enabled: false }], @@ -198,11 +206,11 @@ describe('RemoteProject', () => { expect(mocks.openTab).toHaveBeenCalledWith('servers') }) - it('goes back to the linked server page', async () => { + it('goes back to the projects list', async () => { renderProject() fireEvent.click(await screen.findByRole('button', { name: 'Go back' })) - expect(screen.getByTestId('server-route')).toBeInTheDocument() + expect(screen.getByTestId('home-route')).toBeInTheDocument() }) }) diff --git a/frontend/src/pages/__tests__/RemoteServer.test.tsx b/frontend/src/pages/__tests__/RemoteServer.test.tsx deleted file mode 100644 index 115490f3c..000000000 --- a/frontend/src/pages/__tests__/RemoteServer.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { RemoteServer } from '../RemoteServer' - -const mocks = vi.hoisted(() => ({ - listProjects: vi.fn(), - useSessionsAcrossDirectories: vi.fn(), - useCreateSession: vi.fn(), - useOpenCodeServers: vi.fn(), - useOpenCodeServerStatuses: vi.fn(), - useSettingsDialog: vi.fn(), - useSSE: vi.fn(), - openTab: vi.fn(), - createSession: vi.fn(), -})) - -vi.mock('@/api/opencode', () => ({ - listProjects: mocks.listProjects, -})) - -vi.mock('@/hooks/useOpenCode', () => ({ - useSessionsAcrossDirectories: mocks.useSessionsAcrossDirectories, - useCreateSession: mocks.useCreateSession, -})) - -vi.mock('@/hooks/useOpenCodeServers', () => ({ - useOpenCodeServers: mocks.useOpenCodeServers, - useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, -})) - -vi.mock('@/hooks/useSettingsDialog', () => ({ - useSettingsDialog: mocks.useSettingsDialog, -})) - -vi.mock('@/hooks/useSSE', () => ({ - useSSE: mocks.useSSE, -})) - -const server = { - id: 'srv_kitchen', - name: 'Kitchen', - baseUrl: 'https://kitchen.example.com', - username: 'opencode', - enabled: true, - hasPassword: true, - createdAt: 1, - updatedAt: 2, -} - -const project = { - id: 'prj_1', - canonical: '/home/me/app', - name: 'App', - time: { created: 1, updated: 2, active: 2 }, - sandboxes: [], -} - -const session = { - id: 'ses_1', - projectID: 'prj_1', - title: 'Fix the bug', - time: { created: 1, updated: Date.now() - 60000 }, - location: { directory: '/home/me/app' }, -} - -function createQueryClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }) -} - -function renderServer(serverId = 'srv_kitchen') { - return render( - - - - } /> - } /> - - - , - ) -} - -describe('RemoteServer', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.listProjects.mockResolvedValue([project]) - mocks.useSessionsAcrossDirectories.mockReturnValue({ data: [session], isLoading: false }) - mocks.useCreateSession.mockReturnValue({ mutateAsync: mocks.createSession, isPending: false }) - mocks.useOpenCodeServers.mockReturnValue({ servers: [server], isLoading: false }) - mocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: { - srv_kitchen: { - serverId: 'srv_kitchen', - connected: true, - version: '2.0.15', - error: null, - }, - }, - isLoading: false, - }) - mocks.useSettingsDialog.mockReturnValue({ openTab: mocks.openTab }) - mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) - }) - - it('renders the server identity and connection status', async () => { - renderServer() - - expect(screen.getByText('Kitchen')).toBeInTheDocument() - expect(screen.getByText('https://kitchen.example.com')).toBeInTheDocument() - await waitFor(() => { - expect(screen.getByText(/Connected/)).toBeInTheDocument() - }) - expect(screen.getByText(/v2\.0\.15/)).toBeInTheDocument() - }) - - it('lists projects from the remote server', async () => { - renderServer() - - await waitFor(() => { - expect(screen.getByText('App')).toBeInTheDocument() - }) - expect(screen.getByText('/home/me/app')).toBeInTheDocument() - expect(mocks.listProjects).toHaveBeenCalledWith('srv_kitchen') - }) - - it('links the project name to the remote project page', async () => { - renderServer() - - const link = await screen.findByRole('link', { name: /App/ }) - expect(link).toHaveAttribute( - 'href', - '/servers/srv_kitchen/projects?directory=%2Fhome%2Fme%2Fapp', - ) - }) - - it('links each expanded project session to the remote session route', async () => { - renderServer() - - fireEvent.click(await screen.findByRole('button', { name: 'Expand App' })) - - const link = await screen.findByRole('link', { name: /Fix the bug/ }) - expect(link).toHaveAttribute('href', '/servers/srv_kitchen/sessions/ses_1') - expect(mocks.useSessionsAcrossDirectories).toHaveBeenCalledWith(['/home/me/app']) - }) - - it('creates a session in a project directory and navigates to it', async () => { - mocks.createSession.mockResolvedValue({ id: 'ses_new' }) - renderServer() - - await screen.findByText('App') - fireEvent.click(screen.getByRole('button', { name: /New session/ })) - - await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) - }) - await waitFor(() => { - expect(screen.getByTestId('session-route')).toBeInTheDocument() - }) - }) - - it('opens a session in an arbitrary directory', async () => { - mocks.createSession.mockResolvedValue({ id: 'ses_other' }) - renderServer() - - fireEvent.change(screen.getByLabelText('Directory path'), { target: { value: '/srv/other' } }) - fireEvent.click(screen.getByRole('button', { name: /Start session/ })) - - await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/other' }) - }) - await waitFor(() => { - expect(screen.getByTestId('session-route')).toBeInTheDocument() - }) - }) - - it('shows the disabled state without loading projects', async () => { - mocks.useOpenCodeServers.mockReturnValue({ - servers: [{ ...server, enabled: false }], - isLoading: false, - }) - - renderServer() - - expect(screen.getByText('This server is disabled')).toBeInTheDocument() - expect(mocks.listProjects).not.toHaveBeenCalled() - }) - - it('shows an empty state when the server has no projects', async () => { - mocks.listProjects.mockResolvedValue([]) - - renderServer() - - await waitFor(() => { - expect(screen.getByText('No projects on this server yet')).toBeInTheDocument() - }) - }) - - it('shows the unreachable state from the status hook', async () => { - mocks.useOpenCodeServerStatuses.mockReturnValue({ - statuses: [], - statusById: { - srv_kitchen: { - serverId: 'srv_kitchen', - connected: false, - version: null, - error: 'connection refused', - }, - }, - isLoading: false, - }) - - renderServer() - - expect(screen.getByText('Unreachable')).toBeInTheDocument() - expect(screen.getByText(/connection refused/)).toBeInTheDocument() - }) - - it('opens the servers settings tab from the header', () => { - renderServer() - - fireEvent.click(screen.getByRole('button', { name: /Manage servers/ })) - - expect(mocks.openTab).toHaveBeenCalledWith('servers') - }) -}) diff --git a/frontend/src/pages/__tests__/Repos.test.tsx b/frontend/src/pages/__tests__/Repos.test.tsx new file mode 100644 index 000000000..4fb6460af --- /dev/null +++ b/frontend/src/pages/__tests__/Repos.test.tsx @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' +import { Repos } from '../Repos' +import { useSidebarServerStore } from '@/stores/sidebarServerStore' + +const mocks = vi.hoisted(() => ({ + useOpenCodeServers: vi.fn(), + useOpenCodeServerStatuses: vi.fn(), + openTab: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCodeServers', () => ({ + useOpenCodeServers: mocks.useOpenCodeServers, + useOpenCodeServerStatuses: mocks.useOpenCodeServerStatuses, +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: () => ({ + isOpen: false, + open: vi.fn(), + openTab: mocks.openTab, + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + selectedTab: null, + setActiveTab: vi.fn(), + }), +})) + +vi.mock('@/hooks/useMobile', () => ({ + isMobileViewport: () => false, + useMobile: () => false, +})) + +vi.mock('@/components/repo/RepoList', () => ({ + RepoList: () =>
local-repos
, +})) + +vi.mock('@/components/repo/AddRepoDialog', () => ({ + AddRepoDialog: () => null, +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: () => null, +})) + +vi.mock('@/components/navigation/ToolSidePanel', () => ({ + ToolSidePanel: () => null, +})) + +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ + PendingActionsGroup: () => null, +})) + +vi.mock('@/components/servers/RemoteProjectList', () => ({ + RemoteProjectList: ({ serverId }: { serverId: string }) => ( +
remote-projects:{serverId}
+ ), +})) + +const kitchen = { + id: 'srv_kitchen', + name: 'Kitchen', + baseUrl: 'https://kitchen.example.com', + username: 'opencode', + enabled: true, + hasPassword: true, + createdAt: 1, + updatedAt: 2, +} + +function LocationDisplay() { + return
{useLocation().pathname}
+} + +function renderRepos() { + return render( + + + + } /> + + , + ) +} + +describe('Repos', () => { + beforeEach(() => { + localStorage.clear() + useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) + mocks.useOpenCodeServers.mockReturnValue({ + servers: [kitchen], + isLoading: false, + isSuccess: true, + }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { + srv_kitchen: { + serverId: 'srv_kitchen', + connected: true, + version: '2.0.15', + error: null, + }, + }, + isLoading: false, + }) + }) + + it('shows the local repositories and local-only actions', () => { + renderRepos() + + expect(screen.getByText('local-repos')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Repo' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open files' })).toBeInTheDocument() + expect(screen.queryByText(/remote-projects/)).not.toBeInTheDocument() + }) + + it('shows the selected linked server projects and hides local-only actions', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + expect(screen.getByText('remote-projects:srv_kitchen')).toBeInTheDocument() + expect(screen.queryByText('local-repos')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Repo' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Open files' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Manage servers/ })).toHaveClass('hidden', 'sm:flex') + expect(screen.getByRole('button', { name: 'All Schedules' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument() + }) + + it('swaps the center section without navigating when the selection changes', () => { + renderRepos() + + act(() => useSidebarServerStore.getState().selectServer('srv_kitchen')) + + expect(screen.getByText('remote-projects:srv_kitchen')).toBeInTheDocument() + expect(screen.queryByText('local-repos')).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') + + act(() => useSidebarServerStore.getState().selectServer(LOCAL_OPENCODE_SERVER_ID)) + + expect(screen.getByText('local-repos')).toBeInTheDocument() + expect(screen.queryByText(/remote-projects/)).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent('/') + }) + + it('shows the connection status of the selected linked server', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + expect(screen.getByText(/Connected/)).toBeInTheDocument() + expect(screen.getByText(/v2\.0\.15/)).toBeInTheDocument() + }) + + it('shows an unreachable linked server with its error', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + mocks.useOpenCodeServerStatuses.mockReturnValue({ + statuses: [], + statusById: { + srv_kitchen: { + serverId: 'srv_kitchen', + connected: false, + version: null, + error: 'connection refused', + }, + }, + isLoading: false, + }) + + renderRepos() + + expect(screen.getByText(/Unreachable/)).toBeInTheDocument() + expect(screen.getByText(/connection refused/)).toBeInTheDocument() + }) + + it('opens the servers settings tab from the header', () => { + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + renderRepos() + + fireEvent.click(screen.getByRole('button', { name: /Manage servers/ })) + + expect(mocks.openTab).toHaveBeenCalledWith('servers') + }) +}) diff --git a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx index e6b5cdf81..fcadbc868 100644 --- a/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.remote.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor, act } from '@testing-library/react' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { FetchError } from '@/api/fetchWrapper' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' +import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' +import { getServerProjectPath } from '@/lib/navigation' import { LocationCatcher } from '@/test/test-utils' import { MessageThread } from '@/components/message/MessageThread' import { ContextUsageIndicator } from '@/components/session/ContextUsageIndicator' @@ -183,23 +186,36 @@ function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } +type SwipeNavigation = ReturnType + +function SwipeNavigationProbe({ captured }: { captured: { current: SwipeNavigation } }) { + captured.current = useSwipeNavigation() + return null +} + function renderRemoteSession(sessionId = 'ses_remote') { const capturedPathname = { current: '' } const capturedSearch = { current: '' } + const capturedSwipeNavigation = { current: null as SwipeNavigation } const rendered = render( - - - - - } /> - - - + + + + + + + + + } /> + + + + , ) - return { ...rendered, capturedPathname } + return { ...rendered, capturedPathname, capturedSearch, capturedSwipeNavigation } } describe('SessionDetail remote server mode', () => { @@ -271,6 +287,47 @@ describe('SessionDetail remote server mode', () => { expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') }) + it('returns to the project page from the header back button', async () => { + const { capturedPathname, capturedSearch } = renderRemoteSession() + + await waitFor(() => { + expect(screen.getByText('Kitchen').parentElement).toHaveTextContent('Kitchen · app') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + + expect(capturedPathname.current).toBe('/servers/srv_kitchen/projects') + expect(capturedSearch.current).toBe('?directory=%2Fhome%2Fme%2Fapp') + }) + + it('registers the remote project page as the swipe-back target once the directory resolves', async () => { + const { capturedSwipeNavigation, unmount } = renderRemoteSession() + + await waitFor(() => { + expect(capturedSwipeNavigation.current?.backTarget()).toBe( + getServerProjectPath('srv_kitchen', '/home/me/app'), + ) + }) + + unmount() + + expect(capturedSwipeNavigation.current?.backTarget()).toBeNull() + }) + + it('falls back to the projects list when the session is not found', async () => { + mocks.useSession.mockReturnValue({ + data: undefined, + isLoading: false, + error: new FetchError('Session not found', 404), + }) + + const { capturedPathname } = renderRemoteSession() + + fireEvent.click(await screen.findByRole('button', { name: 'Back to projects' })) + + expect(capturedPathname.current).toBe('/') + }) + it('uses the session directory as the only directory source', async () => { renderRemoteSession() From 8605dbf494710402669c040234fb760eb2396c19 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:41:23 -0400 Subject: [PATCH 14/15] feat(servers): open a remote directory session from a dialog The open-directory form no longer sits at the bottom of the project list, where it overlapped the mobile tab bar. A button beside the search input opens it in a dialog. --- .../servers/OpenDirectoryDialog.tsx | 103 ++++++++++++++++++ .../components/servers/RemoteProjectList.tsx | 75 ++++++------- .../__tests__/RemoteProjectList.test.tsx | 58 +++++++++- 3 files changed, 189 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/servers/OpenDirectoryDialog.tsx diff --git a/frontend/src/components/servers/OpenDirectoryDialog.tsx b/frontend/src/components/servers/OpenDirectoryDialog.tsx new file mode 100644 index 000000000..468947f7d --- /dev/null +++ b/frontend/src/components/servers/OpenDirectoryDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertCircle, Loader2, Plus } from 'lucide-react' +import { useCreateSession } from '@/hooks/useOpenCode' +import { getServerSessionPath } from '@/lib/navigation' +import { parseNetworkError } from '@/lib/opencode-errors' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +interface OpenDirectoryDialogProps { + serverId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +/** + * Dialog that starts a new session in an absolute directory on the linked server. + */ +export function OpenDirectoryDialog({ serverId, open, onOpenChange }: OpenDirectoryDialogProps) { + const navigate = useNavigate() + const [directory, setDirectory] = useState('') + const [error, setError] = useState(null) + const createSession = useCreateSession(directory) + + useEffect(() => { + if (!open) { + setDirectory('') + setError(null) + } + }, [open]) + + const canSubmit = directory.trim().length > 0 && !createSession.isPending + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!canSubmit) return + + setError(null) + try { + const session = await createSession.mutateAsync({ agent: undefined }) + onOpenChange(false) + navigate(getServerSessionPath(serverId, session.id)) + } catch (cause) { + setError(parseNetworkError(cause).message) + } + } + + return ( + + + + Open directory + + Start a session in an absolute path on the linked server. + + +
+
+ + setDirectory(event.target.value)} + placeholder="/absolute/path/on/the/server" + autoComplete="off" + name="remote-directory" + className="font-mono text-sm" + autoFocus + disabled={createSession.isPending} + /> +
+ {error ? ( +
+ +

{error}

+
+ ) : null} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/servers/RemoteProjectList.tsx b/frontend/src/components/servers/RemoteProjectList.tsx index 0ea7b65be..4ef717c14 100644 --- a/frontend/src/components/servers/RemoteProjectList.tsx +++ b/frontend/src/components/servers/RemoteProjectList.tsx @@ -1,21 +1,20 @@ -import { useMemo, useState, type FormEvent } from 'react' +import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { Folder, FolderGit2, GitBranch, Loader2, Plus, Search, Server } from 'lucide-react' +import { Folder, FolderGit2, FolderPlus, GitBranch, Search, Server } from 'lucide-react' import { listProjects, type OpenCodeProject } from '@/api/opencode' -import { useCreateSession } from '@/hooks/useOpenCode' import { useOpenCodeServers } from '@/hooks/useOpenCodeServers' import { useSettingsDialog } from '@/hooks/useSettingsDialog' import { useProjectActivityState } from '@/hooks/useActiveWork' import { serverScopedKey } from '@/lib/serverScope' -import { getServerProjectPath, getServerSessionPath } from '@/lib/navigation' +import { getServerProjectPath } from '@/lib/navigation' import { formatShortRelativeTime, getDirectoryName } from '@/lib/utils' import { OpenCodeServerProvider } from '@/contexts/OpenCodeServerContext' import { ActivityStateIndicator } from '@/components/activity/ActivityStateIndicator' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { Input } from '@/components/ui/input' import { LinkedServerStateCard } from '@/components/servers/LinkedServerStateCard' +import { OpenDirectoryDialog } from '@/components/servers/OpenDirectoryDialog' import { RemoteProjectRowActions } from '@/components/servers/RemoteProjectRowActions' import { RepoCardShell } from '@/components/repo/RepoCardShell' import { RepoCardSkeleton } from '@/components/repo/RepoCardSkeleton' @@ -53,6 +52,21 @@ function RemoteProjectCard({ project, serverId }: { project: OpenCodeProject; se ) } +function OpenDirectoryTrigger({ onClick }: { onClick: () => void }) { + return ( + + ) +} + function RemoteProjectListLoading() { return (
@@ -73,11 +87,9 @@ function RemoteProjectListLoading() { } function RemoteProjectListContent({ serverId }: { serverId: string }) { - const navigate = useNavigate() const { servers, isLoading: serversLoading } = useOpenCodeServers() const { openTab } = useSettingsDialog() - const createSession = useCreateSession() - const [openDirectory, setOpenDirectory] = useState('') + const [openDirectoryOpen, setOpenDirectoryOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const server = servers.find((candidate) => candidate.id === serverId) @@ -104,18 +116,6 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { const openSettings = () => openTab('servers') - const handleOpenDirectory = async (event: FormEvent) => { - event.preventDefault() - const directory = openDirectory.trim() - if (!directory) return - try { - const session = await createSession.mutateAsync({ directory }) - navigate(getServerSessionPath(serverId, session.id)) - } catch { - return - } - } - return (
{!serversLoading && !server ? ( @@ -163,18 +163,22 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) {

No projects on this server yet

- Start a session in a directory below to create one. + Start a session in a directory to create one.

+
+ setOpenDirectoryOpen(true)} /> +
) : ( <> -
+
+ setOpenDirectoryOpen(true)} />
@@ -203,28 +207,11 @@ function RemoteProjectListContent({ serverId }: { serverId: string }) { ) : null} {server && !isDisabled ? ( -
-

Open directory

-
- setOpenDirectory(event.target.value)} - placeholder="/absolute/path/on/the/server" - aria-label="Directory path" - autoComplete="off" - name="remote-directory" - className="font-mono text-sm" - /> - -
-
+ ) : null}
) diff --git a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx index 8fd227f77..aa9c6532c 100644 --- a/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx +++ b/frontend/src/components/servers/__tests__/RemoteProjectList.test.tsx @@ -33,6 +33,11 @@ vi.mock('@/hooks/useSettingsDialog', () => ({ vi.mock('@/hooks/useMobile', () => ({ useMobile: () => mocks.isMobile, + useSwipeBack: () => ({ + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: {}, + }), })) vi.mock('@/hooks/useActiveWork', () => ({ @@ -215,22 +220,69 @@ describe('RemoteProjectList', () => { expect(screen.queryByTestId('project-route')).not.toBeInTheDocument() }) - it('opens a session in an arbitrary directory', async () => { + it('opens a session in an arbitrary directory from the dialog', async () => { const user = userEvent.setup() mocks.createSession.mockResolvedValue({ id: 'ses_other' }) renderProjectList() - await user.type(screen.getByLabelText('Directory path'), '/srv/other') + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + await user.type(screen.getByLabelText('Directory'), '/srv/other') await user.click(screen.getByRole('button', { name: /Start session/ })) await waitFor(() => { - expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/srv/other' }) + expect(mocks.useCreateSession).toHaveBeenCalledWith('/srv/other') }) + expect(mocks.createSession).toHaveBeenCalledWith({ agent: undefined }) await waitFor(() => { expect(screen.getByTestId('session-route')).toBeInTheDocument() }) }) + it('does not render the open-directory form in the list until the dialog opens', async () => { + const user = userEvent.setup() + renderProjectList() + + await screen.findByText('App') + expect(screen.queryByLabelText('Directory')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('/absolute/path/on/the/server')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open directory' })) + + expect(await screen.findByLabelText('Directory')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('/absolute/path/on/the/server')).toBeInTheDocument() + }) + + it('disables Start session until a directory is entered', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + + expect(screen.getByRole('button', { name: /Start session/ })).toBeDisabled() + + await user.type(screen.getByLabelText('Directory'), ' ') + expect(screen.getByRole('button', { name: /Start session/ })).toBeDisabled() + + await user.type(screen.getByLabelText('Directory'), '/srv/other') + expect(screen.getByRole('button', { name: /Start session/ })).toBeEnabled() + }) + + it('clears the directory when the dialog is cancelled', async () => { + const user = userEvent.setup() + renderProjectList() + + await user.click(await screen.findByRole('button', { name: 'Open directory' })) + await user.type(screen.getByLabelText('Directory'), '/srv/other') + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByLabelText('Directory')).not.toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Open directory' })) + expect(await screen.findByLabelText('Directory')).toHaveValue('') + }) + it('filters projects by name and path', async () => { const user = userEvent.setup() mocks.listProjects.mockResolvedValue([project, otherProject]) From 360ceea3ac854c9adba45d92d230419132b0de74 Mon Sep 17 00:00:00 2001 From: ioplex Date: Mon, 19 Oct 2026 19:01:31 -0400 Subject: [PATCH 15/15] fix(servers): reconcile linked server statuses once the server list loads The browser stream usually connects before the linked server list resolves, so a fresh page never fetched remote active sessions and busy remote sessions showed as idle until their status changed. Reconcile each enabled linked server once per stream connection, including servers that arrive or are enabled after the stream connects. --- frontend/src/contexts/EventContext.test.tsx | 35 +++++++++++++++++++++ frontend/src/contexts/EventContext.tsx | 34 +++++++++++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index 98e8190e5..c9a0191f2 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -522,6 +522,41 @@ describe('EventProvider permissions and forms', () => { }) }) + it('reconciles a linked server enabled after the browser stream connects', async () => { + const busySessionID = 'session-remote-busy' + mocks.listActiveSessions.mockImplementation((serverId?: string) => + Promise.resolve(serverId === 'srv_remote' ? { [busySessionID]: {} } : {}), + ) + + const { rerender } = render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + handleStatusChange(true) + }) + + expect(mocks.listActiveSessions).not.toHaveBeenCalledWith('srv_remote') + + mocks.servers = [{ id: 'srv_remote', enabled: true }] + rerender() + + await waitFor(() => { + expect(useSessionStatus.getState().statuses.get(busySessionID)).toEqual({ type: 'busy' }) + }) + const remoteFetches = () => + mocks.listActiveSessions.mock.calls.filter(([serverId]) => serverId === 'srv_remote') + expect(remoteFetches()).toHaveLength(1) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + mocks.servers = [{ id: 'srv_remote', enabled: true }] + rerender() + + expect(remoteFetches()).toHaveLength(1) + }) + it('clears settled pending actions on an upstream resync while the browser stream stays connected', async () => { mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) mocks.listPendingPermissions.mockResolvedValue([]) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index d24f14362..ac1368538 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -311,6 +311,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const initialFetchDoneRef = useRef(false) const statusSyncVersionRef = useRef(0) const remoteStatusSyncVersionsRef = useRef(new Map()) + const streamConnectedRef = useRef(false) + const reconciledRemoteServerIdsRef = useRef(new Set()) const statusPollIntervalRef = useRef | null>(null) const subscriptionRef = useRef | null>(null) const reposRef = useRef(null) @@ -776,6 +778,24 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } }, [resolveSessionAncestry]) + const reconcileRemoteServersOnce = useCallback((serversToReconcile: Array<{ id: string }>) => { + const reconciled = reconciledRemoteServerIdsRef.current + for (const server of serversToReconcile) { + if (reconciled.has(server.id)) continue + reconciled.add(server.id) + void reconcileRemoteSessionStatuses(server.id) + } + }, [reconcileRemoteSessionStatuses]) + + useEffect(() => { + const enabledIds = new Set(enabledRemoteServers.map((server) => server.id)) + for (const serverId of reconciledRemoteServerIdsRef.current) { + if (!enabledIds.has(serverId)) reconciledRemoteServerIdsRef.current.delete(serverId) + } + if (!streamConnectedRef.current) return + reconcileRemoteServersOnce(enabledRemoteServers) + }, [enabledRemoteServers, reconcileRemoteServersOnce]) + const stopStatusPoll = useCallback(() => { if (statusPollIntervalRef.current === null) return clearInterval(statusPollIntervalRef.current) @@ -984,19 +1004,23 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } } + const reconciledRemoteServerIds = reconciledRemoteServerIdsRef.current + const handleStatusChange = (connected: boolean) => { if (connected) { + streamConnectedRef.current = true + reconciledRemoteServerIdsRef.current.clear() initialFetchDoneRef.current = false fetchInitialPendingData() fetchInitialSessionStatuses() - for (const server of enabledRemoteServersRef.current) { - void reconcileRemoteSessionStatuses(server.id) - } + reconcileRemoteServersOnce(enabledRemoteServersRef.current) void syncRunningShells(collectTrackedDirectories()) invalidateChildSessionCaches(queryClient) invalidateQueryKeysDebounced(queryClient, LOCATION_CATALOG_QUERY_KEYS) startStatusPoll() } else { + streamConnectedRef.current = false + reconciledRemoteServerIdsRef.current.clear() statusSyncVersionRef.current += 1 invalidateRemoteStatusSyncs() stopStatusPoll() @@ -1041,13 +1065,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { subscriptionRef.current = subscription return () => { + streamConnectedRef.current = false + reconciledRemoteServerIds.clear() statusSyncVersionRef.current += 1 invalidateRemoteStatusSyncs() stopStatusPoll() subscription.dispose() subscriptionRef.current = null } - }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteSessionStatuses, invalidateRemoteStatusSyncs]) + }, [addPermission, removePermission, addForm, removeForm, rememberSessionDirectory, fetchInitialPendingData, fetchInitialSessionStatuses, startStatusPoll, stopStatusPoll, queryClient, handleHealthChange, reconcilePendingActionsForDirectories, collectTrackedDirectories, syncRunningShells, resolveSessionAncestry, reconcileRemoteSessionStatuses, reconcileRemoteServersOnce, invalidateRemoteStatusSyncs]) useEffect(() => { reposRef.current = repos