diff --git a/backend/src/index.ts b/backend/src/index.ts index b7b9409cd..67a0b450c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -340,7 +340,7 @@ app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth)) -app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService)) +app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, sessionPermissionModeService, repoWorkspaces, gitAuthService, sessionGoalService, multiRunService)) app.route('/api/opencode-proxy', createOpenCodeProxyRoutes(db, settingsService)) const protectedApi = new Hono() diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 346b9414e..4ca67c54e 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -17,9 +17,13 @@ import { createInternalSessionRoutes } from './sessions' import { createInternalAssistantRoutes } from './assistant' import { createInternalGitCredentialsRoutes } from './git-credentials' import { createInternalSandboxRoutes } from './sandbox' +import { createSessionGoalRoutes } from '../session-goals' +import { createMultiRunRoutes } from '../multi-runs' import type { SessionPermissionModeService } from '../../services/session-permission-modes' import type { RepoWorkspaceService } from '../../services/repo-workspace' import type { GitAuthService } from '../../services/git-auth' +import type { SessionGoalService } from '../../services/session-goals' +import type { MultiRunService } from '../../services/multi-runs' export function createInternalRoutes( db: Database, @@ -30,10 +34,14 @@ export function createInternalRoutes( permissionModes: SessionPermissionModeService, repoWorkspaces: RepoWorkspaceService, gitAuthService: GitAuthService, + sessionGoals: SessionGoalService, + multiRuns: MultiRunService, ) { const app = new Hono() app.use('/*', createInternalTokenMiddleware(db)) app.route('/schedules', createScheduleRoutes(scheduleService)) + app.route('/session-goals', createSessionGoalRoutes(sessionGoals)) + app.route('/multi-runs', createMultiRunRoutes(multiRuns)) app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) app.route('/opencode-config', createOpenCodeConfigRoutes(settingsService, openCodeClient, { redactSecrets: true })) diff --git a/backend/test/routes/internal-assistant.test.ts b/backend/test/routes/internal-assistant.test.ts index ea4bc953a..4c91a790e 100644 --- a/backend/test/routes/internal-assistant.test.ts +++ b/backend/test/routes/internal-assistant.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -46,7 +48,7 @@ describe('internal/assistant routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-multi-runs.test.ts b/backend/test/routes/internal-multi-runs.test.ts new file mode 100644 index 000000000..06a830365 --- /dev/null +++ b/backend/test/routes/internal-multi-runs.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +function createMultiRunStub() { + return { + list: vi.fn(), + launch: vi.fn(), + discard: vi.fn(), + fuse: vi.fn(), + } +} + +function createRun(overrides: Record = {}) { + return { + id: 1, + repoId: 1, + name: 'Sweep', + prompt: 'go', + isolated: true, + baseRef: null, + createdAt: 1, + entries: [], + fusions: [], + ...overrides, + } +} + +function fuseBody(overrides: Record = {}) { + return { + requestId: '11111111-1111-4111-8111-111111111111', + entryIds: [1, 2], + model: 'openai/a', + ...overrides, + } +} + +describe('internal-multi-runs routes', () => { + let db: Database + let multiRuns: ReturnType + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + multiRuns = createMultiRunStub() + app = new Hono() + app.route( + '/api/internal', + createInternalRoutes( + db, + {} as ScheduleService, + {} as NotificationService, + {} as SettingsService, + {} as OpenCodeClient, + {} as SessionPermissionModeService, + {} as unknown as RepoWorkspaceService, + {} as unknown as GitAuthService, + {} as unknown as SessionGoalService, + multiRuns as unknown as MultiRunService, + ), + ) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/multi-runs returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/multi-runs?repoId=1') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/multi-runs returns the repository runs with a bearer token', async () => { + multiRuns.list.mockReturnValue([]) + + const res = await app.request('/api/internal/multi-runs?repoId=1', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ runs: [] }) + expect(multiRuns.list).toHaveBeenCalledWith(1) + }) + + it('POST /api/internal/multi-runs/3/entries/4/discard discards the entry', async () => { + multiRuns.discard.mockResolvedValue(createRun()) + + const res = await app.request('/api/internal/multi-runs/3/entries/4/discard', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + expect(multiRuns.discard).toHaveBeenCalledWith(3, 4) + }) + + it('POST /api/internal/multi-runs/3/fusions fuses the run', async () => { + multiRuns.fuse.mockResolvedValue({ run: createRun(), created: true }) + const body = fuseBody() + + const res = await app.request('/api/internal/multi-runs/3/fusions', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ run: createRun() }) + expect(multiRuns.fuse).toHaveBeenCalledWith(3, body) + }) +}) diff --git a/backend/test/routes/internal-notifications.test.ts b/backend/test/routes/internal-notifications.test.ts index 36b90f237..701f806b7 100644 --- a/backend/test/routes/internal-notifications.test.ts +++ b/backend/test/routes/internal-notifications.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -32,7 +34,7 @@ describe('internal/notifications routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-config.test.ts b/backend/test/routes/internal-opencode-config.test.ts index 01170e5b5..dfcbec4d2 100644 --- a/backend/test/routes/internal-opencode-config.test.ts +++ b/backend/test/routes/internal-opencode-config.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { readFile, writeFile } from 'fs/promises' @@ -62,7 +64,7 @@ describe('internal/opencode-config routes', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-opencode-workspaces.test.ts b/backend/test/routes/internal-opencode-workspaces.test.ts index d302cd97a..738f76e14 100644 --- a/backend/test/routes/internal-opencode-workspaces.test.ts +++ b/backend/test/routes/internal-opencode-workspaces.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -85,7 +87,7 @@ describe('internal-opencode-workspaces routes', () => { forwardRaw: vi.fn(), } as unknown as OpenCodeClient app = new Hono() - app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts index 4dc3ec0c9..6b518a8a2 100644 --- a/backend/test/routes/internal-repos.test.ts +++ b/backend/test/routes/internal-repos.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -33,7 +35,7 @@ describe('internal-repos routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-sandbox.test.ts b/backend/test/routes/internal-sandbox.test.ts index b30f8fe83..4a1d62b30 100644 --- a/backend/test/routes/internal-sandbox.test.ts +++ b/backend/test/routes/internal-sandbox.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, mock, vi } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { mkdirSync, rmSync } from 'node:fs' @@ -96,7 +98,7 @@ describe('internal sandbox routes', () => { const notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) repoDir = path.join(getReposPath(), 'sandbox-route-test') mkdirSync(repoDir, { recursive: true }) diff --git a/backend/test/routes/internal-schedules.test.ts b/backend/test/routes/internal-schedules.test.ts index dc42d4994..d0f41635e 100644 --- a/backend/test/routes/internal-schedules.test.ts +++ b/backend/test/routes/internal-schedules.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -31,7 +33,7 @@ describe('internal-schedules routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/routes/internal-session-goals.test.ts b/backend/test/routes/internal-session-goals.test.ts new file mode 100644 index 000000000..e7ddc6eed --- /dev/null +++ b/backend/test/routes/internal-session-goals.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' +import type { RepoWorkspaceService } from '../../src/services/repo-workspace' +import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import type { ScheduleService } from '../../src/services/schedules' +import type { NotificationService } from '../../src/services/notification' +import type { SettingsService } from '../../src/services/settings' +import type { OpenCodeClient } from '../../src/services/opencode/client' +import { allMigrations } from '../../src/db/migrations' +import { getOrCreateInternalToken } from '../../src/services/internal-token' +import { migrate } from '../../src/db/migration-runner' + +function createSessionGoalStub() { + return { + getLatest: vi.fn(), + start: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + cancel: vi.fn(), + } +} + +describe('internal-session-goals routes', () => { + let db: Database + let sessionGoals: ReturnType + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + sessionGoals = createSessionGoalStub() + app = new Hono() + app.route( + '/api/internal', + createInternalRoutes( + db, + {} as ScheduleService, + {} as NotificationService, + {} as SettingsService, + {} as OpenCodeClient, + {} as SessionPermissionModeService, + {} as unknown as RepoWorkspaceService, + {} as unknown as GitAuthService, + sessionGoals as unknown as SessionGoalService, + {} as unknown as MultiRunService, + ), + ) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/session-goals returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/session-goals?sessionId=ses_1') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/session-goals returns the latest goal with a bearer token', async () => { + sessionGoals.getLatest.mockReturnValue(null) + + const res = await app.request('/api/internal/session-goals?sessionId=ses_1', { + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ goal: null }) + expect(sessionGoals.getLatest).toHaveBeenCalledWith('ses_1') + }) + + it('POST /api/internal/session-goals starts a goal', async () => { + sessionGoals.start.mockResolvedValue({ id: 1, status: 'active' }) + const body = { sessionId: 'ses_1', directory: '/abs/repo', objective: 'Ship it' } + + const res = await app.request('/api/internal/session-goals', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + expect(res.status).toBe(201) + await expect(res.json()).resolves.toEqual({ goal: { id: 1, status: 'active' } }) + expect(sessionGoals.start).toHaveBeenCalledWith(body) + }) + + it('POST /api/internal/session-goals/7/pause pauses the goal', async () => { + sessionGoals.pause.mockReturnValue({ id: 7, status: 'paused' }) + + const res = await app.request('/api/internal/session-goals/7/pause', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ goal: { id: 7, status: 'paused' } }) + expect(sessionGoals.pause).toHaveBeenCalledWith(7) + }) +}) diff --git a/backend/test/routes/internal-sessions.test.ts b/backend/test/routes/internal-sessions.test.ts index 374912716..f4e412151 100644 --- a/backend/test/routes/internal-sessions.test.ts +++ b/backend/test/routes/internal-sessions.test.ts @@ -9,6 +9,8 @@ import type { OpenCodeClient } from '../../src/services/opencode/client' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import type { Repo } from '../../src/types/repo' const mockDb = { @@ -158,7 +160,7 @@ describe('internal-sessions routes', () => { app = new Hono() app.route( '/api/internal', - createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService), + createInternalRoutes(mockDb, scheduleService, notificationService, settingsService, openCodeClient, permissionModes, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService), ) token = 'test-internal-token' }) diff --git a/backend/test/routes/internal-settings.test.ts b/backend/test/routes/internal-settings.test.ts index 8316b0541..4110bbfc0 100644 --- a/backend/test/routes/internal-settings.test.ts +++ b/backend/test/routes/internal-settings.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { createInternalRoutes } from '../../src/routes/internal' @@ -32,7 +34,7 @@ describe('internal/settings routes', () => { notificationService = new NotificationService(db) settingsService = new SettingsService(db) app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, openCodeClient, {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) token = getOrCreateInternalToken(db) }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index cd58e27f5..eae66156c 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import type { SessionPermissionModeService } from '../../src/services/session-permission-modes' import type { RepoWorkspaceService } from '../../src/services/repo-workspace' import type { GitAuthService } from '../../src/services/git-auth' +import type { SessionGoalService } from '../../src/services/session-goals' +import type { MultiRunService } from '../../src/services/multi-runs' import path from 'path' import { access, readFile, writeFile } from 'fs/promises' import { Hono } from 'hono' @@ -688,7 +690,7 @@ describe('assistant-mode end-to-end', () => { const notificationService = new NotificationService(db) const settingsService = new SettingsService(db) const app = new Hono() - app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService)) + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService, createOpenCodeClient(), {} as SessionPermissionModeService, {} as unknown as RepoWorkspaceService, {} as unknown as GitAuthService, {} as unknown as SessionGoalService, {} as unknown as MultiRunService)) const unauth = await app.request('/api/internal/schedules/all') expect(unauth.status).toBe(401) diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index c587fb61d..c83e30f7e 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -5,6 +5,8 @@ import { getFormText, getGoalOutcomeTitle, getGoalStopReasonLabel, + getGoalTurnLabel, + getGoalTokenLabel, } from '@opencode-manager/shared/notifications' import { buildEventNotificationPayload, buildNotificationUrl } from '../../src/services/notification' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' @@ -35,6 +37,20 @@ describe('goal notification labels', () => { }) }) +describe('goal progress labels', () => { + it('formats the turn counter', () => { + expect(getGoalTurnLabel({ continuationCount: 2, maxContinuations: 20 })).toBe('Turn 2/20') + }) + + it('formats the token usage with locale separators', () => { + expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: 1000 })).toBe('250/1,000 tokens') + }) + + it('returns null when there is no token budget', () => { + expect(getGoalTokenLabel({ tokensUsed: 250, tokenBudget: null })).toBeNull() + }) +}) + describe('getPermissionLabel', () => { it('maps known V2 permission actions to friendly labels', () => { expect(getPermissionLabel('shell')).toBe('Run Command') diff --git a/backend/test/services/opencode-manager-tool-plugin.test.ts b/backend/test/services/opencode-manager-tool-plugin.test.ts index 028fdbb29..63e446ffe 100644 --- a/backend/test/services/opencode-manager-tool-plugin.test.ts +++ b/backend/test/services/opencode-manager-tool-plugin.test.ts @@ -289,6 +289,14 @@ describe('ocm-manager plugin', () => { ['DELETE', '/settings'], ['PUT', '/opencode-config'], ['DELETE', '/sessions/abc'], + ['GET', '/session-goals'], + ['POST', '/session-goals'], + ['POST', '/session-goals/1/pause'], + ['POST', '/session-goals/1/cancel'], + ['GET', '/multi-runs'], + ['POST', '/multi-runs'], + ['POST', '/multi-runs/1/fusions'], + ['POST', '/multi-runs/1/entries/2/discard'], ] as const for (const [method, path] of deniedRoutes) { diff --git a/frontend/src/components/session/SessionGoalBar.tsx b/frontend/src/components/session/SessionGoalBar.tsx index 44dd797be..769c6012d 100644 --- a/frontend/src/components/session/SessionGoalBar.tsx +++ b/frontend/src/components/session/SessionGoalBar.tsx @@ -8,7 +8,7 @@ import { useResumeSessionGoal, useSessionGoal, } from '@/hooks/useSessionGoals' -import { getGoalOutcomeTitle, getGoalStopReasonLabel } from '@opencode-manager/shared/notifications' +import { getGoalOutcomeTitle, getGoalStopReasonLabel, getGoalTokenLabel, getGoalTurnLabel } from '@opencode-manager/shared/notifications' import type { SessionGoalStatus } from '@opencode-manager/shared/schemas' const STATUS_CHIP_CLASSES: Record = { @@ -38,6 +38,7 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { const terminal = isTerminal(goal.status) const reason = goal.stopReason ? getGoalStopReasonLabel(goal.stopReason) : goal.lastReason + const tokenLabel = getGoalTokenLabel(goal) return (
@@ -52,13 +53,13 @@ export function SessionGoalBar({ sessionID }: { sessionID: string }) { {!terminal && ( - Turn {goal.continuationCount}/{goal.maxContinuations} + {getGoalTurnLabel(goal)} )} - {!terminal && goal.tokenBudget !== null && ( + {!terminal && tokenLabel !== null && ( - {goal.tokensUsed.toLocaleString()}/{goal.tokenBudget.toLocaleString()} tokens + {tokenLabel} )} diff --git a/ocm-cli/README.md b/ocm-cli/README.md index 842144cfd..ab8c567fa 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -144,6 +144,39 @@ on the Manager immediately. Use it from inside an OpenCode session after `ocm login` and after the repo already exists on the Manager (`ocm push --create` if needed). +`/ocm` switches this TUI to another server, the same way the `ocm` CLI picks +one. In a local TUI it finds the Manager repo that matches the current +directory (by git origin) and asks to attach to it; when no repo or several +repos match, it shows a picker of the Manager's ready repos. In a TUI that is +already attached, the picker offers the other Manager repos and **Local +opencode**. Switching exits the TUI and reattaches; the current session stays +where it is (use `/ocm-move` to bring a local session along). + +The plugin also registers two Manager-backed commands, both of which need an +attached Manager repo through `ocm`: + +- `/goal [objective]` starts a Manager-driven goal on the current top-level + session and sends the objective as the next message. With no objective, a + dialog asks for it, plus optional max turns and token budget (blank uses the + Manager defaults). Scheduled runs and subagent sessions are rejected by the + Manager. While a goal is open, a status line above the composer shows its + turn and token usage, and a toast reports the outcome. Running `/goal` while + a goal is open shows its live status with `p` to pause or resume and `x` + (twice) to cancel. +- `/multirun [prompt]` opens a launch dialog: prompt, name, a filterable model + checklist (up to 5), isolated worktrees or the shared repo directory, and an + optional base ref. `ctrl+s` launches, and the started sessions open in tabs. + With no prompt, `/multirun` opens the runs browser for the attached repo: + pick a run, open entry or fusion sessions, discard entries (`d` twice), select + results with `space`, and press `f` to fuse them with a synthesis model. On + the run list, `f` fuses every started result of the highlighted run. A fusion + always runs in a new worktree. + +Both commands need an OpenCode Manager release that exposes +`/api/internal/session-goals` and `/api/internal/multi-runs`. An older Manager +answers these requests with `404 Not Found`, and the TUI reports that the +Manager must be upgraded. + Enable it in `~/.config/opencode/cli.json`: ```jsonc diff --git a/ocm-cli/bin/ocm.ts b/ocm-cli/bin/ocm.ts index 140af6896..ca384e5f6 100644 --- a/ocm-cli/bin/ocm.ts +++ b/ocm-cli/bin/ocm.ts @@ -14,6 +14,7 @@ import { resolveTarget, formatRepoIdentities, parseRepoIdPositional, restrictMat import { buildAttachInvocation } from '../src/warp.js' import { type ManagerRepo, fetchRepos, toRemoteRepoSummaries } from '../src/manager-repos.js' import { OCM_VERSION as VERSION, warmRepoProxy } from '../src/repo-proxy.js' +import { normalizeManagerUrl } from '../src/manager-auth.js' const USAGE = `ocm v${VERSION} - OpenCode Manager workspace launcher @@ -151,7 +152,7 @@ function findRepo(repos: ManagerRepo[], needle: string | number): ManagerRepo | export async function cmdLogin(args: string[]): Promise { const url = args[0] if (!url) die('usage: ocm login [token]') - const normalisedUrl = url.replace(/\/+$/, '') + const normalisedUrl = normalizeManagerUrl(url) let token = args[1] if (!token) { diff --git a/ocm-cli/src/goal-store.ts b/ocm-cli/src/goal-store.ts new file mode 100644 index 000000000..9e4e131d5 --- /dev/null +++ b/ocm-cli/src/goal-store.ts @@ -0,0 +1,113 @@ +import type { SessionGoal } from '@opencode-manager/shared/schemas' + +export const GOAL_POLL_INTERVAL_MS = 3000 + +export type GoalStoreDeps = { + load: (sessionID: string) => Promise + onOutcome: (goal: SessionGoal) => void + pollIntervalMs?: number +} + +export type GoalStore = { + watch(sessionID: string, listener: (goal: SessionGoal | null) => void): () => void + set(goal: SessionGoal): void + refresh(sessionID: string): Promise +} + +interface GoalEntry { + readonly sessionID: string + goal: SessionGoal | null + listeners: Set<(goal: SessionGoal | null) => void> + timer: ReturnType | null +} + +export function isOpenGoal(goal: SessionGoal | null | undefined): goal is SessionGoal { + return !!goal && (goal.status === 'active' || goal.status === 'paused') +} + +function isTerminalGoal(goal: SessionGoal): boolean { + return goal.status === 'completed' || goal.status === 'blocked' || goal.status === 'stopped' +} + +export function createGoalStore(deps: GoalStoreDeps): GoalStore { + const pollIntervalMs = deps.pollIntervalMs ?? GOAL_POLL_INTERVAL_MS + const entries = new Map() + + function entryFor(sessionID: string): GoalEntry { + let entry = entries.get(sessionID) + if (!entry) { + entry = { sessionID, goal: null, listeners: new Set(), timer: null } + entries.set(sessionID, entry) + } + return entry + } + + function stopPolling(entry: GoalEntry): void { + if (entry.timer !== null) { + clearInterval(entry.timer) + entry.timer = null + } + } + + function isLive(entry: GoalEntry): boolean { + return entry.listeners.size > 0 && entries.get(entry.sessionID) === entry + } + + function startPolling(entry: GoalEntry): void { + stopPolling(entry) + entry.timer = setInterval(() => { + void loadInto(entry) + }, pollIntervalMs) + } + + function applyGoal(entry: GoalEntry, goal: SessionGoal | null): void { + const previous = entry.goal + entry.goal = goal + for (const listener of entry.listeners) listener(goal) + if (entry.listeners.size > 0 && isOpenGoal(previous) && goal !== null && isTerminalGoal(goal)) { + deps.onOutcome(goal) + } + if (goal?.status === 'active' && isLive(entry)) startPolling(entry) + else stopPolling(entry) + } + + async function loadInto(entry: GoalEntry): Promise { + let goal: SessionGoal | null + try { + goal = await deps.load(entry.sessionID) + } catch { + if (entry.goal?.status !== 'active' || !isLive(entry)) stopPolling(entry) + return + } + if (entries.get(entry.sessionID) !== entry) { + stopPolling(entry) + return + } + applyGoal(entry, goal) + } + + return { + watch(sessionID, listener) { + const entry = entryFor(sessionID) + entry.listeners.add(listener) + void loadInto(entry) + return () => { + entry.listeners.delete(listener) + if (entry.listeners.size === 0) { + stopPolling(entry) + entries.delete(sessionID) + } + } + }, + set(goal) { + const entry = entries.get(goal.sessionId) + if (!entry) return + applyGoal(entry, goal) + }, + refresh(sessionID) { + const entry = entries.get(sessionID) + if (!entry) return Promise.resolve() + return loadInto(entry) + }, + } +} diff --git a/ocm-cli/src/manager-api.ts b/ocm-cli/src/manager-api.ts index 806b46af0..5a54b9e99 100644 --- a/ocm-cli/src/manager-api.ts +++ b/ocm-cli/src/manager-api.ts @@ -3,8 +3,15 @@ import { Readable } from 'stream' import { MirrorTargetEnsureResponseSchema, MirrorTargetPlanResponseSchema, + MultiRunSchema, + SessionGoalSchema, + type FuseMultiRunRequest, + type LaunchMultiRunRequest, type MirrorTargetEnsureResponse, type MirrorTargetPlanResponse, + type MultiRun, + type SessionGoal, + type StartSessionGoalRequest, } from '@opencode-manager/shared/schemas' export interface MirrorBeginOpts { @@ -75,23 +82,30 @@ export class ManagerApiError extends Error { public readonly status: number, public readonly code: string | null, public readonly operation: string, + public readonly details: unknown = null, ) { super(message) this.name = 'ManagerApiError' } } +export function isManagerRouteMissing(error: unknown): boolean { + return error instanceof ManagerApiError && error.status === 404 && error.code === null +} + async function formatErrorResponse(res: Response, operation: string): Promise { const text = await res.text().catch(() => '') let code: string | null = null let detail = text + let details: unknown = null if (text) { try { - const parsed = JSON.parse(text) as { error?: unknown; message?: unknown } + const parsed = JSON.parse(text) as { error?: unknown; message?: unknown; details?: unknown } const errField = typeof parsed.error === 'string' ? parsed.error : null const msgField = typeof parsed.message === 'string' ? parsed.message : null code = errField detail = msgField ?? errField ?? text + details = parsed.details ?? null } catch { /* not JSON, keep raw text */ } @@ -99,7 +113,7 @@ async function formatErrorResponse(res: Response, operation: string): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals?sessionId=${encodeURIComponent(sessionId)}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'read session goal') + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.nullable().parse(body.goal) + } + + async startSessionGoal(input: StartSessionGoalRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'start session goal') + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.parse(body.goal) + } + + private async runSessionGoalAction(id: number, action: 'pause' | 'resume' | 'cancel'): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/session-goals/${encodeURIComponent(id)}/${action}`, { + method: 'POST', + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, `${action} session goal`) + const body = (await res.json()) as { goal: unknown } + return SessionGoalSchema.parse(body.goal) + } + + pauseSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'pause') + } + + resumeSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'resume') + } + + cancelSessionGoal(id: number): Promise { + return this.runSessionGoalAction(id, 'cancel') + } + + async listMultiRuns(repoId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs?repoId=${encodeURIComponent(repoId)}`, { + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'list multi-runs') + const body = (await res.json()) as { runs: unknown } + return MultiRunSchema.array().parse(body.runs) + } + + async launchMultiRun(request: LaunchMultiRunRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'launch multi-run') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } + + async fuseMultiRun(runId: number, request: FuseMultiRunRequest): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs/${encodeURIComponent(runId)}/fusions`, { + method: 'POST', + headers: { ...this.headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'fuse multi-run') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } + + async discardMultiRunEntry(runId: number, entryId: number): Promise { + const res = await fetch(`${this.baseUrl}/api/internal/multi-runs/${encodeURIComponent(runId)}/entries/${encodeURIComponent(entryId)}/discard`, { + method: 'POST', + headers: this.headers(), + }) + + if (!res.ok) throw await formatErrorResponse(res, 'discard multi-run entry') + const body = (await res.json()) as { run: unknown } + return MultiRunSchema.parse(body.run) + } } diff --git a/ocm-cli/src/manager-auth.ts b/ocm-cli/src/manager-auth.ts new file mode 100644 index 000000000..687d77982 --- /dev/null +++ b/ocm-cli/src/manager-auth.ts @@ -0,0 +1,37 @@ +import { readState } from './state.js' +import { getToken } from './internal-token-store.js' +import { TokenStoreError } from './token-store.js' + +export type ManagerAuth = + | { ok: true; managerUrl: string; token: string } + | { ok: false; message: string } + +export type ManagerAuthOk = Extract + +/** Canonical Manager URL form used as the token-store account: trimmed, without trailing slashes. */ +export function normalizeManagerUrl(url: string): string { + return url.trim().replace(/\/+$/, '') +} + +export async function resolveManagerAuth( + rawManagerUrl: string | undefined = readState()?.managerUrl, +): Promise { + const managerUrl = rawManagerUrl ? normalizeManagerUrl(rawManagerUrl) : '' + if (!managerUrl) { + return { ok: false, message: 'No manager configured. Run `ocm login ` first.' } + } + + let token: string | null + try { + token = await getToken(managerUrl) + } catch (err) { + const reason = err instanceof TokenStoreError ? err.message : String(err) + return { ok: false, message: `Token store unavailable: ${reason}` } + } + + if (!token) { + return { ok: false, message: `No token stored. Run \`ocm login ${managerUrl}\`.` } + } + + return { ok: true, managerUrl, token } +} diff --git a/ocm-cli/src/remote-context.ts b/ocm-cli/src/remote-context.ts index f483f33de..97c61fa64 100644 --- a/ocm-cli/src/remote-context.ts +++ b/ocm-cli/src/remote-context.ts @@ -1,15 +1,19 @@ export const REMOTE_MANAGER_URL_ENV = 'OCM_REMOTE_MANAGER_URL' export const REMOTE_REPO_NAME_ENV = 'OCM_REMOTE_REPO_NAME' +export const REMOTE_REPO_ID_ENV = 'OCM_REMOTE_REPO_ID' export type RemoteContext = { + managerUrl: string managerHost: string repoName?: string + repoId?: number } -export function buildRemoteAttachEnv(managerUrl: string, repoName: string): Record { +export function buildRemoteAttachEnv(managerUrl: string, repoName: string, repoId: number): Record { return { [REMOTE_MANAGER_URL_ENV]: managerUrl, [REMOTE_REPO_NAME_ENV]: repoName, + [REMOTE_REPO_ID_ENV]: String(repoId), } } @@ -17,14 +21,19 @@ export function readRemoteContext(env: NodeJS.ProcessEnv): RemoteContext | undef const urlValue = env[REMOTE_MANAGER_URL_ENV] if (!urlValue) return undefined + const managerUrl = urlValue.trim() + let managerHost: string try { - managerHost = new URL(urlValue).host + managerHost = new URL(managerUrl).host } catch { - managerHost = urlValue.trim() + managerHost = managerUrl } const repoName = env[REMOTE_REPO_NAME_ENV] || undefined - return { managerHost, repoName } -} \ No newline at end of file + const repoIdValue = Number(env[REMOTE_REPO_ID_ENV]) + const repoId = Number.isInteger(repoIdValue) && repoIdValue > 0 ? repoIdValue : undefined + + return { managerUrl, managerHost, repoName, repoId } +} diff --git a/ocm-cli/src/tui-dialogs.ts b/ocm-cli/src/tui-dialogs.ts index 723efced6..6ed62584e 100644 --- a/ocm-cli/src/tui-dialogs.ts +++ b/ocm-cli/src/tui-dialogs.ts @@ -1,4 +1,4 @@ -import type { Context } from '@opencode/plugin/tui/context' +import type { Context, DialogSelectOption } from '@opencode/plugin/tui/context' export function confirmDialog(context: Context, props: { title: string; message: string }): Promise { return context.ui.dialog.confirm(props).then((value) => value === true) @@ -7,7 +7,15 @@ export function confirmDialog(context: Context, props: { title: string; message: export function selectDialog( context: Context, title: string, - options: { title: string; description?: string; value: Value }[], + options: readonly DialogSelectOption[], ): Promise { return context.ui.dialog.select({ title, options }) } + +export function slashArgument(input: string | undefined, name: string): string { + const trimmed = (input ?? '').trim() + const prefix = `/${name}` + if (trimmed === prefix) return '' + if (trimmed.startsWith(`${prefix} `)) return trimmed.slice(prefix.length).trim() + return trimmed +} diff --git a/ocm-cli/src/tui-form.tsx b/ocm-cli/src/tui-form.tsx new file mode 100644 index 000000000..332cfd053 --- /dev/null +++ b/ocm-cli/src/tui-form.tsx @@ -0,0 +1,304 @@ +/** @jsxImportSource @opentui/solid */ +import { TextAttributes, type KeyEvent, type TextareaRenderable } from '@opentui/core' +import { useKeyboard } from '@opentui/solid' +import { createEffect, createMemo, createSignal, For, Show, type JSX } from 'solid-js' +import type { Context } from '@opencode/plugin/tui/context' +import { filterModelOptions } from './tui-multi-run.js' +import type { ModelOption } from './tui-multi-run.js' + +export type DialogTheme = ReturnType + +export type KeyHint = readonly [key: string, label: string] + +const PICKER_ROWS = 8 + +/** + * Row colors matching OpenCode's select dialogs: every text in the cursor row uses the + * primary-action focused color on the focused background; a chosen row away from the + * cursor uses the selected form-field color for its marker and title. + */ +export function rowColors(theme: DialogTheme, state: { active: boolean; chosen?: boolean }) { + if (state.active) { + const text = theme.text.action.primary.focused + return { background: theme.background.action.primary.focused, title: text, detail: text, marker: text, bold: true } + } + return { + background: undefined, + title: state.chosen ? theme.text.formfield.selected : theme.text.base, + detail: theme.text.muted, + marker: state.chosen ? theme.text.formfield.selected : theme.text.muted, + bold: false, + } +} + +/** True for the dialog-wide submit chord. */ +export function isSubmitKey(event: KeyEvent): boolean { + return event.ctrl && event.name === 's' +} + +/** Tab / Shift+Tab focus cycling over `count` fields; returns the focused index accessor. */ +export function useFieldFocus(count: () => number, enabled: () => boolean = () => true) { + const [index, setIndex] = createSignal(0) + createEffect(() => { + if (index() >= count()) setIndex(Math.max(0, count() - 1)) + }) + useKeyboard((event) => { + if (!enabled() || event.name !== 'tab') return + event.preventDefault() + setIndex((current) => (current + (event.shift ? count() - 1 : 1)) % count()) + }) + return [index, setIndex] as const +} + +export function DialogShell(props: { + theme: DialogTheme + title: string + subtitle?: string + hints: readonly KeyHint[] + error?: string | null + busy?: string | null + children: JSX.Element +}) { + return ( + + + + + {props.title} + + + {(subtitle) => ( + + {subtitle()} + + )} + + + + esc + + + {props.children} + {(message) => {message()}} + {(message) => {message()}} + + + {(hint) => ( + + {hint[0]} {hint[1]} + + )} + + + + ) +} + +export function FieldLabel(props: { theme: DialogTheme; label: string; focused: boolean; detail?: string }) { + return ( + + {props.label} + {(detail) => {detail()}} + + ) +} + +export function TextField(props: { + theme: DialogTheme + label: string + initialValue: string + placeholder?: string + focused: boolean + onInput: (value: string) => void + onSubmit?: () => void +}) { + const initial = props.initialValue + return ( + + + props.onSubmit?.()} + /> + + ) +} + +export function TextAreaField(props: { + theme: DialogTheme + label: string + initialValue: string + placeholder?: string + focused: boolean + height: number + onInput: (value: string) => void +}) { + const initial = props.initialValue + let textarea: TextareaRenderable | undefined + return ( + + +