diff --git a/backend/package.json b/backend/package.json index 45562a3a..a7cb5f36 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,7 @@ "build": "bun build src/index.ts --outdir=dist --target=bun", "typecheck": "tsc --noEmit", "test": "pnpm run test:bun && pnpm run test:vitest", - "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts src/db/model-state.test.ts src/routes/providers.test.ts", + "test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts", "test:vitest": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest --watch", diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 9dbb2fde..7663b3e2 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -7,6 +7,7 @@ import { createScheduleRoutes } from '../schedules' import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware' import { createInternalNotificationRoutes } from './notifications' import { createInternalSettingsRoutes } from './settings' +import { createInternalRepoRoutes } from './repos' export function createInternalRoutes( db: Database, @@ -20,6 +21,7 @@ export function createInternalRoutes( app.route('/notifications', createInternalNotificationRoutes(notificationService)) app.route('/settings', createInternalSettingsRoutes(settingsService)) const repos = new Hono() + repos.route('/', createInternalRepoRoutes(db, settingsService)) repos.route('/:id/schedules', createScheduleRoutes(scheduleService)) app.route('/repos', repos) return app diff --git a/backend/src/routes/internal/repos.ts b/backend/src/routes/internal/repos.ts new file mode 100644 index 00000000..ed0de0b4 --- /dev/null +++ b/backend/src/routes/internal/repos.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import type { SettingsService } from '../../services/settings' +import { listRepos } from '../../db/queries' +import { logger } from '../../utils/logger' +import { getErrorMessage } from '../../utils/error-utils' + +export function createInternalRepoRoutes(db: Database, settingsService: SettingsService) { + const app = new Hono() + + app.get('/', (c) => { + try { + const settings = settingsService.getSettings() + const repos = listRepos(db, settings.preferences.repoOrder) + return c.json({ repos }) + } catch (error) { + logger.error('Failed to list internal repos:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) + + return app +} \ No newline at end of file diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 559eee52..43ea8278 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' +import type { Repo } from '@opencode-manager/shared/types' import { DiscoverReposRequestSchema, AssistantModeInitRequestSchema } from '@opencode-manager/shared/schemas' import { listRepos, getRepoById, updateLastAccessed, updateRepoConfigName } from '../db/queries' import * as repoService from '../services/repo' @@ -17,7 +18,7 @@ import { createRepoGitRoutes } from './repo-git' import { createScheduleRoutes } from './schedules' import type { GitAuthService } from '../services/git-auth' import { ScheduleService } from '../services/schedules' -import { ensureAssistantMode, getAssistantModeStatus } from '../services/assistant-mode' +import { ensureAssistantMode, getAssistantModeStatus, getAssistantModeDirectory } from '../services/assistant-mode' import path from 'path' async function restartOpenCode(openCodeSupervisor?: OpenCodeSupervisor): Promise { @@ -155,13 +156,36 @@ app.get('/', async (c) => { app.get('/:id', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + + let repo: Repo | null + let isAssistant = false + if (id === 0) { + isAssistant = true + repo = { + id: 0, + repoUrl: undefined, + localPath: 'assistant', + sourcePath: undefined, + fullPath: getAssistantModeDirectory(), + branch: undefined, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + lastPulled: undefined, + lastAccessedAt: undefined, + openCodeConfigName: undefined, + isWorktree: false, + isLocal: false, + } + } else { + repo = getRepoById(database, id) + } if (!repo) { return c.json({ error: 'Repo not found' }, 404) } - const currentBranch = await repoService.getCurrentBranch(repo, gitAuthService.getGitEnvironment()) + const currentBranch = isAssistant ? undefined : await repoService.getCurrentBranch(repo, gitAuthService.getGitEnvironment()) return c.json({ ...repo, currentBranch }) } catch (error: unknown) { @@ -397,7 +421,28 @@ app.get('/', async (c) => { app.get('/:id/assistant-mode', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + + let repo: Repo | null + if (id === 0) { + repo = { + id: 0, + repoUrl: undefined, + localPath: 'assistant', + sourcePath: undefined, + fullPath: '', + branch: undefined, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + lastPulled: undefined, + lastAccessedAt: undefined, + openCodeConfigName: undefined, + isWorktree: false, + isLocal: false, + } + } else { + repo = getRepoById(database, id) + } if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -414,7 +459,28 @@ app.get('/', async (c) => { app.post('/:id/assistant-mode', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = getRepoById(database, id) + + let repo: Repo | null + if (id === 0) { + repo = { + id: 0, + repoUrl: undefined, + localPath: 'assistant', + sourcePath: undefined, + fullPath: '', + branch: undefined, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + lastPulled: undefined, + lastAccessedAt: undefined, + openCodeConfigName: undefined, + isWorktree: false, + isLocal: false, + } + } else { + repo = getRepoById(database, id) + } if (!repo) { return c.json({ error: 'Repo not found' }, 404) diff --git a/backend/src/services/assistant-mode.ts b/backend/src/services/assistant-mode.ts index 4749ef30..b19d71b4 100644 --- a/backend/src/services/assistant-mode.ts +++ b/backend/src/services/assistant-mode.ts @@ -1,4 +1,5 @@ import path from 'path' +import { createHash } from 'node:crypto' import type { Repo } from '@opencode-manager/shared/types' import type { AssistantModeStatus, @@ -26,7 +27,11 @@ const ASSISTANT_SKILLS_DIR = 'skills' const ASSISTANT_SCHEDULES_SKILL_DIR = 'schedule-management' const ASSISTANT_NOTIFICATIONS_SKILL_DIR = 'notifications' const ASSISTANT_SETTINGS_SKILL_DIR = 'manager-settings' +const ASSISTANT_REPOS_SKILL_DIR = 'repo-management' const ASSISTANT_SKILL_FILENAME = 'SKILL.md' +const ASSISTANT_AGENTS_DIR = 'agents' +const ASSISTANT_DEFAULT_AGENT_NAME = 'assistant' +const ASSISTANT_DEFAULT_AGENT_FILENAME = `${ASSISTANT_DEFAULT_AGENT_NAME}.md` export function getAssistantModeDirectory(): string { const reposPath = getReposPath() @@ -57,7 +62,28 @@ function getSettingsSkillPath(assistantDir: string): string { return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SETTINGS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) } -export function buildAssistantAgentsMd(): string { +function getReposSkillPath(assistantDir: string): string { + return path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_REPOS_SKILL_DIR, ASSISTANT_SKILL_FILENAME) +} + +function getAssistantDefaultAgentPath(assistantDir: string): string { + return path.join( + assistantDir, + ASSISTANT_OPENCODE_DIR, + ASSISTANT_AGENTS_DIR, + ASSISTANT_DEFAULT_AGENT_FILENAME, + ) +} + +function hashContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +function hasSameContentHash(existingContent: string | undefined, generatedContent: string): boolean { + return existingContent !== undefined && hashContent(existingContent) === hashContent(generatedContent) +} + +function buildLegacyAssistantAgentsMd(): string { return `# Assistant Mode Instructions This folder is the shared Assistant mode workspace for OpenCode Manager. @@ -89,9 +115,13 @@ The agent MAY self-edit the following files within this workspace: 3. Maintain version control awareness 4. Document significant changes in commit messages +## Repo Management + +This workspace includes a skill at \`.opencode/skills/repo-management/SKILL.md\` for listing repos available to OpenCode Manager via the internal HTTP API. Load it before the schedule-management skill when you don't know the repo ID. + ## Schedule Management -This workspace ships a workspace-scoped skill at \`.opencode/skills/schedule-management/SKILL.md\` that documents how to list, create, update, delete, run, inspect, and cancel schedule jobs and runs across any repo via the internal HTTP API. Load it whenever the user asks about schedules. +This workspace ships with a workspace-scoped skill at \`.opencode/skills/schedule-management/SKILL.md\` that documents how to list, create, update, delete, run, inspect, and cancel schedule jobs and runs across any repo via the internal HTTP API. Load it whenever the user asks about schedules. ## Notifications @@ -103,6 +133,154 @@ This workspace includes a skill at \`.opencode/skills/manager-settings/SKILL.md\ ` } +function buildLegacyAssistantAgentPrompt(): string { + return [ + 'You are the default Assistant Mode agent for OpenCode Manager.', + '', + 'This workspace is the shared assistant workspace. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely.', + '', + 'Use the workspace skills when relevant:', + '- Load repo-management before schedule-management when you need a repo ID.', + '- Load schedule-management for schedule jobs and runs.', + '- Load notifications when the user should be notified about important events.', + '- Load manager-settings when reading or safely updating UI preferences.', + '', + 'Preserve user-customized workspace files unless the user explicitly asks you to change them.', + 'Ask before destructive operations or changes outside this assistant workspace.', + ].join('\n') +} + +function buildLegacyAssistantDefaultAgentMd(): string { + const prompt = buildLegacyAssistantAgentPrompt() + const permission = buildAssistantAgentPermission() + + return `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: ${permission.read} + edit: ${permission.edit} + glob: ${permission.glob} + grep: ${permission.grep} + list: ${permission.list} + bash: ${permission.bash} + external_directory: ${permission.external_directory} +--- + +${prompt} +` +} + +function matchesGeneratedAssistantAgentsMd(content: string): boolean { + const currentHash = hashContent(buildAssistantAgentsMd()) + const legacyHash = hashContent(buildLegacyAssistantAgentsMd()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function matchesGeneratedAssistantDefaultAgentMd(content: string): boolean { + const currentHash = hashContent(buildAssistantDefaultAgentMd()) + const legacyHash = hashContent(buildLegacyAssistantDefaultAgentMd()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function matchesGeneratedAssistantAgentPrompt(content: unknown): content is string { + if (typeof content !== 'string') return false + const currentHash = hashContent(buildAssistantAgentPrompt()) + const legacyHash = hashContent(buildLegacyAssistantAgentPrompt()) + const contentHash = hashContent(content) + return contentHash === currentHash || contentHash === legacyHash +} + +function containsLegacyAssistantAgentsGuidance(content: string): boolean { + return content.includes('## Self-Editing Rules') && + content.includes('AGENTS.md') && + content.includes('durable preferences') +} + +export function buildAssistantAgentsMd(): string { + return `# Assistant Mode Workspace + +This directory is the shared Assistant Mode workspace for OpenCode Manager. + +## Directory Contents + +- \`opencode.json\` configures this workspace and selects the default assistant agent. +- \`.opencode/agents/assistant.md\` contains the default assistant agent instructions, behavior, durable preferences, and self-editing rules. +- \`.opencode/skills/\` contains managed workspace skills for repos, schedules, notifications, and settings. +- \`.opencode/internal-token\` is managed by OpenCode Manager for internal API authentication. + +Assistant-specific instructions belong in \`.opencode/agents/assistant.md\`. +` +} + +function buildAssistantAgentPrompt(): string { + return [ + 'You are the default Assistant Mode agent for OpenCode Manager.', + '', + 'This workspace is the shared assistant workspace for OpenCode Manager. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely.', + '', + '## Self-Editing Rules', + '', + 'Durable assistant instructions, behavior, and preferences belong in `.opencode/agents/assistant.md`. Edit that file when the user expresses lasting preferences or when you need to refine your behavior.', + '', + 'The workspace directory explanation belongs in `AGENTS.md`. Keep that file focused on describing the directory contents and pointing to managed files.', + '', + 'Preserve user-customized workspace files unless the user explicitly asks you to change them. Ask before making significant, destructive, or out-of-workspace changes.', + '', + '## Skill Usage', + '', + 'Use the workspace skills when relevant:', + '- Load `repo-management` before `schedule-management` when you need a repo ID.', + '- Load `schedule-management` for schedule jobs and runs.', + '- Load `notifications` when the user should be notified about important events.', + '- Load `manager-settings` when reading or safely updating UI preferences.', + ].join('\n') +} + +function buildAssistantAgentPermission(): { read: 'allow'; edit: 'allow'; glob: 'allow'; grep: 'allow'; list: 'allow'; bash: 'allow'; external_directory: 'ask' } { + return { + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + } +} + +export function buildAssistantDefaultAgentConfig() { + return { + description: 'Default OpenCode Manager assistant workspace agent', + mode: 'primary', + prompt: buildAssistantAgentPrompt(), + permission: buildAssistantAgentPermission(), + } +} + +export function buildAssistantDefaultAgentMd(): string { + const prompt = buildAssistantAgentPrompt() + const permission = buildAssistantAgentPermission() + + return `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: ${permission.read} + edit: ${permission.edit} + glob: ${permission.glob} + grep: ${permission.grep} + list: ${permission.list} + bash: ${permission.bash} + external_directory: ${permission.external_directory} +--- + +${prompt} +` +} + function toLocalhostInternalBaseUrl(baseUrl: string): string { const url = new URL(baseUrl) url.protocol = 'http' @@ -412,19 +590,82 @@ Returns the updated settings object with the same structure as GET. ` } +export function buildReposSkill(baseUrl: string): string { + const internalBaseUrl = toLocalhostInternalBaseUrl(baseUrl) + + return `--- +name: repo-management +description: List repos available to OpenCode Manager via the internal HTTP API +--- + +## When to Load + +Load this skill when you need to discover repos, look up repo IDs, or need to reference repo information before managing schedules. Load it before the schedule-management skill if you don't know the repo ID. + +## Authentication + +All API calls require a bearer token. Read the token from \`.opencode/internal-token\` (relative to the assistant workspace cwd) and pass it as: + +\`\`\` +Authorization: Bearer +\`\`\` + +## Base URL + +\`${internalBaseUrl}\` + +## Endpoints + +### GET /repos + +List all repos available to OpenCode Manager. The repos are returned in the order configured by the user (respecting \`repoOrder\` preference). + +**Example:** +\`\`\`bash +curl -H "Authorization: Bearer " "${internalBaseUrl}/repos" +\`\`\` + +**Response:** +\`\`\`ts +{ + repos: Array<{ + id: number // Use as :repoId in other endpoints + repoUrl?: string // Git remote URL if cloned + localPath: string // Relative path under repos root + fullPath: string // Absolute local path + sourcePath?: string // Source path for worktrees + branch?: string // Current branch (not always available) + defaultBranch: string + cloneStatus: 'cloning' | 'ready' | 'error' + clonedAt: number // Unix timestamp + lastPulled?: number + lastAccessedAt?: number + openCodeConfigName?: string + isWorktree?: boolean + isLocal?: boolean + }> +} +\`\`\` + +## Notes + +- Use \`id\` as \`:repoId\` in other API endpoints (e.g., \`/repos/:repoId/schedules\`) +- \`fullPath\` is the absolute local path - use it for file operations +- This endpoint is read-only - there are no POST/PUT/DELETE operations for repos +- \`currentBranch\` is not included in the response - it requires git operations to determine +- Repo order is controlled by the \`repoOrder\` preference in settings +` +} + export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput { const config: OpenCodeConfigInput = { + default_agent: ASSISTANT_DEFAULT_AGENT_NAME, instructions: [ 'AGENTS.md', ], - permission: { - read: 'allow', - edit: 'allow', - glob: 'allow', - grep: 'allow', - list: 'allow', - bash: 'allow', - external_directory: 'ask', + permission: buildAssistantAgentPermission(), + agent: { + [ASSISTANT_DEFAULT_AGENT_NAME]: buildAssistantDefaultAgentConfig(), }, } @@ -449,29 +690,85 @@ export async function ensureAssistantMode( const opencodeJsonPath = path.join(assistantDir, ASSISTANT_OPENCODE_CONFIG_FILENAME) const tokenPath = getInternalTokenPath(assistantDir) const skillPath = getSchedulesSkillPath(assistantDir) + const assistantAgentPath = getAssistantDefaultAgentPath(assistantDir) const agentsMdExists = await fileExists(agentsMdPath) const opencodeJsonExists = await fileExists(opencodeJsonPath) - const overwriteAgentsMd = options?.overwriteAgentsMd ?? false const overwriteOpenCodeConfig = options?.overwriteOpenCodeConfig ?? false - if (!agentsMdExists || overwriteAgentsMd) { - const content = buildAssistantAgentsMd() - await writeFileContent(agentsMdPath, content) + const overwriteAgentsMd = options?.overwriteAgentsMd ?? false + const agentsMdContent = buildAssistantAgentsMd() + const existingAgentsMdContent = agentsMdExists ? await readFileContent(agentsMdPath) : undefined + + const agentsMdShouldMigrate = + existingAgentsMdContent !== undefined && + matchesGeneratedAssistantAgentsMd(existingAgentsMdContent) && + !hasSameContentHash(existingAgentsMdContent, agentsMdContent) + + const agentsMdHasPreservedLegacyGuidance = + existingAgentsMdContent !== undefined && + !overwriteAgentsMd && + !matchesGeneratedAssistantAgentsMd(existingAgentsMdContent) && + containsLegacyAssistantAgentsGuidance(existingAgentsMdContent) + + const agentsMdCreated = + !agentsMdExists || + overwriteAgentsMd || + agentsMdShouldMigrate + + if (agentsMdCreated && !hasSameContentHash(existingAgentsMdContent, agentsMdContent)) { + await writeFileContent(agentsMdPath, agentsMdContent) } const hasLegacyOpenCodeConfig = opencodeJsonExists && await isLegacyAssistantOpenCodeConfig(opencodeJsonPath) + let opencodeJsonUpdated = false if (!opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig) { - const config = buildAssistantOpenCodeConfig() + const config = hasLegacyOpenCodeConfig && opencodeJsonExists + ? await (async () => { + try { + const existingContent = await readFileContent(opencodeJsonPath) + const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput + const mergedConfig = mergeAssistantOpenCodeConfig(existingConfig) + return assistantOpenCodeConfigPromptNeedsMigration(mergedConfig) + ? migrateGeneratedAssistantOpenCodePrompt(mergedConfig) + : mergedConfig + } catch { + return buildAssistantOpenCodeConfig() + } + })() + : buildAssistantOpenCodeConfig() await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) + opencodeJsonUpdated = true + } else if (opencodeJsonExists) { + try { + const existingContent = await readFileContent(opencodeJsonPath) + const existingConfig = JSON.parse(existingContent) as OpenCodeConfigInput + const repairedConfig = assistantOpenCodeConfigNeedsRepair(existingConfig) + ? mergeAssistantOpenCodeConfig(existingConfig) + : existingConfig + const updatedConfig = assistantOpenCodeConfigPromptNeedsMigration(repairedConfig) + ? migrateGeneratedAssistantOpenCodePrompt(repairedConfig) + : repairedConfig + + if (updatedConfig !== existingConfig) { + await writeFileContent(opencodeJsonPath, JSON.stringify(updatedConfig, null, 2)) + opencodeJsonUpdated = true + } + } catch { + const config = buildAssistantOpenCodeConfig() + await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2)) + opencodeJsonUpdated = true + } } await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_AGENTS_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SCHEDULES_SKILL_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_NOTIFICATIONS_SKILL_DIR)) await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_SETTINGS_SKILL_DIR)) + await ensureDirectoryExists(path.join(assistantDir, ASSISTANT_OPENCODE_DIR, ASSISTANT_SKILLS_DIR, ASSISTANT_REPOS_SKILL_DIR)) const token = getOrCreateInternalToken(deps.db) const existingTokenContent = await fileExists(tokenPath) ? await readFileContent(tokenPath) : undefined @@ -482,7 +779,7 @@ export async function ensureAssistantMode( const schedulesSkillContent = buildSchedulesSkill(deps.apiBaseUrl) const existingSchedulesSkillContent = await fileExists(skillPath) ? await readFileContent(skillPath) : undefined - const schedulesSkillCreated = !existingSchedulesSkillContent || existingSchedulesSkillContent !== schedulesSkillContent + const schedulesSkillCreated = !hasSameContentHash(existingSchedulesSkillContent, schedulesSkillContent) if (schedulesSkillCreated) { await writeFileContent(skillPath, schedulesSkillContent) } @@ -490,7 +787,7 @@ export async function ensureAssistantMode( const notificationsSkillPath = getNotificationsSkillPath(assistantDir) const notificationsSkillContent = buildNotificationsSkill(deps.apiBaseUrl) const existingNotificationsSkillContent = await fileExists(notificationsSkillPath) ? await readFileContent(notificationsSkillPath) : undefined - const notificationsSkillCreated = !existingNotificationsSkillContent || existingNotificationsSkillContent !== notificationsSkillContent + const notificationsSkillCreated = !hasSameContentHash(existingNotificationsSkillContent, notificationsSkillContent) if (notificationsSkillCreated) { await writeFileContent(notificationsSkillPath, notificationsSkillContent) } @@ -498,25 +795,62 @@ export async function ensureAssistantMode( const settingsSkillPath = getSettingsSkillPath(assistantDir) const settingsSkillContent = buildSettingsSkill(deps.apiBaseUrl) const existingSettingsSkillContent = await fileExists(settingsSkillPath) ? await readFileContent(settingsSkillPath) : undefined - const settingsSkillCreated = !existingSettingsSkillContent || existingSettingsSkillContent !== settingsSkillContent + const settingsSkillCreated = !hasSameContentHash(existingSettingsSkillContent, settingsSkillContent) if (settingsSkillCreated) { await writeFileContent(settingsSkillPath, settingsSkillContent) } + const reposSkillPath = getReposSkillPath(assistantDir) + const reposSkillContent = buildReposSkill(deps.apiBaseUrl) + const existingReposSkillContent = await fileExists(reposSkillPath) ? await readFileContent(reposSkillPath) : undefined + const reposSkillCreated = !hasSameContentHash(existingReposSkillContent, reposSkillContent) + if (reposSkillCreated) { + await writeFileContent(reposSkillPath, reposSkillContent) + } + + const assistantAgentExists = await fileExists(assistantAgentPath) + const assistantAgentContent = buildAssistantDefaultAgentMd() + const existingAssistantAgentContent = assistantAgentExists + ? await readFileContent(assistantAgentPath) + : undefined + + const assistantAgentShouldMigrate = + existingAssistantAgentContent !== undefined && + matchesGeneratedAssistantDefaultAgentMd(existingAssistantAgentContent) && + !hasSameContentHash(existingAssistantAgentContent, assistantAgentContent) + + const assistantAgentCreated = !assistantAgentExists || assistantAgentShouldMigrate + + if (assistantAgentCreated) { + await writeFileContent(assistantAgentPath, assistantAgentContent) + } + + const managedUpdatesApplied = agentsMdCreated || opencodeJsonUpdated || assistantAgentCreated + const warnings = managedUpdatesApplied && agentsMdHasPreservedLegacyGuidance + ? [ + { + code: 'assistant-agents-md-preserved', + path: agentsMdPath, + message: 'Some Assistant Mode instruction updates were not applied because AGENTS.md appears to contain customized legacy assistant instructions. To regenerate the default workspace explanation, manually delete AGENTS.md and initialize Assistant Mode again.', + }, + ] + : undefined + return { repoId: repo.id, directory: assistantDir, relativePath: ASSISTANT_MODE_RELATIVE_PATH, + warnings, files: { agentsMd: { path: agentsMdPath, exists: true, - created: !agentsMdExists || overwriteAgentsMd, + created: agentsMdCreated, }, opencodeJson: { path: opencodeJsonPath, exists: true, - created: !opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig, + created: opencodeJsonUpdated, }, }, internalToken: { @@ -535,6 +869,74 @@ export async function ensureAssistantMode( path: settingsSkillPath, created: settingsSkillCreated, }, + repoManagementSkill: { + path: reposSkillPath, + created: reposSkillCreated, + }, + defaultAgent: { + name: ASSISTANT_DEFAULT_AGENT_NAME, + path: assistantAgentPath, + exists: true, + created: assistantAgentCreated, + }, + } +} + +function assistantOpenCodeConfigNeedsRepair(config: OpenCodeConfigInput): boolean { + if (config.default_agent !== ASSISTANT_DEFAULT_AGENT_NAME) return true + if (!config.agent || typeof config.agent !== 'object') return true + const assistantAgent = config.agent[ASSISTANT_DEFAULT_AGENT_NAME] + if (!assistantAgent || typeof assistantAgent !== 'object') return true + const mode = (assistantAgent as { mode?: unknown }).mode + if (mode !== 'primary' && mode !== 'all') return true + if ((assistantAgent as { disable?: unknown }).disable === true) return true + return false +} + +function assistantOpenCodeConfigPromptNeedsMigration(config: OpenCodeConfigInput): boolean { + const prompt = (config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] as { prompt?: unknown } | undefined)?.prompt + return matchesGeneratedAssistantAgentPrompt(prompt) && prompt !== buildAssistantAgentPrompt() +} + +function migrateGeneratedAssistantOpenCodePrompt(config: OpenCodeConfigInput): OpenCodeConfigInput { + const existingAssistantAgent = config.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] + if (typeof existingAssistantAgent !== 'object' || existingAssistantAgent === null) return config + + return { + ...config, + agent: { + ...(config.agent ?? {}), + [ASSISTANT_DEFAULT_AGENT_NAME]: { + ...existingAssistantAgent, + prompt: buildAssistantAgentPrompt(), + }, + }, + } +} + +function mergeAssistantOpenCodeConfig(existing?: OpenCodeConfigInput): OpenCodeConfigInput { + const generated = buildAssistantOpenCodeConfig() + const existingAssistantAgent = existing?.agent?.[ASSISTANT_DEFAULT_AGENT_NAME] + const existingMode = (existingAssistantAgent as { mode?: 'primary' | 'all' | unknown } | undefined)?.mode + const validMode = existingMode === 'primary' || existingMode === 'all' ? existingMode : 'primary' + + const mergedAssistantAgent = { + ...generated.agent?.[ASSISTANT_DEFAULT_AGENT_NAME], + ...(typeof existingAssistantAgent === 'object' && existingAssistantAgent !== null ? existingAssistantAgent : {}), + mode: validMode, + disable: false, + } + + return { + ...generated, + ...existing, + default_agent: ASSISTANT_DEFAULT_AGENT_NAME, + instructions: existing?.instructions ?? generated.instructions, + permission: existing?.permission ?? generated.permission, + agent: { + ...(existing?.agent ?? {}), + [ASSISTANT_DEFAULT_AGENT_NAME]: mergedAssistantAgent, + }, } } @@ -560,13 +962,12 @@ export async function getAssistantModeStatus(repo: Repo): Promise { @@ -24,7 +24,7 @@ describe('atomic-json', () => { it('returns fallback when file contains invalid JSON and logs a warning', async () => { const filePath = join(tmpDir, 'invalid.json') - await Bun.write(filePath, '{ invalid json }') + await writeFile(filePath, '{ invalid json }', 'utf8') const fallback = { foo: 'bar' } const result = await readJsonSafe(filePath, fallback) expect(result).toEqual(fallback) @@ -33,7 +33,7 @@ describe('atomic-json', () => { it('returns parsed value when file contains valid JSON', async () => { const filePath = join(tmpDir, 'valid.json') const data = { foo: 'bar', nested: { value: 42 } } - await Bun.write(filePath, JSON.stringify(data)) + await writeFile(filePath, JSON.stringify(data), 'utf8') const result = await readJsonSafe(filePath, { fallback: true }) expect(result).toEqual(data) }) @@ -44,7 +44,7 @@ describe('atomic-json', () => { const filePath = join(tmpDir, 'output.json') const data = { test: 'value', number: 123 } await writeJsonAtomic(filePath, data) - const content = await Bun.file(filePath).text() + const content = await readFile(filePath, 'utf8') const parsed = JSON.parse(content) expect(parsed).toEqual(data) }) diff --git a/backend/test/routes/internal-repos.test.ts b/backend/test/routes/internal-repos.test.ts new file mode 100644 index 00000000..1d9ba2f7 --- /dev/null +++ b/backend/test/routes/internal-repos.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { Hono } from 'hono' +import { Database } from 'bun:sqlite' +import { createInternalRoutes } from '../../src/routes/internal' +import { ScheduleService } from '../../src/services/schedules' +import { NotificationService } from '../../src/services/notification' +import { SettingsService } from '../../src/services/settings' +import { createOpenCodeClient } 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' +import { createRepo } from '../../src/db/queries' +import type { CreateRepoInput } from '../../src/types/repo' + +describe('internal-repos routes', () => { + let db: Database + let scheduleService: ScheduleService + let notificationService: NotificationService + let settingsService: SettingsService + let app: Hono + let token: string + + beforeEach(() => { + db = new Database(':memory:') + migrate(db, allMigrations) + const openCodeClient = createOpenCodeClient() + scheduleService = new ScheduleService(db, openCodeClient) + notificationService = new NotificationService(db) + settingsService = new SettingsService(db) + app = new Hono() + app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService)) + token = getOrCreateInternalToken(db) + }) + + it('GET /api/internal/repos returns 401 without bearer token', async () => { + const res = await app.request('/api/internal/repos') + expect(res.status).toBe(401) + }) + + it('GET /api/internal/repos returns 200 with bearer token', async () => { + const res = await app.request('/api/internal/repos', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { repos: unknown[] } + expect(body).toHaveProperty('repos') + expect(Array.isArray(body.repos)).toBe(true) + }) + + it('GET /api/internal/repos returns repos in default order', async () => { + const repo1Input: CreateRepoInput = { + localPath: 'repo1', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + } + const repo2Input: CreateRepoInput = { + localPath: 'repo2', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + } + createRepo(db, repo1Input) + createRepo(db, repo2Input) + + const res = await app.request('/api/internal/repos', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { repos: Array<{ id: number; localPath: string }> } + expect(body.repos.length).toBe(2) + }) + + it('GET /api/internal/repos respects repoOrder preference', async () => { + const repo1Input: CreateRepoInput = { + localPath: 'repo1', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + } + const repo2Input: CreateRepoInput = { + localPath: 'repo2', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + } + const repo1 = createRepo(db, repo1Input) + const repo2 = createRepo(db, repo2Input) + + settingsService.updateSettings({ + repoOrder: [repo2.id, repo1.id], + }) + + const res = await app.request('/api/internal/repos', { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { repos: Array<{ id: number; localPath: string }> } + expect(body.repos.length).toBe(2) + expect(body.repos[0]?.id).toBe(repo2.id) + expect(body.repos[1]?.id).toBe(repo1.id) + }) + + it('GET /api/internal/repos/:id/schedules still works after adding repos route', async () => { + const repoInput: CreateRepoInput = { + localPath: 'test-repo', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + isLocal: true, + } + const repo = createRepo(db, repoInput) + + const res = await app.request(`/api/internal/repos/${repo.id}/schedules`, { + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { jobs: unknown[] } + expect(body).toHaveProperty('jobs') + expect(Array.isArray(body.jobs)).toBe(true) + }) +}) \ No newline at end of file diff --git a/backend/test/routes/sse.test.ts b/backend/test/routes/sse.test.ts index dd434859..e24fb891 100644 --- a/backend/test/routes/sse.test.ts +++ b/backend/test/routes/sse.test.ts @@ -11,36 +11,36 @@ describe('SSE Routes', () => { it('should fire heartbeats at correct interval', async () => { vi.useFakeTimers() - + const heartbeatSpy = vi.fn() const intervalId = setInterval(() => { heartbeatSpy() }, HEARTBEAT_INTERVAL_MS) - + await vi.advanceTimersByTimeAsync(0) + expect(heartbeatSpy).toHaveBeenCalledTimes(0) + + await vi.advanceTimersByTimeAsync(30000) expect(heartbeatSpy).toHaveBeenCalledTimes(1) - + await vi.advanceTimersByTimeAsync(30000) expect(heartbeatSpy).toHaveBeenCalledTimes(2) - - await vi.advanceTimersByTimeAsync(40000) - expect(heartbeatSpy).toHaveBeenCalledTimes(3) - + clearInterval(intervalId) vi.useRealTimers() }) it('should fire two heartbeats within 70 seconds', async () => { vi.useFakeTimers() - + const heartbeatSpy = vi.fn() const intervalId = setInterval(() => { heartbeatSpy() }, HEARTBEAT_INTERVAL_MS) - + await vi.advanceTimersByTimeAsync(70000) - expect(heartbeatSpy).toHaveBeenCalledTimes(3) - + expect(heartbeatSpy).toHaveBeenCalledTimes(2) + clearInterval(intervalId) vi.useRealTimers() }) diff --git a/backend/test/services/assistant-mode.test.ts b/backend/test/services/assistant-mode.test.ts index 064bd16d..3b26365f 100644 --- a/backend/test/services/assistant-mode.test.ts +++ b/backend/test/services/assistant-mode.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, beforeEach, afterEach } from 'bun:test' import path from 'path' -import { readFile, stat } from 'fs/promises' +import { readFile, stat, writeFile } from 'fs/promises' import { Hono } from 'hono' -import { ensureAssistantMode, buildSchedulesSkill } from '../../src/services/assistant-mode' +import { ensureAssistantMode, getAssistantModeStatus, buildSchedulesSkill, buildReposSkill, buildAssistantDefaultAgentMd, buildAssistantOpenCodeConfig } from '../../src/services/assistant-mode' import { createTempAssistantWorkspace, createTestDb, mockRepo } from '../helpers/assistant-workspace' import { createInternalRoutes } from '../../src/routes/internal' import { ScheduleService } from '../../src/services/schedules' @@ -19,6 +19,63 @@ describe('buildSchedulesSkill', () => { }) }) +describe('buildReposSkill', () => { + it('uses ENV.SERVER.PORT in the internal base URL', () => { + const skill = buildReposSkill('https://example.com:443/api/internal') + expect(skill).toContain(`http://localhost:${ENV.SERVER.PORT}/api/internal`) + expect(skill).not.toContain(':443') + }) + + it('contains GET /repos endpoint documentation', () => { + const skill = buildReposSkill('http://localhost:5003/api/internal') + expect(skill).toContain('GET /repos') + }) + + it('contains Authorization Bearer header documentation', () => { + const skill = buildReposSkill('http://localhost:5003/api/internal') + expect(skill).toContain('Authorization: Bearer') + expect(skill).toContain('.opencode/internal-token') + }) + + it('contains internal localhost URL', () => { + const localApiBaseUrl = 'http://localhost:5003/api/internal' + const skill = buildReposSkill('http://localhost:5003/api/internal') + expect(skill).toContain(localApiBaseUrl) + }) +}) + +describe('buildAssistantDefaultAgentMd', () => { + it('contains description and mode in frontmatter', () => { + const content = buildAssistantDefaultAgentMd() + expect(content).toContain('description: Default OpenCode Manager assistant workspace agent') + expect(content).toContain('mode: primary') + }) + + it('references workspace skills', () => { + const content = buildAssistantDefaultAgentMd() + expect(content).toContain('repo-management') + expect(content).toContain('schedule-management') + expect(content).toContain('notifications') + expect(content).toContain('manager-settings') + }) + + it('does not contain v file', () => { + const content = buildAssistantDefaultAgentMd() + expect(content).not.toContain('v file') + }) +}) + +describe('buildAssistantOpenCodeConfig', () => { + it('includes default_agent and agent.assistant with primary mode', () => { + const config = buildAssistantOpenCodeConfig() + expect(config.default_agent).toBe('assistant') + expect(config.agent?.assistant?.mode).toBe('primary') + expect(config.agent?.assistant?.prompt).toContain('default Assistant Mode agent') + expect(config.agent?.assistant?.permission?.read).toBe('allow') + expect(config.agent?.assistant?.permission?.external_directory).toBe('ask') + }) +}) + describe('ensureAssistantMode', () => { let ws: Awaited> let db: ReturnType @@ -37,13 +94,27 @@ describe('ensureAssistantMode', () => { const opencodeJson = await readFile(path.join(ws.assistantDir, 'opencode.json'), 'utf8') const token = await readFile(path.join(ws.assistantDir, '.opencode/internal-token'), 'utf8') const skill = await readFile(path.join(ws.assistantDir, '.opencode/skills/schedule-management/SKILL.md'), 'utf8') + const repoSkill = await readFile(path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md'), 'utf8') + const assistantAgent = await readFile(path.join(ws.assistantDir, '.opencode/agents/assistant.md'), 'utf8') - expect(agentsMd).toContain('schedule-management') - expect(JSON.parse(opencodeJson)).not.toHaveProperty('mcp') + expect(agentsMd).toContain('.opencode/agents/assistant.md') + expect(agentsMd).not.toContain('Self-Editing Rules') + const parsedConfig = JSON.parse(opencodeJson) + expect(parsedConfig.default_agent).toBe('assistant') + expect(parsedConfig).not.toHaveProperty('mcp') + expect(parsedConfig.agent?.assistant?.mode).toBe('primary') + expect(parsedConfig.agent?.assistant?.prompt).toContain('default Assistant Mode agent') expect(token).toMatch(/^[0-9a-f]{64}$/) expect(skill).toContain('Authorization: Bearer') expect(skill).toContain(localApiBaseUrl) expect(skill).not.toContain(apiBaseUrl) + expect(repoSkill).toContain('GET /repos') + expect(repoSkill).toContain('Authorization: Bearer') + expect(repoSkill).toContain('.opencode/internal-token') + expect(repoSkill).toContain(localApiBaseUrl) + expect(assistantAgent).toContain('mode: primary') + expect(assistantAgent).toContain('Default OpenCode Manager assistant workspace agent') + expect(assistantAgent).not.toContain('v file') }) it('does not rewrite the token file on a second run with the same db', async () => { @@ -62,6 +133,394 @@ describe('ensureAssistantMode', () => { expect(secondStat.mtimeMs).toBe(firstStat.mtimeMs) expect(result.internalToken?.created).toBe(false) expect(result.schedulesSkill?.created).toBe(false) + expect(result.repoManagementSkill?.created).toBe(false) + }) + + it('writes all files needed before OpenCode assistant session launch', async () => { + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + const schedulesSkillPath = path.join(ws.assistantDir, '.opencode/skills/schedule-management/SKILL.md') + const notificationsSkillPath = path.join(ws.assistantDir, '.opencode/skills/notifications/SKILL.md') + const settingsSkillPath = path.join(ws.assistantDir, '.opencode/skills/manager-settings/SKILL.md') + const reposSkillPath = path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md') + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + + const opencodeJsonContent = await readFile(opencodeJsonPath, 'utf8') + const opencodeJson = JSON.parse(opencodeJsonContent) + + expect(opencodeJson.default_agent).toBe('assistant') + expect(opencodeJson.instructions).toEqual(['AGENTS.md']) + expect(opencodeJson.permission).toEqual({ + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }) + expect(opencodeJson.agent?.assistant?.description).toBe('Default OpenCode Manager assistant workspace agent') + expect(opencodeJson.agent?.assistant?.mode).toBe('primary') + expect(opencodeJson.agent?.assistant?.prompt).toContain('This workspace is the shared assistant workspace') + expect(opencodeJson.agent?.assistant?.prompt).toContain('Self-Editing') + expect(opencodeJson.agent?.assistant?.prompt).toContain('repo-management') + expect(opencodeJson.agent?.assistant?.prompt).toContain('schedule-management') + expect(opencodeJson.agent?.assistant?.prompt).toContain('notifications') + expect(opencodeJson.agent?.assistant?.prompt).toContain('manager-settings') + expect(opencodeJson.agent?.assistant?.permission).toEqual({ + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }) + + const agentsMdContent = await readFile(agentsMdPath, 'utf8') + expect(agentsMdContent).toContain('Assistant Mode Workspace') + expect(agentsMdContent).toContain('.opencode/agents/assistant.md') + expect(agentsMdContent).not.toContain('Self-Editing Rules') + expect(agentsMdContent).not.toContain('Schedule Management') + expect(agentsMdContent).not.toContain('Notifications') + expect(agentsMdContent).not.toContain('Settings Management') + expect(agentsMdContent).not.toContain('Repo Management') + + const schedulesSkillContent = await readFile(schedulesSkillPath, 'utf8') + expect(schedulesSkillContent).toContain('name: schedule-management') + expect(schedulesSkillContent).toContain('Manage schedule jobs') + + const notificationsSkillContent = await readFile(notificationsSkillPath, 'utf8') + expect(notificationsSkillContent).toContain('name: notifications') + expect(notificationsSkillContent).toContain('Send push notifications') + + const settingsSkillContent = await readFile(settingsSkillPath, 'utf8') + expect(settingsSkillContent).toContain('name: manager-settings') + expect(settingsSkillContent).toContain('Read and modify') + + const reposSkillContent = await readFile(reposSkillPath, 'utf8') + expect(reposSkillContent).toContain('name: repo-management') + expect(reposSkillContent).toContain('List repos available') + + const assistantAgentContent = await readFile(assistantAgentPath, 'utf8') + expect(assistantAgentContent).toContain('mode: primary') + expect(assistantAgentContent).toContain('Self-Editing') + expect(assistantAgentContent).toContain('repo-management') + expect(assistantAgentContent).toContain('schedule-management') + expect(assistantAgentContent).toContain('notifications') + expect(assistantAgentContent).toContain('manager-settings') + + expect(result.files.opencodeJson?.exists).toBe(true) + expect(result.files.agentsMd?.exists).toBe(true) + expect(result.repoManagementSkill?.path).toBe(reposSkillPath) + expect(result.repoManagementSkill?.created).toBe(true) + expect(result.defaultAgent?.name).toBe('assistant') + expect(result.defaultAgent?.path).toBe(assistantAgentPath) + expect(result.defaultAgent?.exists).toBe(true) + expect(result.defaultAgent?.created).toBe(true) + }) + + it('reports repo management skill status from getAssistantModeStatus', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const status = await getAssistantModeStatus(mockRepo) + + expect(status.repoManagementSkill?.path).toBe(path.join(ws.assistantDir, '.opencode/skills/repo-management/SKILL.md')) + expect(status.repoManagementSkill?.created).toBe(false) + }) + + it('preserves custom assistant agent content on subsequent ensureAssistantMode calls', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + + const customContent = '---\ndescription: Custom assistant\nmode: primary\n---\n\nCustom assistant instructions.' + await writeFile(assistantAgentPath, customContent) + + const result2 = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const preservedContent = await readFile(assistantAgentPath, 'utf8') + expect(preservedContent).toBe(customContent) + expect(result2.defaultAgent?.created).toBe(false) + }) + + it('repairs existing assistant opencode config missing configured assistant agent', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, JSON.stringify({ + model: 'provider/model', + instructions: ['AGENTS.md'], + default_agent: 'build', + agent: { + custom: { mode: 'primary', prompt: 'Custom agent' }, + }, + skills: { paths: ['.opencode/skills'] }, + }, null, 2)) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const repaired = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + + expect(repaired.default_agent).toBe('assistant') + expect(repaired.agent.assistant.mode).toBe('primary') + expect(repaired.agent.assistant.prompt).toContain('default Assistant Mode agent') + expect(repaired.agent.custom.prompt).toBe('Custom agent') + expect(repaired.model).toBe('provider/model') + expect(repaired.skills.paths).toEqual(['.opencode/skills']) + expect(result.files.opencodeJson?.created).toBe(true) + }) + + it('preserves custom assistant config while making it selectable', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + agent: { + assistant: { + mode: 'subagent', + prompt: 'Custom assistant prompt', + description: 'Custom assistant', + permission: { bash: 'ask' }, + }, + }, + }, null, 2)) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const repaired = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + + expect(repaired.agent.assistant.prompt).toBe('Custom assistant prompt') + expect(repaired.agent.assistant.description).toBe('Custom assistant') + expect(repaired.agent.assistant.permission.bash).toBe('ask') + expect(repaired.agent.assistant.mode).toBe('primary') + expect(repaired.agent.assistant.disable).toBe(false) + expect(result.files.opencodeJson?.created).toBe(true) + }) + + it('migrates generated legacy AGENTS.md and assistant.md to the new split', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const legacyAgentsMd = `# Assistant Mode Instructions + +This folder is the shared Assistant mode workspace for OpenCode Manager. + +## Purpose + +Assistant mode provides an isolated space for: +- Self-editing agent instructions and preferences +- Customized workflows specific to this assistant workspace +- Iterative improvement of assistant behavior + +## Self-Editing Rules + +The agent MAY self-edit the following files within this workspace: +- \`AGENTS.md\` - Assistant instructions, persona, and durable preferences +- \`opencode.json\` - OpenCode configuration for this workspace + +## Constraints + +- Changes outside this workspace require explicit user direction +- Self-edits should be concise and auditable +- Preserve user-customized content when modifying files +- Always ask for confirmation before making significant changes + +## Guidelines + +1. Keep instructions clear and actionable +2. Update AGENTS.md when learning durable preferences +3. Maintain version control awareness +4. Document significant changes in commit messages + +## Repo Management + +This workspace includes a skill at \`.opencode/skills/repo-management/SKILL.md\` for listing repos available to OpenCode Manager via the internal HTTP API. Load it before the schedule-management skill when you don't know the repo ID. + +## Schedule Management + +This workspace ships with a workspace-scoped skill at \`.opencode/skills/schedule-management/SKILL.md\` that documents how to list, create, update, delete, run, inspect, and cancel schedule jobs and runs across any repo via the internal HTTP API. Load it whenever the user asks about schedules. + +## Notifications + +This workspace includes a skill at \`.opencode/skills/notifications/SKILL.md\` for sending push notifications to the user's registered devices via the internal HTTP API. Load it when you need to notify the user about important events. + +## Settings Management + +This workspace includes a skill at \`.opencode/skills/manager-settings/SKILL.md\` for reading and safely modifying user preferences via the internal HTTP API. Load it when you need to inspect or update UI settings. +` + + const legacyAssistantAgent = `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: allow + edit: allow + glob: allow + grep: allow + list: allow + bash: allow + external_directory: ask +--- + +You are the default Assistant Mode agent for OpenCode Manager. + +This workspace is the shared assistant workspace. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely. + +Use the workspace skills when relevant: +- Load repo-management before schedule-management when you need a repo ID. +- Load schedule-management for schedule jobs and runs. +- Load notifications when the user should be notified about important events. +- Load manager-settings when reading or safely updating UI preferences. + +Preserve user-customized workspace files unless the user explicitly asks you to change them. +Ask before destructive operations or changes outside this assistant workspace. +` + + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + const opencodeJsonPath = path.join(ws.assistantDir, 'opencode.json') + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + const legacyAssistantPrompt = legacyAssistantAgent.split('---\n\n')[1]?.trimEnd() + + if (legacyAssistantPrompt === undefined) throw new Error('Legacy assistant prompt fixture is invalid') + + await writeFile(agentsMdPath, legacyAgentsMd) + await writeFile(assistantAgentPath, legacyAssistantAgent) + await writeFile(opencodeJsonPath, JSON.stringify({ + default_agent: 'assistant', + instructions: ['AGENTS.md'], + permission: { + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }, + agent: { + assistant: { + description: 'Default OpenCode Manager assistant workspace agent', + mode: 'primary', + prompt: legacyAssistantPrompt, + permission: { + read: 'allow', + edit: 'allow', + glob: 'allow', + grep: 'allow', + list: 'allow', + bash: 'allow', + external_directory: 'ask', + }, + }, + }, + }, null, 2)) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const updatedAgentsMd = await readFile(agentsMdPath, 'utf8') + const updatedAssistantAgent = await readFile(assistantAgentPath, 'utf8') + const updatedOpenCodeJson = JSON.parse(await readFile(opencodeJsonPath, 'utf8')) + + expect(updatedAgentsMd).toContain('Assistant Mode Workspace') + expect(updatedAgentsMd).toContain('.opencode/agents/assistant.md') + expect(updatedAgentsMd).not.toContain('Self-Editing Rules') + + expect(updatedAssistantAgent).toContain('Self-Editing') + expect(updatedAssistantAgent).toContain('repo-management') + expect(updatedAssistantAgent).toContain('schedule-management') + expect(updatedAssistantAgent).toContain('notifications') + expect(updatedAssistantAgent).toContain('manager-settings') + + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('Self-Editing') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('.opencode/agents/assistant.md') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('repo-management') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('schedule-management') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('notifications') + expect(updatedOpenCodeJson.agent.assistant.prompt).toContain('manager-settings') + expect(updatedOpenCodeJson.agent.assistant.prompt).not.toContain('Update AGENTS.md') + + expect(result.files.agentsMd?.created).toBe(true) + expect(result.files.opencodeJson?.created).toBe(true) + expect(result.defaultAgent?.created).toBe(true) + }) + + it('preserves custom AGENTS.md content on subsequent ensureAssistantMode calls', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + + const customContent = '# Custom Assistant Workspace\n\nThis is my custom AGENTS.md content.' + await writeFile(agentsMdPath, customContent) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const preservedContent = await readFile(agentsMdPath, 'utf8') + expect(preservedContent).toBe(customContent) + expect(result.files.agentsMd?.created).toBe(false) + }) + + it('warns when managed updates apply but customized legacy AGENTS.md is preserved', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + const assistantAgentPath = path.join(ws.assistantDir, '.opencode/agents/assistant.md') + + await writeFile(agentsMdPath, `# Assistant Mode Instructions + +This folder is the shared Assistant mode workspace for OpenCode Manager. + +## Self-Editing Rules + +The agent MAY self-edit the following files within this workspace: +- \`AGENTS.md\` - Assistant instructions, persona, and durable preferences +`) + await writeFile(assistantAgentPath, `--- +description: Default OpenCode Manager assistant workspace agent +mode: primary +permission: + read: allow + edit: allow + glob: allow + grep: allow + list: allow + bash: allow + external_directory: ask +--- + +You are the default Assistant Mode agent for OpenCode Manager. + +This workspace is the shared assistant workspace. Help the user manage repos, schedules, notifications, settings, and assistant behavior safely. + +Use the workspace skills when relevant: +- Load repo-management before schedule-management when you need a repo ID. +- Load schedule-management for schedule jobs and runs. +- Load notifications when the user should be notified about important events. +- Load manager-settings when reading or safely updating UI preferences. + +Preserve user-customized workspace files unless the user explicitly asks you to change them. +Ask before destructive operations or changes outside this assistant workspace. +`) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + + const preservedAgentsMd = await readFile(agentsMdPath, 'utf8') + expect(preservedAgentsMd).toContain('Self-Editing Rules') + expect(result.files.agentsMd?.created).toBe(false) + expect(result.defaultAgent?.created).toBe(true) + expect(result.warnings?.[0]?.code).toBe('assistant-agents-md-preserved') + expect(result.warnings?.[0]?.message).toContain('manually delete AGENTS.md') + }) + + it('overwrites custom AGENTS.md when overwriteAgentsMd is true', async () => { + await ensureAssistantMode(mockRepo, { db, apiBaseUrl }) + const agentsMdPath = path.join(ws.assistantDir, 'AGENTS.md') + + const customContent = '# Custom Assistant Workspace\n\nThis is my custom AGENTS.md content.' + await writeFile(agentsMdPath, customContent) + + const result = await ensureAssistantMode(mockRepo, { db, apiBaseUrl }, { overwriteAgentsMd: true }) + + const updatedContent = await readFile(agentsMdPath, 'utf8') + expect(updatedContent).toContain('Assistant Mode Workspace') + expect(updatedContent).toContain('.opencode/agents/assistant.md') + expect(updatedContent).not.toBe(customContent) + expect(result.files.agentsMd?.created).toBe(true) }) }) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 8089f65c..9189b0d7 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'test/routes/internal-schedules.test.ts', 'test/routes/internal-notifications.test.ts', 'test/routes/internal-settings.test.ts', + 'test/routes/internal-repos.test.ts', 'src/db/model-state.test.ts', 'src/routes/providers.test.ts', ], diff --git a/frontend/public/audio-worklet-processor.js b/frontend/public/audio-worklet-processor.js index 7a3c9631..0a0e584d 100644 --- a/frontend/public/audio-worklet-processor.js +++ b/frontend/public/audio-worklet-processor.js @@ -1,7 +1,13 @@ class RecorderProcessor extends AudioWorkletProcessor { - constructor() { + constructor(options) { super() + this._targetSampleRate = options?.processorOptions?.targetSampleRate ?? 16000 + this._inputSampleRate = sampleRate + this._ratio = this._inputSampleRate / this._targetSampleRate + this._fraction = 0 + this._lastSample = 0 this._active = true + this._buffer = [] this.port.onmessage = (e) => { if (e.data === 'stop') { this._active = false @@ -10,13 +16,57 @@ class RecorderProcessor extends AudioWorkletProcessor { } process(inputs) { - if (!this._active) return false - const input = inputs[0] - if (input && input[0] && input[0].length > 0) { - this.port.postMessage(new Float32Array(input[0])) + if (!this._active) { + if (this._buffer.length > 0) { + this._flushBuffer() + } + return false + } + + const input = inputs[0]?.[0] + if (!input || input.length === 0) { + return true + } + + const outputLength = Math.floor((input.length - this._fraction) / this._ratio) + for (let i = 0; i < outputLength; i++) { + const index = i * this._ratio + this._fraction + const sample = this._interpolate(input, index) + this._buffer.push(sample) + } + this._fraction = (outputLength * this._ratio) + this._fraction - input.length + this._lastSample = input[input.length - 1] + + if (this._buffer.length >= 1024) { + this._flushBuffer() } + return true } + + _interpolate(input, index) { + if (index < 0) { + const t = (index % 1) + 1 + return this._lastSample * (1 - t) + input[0] * t + } + const prevIndex = Math.floor(index) + const nextIndex = prevIndex + 1 + if (nextIndex >= input.length) { + return input[prevIndex] + } + const t = index - prevIndex + return input[prevIndex] * (1 - t) + input[nextIndex] * t + } + + _flushBuffer() { + const int16 = new Int16Array(this._buffer.length) + for (let i = 0; i < this._buffer.length; i++) { + const sample = Math.max(-1, Math.min(1, this._buffer[i])) + int16[i] = sample < 0 ? sample * 32768 : sample * 32767 + } + this.port.postMessage(int16, [int16.buffer]) + this._buffer = [] + } } registerProcessor('recorder-processor', RecorderProcessor) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c91c056..22d8894c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,13 +24,14 @@ import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { TTSProvider } from './contexts/TTSContext' import { AuthProvider } from './contexts/AuthContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' -import { SwipeNavigationProvider } from '@/contexts/SwipeNavigationContext' +import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' import { getSwipeBackTarget } from '@/lib/navigation' import { useAuth } from '@/hooks/useAuth' import { useServerHealth } from '@/hooks/useServerHealth' +import { useUIState } from '@/stores/uiStateStore' const queryClient = new QueryClient({ defaultOptions: { @@ -85,17 +86,19 @@ function AppShell() { const navigate = useNavigate() const location = useLocation() const rootRef = useRef(null) - const { open: openMobileSheet, openSheet } = useMobileTabBar() + const { openSheet } = useMobileTabBar() useTheme() + const swipeNav = useSwipeNavigation() + const getRouteSwipeBackTarget = useCallback( () => getSwipeBackTarget(location.pathname, location.search), [location.pathname, location.search] ) const canSwipeBack = useCallback( - () => getRouteSwipeBackTarget() !== null, - [getRouteSwipeBackTarget] + () => !swipeNav?.isSuspended() && getRouteSwipeBackTarget() !== null, + [swipeNav, getRouteSwipeBackTarget] ) const handleSwipeBack = useCallback(() => { @@ -117,8 +120,9 @@ function AppShell() { return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(location.pathname) && !openSheet } + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) const { bind: bindMoreSwipe } = useRightEdgeSwipe( - () => openMobileSheet('more'), + () => setMoreDrawerOpen(true), { enabled: canOpenMoreWithSwipe(), edgeWidth: 32, diff --git a/frontend/src/api/stt.test.ts b/frontend/src/api/stt.test.ts new file mode 100644 index 00000000..deb67b37 --- /dev/null +++ b/frontend/src/api/stt.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { sttApi } from './stt' + +describe('WAV extension selection logic', () => { + const originalFetch = global.fetch + const mockFetch = vi.fn() + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) + }) + + afterEach(() => { + vi.restoreAllMocks() + global.fetch = originalFetch + }) + + const createMockResponse = (ok = true, data = {}) => { + return new Response(JSON.stringify(data), { + status: ok ? 200 : 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + it('should return wav for audio/wav blob', async () => { + const blob = new Blob([], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should return webm for audio/webm blob', async () => { + const blob = new Blob([], { type: 'audio/webm' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.webm') + }) + + it('should return ogg for audio/ogg blob', async () => { + const blob = new Blob([], { type: 'audio/ogg' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.ogg') + }) + + it('should return m4a for audio/mp4 blob', async () => { + const blob = new Blob([], { type: 'audio/mp4' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.m4a') + }) + + it('should default to wav for unknown types', async () => { + const blob = new Blob([], { type: 'audio/unknown' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should prioritize wav over webm when both present', async () => { + const blob = new Blob([], { type: 'audio/wav;codecs=pcm' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'test-user') + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + const audioFile = formData.get('audio') as File + expect(audioFile.name).toBe('recording.wav') + }) + + it('should include userId in request URL', async () => { + const blob = new Blob([], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'test' })) + + await sttApi.transcribe(blob, 'custom-user') + + const callArgs = mockFetch.mock.calls[0] + const url = callArgs[0] as string + expect(url).toContain('userId=custom-user') + }) + + it('should send FormData with audio file', async () => { + const blob = new Blob(['audio data'], { type: 'audio/wav' }) + mockFetch.mockResolvedValueOnce(createMockResponse(true, { text: 'transcribed text' })) + + await sttApi.transcribe(blob, 'test-user') + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/stt/transcribe'), + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData), + }) + ) + + const callArgs = mockFetch.mock.calls[0] + const formData = callArgs[1]?.body as FormData + expect(formData.get('audio')).toBeInstanceOf(File) + }) +}) diff --git a/frontend/src/api/stt.ts b/frontend/src/api/stt.ts index 6725daaf..b068c4e1 100644 --- a/frontend/src/api/stt.ts +++ b/frontend/src/api/stt.ts @@ -42,9 +42,12 @@ export const sttApi = { ): Promise => { const formData = new FormData() - const extension = audioBlob.type.includes('webm') ? 'webm' : - audioBlob.type.includes('ogg') ? 'ogg' : - audioBlob.type.includes('mp4') ? 'm4a' : 'webm' + const type = audioBlob.type + const extension = + type.includes('wav') ? 'wav' : + type.includes('webm') ? 'webm' : + type.includes('ogg') ? 'ogg' : + type.includes('mp4') ? 'm4a' : 'wav' formData.append('audio', audioBlob, `recording.${extension}`) const urlObj = new URL(`${API_BASE_URL}/api/stt/transcribe`, window.location.origin) diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 0534d1fd..223fc0f4 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -177,7 +177,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par case 'subtask': { const label = part.description || part.prompt || 'Sub-agent task' return ( -
+
{label} sub-agent diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 3faeac4d..a64d4190 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -429,9 +429,13 @@ export const PromptInput = memo(forwardRef( setMentionRange(null) } - const handleAgentChange = (agent: string) => { - setLocalMode(agent) - setStoredAgent(sessionID, agent) + const handleAgentChange = (agentName: string) => { + setLocalMode(agentName) + setStoredAgent(sessionID, agentName) + const agent = agents.find(a => a.name === agentName) + if (agent?.model) { + setStoredModel({ providerID: agent.model.providerID, modelID: agent.model.modelID }) + } } const startVoiceRecording = async () => { diff --git a/frontend/src/components/message/SessionTodoDisplay.test.tsx b/frontend/src/components/message/SessionTodoDisplay.test.tsx new file mode 100644 index 00000000..94960211 --- /dev/null +++ b/frontend/src/components/message/SessionTodoDisplay.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SessionTodoDisplay } from './SessionTodoDisplay' +import { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from './SessionTodoDisplay' + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, +] + +describe('SessionTodoDisplay', () => { + beforeEach(() => { + useSessionTodos.setState({ todos: new Map() }) + }) + + it('renders collapsed by default', () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + expect(screen.queryByText('Add regression tests')).not.toBeInTheDocument() + }) + + it('expands to show a small scrollable task preview when clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + expect(screen.getByText('Verify completed item grouping')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('collapses again when expanded header is clicked', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + render() + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render when all tasks are completed', () => { + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('dismisses current todo signature and reappears when todo status changes', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + const { rerender } = render() + + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + + const dismissButton = screen.getByLabelText('Dismiss tasks') + await user.click(dismissButton) + + expect(screen.queryByText('Tasks: 1/3 complete')).not.toBeInTheDocument() + + const updatedTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'completed', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, + ] + useSessionTodos.getState().setTodos('session-1', updatedTodos) + + rerender() + + expect(screen.getByText('Tasks: 2/3 complete')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/SessionTodoDisplay.tsx b/frontend/src/components/message/SessionTodoDisplay.tsx index af320298..f8894133 100644 --- a/frontend/src/components/message/SessionTodoDisplay.tsx +++ b/frontend/src/components/message/SessionTodoDisplay.tsx @@ -15,7 +15,7 @@ const todoSignature = (todos: Todo[]) => export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) { const todos = useSessionTodosForSession(sessionID) - const [isCollapsed, setIsCollapsed] = useState(false) + const [isCollapsed, setIsCollapsed] = useState(true) const [isDismissed, setIsDismissed] = useState(false) const dismissedSignatureRef = useRef('') @@ -129,7 +129,7 @@ export function SessionTodoDisplay({ sessionID }: SessionTodoDisplayProps) {
-
+
{renderGroup('In Progress', inProgress)} {renderGroup('Pending', pending)} {renderGroup('Completed', completedTodos)} diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 0b2a7111..77ceae93 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react' import type { components } from '@/api/opencode-types' import { useSettings } from '@/hooks/useSettings' import { useUserBash } from '@/stores/userBashStore' +import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { usePermissions, useQuestions } from '@/contexts/EventContext' import { detectFileReferences } from '@/lib/fileReferences' import { ExternalLink, Loader2 } from 'lucide-react' @@ -68,6 +69,8 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() + const taskSessionId = part.tool === 'task' ? getTaskSessionId(part) : undefined + const taskSessionStatus = useSessionStatusForSession(taskSessionId) const { getForCallID: getPermissionForCallID } = usePermissions() const { getForCallID: getQuestionForCallID } = useQuestions() const outputRef = useRef(null) @@ -152,17 +155,36 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal const isFileTool = ['read', 'write', 'edit'].includes(part.tool) if (part.tool === 'task') { - const sessionId = getTaskSessionId(part) + const sessionId = taskSessionId const description = previewText || 'Sub-agent task' - const isRunning = part.state.status === 'running' || part.state.status === 'pending' + const status = part.state.status + + const isPending = status === 'pending' + const isRunning = status === 'running' && taskSessionStatus.type !== 'idle' + const isCompleted = status === 'completed' || (status === 'running' && !!sessionId && taskSessionStatus.type === 'idle') + const isError = status === 'error' + const content = (
- {isRunning ? ( - - ) : null} + {isPending && ( +
+ + + +
+ )} + {isRunning && ( +
+ + + +
+ )} + {isCompleted && ✓} + {isError && ✗} {description} - sub-agent - {sessionId && } + sub-agent + {sessionId && }
) @@ -170,7 +192,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal return ( -
- {(repoDisplayName || currentBranch) && ( -
- {repoDisplayName && ( - {repoDisplayName} - )} - - {currentBranch && ( - <> - - {currentBranch} - +
+
+
+ {versionLabel && ( + {versionLabel} )} -
- )} -
- - {isSessionDetail && ( -
- {commandsOpen && ( -
- {commands.map((command) => ( - - ))} -
- )} +
+ {(repoDisplayName || currentBranch) && ( +
+ {repoDisplayName && ( + {repoDisplayName} + )} + + {currentBranch && ( + <> + + {currentBranch} + + )} +
+ )} +
+ + {isSessionDetail && ( +
+ + {commandsOpen && ( +
+ {commands.map((command) => ( + + ))} +
+ )} + +
+ )} + {items.map((item) => ( -
- )} - {items.map((item) => ( - - ))} - + ))} + +
setMentionFileBrowserOpen(false)} diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 9e567877..4f07a35f 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -134,7 +134,7 @@ describe('RepoQuickSwitchSheet', () => { expect(handleClose).toHaveBeenCalled() }) - it('navigates directly to assistant when mobileTabAction is assistant', async () => { + it('navigates to assistant when mobileTabAction is assistant', async () => { vi.mocked(listRepos).mockResolvedValue([ { id: 1, @@ -172,11 +172,53 @@ describe('RepoQuickSwitchSheet', () => { fireEvent.click(screen.getByText('repo1')) - expect(screen.getByTestId('location')).toHaveTextContent('/repos/1/assistant') + expect(screen.getByTestId('location')).toHaveTextContent('/assistant') expect(handleClose).not.toHaveBeenCalled() }) - it('navigates from assistant to repo detail when clicking active repo', async () => { + it('navigates from assistant to repo detail when clicking repo on canonical assistant route', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('repo1')) + + expect(screen.getByTestId('location')).toHaveTextContent('/repos/1') + expect(handleClose).not.toHaveBeenCalled() + }) + + it('navigates from legacy assistant route to repo detail when clicking repo', async () => { vi.mocked(listRepos).mockResolvedValue([ { id: 1, @@ -218,6 +260,106 @@ describe('RepoQuickSwitchSheet', () => { expect(handleClose).not.toHaveBeenCalled() }) + it('does not mark any repo active on /assistant', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + repoUrl: 'https://github.com/test/repo2.git', + localPath: '/path/to/repo2', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.getByText('repo2')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { current: 'page' })).toBeNull() + }) + + it('does not mark any repo active on legacy /repos/1/assistant', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + repoUrl: 'https://github.com/test/repo2.git', + localPath: '/path/to/repo2', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + + + + + + + + } + /> + + + , + ) + + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.getByText('repo2')).toBeInTheDocument() + }) + + expect(screen.queryByRole('button', { current: 'page' })).toBeNull() + }) + it('switches repos from the mobile repos sheet without navigating back to the previous repo', async () => { vi.mocked(listRepos).mockResolvedValue([ { diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index d069fa0e..f12c7499 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -8,6 +8,7 @@ import { cn, getRepoDisplayName } from '@/lib/utils' import { listRepos } from '@/api/repos' import { AddRepoDialog } from '@/components/repo/AddRepoDialog' import { FolderGit2, Check, Plus } from 'lucide-react' +import { isAssistantPath, getAssistantPath } from '@/lib/navigation' interface RepoQuickSwitchSheetProps { isOpen: boolean @@ -20,10 +21,13 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const [searchQuery, setSearchQuery] = useState('') const [addRepoOpen, setAddRepoOpen] = useState(false) + const isAssistantRoute = useMemo(() => isAssistantPath(location.pathname), [location.pathname]) + const activeRepoId = useMemo(() => { + if (isAssistantRoute) return null const match = location.pathname.match(/^\/repos\/(\d+)/) return match ? Number(match[1]) : null - }, [location.pathname]) + }, [location.pathname, isAssistantRoute]) const { data: repos, isLoading } = useQuery({ queryKey: ['repos'], @@ -52,18 +56,18 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const handleClick = (id: number) => { const pendingAction = new URLSearchParams(location.search).get('mobileTabAction') + + if (isAssistantRoute) { + navigateAndClose(`/repos/${id}`, { replace: true }) + return + } + if (pendingAction === 'assistant') { - navigateAndClose(`/repos/${id}/assistant`) + navigateAndClose(getAssistantPath()) return } - const isAssistantRoute = location.pathname === `/repos/${id}/assistant` if (id === activeRepoId) { - if (isAssistantRoute) { - navigateAndClose(`/repos/${id}`, { replace: true }) - return - } - onClose() return } diff --git a/frontend/src/components/navigation/SessionMoreButton.tsx b/frontend/src/components/navigation/SessionMoreButton.tsx index 41a56b8f..9264ac41 100644 --- a/frontend/src/components/navigation/SessionMoreButton.tsx +++ b/frontend/src/components/navigation/SessionMoreButton.tsx @@ -1,15 +1,15 @@ import { MoreVertical } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useMobileTabBar } from '@/hooks/useMobileTabBar' +import { useUIState } from '@/stores/uiStateStore' export function SessionMoreButton() { - const { open } = useMobileTabBar() + const setMoreDrawerOpen = useUIState((state) => state.setMoreDrawerOpen) return ( - )}
))}
diff --git a/frontend/src/components/settings/SettingsDialog.tsx b/frontend/src/components/settings/SettingsDialog.tsx index 0243db8f..2a5b23e5 100644 --- a/frontend/src/components/settings/SettingsDialog.tsx +++ b/frontend/src/components/settings/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback } from 'react' import { GeneralSettings } from '@/components/settings/GeneralSettings' import { GitSettings } from '@/components/settings/GitSettings' import { KeyboardShortcuts } from '@/components/settings/KeyboardShortcuts' @@ -12,7 +12,6 @@ import { Dialog, DialogContent } from '@/components/ui/dialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Settings2, Keyboard, Code, ChevronLeft, Key, GitBranch, User, Volume2, Bell, X } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useSwipeBack } from '@/hooks/useMobile' import { useSettingsDialog } from '@/hooks/useSettingsDialog' type SettingsView = 'menu' | 'general' | 'git' | 'shortcuts' | 'opencode' | 'providers' | 'account' | 'voice' | 'notifications' @@ -21,7 +20,6 @@ export function SettingsDialog() { const { isOpen, close, activeTab, setActiveTab } = useSettingsDialog() const [mobileView, setMobileView] = useState('menu') const [sectionHistory, setSectionHistory] = useState([]) - const contentRef = useRef(null) const pushSectionHistory = useCallback((view: SettingsView) => { if (view === 'menu') return @@ -54,16 +52,6 @@ export function SettingsDialog() { setMobileView('menu') }, [mobileView, sectionHistory, close, setActiveTab]) - const { bind: bindSwipe, swipeStyles } = useSwipeBack(close, { - enabled: isOpen, - canBack: () => mobileView !== 'menu', - onBack: handleSettingsBack, - }) - - useEffect(() => { - return bindSwipe(contentRef.current) - }, [bindSwipe]) - useEffect(() => { if (!isOpen) { setMobileView('menu') @@ -105,13 +93,13 @@ export function SettingsDialog() { } return ( - - + !open && close()}> + mobileView !== 'menu'} + onSwipeBack={handleSettingsBack} + >

diff --git a/frontend/src/components/ui/bottom-sheet.test.tsx b/frontend/src/components/ui/bottom-sheet.test.tsx index e633daad..0e3ef98e 100644 --- a/frontend/src/components/ui/bottom-sheet.test.tsx +++ b/frontend/src/components/ui/bottom-sheet.test.tsx @@ -109,7 +109,7 @@ describe('BottomSheet', () => { expect(useSwipeDismiss).toHaveBeenCalledWith(expect.any(Function), { enabled: true, - threshold: 80, + threshold: 60, }) }) diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx index 5133bdd2..473fdd63 100644 --- a/frontend/src/components/ui/dialog.test.tsx +++ b/frontend/src/components/ui/dialog.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { Dialog, @@ -7,7 +7,30 @@ import { DialogTitle, } from "./dialog"; +function withMobileViewport(fn: () => void) { + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 375, + }) + fn() + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: originalWidth, + }) +} + describe("DialogContent", () => { + beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 375, + }) + }) + it("applies safe-area padding when fullscreen prop is true", () => { render( @@ -162,64 +185,225 @@ describe("DialogContent", () => { expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" }); }); - it('renders hidden close trigger when mobileSwipeToClose is enabled', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); - expect(closeTrigger).toBeInTheDocument(); + it('renders hidden close trigger by default on mobile', () => { + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); + expect(closeTrigger).toBeInTheDocument(); + }); + }); + + it('does not render hidden close trigger when mobileSwipeToClose is false', () => { + withMobileViewport(() => { + render( + + + + Swipe Dialog + + + + ); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]'); + expect(closeTrigger).not.toBeInTheDocument(); + }); }); it('closes dialog when hidden close trigger is activated', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - - const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; - expect(closeTrigger).toBeInTheDocument(); - - if (closeTrigger) { - closeTrigger.click(); + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + + const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; + expect(closeTrigger).toBeInTheDocument(); + + if (closeTrigger) { + closeTrigger.click(); + expect(onOpenChange).toHaveBeenCalledWith(false); + } + }); + }); + + it('calls onSwipeBack when canSwipeBack is true and swipe completes', () => { + withMobileViewport(() => { + const mockOnSwipeBack = vi.fn(); + const onOpenChange = vi.fn(); + render( + + true} + onSwipeBack={mockOnSwipeBack} + data-testid="swipe-dialog" + > + Content + + + ); + + const content = screen.getByTestId('swipe-dialog'); + + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 100, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchend', { + changedTouches: [{ clientX: 100, clientY: 100 }] as any, + })); + + expect(mockOnSwipeBack).toHaveBeenCalled(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + }); + + it('attempts close when canSwipeBack is false and swipe completes', () => { + withMobileViewport(() => { + const mockOnSwipeBack = vi.fn(); + const onOpenChange = vi.fn(); + render( + + false} + onSwipeBack={mockOnSwipeBack} + data-testid="swipe-dialog" + > + Content + + + ); + + const content = screen.getByTestId('swipe-dialog'); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 100, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchend', { + changedTouches: [{ clientX: 100, clientY: 100 }] as any, + })); + + expect(mockOnSwipeBack).not.toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); - } + }); + }); + + it('applies safe-area style and hidden trigger for mobileFullscreen', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + expect(content).toHaveStyle({ paddingTop: "env(safe-area-inset-top, 0px)" }); + expect(document.querySelector('[data-swipe-close-trigger]')).toBeInTheDocument(); + }); + }); + + it('does not apply transform styles to non-fullscreen dialogs', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + const style = content.getAttribute("style") || ""; + expect(style).not.toMatch(/transform/); + }); + }); + + it('applies swipe transform to fullscreen dialogs during touchmove', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 50, clientY: 100 }] as any, + })); + + const style = content.getAttribute("style") || ""; + expect(style).toMatch(/transform/); + }); + }); + + it('does not apply swipe transform to non-fullscreen dialogs during touchmove', () => { + withMobileViewport(() => { + render( + + + Content + + + ); + const content = screen.getByTestId("dialog-content"); + content.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })); + content.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 50, clientY: 100 }] as any, + })); + + const style = content.getAttribute("style") || ""; + expect(style).not.toMatch(/transform/); + }); }); it('binds swipe handler when mobileSwipeToClose and mobileFullscreen are enabled', () => { - const onOpenChange = vi.fn(); - render( - - - - Swipe Dialog - - - - ); - - const content = screen.getByTestId('swipe-dialog'); - const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; - - expect(content).toBeInTheDocument(); - expect(closeTrigger).toBeInTheDocument(); - - const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click'); - closeTrigger?.click(); - - expect(clickSpy).toHaveBeenCalled(); - expect(onOpenChange).toHaveBeenCalledWith(false); + withMobileViewport(() => { + const onOpenChange = vi.fn(); + render( + + + + Swipe Dialog + + + + ); + + const content = screen.getByTestId('swipe-dialog'); + const closeTrigger = document.querySelector('[data-swipe-close-trigger]') as HTMLButtonElement | null; + + expect(content).toBeInTheDocument(); + expect(closeTrigger).toBeInTheDocument(); + + const clickSpy = vi.spyOn(closeTrigger as HTMLButtonElement, 'click'); + closeTrigger?.click(); + + expect(clickSpy).toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); }); }); diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 2f0448b8..9c0b7340 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -34,6 +34,8 @@ interface DialogContentProps fullscreen?: boolean mobileFullscreen?: boolean mobileSwipeToClose?: boolean + canSwipeBack?: () => boolean + onSwipeBack?: () => void onOpenChange?: (open: boolean) => void overlayClassName?: string } @@ -41,14 +43,15 @@ interface DialogContentProps const DialogContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, overlayClassName, ...props }, ref) => { +>(({ className, children, hideCloseButton, fullscreen, mobileFullscreen, mobileSwipeToClose, canSwipeBack, onSwipeBack, overlayClassName, style, ...props }, ref) => { const isMobileFullscreenMode = fullscreen || mobileFullscreen + const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' ? window.innerWidth < 768 : false) + const shouldEnableMobileSwipe = mobileSwipeToClose !== false && isMobile + const shouldAnimateSwipe = shouldEnableMobileSwipe && isMobileFullscreenMode const swipeContainerRef = React.useRef(null) - const contentRef = React.useRef(null) const closeTriggerRef = React.useRef(null) const combinedRef = React.useCallback((node: HTMLDivElement | null) => { - contentRef.current = node swipeContainerRef.current = node if (typeof ref === 'function') { ref(node) @@ -57,25 +60,33 @@ const DialogContent = React.forwardRef< } }, [ref]) - const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' ? window.innerWidth < 768 : false) - React.useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) - const { bind: swipeBind } = useSwipeBack( + const { bind: swipeBind, swipeStyles } = useSwipeBack( () => closeTriggerRef.current?.click(), - { enabled: isMobileFullscreenMode && mobileSwipeToClose === true && isMobile } + { enabled: shouldEnableMobileSwipe, canBack: canSwipeBack, onBack: onSwipeBack } ) React.useEffect(() => { - if (isMobileFullscreenMode && mobileSwipeToClose && isMobile) { + if (shouldEnableMobileSwipe) { return swipeBind(swipeContainerRef.current) } return undefined - }, [isMobileFullscreenMode, mobileSwipeToClose, isMobile, swipeBind]) - + }, [shouldEnableMobileSwipe, swipeBind]) + + const baseStyle = isMobileFullscreenMode + ? { paddingTop: 'env(safe-area-inset-top, 0px)' } + : undefined + + const mergedStyle = { + ...baseStyle, + ...style, + ...(shouldAnimateSwipe ? swipeStyles : undefined), + } + return ( {!fullscreen && } @@ -92,13 +103,11 @@ const DialogContent = React.forwardRef< : "left-[50%] top-[50%] w-[90%] max-w-lg translate-x-[-50%] translate-y-[-50%] p-6 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className )} - style={isMobileFullscreenMode ? { - paddingTop: 'env(safe-area-inset-top, 0px)', - } : undefined} + style={Object.keys(mergedStyle).length > 0 ? mergedStyle : undefined} {...props} > {children} - {mobileSwipeToClose && isMobileFullscreenMode && ( + {shouldEnableMobileSwipe && ( )} {!hideCloseButton && !fullscreen && ( diff --git a/frontend/src/components/ui/page-header.test.tsx b/frontend/src/components/ui/page-header.test.tsx index fc8e7a2f..2ba0ca64 100644 --- a/frontend/src/components/ui/page-header.test.tsx +++ b/frontend/src/components/ui/page-header.test.tsx @@ -52,16 +52,9 @@ describe("PageHeader", () => { expect(header).toHaveClass("z-10"); }); - it("applies background styling", () => { + it("applies transparent background", () => { render(Content); const header = screen.getByTestId("header"); - expect(header).toHaveClass("bg-gradient-to-b"); - expect(header).toHaveClass("from-background"); - }); - - it("applies backdrop-blur-sm for frosted glass effect", () => { - render(Content); - const header = screen.getByTestId("header"); - expect(header).toHaveClass("backdrop-blur-sm"); + expect(header).toHaveClass("bg-transparent"); }); }); diff --git a/frontend/src/components/ui/side-drawer.tsx b/frontend/src/components/ui/side-drawer.tsx index cea8ac2f..f105d99d 100644 --- a/frontend/src/components/ui/side-drawer.tsx +++ b/frontend/src/components/ui/side-drawer.tsx @@ -69,7 +69,7 @@ export function SideDrawer({ />
{ expect(mocks.listMessages).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(5000) - - await waitFor(() => { - expect(mocks.listMessages).toHaveBeenCalledTimes(2) - }) + + expect(mocks.listMessages).toHaveBeenCalledTimes(2) vi.useRealTimers() }) diff --git a/frontend/src/hooks/__tests__/useWorkspace.test.tsx b/frontend/src/hooks/__tests__/useWorkspace.test.tsx new file mode 100644 index 00000000..702a85d7 --- /dev/null +++ b/frontend/src/hooks/__tests__/useWorkspace.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useWorkspace } from '../useWorkspace' +import type { AssistantModeStatus, Repo } from '@opencode-manager/shared/types' + +const mocks = vi.hoisted(() => ({ + getRepo: vi.fn(), + getAssistantModeStatus: vi.fn(), +})) + +vi.mock('@/api/repos', () => ({ + getRepo: mocks.getRepo, + getAssistantModeStatus: mocks.getAssistantModeStatus, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => + {children} +} + +describe('useWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('repoId === 0 (assistant)', () => { + it('returns assistant workspace with correct properties', async () => { + const mockStatus: AssistantModeStatus = { + repoId: 0, + directory: '/abs/assistant', + relativePath: 'repos/assistant', + files: { + agentsMd: { path: '', exists: false, created: false }, + opencodeJson: { path: '', exists: false, created: false }, + }, + schedulesSkill: { path: '', exists: false, created: false }, + } + + mocks.getAssistantModeStatus.mockResolvedValue(mockStatus) + + const { result } = renderHook(() => useWorkspace(0), { wrapper: createWrapper() }) + + await waitFor(() => { + expect(result.current.workspace).toBeDefined() + }) + + expect(result.current.workspace?.kind).toBe('assistant') + expect(result.current.workspace?.fullPath).toBe('/abs/assistant') + expect(result.current.workspace?.repoId).toBe(0) + expect(result.current.workspace?.backHref).toBe('/assistant') + expect(result.current.isLoading).toBe(false) + expect(result.current.isError).toBe(false) + }) + + it('does not call getRepo for assistant', async () => { + mocks.getAssistantModeStatus.mockResolvedValue({ + directory: '/abs/assistant', + relativePath: 'repos/assistant', + files: { agentsMd: { path: '', exists: false, created: false }, opencodeJson: { path: '', exists: false, created: false } }, + schedulesSkill: { path: '', exists: false, created: false }, + repoId: 0, + }) + + renderHook(() => useWorkspace(0), { wrapper: createWrapper() }) + + await vi.waitFor(() => { + expect(mocks.getRepo).not.toHaveBeenCalled() + }) + }) + }) + + describe('repoId === 5 (real repo)', () => { + it('returns repo workspace with correct properties', async () => { + const mockRepo: Repo = { + id: 5, + repoUrl: 'https://x/my-repo', + localPath: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + sourcePath: undefined, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + } + + mocks.getRepo.mockResolvedValue(mockRepo) + + const { result } = renderHook(() => useWorkspace(5), { wrapper: createWrapper() }) + + await waitFor(() => { + expect(result.current.workspace).toBeDefined() + }) + + expect(result.current.workspace?.kind).toBe('repo') + expect(result.current.workspace?.repoId).toBe(5) + expect(result.current.workspace?.fullPath).toBe('/abs/repos/my-repo') + expect(result.current.workspace?.backHref).toBe('/repos/5') + }) + + it('does not call getAssistantModeStatus for repo', async () => { + const mockRepo: Repo = { + id: 5, + repoUrl: 'https://x/my-repo', + localPath: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + sourcePath: undefined, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: 0, + } + + mocks.getRepo.mockResolvedValue(mockRepo) + + renderHook(() => useWorkspace(5), { wrapper: createWrapper() }) + + await vi.waitFor(() => { + expect(mocks.getAssistantModeStatus).not.toHaveBeenCalled() + }) + }) + }) + + describe('repoId === undefined', () => { + it('returns undefined workspace', () => { + const { result } = renderHook(() => useWorkspace(undefined), { wrapper: createWrapper() }) + + expect(result.current.workspace).toBeUndefined() + expect(result.current.isLoading).toBe(false) + }) + }) +}) diff --git a/frontend/src/hooks/useAssistantMode.ts b/frontend/src/hooks/useAssistantMode.ts index 02232626..fc03e062 100644 --- a/frontend/src/hooks/useAssistantMode.ts +++ b/frontend/src/hooks/useAssistantMode.ts @@ -5,21 +5,21 @@ import { } from '@/api/repos' import type { AssistantModeStatus, AssistantModeInitRequest } from '@opencode-manager/shared/types' -export function useAssistantMode(repoId: number) { +export function useAssistantMode(repoId?: number) { const queryClient = useQueryClient() const statusQuery = useQuery({ - queryKey: ['repo', repoId, 'assistant-mode'], - queryFn: () => getAssistantModeStatus(repoId), - enabled: !!repoId, + queryKey: ['assistant-mode'], + queryFn: () => getAssistantModeStatus(0), + enabled: repoId === 0, }) const initializeMutation = useMutation({ mutationFn: (options?: AssistantModeInitRequest) => - initializeAssistantMode(repoId, options), + initializeAssistantMode(0, options), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ['repo', repoId, 'assistant-mode'], + queryKey: ['assistant-mode'], }) }, }) diff --git a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx index f784faf6..fb4360b1 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.test.tsx +++ b/frontend/src/hooks/useAssistantSessionLauncher.test.tsx @@ -7,7 +7,7 @@ import { initializeAssistantMode } from '@/api/repos' const mocks = vi.hoisted(() => ({ listSessions: vi.fn(), createSession: vi.fn(), - sendPrompt: vi.fn(), + sendPromptAsync: vi.fn(), initializeAssistantMode: vi.fn(), })) @@ -19,10 +19,14 @@ vi.mock('@/api/opencode', () => ({ OpenCodeClient: vi.fn(() => ({ listSessions: mocks.listSessions, createSession: mocks.createSession, - sendPrompt: mocks.sendPrompt, + sendPromptAsync: mocks.sendPromptAsync, })), })) +beforeEach(() => { + mocks.sendPromptAsync.mockResolvedValue(undefined) +}) + describe('useAssistantSessionLauncher', () => { beforeEach(() => { vi.clearAllMocks() @@ -51,6 +55,45 @@ describe('useAssistantSessionLauncher', () => { expect(OpenCodeClient).toHaveBeenCalledWith('http://localhost:5551', '/assistant') expect(onNavigate).toHaveBeenCalledWith('newest') expect(mocks.createSession).not.toHaveBeenCalled() + expect(mocks.sendPromptAsync).not.toHaveBeenCalled() + }) + + it('notifies an existing assistant session when some generated updates were preserved', async () => { + mocks.initializeAssistantMode.mockResolvedValue({ + directory: '/assistant', + warnings: [ + { + code: 'assistant-agents-md-preserved', + path: '/assistant/AGENTS.md', + message: 'Some Assistant Mode instruction updates were not applied because AGENTS.md appears to contain customized legacy assistant instructions. To regenerate the default workspace explanation, manually delete AGENTS.md and initialize Assistant Mode again.', + }, + ], + }) + mocks.listSessions.mockResolvedValue([ + { id: 'existing', directory: '/assistant', time: { updated: 10 } }, + ]) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + + expect(onNavigate).toHaveBeenCalledWith('existing') + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('existing', { + parts: [ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('some generated instruction changes were not applied'), + }), + ], + }) + const promptText = mocks.sendPromptAsync.mock.calls[0][1].parts[0].text as string + expect(promptText).toContain('manually delete AGENTS.md') }) it('creates a session when the assistant directory has no root sessions', async () => { @@ -70,7 +113,49 @@ describe('useAssistantSessionLauncher', () => { }) expect(mocks.createSession).toHaveBeenCalledWith({ title: 'Assistant' }) - expect(mocks.sendPrompt).toHaveBeenCalledWith('created', { + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('created', { + parts: [ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Welcome to OpenCode Manager!'), + }), + ], + }) + expect(onNavigate).toHaveBeenCalledWith('created') + + const promptCall = mocks.sendPromptAsync.mock.calls[0] + const promptText = promptCall[1].parts[0].text as string + expect(promptText).toContain('.opencode/agents/assistant.md') + expect(promptText).toContain('AGENTS.md') + expect(promptText).toContain('.opencode/skills/') + expect(promptText).toContain('directory') + expect(promptText).toContain('durable preferences') + expect(promptText).toContain('self-editing rules') + expect(promptText).not.toContain('v file') + expect(promptText).not.toMatch(/AGENTS\.md contains workspace-level instructions, durable preferences, and self-editing rules/) + }) + + it('navigates after creating a session without waiting for the welcome prompt to complete', async () => { + mocks.listSessions.mockResolvedValue([]) + let resolvePrompt: () => void + const promptPromise = new Promise((resolve) => { + resolvePrompt = resolve + }) + mocks.createSession.mockResolvedValue({ id: 'created' }) + mocks.sendPromptAsync.mockImplementation(() => promptPromise) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + + expect(onNavigate).toHaveBeenCalledWith('created') + expect(mocks.sendPromptAsync).toHaveBeenCalledWith('created', { parts: [ expect.objectContaining({ type: 'text', @@ -78,6 +163,25 @@ describe('useAssistantSessionLauncher', () => { }), ], }) + resolvePrompt!() + }) + + it('navigates even when welcome prompt fails', async () => { + mocks.listSessions.mockResolvedValue([]) + mocks.createSession.mockResolvedValue({ id: 'created' }) + mocks.sendPromptAsync.mockRejectedValueOnce(new Error('provider unavailable')) + const onNavigate = vi.fn() + const { result } = renderHook(() => useAssistantSessionLauncher({ + repoId: 123, + opcodeUrl: 'http://localhost:5551', + onNavigate, + })) + + await act(async () => { + await result.current.openAssistant() + }) + expect(onNavigate).toHaveBeenCalledWith('created') + expect(mocks.sendPromptAsync).toHaveBeenCalled() }) }) diff --git a/frontend/src/hooks/useAssistantSessionLauncher.ts b/frontend/src/hooks/useAssistantSessionLauncher.ts index b3c22869..5ebc2619 100644 --- a/frontend/src/hooks/useAssistantSessionLauncher.ts +++ b/frontend/src/hooks/useAssistantSessionLauncher.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { initializeAssistantMode } from '@/api/repos' import { OpenCodeClient } from '@/api/opencode' +import type { AssistantModeStatus } from '@opencode-manager/shared/types' interface UseAssistantSessionLauncherOptions { repoId: number @@ -8,6 +9,66 @@ interface UseAssistantSessionLauncherOptions { onNavigate: (sessionId: string) => void } +const ASSISTANT_WELCOME_PROMPT = `Welcome to OpenCode Manager! I'm your assistant and I'm here to help you work with your code. + +To get started, let's set up your assistant: + +**1. Name your assistant** +What would you like to call me? This name will help personalize our interactions. + +**2. Review AGENTS.md** +AGENTS.md explains the assistant workspace directory and points to the files OpenCode Manager manages. + +**3. Review the assistant agent** +.opencode/agents/assistant.md contains the default Assistant Mode agent instructions, durable preferences, self-editing rules, and skill guidance. + +**4. Use workspace skills** +.opencode/skills/ contains managed workspace skills for repos, schedules, notifications, and settings. + +Take your time exploring and customizing these settings. Let me know when you're ready to start coding, or if you have any questions about getting set up!` + +function buildAssistantModeWarningsPrompt(assistant: AssistantModeStatus): string | undefined { + if (!assistant.warnings?.length) return undefined + + return [ + 'Assistant Mode was updated, but some generated instruction changes were not applied.', + '', + ...assistant.warnings.map((warning) => `- ${warning.message}`), + ].join('\n') +} + +function buildAssistantWelcomePrompt(assistant: AssistantModeStatus): string { + const warningsPrompt = buildAssistantModeWarningsPrompt(assistant) + return warningsPrompt + ? `${ASSISTANT_WELCOME_PROMPT}\n\n${warningsPrompt}` + : ASSISTANT_WELCOME_PROMPT +} + +async function sendAssistantWelcomePrompt(client: OpenCodeClient, sessionId: string, assistant: AssistantModeStatus): Promise { + await client.sendPromptAsync(sessionId, { + parts: [ + { + type: 'text', + text: buildAssistantWelcomePrompt(assistant), + }, + ], + }).catch(() => undefined) +} + +async function sendAssistantModeWarningsPrompt(client: OpenCodeClient, sessionId: string, assistant: AssistantModeStatus): Promise { + const warningsPrompt = buildAssistantModeWarningsPrompt(assistant) + if (!warningsPrompt) return + + await client.sendPromptAsync(sessionId, { + parts: [ + { + type: 'text', + text: warningsPrompt, + }, + ], + }).catch(() => undefined) +} + export function useAssistantSessionLauncher({ repoId, opcodeUrl, @@ -34,30 +95,11 @@ export function useAssistantSessionLauncher({ if (newest) { onNavigate(newest.id) + void sendAssistantModeWarningsPrompt(client, newest.id, assistant) } else { const session = await client.createSession({ title: 'Assistant' }) - await client.sendPrompt(session.id, { - parts: [ - { - type: 'text', - text: `Welcome to OpenCode Manager! I'm your assistant and I'm here to help you work with your code. - -To get started, let's set up your assistant: - -**1. Name your assistant** -What would you like to call me? This name will help personalize our interactions. - -**2. Configure AGENTS.md** -This file contains instructions that define my behavior, persona, and preferences. You can customize it to match your workflow. Take a moment to review and edit it - you can always adjust it later. - -**3. Set up your v file (optional)** -The v file stores conversation state and context between sessions. This helps me maintain memory of our work together. - -Take your time exploring and customizing these settings. Let me know when you're ready to start coding, or if you have any questions about getting set up!`, - }, - ], - }) onNavigate(session.id) + void sendAssistantWelcomePrompt(client, session.id, assistant) } }, [repoId, opcodeUrl, onNavigate]) diff --git a/frontend/src/hooks/useContextUsage.ts b/frontend/src/hooks/useContextUsage.ts index d5347cfa..7084b50b 100644 --- a/frontend/src/hooks/useContextUsage.ts +++ b/frontend/src/hooks/useContextUsage.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { useMessages } from './useOpenCode' import { useQuery } from '@tanstack/react-query' -import { useModelSelection } from './useModelSelection' import { fetchWrapper } from '@/api/fetchWrapper' interface ContextUsage { @@ -39,8 +38,6 @@ async function fetchProviders(opcodeUrl: string): Promise { export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: string | undefined, directory?: string): ContextUsage => { const { data: messages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionID, directory) - const { modelString: globalModelString } = useModelSelection(opcodeUrl, directory) - const modelString = globalModelString const { data: providersData } = useQuery({ queryKey: ['providers', opcodeUrl], @@ -53,8 +50,6 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: }) return useMemo(() => { - const currentModel = modelString || null - const assistantMessages = messages?.filter(msg => msg.info.role === 'assistant') || [] let latestAssistantMessage = assistantMessages[assistantMessages.length - 1] @@ -66,6 +61,17 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: } } + const currentModel = (() => { + if (!latestAssistantMessage || latestAssistantMessage.info.role !== 'assistant') { + return null + } + const msg = latestAssistantMessage.info as { providerID?: string; modelID?: string } + if (msg.providerID && msg.modelID) { + return `${msg.providerID}/${msg.modelID}` + } + return null + })() + let contextLimit: number | null = null if (currentModel && providersData) { const [providerId, modelId] = currentModel.split('/') @@ -103,5 +109,5 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: currentModel, isLoading: false } - }, [messages, messagesLoading, modelString, providersData]) + }, [messages, messagesLoading, providersData]) } diff --git a/frontend/src/hooks/useMobile.test.tsx b/frontend/src/hooks/useMobile.test.tsx index 74fa7f6a..7a9b4aa4 100644 --- a/frontend/src/hooks/useMobile.test.tsx +++ b/frontend/src/hooks/useMobile.test.tsx @@ -101,6 +101,41 @@ describe('useSwipeBack', () => { document.body.removeChild(element) }) + it('does not call onBack when canBack was false at swipe start', () => { + mockCanBack.mockReturnValueOnce(false).mockReturnValue(true) + + const element = document.createElement('div') + document.body.appendChild(element) + + const { result } = renderHook(() => + useSwipeBack(mockOnClose, { + enabled: true, + canBack: mockCanBack, + onBack: mockOnBack, + threshold: 80, + }) + ) + + const cleanup = result.current.bind(element) + + element.dispatchEvent(new TouchEvent('touchstart', { + touches: [{ clientX: 10, clientY: 100 }] as any, + })) + element.dispatchEvent(new TouchEvent('touchmove', { + touches: [{ clientX: 100, clientY: 100 }] as any, + })) + element.dispatchEvent(new TouchEvent('touchend', { + changedTouches: [{ clientX: 100, clientY: 100 }] as any, + })) + + expect(mockCanBack).toHaveBeenCalledTimes(1) + expect(mockOnBack).not.toHaveBeenCalled() + expect(mockOnClose).toHaveBeenCalled() + + cleanup?.() + document.body.removeChild(element) + }) + it('falls back to onClose when canBack is not provided', () => { const { result } = renderHook(() => useSwipeBack(mockOnClose, { diff --git a/frontend/src/hooks/useMobile.ts b/frontend/src/hooks/useMobile.ts index 49d8c2b6..658077e9 100644 --- a/frontend/src/hooks/useMobile.ts +++ b/frontend/src/hooks/useMobile.ts @@ -68,6 +68,7 @@ function useSwipeHandlers( isEdgeSwipe: boolean startTime: number blockedByScroll: boolean + canBackAtStart: boolean }>({ startX: 0, startY: 0, @@ -77,6 +78,7 @@ function useSwipeHandlers( isEdgeSwipe: false, startTime: 0, blockedByScroll: false, + canBackAtStart: true, }) const [swipeProgress, setSwipeProgress] = useState(0) const [swipeDeltaPx, setSwipeDeltaPx] = useState(0) @@ -118,6 +120,7 @@ function useSwipeHandlers( isEdgeSwipe: false, startTime: Date.now(), blockedByScroll: !!blockedByScroll, + canBackAtStart: canBack?.() ?? true, } } else { swipeRef.current = { @@ -129,9 +132,10 @@ function useSwipeHandlers( isEdgeSwipe: touch.clientX <= edgeWidth, startTime: Date.now(), blockedByScroll: false, + canBackAtStart: canBack?.() ?? true, } } - }, [enabled, isMobile, edgeWidth, direction]) + }, [enabled, isMobile, edgeWidth, direction, canBack]) const handleTouchMove = useCallback((e: TouchEvent) => { if (!enabled || !isMobile) return @@ -196,7 +200,7 @@ function useSwipeHandlers( if (deltaY >= threshold || velocity >= velocityThreshold) { setDismissing(true) setTimeout(() => { - if (canBack?.()) { + if (state.canBackAtStart && canBack?.()) { onBack?.() } else { onClose() @@ -209,7 +213,7 @@ function useSwipeHandlers( } else { const deltaX = state.currentX - state.startX if (deltaX >= threshold) { - if (canBack?.()) { + if (state.canBackAtStart && canBack?.()) { onBack?.() } else { onClose() @@ -231,6 +235,7 @@ function useSwipeHandlers( isEdgeSwipe: false, startTime: 0, blockedByScroll: false, + canBackAtStart: true, } }, [enabled, isMobile, threshold, direction, velocityThreshold, onClose, canBack, onBack]) diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index f4dc5e5c..384cdf1b 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -25,6 +25,9 @@ export function useSTT(userId = 'default') { const lastProcessedBlobRef = useRef(null) const startupTimeoutRef = useRef | null>(null) const startOpIdRef = useRef(0) + const recorderConfiguredRef = useRef(false) + const interimRafRef = useRef(null) + const pendingInterimRef = useRef('') useEffect(() => { userIdRef.current = userId @@ -51,12 +54,21 @@ export function useSTT(userId = 'default') { rec.onResult((result: SpeechRecognitionResult) => { setIsProcessing(false) - setTranscript((prev) => prev + ' ' + result.transcript) + setTranscript((prev) => { + const prevTrimmed = prev.trim() + const next = result.transcript.trim() + return prevTrimmed ? `${prevTrimmed} ${next}` : next + }) setIsRecording(false) }) rec.onInterimResult((interim: string) => { - setInterimTranscript(interim.trim()) + pendingInterimRef.current = interim.trim() + if (interimRafRef.current != null) return + interimRafRef.current = requestAnimationFrame(() => { + interimRafRef.current = null + setInterimTranscript(pendingInterimRef.current) + }) }) rec.onError((errorMessage: string) => { @@ -88,6 +100,10 @@ export function useSTT(userId = 'default') { return () => { rec.clearCallbacks() + if (interimRafRef.current != null) { + cancelAnimationFrame(interimRafRef.current) + interimRafRef.current = null + } } }, [isEnabled, isExternalProvider]) @@ -178,7 +194,10 @@ export function useSTT(userId = 'default') { audioRecorder.current = new AudioRecorder() } - setupAudioRecorder(audioRecorder.current) + if (!recorderConfiguredRef.current) { + setupAudioRecorder(audioRecorder.current) + recorderConfiguredRef.current = true + } return () => { if (audioRecorder.current) { diff --git a/frontend/src/hooks/useSessionAgent.test.tsx b/frontend/src/hooks/useSessionAgent.test.tsx index 503f350b..86a0ea68 100644 --- a/frontend/src/hooks/useSessionAgent.test.tsx +++ b/frontend/src/hooks/useSessionAgent.test.tsx @@ -99,6 +99,12 @@ describe('resolveDefaultSessionAgent', () => { const result = resolveDefaultSessionAgent('missing-agent', agents, true) expect(result).toBe('build') }) + + it('returns assistant when assistant workspace config sets default_agent to assistant', () => { + const agents = [{ name: 'assistant', mode: 'primary' }] + const result = resolveDefaultSessionAgent('assistant', agents, true) + expect(result).toBe('assistant') + }) }) describe('useSessionAgent', () => { diff --git a/frontend/src/hooks/useWorkspace.ts b/frontend/src/hooks/useWorkspace.ts new file mode 100644 index 00000000..7843c348 --- /dev/null +++ b/frontend/src/hooks/useWorkspace.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query' +import { getRepo } from '@/api/repos' +import { useAssistantMode } from '@/hooks/useAssistantMode' +import { isAssistantRepoId, workspaceFromAssistant, workspaceFromRepo } from '@/lib/schedules/workspace' +import type { Workspace } from '@/lib/schedules/workspace' + +export function useWorkspace(repoId: number | undefined): { + workspace: Workspace | undefined + isLoading: boolean + isError: boolean +} { + const assistantQuery = useAssistantMode(repoId) + + const repoQuery = useQuery({ + queryKey: ['repo', repoId], + queryFn: () => getRepo(repoId!), + enabled: repoId !== undefined && repoId > 0, + }) + + if (isAssistantRepoId(repoId)) { + return { + workspace: assistantQuery.status ? workspaceFromAssistant(assistantQuery.status) : undefined, + isLoading: assistantQuery.isLoading, + isError: assistantQuery.isError, + } + } + + return { + workspace: repoQuery.data ? workspaceFromRepo(repoQuery.data) : undefined, + isLoading: repoQuery.isLoading, + isError: repoQuery.isError, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 2858bf3e..38d1cd9a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -43,6 +43,7 @@ html { background-color: #0a0a0a; min-height: calc(100% + env(safe-area-inset-top)); + overscroll-behavior-x: none; } :root:not(.dark) html { @@ -58,6 +59,7 @@ body { overflow: hidden; position: fixed; width: 100%; + overscroll-behavior-x: none; } #root { diff --git a/frontend/src/lib/audioRecorder.test.ts b/frontend/src/lib/audioRecorder.test.ts new file mode 100644 index 00000000..d8dc0eaa --- /dev/null +++ b/frontend/src/lib/audioRecorder.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { AudioRecorder, downsampleAndConvert, encodeWavFromInt16 } from './audioRecorder' + +describe('downsampleAndConvert', () => { + it('should produce correct output length for 48kHz to 16kHz', () => { + const inputLength = 4800 + const input = new Float32Array(inputLength) + const output = downsampleAndConvert(input, 48000, 16000) + + const expectedLength = Math.floor(inputLength * 16000 / 48000) + expect(output.length).toBe(expectedLength) + expect(output.length).toBe(inputLength / 3) + }) + + it('should produce correct output length for 44.1kHz to 16kHz', () => { + const inputLength = 4410 + const input = new Float32Array(inputLength) + const output = downsampleAndConvert(input, 44100, 16000) + + const expectedLength = Math.floor(inputLength * 16000 / 44100) + expect(output.length).toBeCloseTo(expectedLength, 0) + }) + + it('should clamp values in range [-1, 1] to Int16 range', () => { + const input = new Float32Array([1.0, -1.0, 0.5, -0.5, 0.0]) + const output = downsampleAndConvert(input, 16000, 16000) + + expect(output[0]).toBe(32767) + expect(output[1]).toBe(-32768) + expect(output[2]).toBeCloseTo(16383, 0) + expect(output[3]).toBeCloseTo(-16384, 0) + expect(output[4]).toBe(0) + }) + + it('should clamp values outside [-1, 1] range', () => { + const input = new Float32Array([1.5, -1.5, 2.0, -2.0]) + const output = downsampleAndConvert(input, 16000, 16000) + + expect(output[0]).toBe(32767) + expect(output[1]).toBe(-32768) + expect(output[2]).toBe(32767) + expect(output[3]).toBe(-32768) + }) + + it('should return Int16Array type', () => { + const input = new Float32Array(100) + const output = downsampleAndConvert(input, 48000, 16000) + + expect(output instanceof Int16Array).toBe(true) + }) +}) + +describe('encodeWavFromInt16', () => { + it('should create a Blob with audio/wav type', () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + + expect(blob.type).toBe('audio/wav') + }) + + it('should have RIFF header at offset 0', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const riff = String.fromCharCode( + view.getUint8(0), + view.getUint8(1), + view.getUint8(2), + view.getUint8(3) + ) + expect(riff).toBe('RIFF') + }) + + it('should have WAVE identifier at offset 8', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const wave = String.fromCharCode( + view.getUint8(8), + view.getUint8(9), + view.getUint8(10), + view.getUint8(11) + ) + expect(wave).toBe('WAVE') + }) + + it('should have sample rate at offset 24', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const sampleRate = view.getUint32(24, true) + expect(sampleRate).toBe(16000) + }) + + it('should have data identifier at offset 36', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const data = String.fromCharCode( + view.getUint8(36), + view.getUint8(37), + view.getUint8(38), + view.getUint8(39) + ) + expect(data).toBe('data') + }) + + it('should have correct file size for 1000 samples', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 1) + const arrayBuffer = await blob.arrayBuffer() + + expect(arrayBuffer.byteLength).toBe(44 + 1000 * 2) + }) + + it('should handle different sample rates', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 44100, 1) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const sampleRate = view.getUint32(24, true) + expect(sampleRate).toBe(44100) + }) + + it('should handle stereo channels', async () => { + const samples = new Int16Array(1000) + const blob = encodeWavFromInt16(samples, 16000, 2) + const arrayBuffer = await blob.arrayBuffer() + const view = new DataView(arrayBuffer) + + const channels = view.getUint16(22, true) + expect(channels).toBe(2) + }) +}) + +describe('AudioRecorder.isSupported', () => { + it('should return boolean without throwing', () => { + expect(() => { + const result = AudioRecorder.isSupported() + expect(typeof result).toBe('boolean') + }).not.toThrow() + }) +}) diff --git a/frontend/src/lib/audioRecorder.ts b/frontend/src/lib/audioRecorder.ts index 0f80b61b..526eacd6 100644 --- a/frontend/src/lib/audioRecorder.ts +++ b/frontend/src/lib/audioRecorder.ts @@ -10,54 +10,68 @@ const DEFAULT_OPTIONS: AudioRecorderOptions = { channelCount: 1, } -function encodeWAV(audioBuffer: AudioBuffer): Blob { - const numberOfChannels = audioBuffer.numberOfChannels - const sampleRate = audioBuffer.sampleRate - const format = 1 - const bitDepth = 16 +const workletModulePromises = new WeakMap>() - const channelData: Float32Array[] = [] +function ensureWorkletLoaded(ctx: AudioContext): Promise { + const existingPromise = workletModulePromises.get(ctx) - for (let i = 0; i < numberOfChannels; i++) { - channelData.push(audioBuffer.getChannelData(i)) + if (existingPromise) { + return existingPromise } - const interleaved = interleave(channelData) - const dataLength = interleaved.length * (bitDepth / 8) - const buffer = new ArrayBuffer(44 + dataLength) - const view = new DataView(buffer) + const promise = ctx.audioWorklet.addModule('/audio-worklet-processor.js') + workletModulePromises.set(ctx, promise) + return promise +} +export function downsampleAndConvert(input: Float32Array, inputRate: number, targetRate: number): Int16Array { + const ratio = inputRate / targetRate + const outputLength = Math.floor(input.length / ratio) + const output = new Int16Array(outputLength) + + for (let i = 0; i < outputLength; i++) { + const index = i * ratio + const prevIndex = Math.floor(index) + const nextIndex = prevIndex + 1 + const t = index - prevIndex + + let sample: number + if (nextIndex >= input.length) { + sample = input[prevIndex] + } else { + sample = input[prevIndex] * (1 - t) + input[nextIndex] * t + } + + const clamped = Math.max(-1, Math.min(1, sample)) + output[i] = clamped < 0 ? clamped * 32768 : clamped * 32767 + } + + return output +} + +export function encodeWavFromInt16(samples: Int16Array, sampleRate: number, channels: number): Blob { + const dataLength = samples.length * 2 + const bufferSize = 44 + dataLength + const buffer = new ArrayBuffer(bufferSize) + const view = new DataView(buffer) + writeString(view, 0, 'RIFF') view.setUint32(4, 36 + dataLength, true) writeString(view, 8, 'WAVE') writeString(view, 12, 'fmt ') view.setUint32(16, 16, true) - view.setUint16(20, format, true) - view.setUint16(22, numberOfChannels, true) + view.setUint16(20, 1, true) + view.setUint16(22, channels, true) view.setUint32(24, sampleRate, true) - view.setUint32(28, sampleRate * numberOfChannels * (bitDepth / 8), true) - view.setUint16(32, numberOfChannels * (bitDepth / 8), true) - view.setUint16(34, bitDepth, true) + view.setUint32(28, sampleRate * channels * 2, true) + view.setUint16(32, channels * 2, true) + view.setUint16(34, 16, true) writeString(view, 36, 'data') view.setUint32(40, dataLength, true) - - floatTo16BitPCM(view, 44, interleaved) - - return new Blob([view], { type: 'audio/wav' }) -} - -function interleave(channelData: Float32Array[]): Float32Array { - const length = channelData[0].length * channelData.length - const result = new Float32Array(length) - let offset = 0 - - for (let i = 0; i < channelData[0].length; i++) { - for (let channel = 0; channel < channelData.length; channel++) { - result[offset++] = channelData[channel][i] - } - } - - return result + + new Int16Array(buffer, 44).set(samples) + + return new Blob([buffer], { type: 'audio/wav' }) } function writeString(view: DataView, offset: number, string: string): void { @@ -66,20 +80,13 @@ function writeString(view: DataView, offset: number, string: string): void { } } -function floatTo16BitPCM(view: DataView, offset: number, input: Float32Array): void { - for (let i = 0; i < input.length; i++, offset += 2) { - const s = Math.max(-1, Math.min(1, input[i])) - view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true) - } -} - export class AudioRecorder { private audioContext: AudioContext | null = null private mediaStream: MediaStream | null = null private source: MediaStreamAudioSourceNode | null = null private processor: ScriptProcessorNode | null = null private workletNode: AudioWorkletNode | null = null - private chunks: Float32Array[] = [] + private chunks: Int16Array[] = [] private totalSamples: number = 0 private state: AudioRecorderState = 'idle' private options: AudioRecorderOptions @@ -152,29 +159,32 @@ export class AudioRecorder { if (this.audioContext.audioWorklet) { try { - await this.audioContext.audioWorklet.addModule('/audio-worklet-processor.js') - this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor') - this.workletNode.port.onmessage = (e: MessageEvent) => { + await ensureWorkletLoaded(this.audioContext) + this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor', { + processorOptions: { targetSampleRate: this.options.sampleRate }, + }) + this.workletNode.port.onmessage = (e: MessageEvent) => { this.chunks.push(e.data) this.totalSamples += e.data.length } this.source.connect(this.workletNode) - this.workletNode.connect(this.audioContext.destination) - } catch { + } catch (error) { this.audioContext.close() this.audioContext = null - throw new Error('Failed to load audio worklet processor') + throw new Error('Failed to load audio worklet processor', { cause: error }) } } else if (this.audioContext) { const bufferSize = 4096 this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1) + const targetRate = this.options.sampleRate ?? 16000 + const inputRate = this.audioContext.sampleRate this.processor.onaudioprocess = (e) => { - const inputData = new Float32Array(e.inputBuffer.getChannelData(0)) - this.chunks.push(inputData) - this.totalSamples += inputData.length + const inputData = e.inputBuffer.getChannelData(0) + const int16Chunk = downsampleAndConvert(inputData, inputRate, targetRate) + this.chunks.push(int16Chunk) + this.totalSamples += int16Chunk.length } this.source.connect(this.processor) - this.processor.connect(this.audioContext.destination) } this.setState('recording') @@ -220,21 +230,13 @@ export class AudioRecorder { } try { - const merged = new Float32Array(this.totalSamples) + const merged = new Int16Array(this.totalSamples) let offset = 0 for (const chunk of this.chunks) { merged.set(chunk, offset) offset += chunk.length } - - const audioBuffer = this.audioContext!.createBuffer( - 1, - this.totalSamples, - this.audioContext!.sampleRate - ) - - audioBuffer.copyToChannel(merged, 0) - const wavBlob = encodeWAV(audioBuffer) + const wavBlob = encodeWavFromInt16(merged, this.options.sampleRate ?? 16000, 1) this.onDataAvailable?.(wavBlob) } catch { this.onError?.('Failed to process recording') @@ -276,30 +278,4 @@ export class AudioRecorder { this.chunks = [] this.totalSamples = 0 } - - getRecordingBlob(): Blob | null { - if (!this.audioContext || this.chunks.length === 0 || this.totalSamples === 0) { - return null - } - - try { - const merged = new Float32Array(this.totalSamples) - let offset = 0 - for (const chunk of this.chunks) { - merged.set(chunk, offset) - offset += chunk.length - } - - const audioBuffer = this.audioContext.createBuffer( - 1, - this.totalSamples, - this.audioContext.sampleRate - ) - - audioBuffer.copyToChannel(merged, 0) - return encodeWAV(audioBuffer) - } catch { - return null - } - } } diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts index 115d5f61..d5bc4f42 100644 --- a/frontend/src/lib/navigation.test.ts +++ b/frontend/src/lib/navigation.test.ts @@ -1,5 +1,39 @@ import { describe, it, expect } from 'vitest'; -import { getSessionListPath, getSwipeBackTarget } from './navigation'; +import { getSessionListPath, getSwipeBackTarget, getAssistantPath, getAssistantSessionListPath, isAssistantPath } from './navigation'; + +describe('getAssistantPath', () => { + it('returns /assistant', () => { + expect(getAssistantPath()).toBe('/assistant'); + }); +}); + +describe('getAssistantSessionListPath', () => { + it('returns /assistant?view=sessions', () => { + expect(getAssistantSessionListPath()).toBe('/assistant?view=sessions'); + }); +}); + +describe('isAssistantPath', () => { + it('returns true for /assistant', () => { + expect(isAssistantPath('/assistant')).toBe(true); + }); + + it('returns true for legacy /repos/0/assistant', () => { + expect(isAssistantPath('/repos/0/assistant')).toBe(true); + }); + + it('returns true for legacy /repos/5/assistant', () => { + expect(isAssistantPath('/repos/5/assistant')).toBe(true); + }); + + it('returns false for /repos/5', () => { + expect(isAssistantPath('/repos/5')).toBe(false); + }); + + it('returns false for /schedules', () => { + expect(isAssistantPath('/schedules')).toBe(false); + }); +}); describe('getSessionListPath', () => { it('returns repo path for non-assistant sessions', () => { @@ -7,9 +41,9 @@ describe('getSessionListPath', () => { expect(getSessionListPath('123', false)).toBe('/repos/123'); }); - it('returns assistant path with view=sessions for assistant sessions', () => { - expect(getSessionListPath(42, true)).toBe('/repos/42/assistant?view=sessions'); - expect(getSessionListPath('123', true)).toBe('/repos/123/assistant?view=sessions'); + it('returns assistant session list path for assistant sessions', () => { + expect(getSessionListPath(42, true)).toBe('/assistant?view=sessions'); + expect(getSessionListPath('123', true)).toBe('/assistant?view=sessions'); }); }); @@ -20,12 +54,12 @@ describe('getSwipeBackTarget', () => { expect(getSwipeBackTarget('/repos/123/sessions/xyz-789', '')).toBe('/repos/123'); }); - it('returns assistant path for assistant session detail with assistant=1', () => { + it('returns assistant session list path for assistant session detail with assistant=1', () => { expect(getSwipeBackTarget('/repos/42/sessions/abc', '?assistant=1')).toBe( - '/repos/42/assistant?view=sessions' + '/assistant?view=sessions' ); expect(getSwipeBackTarget('/repos/123/sessions/xyz', '?assistant=1')).toBe( - '/repos/123/assistant?view=sessions' + '/assistant?view=sessions' ); }); @@ -36,12 +70,20 @@ describe('getSwipeBackTarget', () => { }); describe('assistant route', () => { - it('returns assistant sessions path for assistant route', () => { - expect(getSwipeBackTarget('/repos/123/assistant', '')).toBe('/repos/123/assistant?view=sessions'); + it('returns assistant session list path for canonical assistant route', () => { + expect(getSwipeBackTarget('/assistant', '')).toBe('/assistant?view=sessions'); }); - it('returns repo path for assistant session list route', () => { - expect(getSwipeBackTarget('/repos/42/assistant', '?view=sessions')).toBe('/repos/42'); + it('returns root for assistant session list route', () => { + expect(getSwipeBackTarget('/assistant', '?view=sessions')).toBe('/'); + }); + + it('returns assistant session list path for legacy assistant route', () => { + expect(getSwipeBackTarget('/repos/123/assistant', '')).toBe('/assistant?view=sessions'); + }); + + it('returns root for legacy assistant session list route', () => { + expect(getSwipeBackTarget('/repos/42/assistant', '?view=sessions')).toBe('/'); }); }); @@ -53,6 +95,10 @@ describe('getSwipeBackTarget', () => { }); describe('schedules routes', () => { + it('returns /assistant for assistant schedules', () => { + expect(getSwipeBackTarget('/repos/0/schedules', '')).toBe('/assistant'); + }); + it('returns repo path for repo schedules', () => { expect(getSwipeBackTarget('/repos/42/schedules', '')).toBe('/repos/42'); }); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 0748050d..a4192746 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,9 +1,20 @@ +export function getAssistantPath(): string { + return '/assistant'; +} + +export function getAssistantSessionListPath(): string { + return '/assistant?view=sessions'; +} + +export function isAssistantPath(pathname: string): boolean { + return pathname === '/assistant' || /^\/repos\/[^/]+\/assistant$/.test(pathname); +} + export function getSessionListPath(repoId: string | number, isAssistantSession: boolean): string { - const id = String(repoId); if (isAssistantSession) { - return `/repos/${id}/assistant?view=sessions`; + return getAssistantSessionListPath(); } - return `/repos/${id}`; + return `/repos/${String(repoId)}`; } export function getSwipeBackTarget(pathname: string, search = ''): string | null { @@ -17,13 +28,12 @@ export function getSwipeBackTarget(pathname: string, search = ''): string | null return getSessionListPath(repoId, isAssistant); } - if (pathname === '/repos/:id/assistant' || /^\/repos\/[^/]+\/assistant$/.test(pathname)) { - const repoId = pathname.split('/')[2]; + if (isAssistantPath(pathname)) { const params = new URLSearchParams(search); if (params.get('view') !== 'sessions') { - return getSessionListPath(repoId, true); + return getAssistantSessionListPath(); } - return `/repos/${repoId}`; + return '/'; } if (/^\/repos\/[^/]+$/.test(pathname)) { @@ -32,6 +42,9 @@ export function getSwipeBackTarget(pathname: string, search = ''): string | null if (/^\/repos\/[^/]+\/schedules$/.test(pathname)) { const repoId = pathname.split('/')[2]; + if (repoId === '0') { + return getAssistantPath(); + } return `/repos/${repoId}`; } diff --git a/frontend/src/lib/schedules/workspace.test.ts b/frontend/src/lib/schedules/workspace.test.ts new file mode 100644 index 00000000..06461d21 --- /dev/null +++ b/frontend/src/lib/schedules/workspace.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { + isAssistantRepoId, + workspaceFromRepo, + workspaceFromAssistant, +} from './workspace' +import type { AssistantModeStatus } from '@opencode-manager/shared/types' + +describe('isAssistantRepoId', () => { + it('returns true for repoId 0', () => { + expect(isAssistantRepoId(0)).toBe(true) + }) + + it('returns false for positive repoId', () => { + expect(isAssistantRepoId(5)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isAssistantRepoId(undefined)).toBe(false) + }) +}) + +describe('workspaceFromRepo', () => { + it('returns correct workspace for a repo', () => { + const repo = { + id: 5, + repoUrl: 'https://x/y', + localPath: 'y', + fullPath: '/abs/y', + sourcePath: undefined, + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: 0, + } + + const workspace = workspaceFromRepo(repo) + + expect(workspace).toEqual({ + repoId: 5, + kind: 'repo', + name: 'y', + subtitle: 'y', + fullPath: '/abs/y', + backHref: '/repos/5', + }) + }) +}) + +describe('workspaceFromAssistant', () => { + it('returns correct workspace for assistant', () => { + const status: AssistantModeStatus = { + repoId: 0, + directory: '/abs/assistant', + relativePath: 'repos/assistant', + files: { + agentsMd: { path: '', exists: false, created: false }, + opencodeJson: { path: '', exists: false, created: false }, + }, + schedulesSkill: { path: '', exists: false, created: false }, + } + + const workspace = workspaceFromAssistant(status) + + expect(workspace).toEqual({ + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/assistant', + }) + }) +}) diff --git a/frontend/src/lib/schedules/workspace.ts b/frontend/src/lib/schedules/workspace.ts new file mode 100644 index 00000000..3952db99 --- /dev/null +++ b/frontend/src/lib/schedules/workspace.ts @@ -0,0 +1,41 @@ +import type { Repo } from '@/api/types' +import type { AssistantModeStatus } from '@opencode-manager/shared/types' +import { getRepoDisplayName } from '@/lib/utils' +import { getAssistantPath } from '@/lib/navigation' + +export interface Workspace { + repoId: number + kind: 'assistant' | 'repo' + name: string + subtitle: string + fullPath: string + backHref: string +} + +export const ASSISTANT_REPO_ID = 0 + +export function isAssistantRepoId(repoId: number | undefined): boolean { + return repoId === ASSISTANT_REPO_ID +} + +export function workspaceFromRepo(repo: Repo): Workspace { + return { + repoId: repo.id, + kind: 'repo', + name: getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath), + subtitle: repo.localPath, + fullPath: repo.fullPath, + backHref: `/repos/${repo.id}`, + } +} + +export function workspaceFromAssistant(status: AssistantModeStatus): Workspace { + return { + repoId: ASSISTANT_REPO_ID, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: status.directory, + backHref: getAssistantPath(), + } +} diff --git a/frontend/src/pages/AssistantRedirect.tsx b/frontend/src/pages/AssistantRedirect.tsx index b009f86b..067e1422 100644 --- a/frontend/src/pages/AssistantRedirect.tsx +++ b/frontend/src/pages/AssistantRedirect.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react" import { useParams, useNavigate, useLocation } from "react-router-dom" import { useQuery, useQueryClient } from "@tanstack/react-query" -import { getRepo, initializeAssistantMode, listRepos } from "@/api/repos" +import { getRepo, initializeAssistantMode } from "@/api/repos" import { useAssistantSessionLauncher } from "@/hooks/useAssistantSessionLauncher" import { useCreateSession } from "@/hooks/useOpenCode" import { useDialogParam } from "@/hooks/useDialogParam" @@ -17,7 +17,7 @@ import { SourceControlPanel } from "@/components/source-control" import { ResetPermissionsDialog } from "@/components/repo/ResetPermissionsDialog" import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup" import { invalidateConfigCaches } from "@/lib/queryInvalidation" -import { getSessionListPath } from "@/lib/navigation" +import { getSessionListPath, getAssistantPath } from "@/lib/navigation" import { SwitchConfigDialog } from "@/components/repo/SwitchConfigDialog" import { Loader2, Plus } from "lucide-react" @@ -41,7 +41,7 @@ export function AssistantRedirect() { const { data: repo } = useQuery({ queryKey: ["repo", repoId], queryFn: () => getRepo(repoId), - enabled: showSessionList && !!repoId, + enabled: showSessionList && repoId !== undefined, }) const handleNavigate = useCallback((sessionId: string) => { @@ -61,7 +61,7 @@ export function AssistantRedirect() { const { data: assistantMode, isLoading: assistantModeLoading, error: assistantModeError } = useQuery({ queryKey: ["repo", repoId, "assistant-mode"], queryFn: () => initializeAssistantMode(repoId), - enabled: showSessionList && !!repoId, + enabled: showSessionList && repoId !== undefined, }) const assistantDirectory = assistantMode?.directory @@ -84,11 +84,10 @@ export function AssistantRedirect() { try { if (showSessionList) return setStatus("preparing") - if (!repoId) { - const repos = await listRepos() - const fallbackRepo = repos.sort((a, b) => (b.lastAccessedAt ?? 0) - (a.lastAccessedAt ?? 0))[0] - if (!fallbackRepo) throw new Error("No repository available to open Assistant") - navigate(`/repos/${fallbackRepo.id}/assistant`, { replace: true }) + if (repoId <= 0) { + if (cancelled) return + setStatus("creating") + await openAssistant() return } @@ -108,13 +107,13 @@ export function AssistantRedirect() { return () => { cancelled = true } - }, [repoId, openAssistant, navigate, showSessionList]) + }, [repoId, openAssistant, navigate, showSessionList, id]) if (showSessionList) { return (
- + Assistant
@@ -174,7 +173,7 @@ export function AssistantRedirect() { <>

{errorMessage}

+
+ )), + JobDetailTab: vi.fn(({ onRunNow }) => ( +
+ +
+ )), + RunHistoryTab: vi.fn(() =>
RunHistoryTab
), + ScheduleTabMenu: vi.fn(() =>
ScheduleTabMenu
), +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => + {children} +} + +const renderSchedules = (repoId: string) => { + return render( + + + } /> + + , + { wrapper: createWrapper() } + ) +} + +describe('Schedules', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useScheduleTab.mockReturnValue({ + scheduleTab: 'jobs', + setScheduleTab: vi.fn(), + }) + 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 }) + }) + + describe('assistant workspace (repoId=0)', () => { + it('renders assistant title and subtitle', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.getByText('Assistant')).toBeInTheDocument() + expect(screen.getByText('Assistant Workspace')).toBeInTheDocument() + }) + + it('does not render Repository not found', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.queryByText('Repository not found')).not.toBeInTheDocument() + }) + + it('renders back button with correct href', () => { + mockNavigate.mockClear() + + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/assistant', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + const backButton = screen.getAllByRole('button')[0] + expect(backButton).toBeInTheDocument() + fireEvent.click(backButton) + expect(mockNavigate).toHaveBeenCalledWith('/assistant') + }) + + it('calls runMutation with repoId=0 when Run Now is clicked', () => { + const mutateMock = vi.fn() + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 0, + kind: 'assistant', + name: 'Assistant', + subtitle: 'Assistant Workspace', + fullPath: '/abs/assistant', + backHref: '/assistant', + }, + isLoading: false, + isError: false, + }) + const mockJob = { + id: 123, + name: 'Test Job', + repoId: 0, + cronExpression: null, + intervalMinutes: 30, + timezone: 'UTC', + enabled: true, + createdAt: 0, + updatedAt: 0, + scheduleMode: 'interval' as const, + agentSlug: null, + prompt: 'test', + triggerSource: 'manual' as const, + lastRunAt: null, + nextRunAt: null, + skillMetadata: null, + } + mocks.useScheduleTab.mockReturnValue({ + scheduleTab: 'detail', + setScheduleTab: vi.fn(), + }) + mocks.useRepoSchedules.mockReturnValue({ data: [mockJob], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: mockJob, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRunRepoSchedule.mockReturnValue({ mutate: mutateMock, isPending: false }) + + renderSchedules('0') + + const runNowButton = screen.getByTestId('run-now') + runNowButton.click() + + expect(mutateMock).toHaveBeenCalledWith({ repoId: 0, jobId: 123 }, expect.any(Object)) + }) + }) + + describe('repo workspace (repoId=5)', () => { + it('renders repo name and subtitle', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 5, + kind: 'repo', + name: 'my-repo', + subtitle: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + backHref: '/repos/5', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('5') + + expect(screen.getByText('my-repo')).toBeInTheDocument() + expect(screen.getByText('repos/my-repo')).toBeInTheDocument() + }) + + it('renders back button with correct href', () => { + mockNavigate.mockClear() + + mocks.useWorkspace.mockReturnValue({ + workspace: { + repoId: 5, + kind: 'repo', + name: 'my-repo', + subtitle: 'repos/my-repo', + fullPath: '/abs/repos/my-repo', + backHref: '/repos/5', + }, + isLoading: false, + isError: false, + }) + mocks.useRepoSchedules.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('5') + + const backButton = screen.getAllByRole('button')[0] + expect(backButton).toBeInTheDocument() + fireEvent.click(backButton) + expect(mockNavigate).toHaveBeenCalledWith('/repos/5') + }) + }) + + describe('workspace not found', () => { + it('renders not found fallback for real repo', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: undefined, + isLoading: false, + isError: true, + }) + mocks.useRepoSchedules.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('999') + + expect(screen.getByText('Repository not found')).toBeInTheDocument() + }) + + it('renders not found fallback for assistant', () => { + mocks.useWorkspace.mockReturnValue({ + workspace: undefined, + isLoading: false, + isError: true, + }) + mocks.useRepoSchedules.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useRepoSchedule.mockReturnValue({ data: undefined, isFetching: false }) + mocks.useRepoScheduleRuns.mockReturnValue({ data: [], isLoading: false }) + mocks.useRepoScheduleRun.mockReturnValue({ data: undefined, isLoading: false }) + + renderSchedules('0') + + expect(screen.getByText('Workspace not found')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx index 5a12599d..e4be6445 100644 --- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx +++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { renderHook } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query' @@ -51,10 +51,8 @@ describe('SessionDetail pending-actions polling', () => { expect(mocks.syncPendingActions).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(30000) - - await waitFor(() => { - expect(mocks.syncPendingActions).toHaveBeenCalledTimes(2) - }) + + expect(mocks.syncPendingActions).toHaveBeenCalledTimes(2) vi.useRealTimers() }) diff --git a/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx new file mode 100644 index 00000000..73fcc4d3 --- /dev/null +++ b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx @@ -0,0 +1,330 @@ +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 { useSessionTodos } from '@/stores/sessionTodosStore' +import type { Todo } from '@/components/message/SessionTodoDisplay' +import { SessionDetail } from '../SessionDetail' + +const mocks = vi.hoisted(() => ({ + useSession: vi.fn(), + useMessages: vi.fn(), + useSSE: vi.fn(), + useRepoActivity: vi.fn(), + usePermissions: vi.fn(), + useQuestions: vi.fn(), + useSSEHealth: vi.fn(), + useConfig: vi.fn(), + useOpenCodeClient: vi.fn(), + useSettings: vi.fn(), + useSettingsDialog: vi.fn(), + useMobile: vi.fn(), + useVisualViewport: vi.fn(), + useKeyboardShortcuts: vi.fn(), + useAutoScroll: vi.fn(), + useDialogParam: vi.fn(), + useSidebarAction: vi.fn(), +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSession: mocks.useSession, + useAbortSession: vi.fn(() => ({ mutate: vi.fn() })), + useUpdateSession: vi.fn(() => ({ mutate: vi.fn() })), + useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })), + useMessages: mocks.useMessages, + useConfig: mocks.useConfig, +})) + +vi.mock('@/hooks/useModelSelection', () => ({ + useModelSelection: vi.fn(() => ({ model: null, modelString: null })), +})) + +vi.mock('@/hooks/useOpenCodeClient', () => ({ + useOpenCodeClient: mocks.useOpenCodeClient, +})) + +vi.mock('@/hooks/useTTS', () => ({ + useTTS: vi.fn(() => ({ isEnabled: false })), +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + })), +})) + +vi.mock('@/hooks/useSettingsDialog', () => ({ + useSettingsDialog: vi.fn(() => ({ open: vi.fn() })), +})) + +vi.mock('@/hooks/useMobile', () => ({ + useMobile: vi.fn(() => false), + useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), +})) + +vi.mock('@/hooks/useVisualViewport', () => ({ + useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })), +})) + +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ + useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })), +})) + +vi.mock('@/hooks/useAutoScroll', () => ({ + useAutoScroll: vi.fn(() => ({ scrollToBottom: vi.fn() })), +})) + +vi.mock('@/hooks/useDialogParam', () => ({ + useDialogParam: vi.fn(() => [false, vi.fn()]), +})) + +vi.mock('@/hooks/useSidebarAction', () => ({ + useSidebarAction: vi.fn(() => {}), +})) + +vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ + getAssistantText: vi.fn(() => ''), + getLatestPlayableAssistantMessage: vi.fn(() => null), + useAutoPlayLastResponse: vi.fn(() => {}), +})) + +vi.mock('@/stores/uiStateStore', () => ({ + useUIState: vi.fn(() => vi.fn()), +})) + +vi.mock('@/stores/sessionStatusStore', () => ({ + useSessionStatus: vi.fn(() => ({ setStatus: vi.fn() })), + useSessionStatusForSession: vi.fn(() => ({ type: 'idle' })), +})) + +vi.mock('@/hooks/useSSE', () => ({ + useSSE: mocks.useSSE, +})) + +vi.mock('@/hooks/useRepoActivity', () => ({ + useRepoActivity: mocks.useRepoActivity, +})) + +vi.mock('@/contexts/EventContext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + usePermissions: mocks.usePermissions, + useQuestions: mocks.useQuestions, + useSSEHealth: mocks.useSSEHealth, + } +}) + +vi.mock('@/api/repos', () => ({ + getRepo: vi.fn(() => Promise.resolve({ + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: '/test/repo', + sourcePath: null, + fullPath: '/test/repo', + branch: 'main', + currentBranch: 'main', + fullSlug: 'test/repo', + repoType: 'github' as const, + })), + initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), +})) + +vi.mock('@/components/model/ModelSelectDialog', () => ({ + ModelSelectDialog: vi.fn(() => null), +})) + +vi.mock('@/components/session/SessionList', () => ({ + SessionList: vi.fn(() => null), +})) + +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoMcpDialog', () => ({ + RepoMcpDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ + ResetPermissionsDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoLspDialog', () => ({ + RepoLspDialog: vi.fn(() => null), +})) + +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ + RepoSkillsDialog: vi.fn(() => null), +})) + +vi.mock('@/components/source-control', () => ({ + SourceControlPanel: vi.fn(() => null), +})) + +vi.mock('@/components/session/QuestionPrompt', () => ({ + QuestionPrompt: vi.fn(() => null), +})) + +vi.mock('@/components/session/MinimizedQuestionIndicator', () => ({ + MinimizedQuestionIndicator: vi.fn(() => null), +})) + +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ + PendingActionsGroup: vi.fn(() => null), +})) + +const activeTodos: Todo[] = [ + { id: '1', content: 'Implement mobile header fix', status: 'in_progress', priority: 'high' }, + { id: '2', content: 'Add regression tests', status: 'pending', priority: 'medium' }, + { id: '3', content: 'Verify completed item grouping', status: 'completed', priority: 'low' }, +] + +describe('SessionDetail todo-header integration', () => { + beforeEach(() => { + vi.clearAllMocks() + useSessionTodos.setState({ todos: new Map() }) + + mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useMessages.mockReturnValue({ data: [], isLoading: false }) + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) + mocks.useRepoActivity.mockReturnValue(undefined) + mocks.usePermissions.mockReturnValue({ + pendingCount: 0, + hasPermissionsForSession: vi.fn(() => false), + syncForSession: vi.fn(), + }) + mocks.useQuestions.mockReturnValue({ + current: null, + pendingCount: 0, + hasQuestionsForSession: vi.fn(() => false), + reply: vi.fn(), + reject: vi.fn(), + syncForSession: vi.fn(), + }) + mocks.useSSEHealth.mockReturnValue({ isHealthy: true }) + mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false }) + mocks.useOpenCodeClient.mockReturnValue({}) + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + updateSettings: vi.fn(), + }) + mocks.useSettingsDialog.mockReturnValue({ open: vi.fn() }) + mocks.useMobile.mockReturnValue(false) + mocks.useVisualViewport.mockReturnValue({ keyboardHeight: 0 }) + mocks.useKeyboardShortcuts.mockReturnValue({ leaderActive: false }) + mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() }) + mocks.useDialogParam.mockReturnValue([false, vi.fn()]) + mocks.useSidebarAction.mockReturnValue(undefined) + }) + + const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + const renderSessionDetail = (sessionId: string, repoId: number) => { + return render( + + + + } /> + + + + ) + } + + it('renders SessionTodoDisplay collapsed by default inside SessionDetail header', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + expect(screen.queryByText('Implement mobile header fix')).not.toBeInTheDocument() + }) + + it('expands todo list when clicked inside SessionDetail header', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByText('Implement mobile header fix')).toBeInTheDocument() + expect(screen.getByText('Add regression tests')).toBeInTheDocument() + + const expandedContainer = screen.getByTestId('todo-expanded-list') + expect(expandedContainer).toHaveClass('max-h-[80px]') + expect(expandedContainer).toHaveClass('sm:max-h-[160px]') + expect(expandedContainer).toHaveClass('overflow-y-auto') + }) + + it('header wrapper uses max-h-72 sm:max-h-80 and overflow-hidden for proper containment', async () => { + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByTestId('session-header-region')).toBeInTheDocument() + }) + + const headerRegion = screen.getByTestId('session-header-region') + + expect(headerRegion.className).toContain('max-h-72') + expect(headerRegion.className).toContain('sm:max-h-80') + expect(headerRegion.className).toContain('overflow-hidden') + expect(headerRegion.className).not.toContain('max-h-40') + }) + + it('collapses todo list when expanded header is clicked again', async () => { + const user = userEvent.setup() + useSessionTodos.getState().setTodos('session-1', activeTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + expect(screen.getByText('Tasks: 1/3 complete')).toBeInTheDocument() + }) + + const collapsedRow = screen.getByText('Tasks: 1/3 complete') + await user.click(collapsedRow) + + expect(screen.getByTestId('todo-expanded-list')).toBeInTheDocument() + + const expandedHeader = screen.getByText('Tasks: 1/3 complete') + await user.click(expandedHeader) + + expect(screen.queryByTestId('todo-expanded-list')).not.toBeInTheDocument() + }) + + it('does not render SessionTodoDisplay when all tasks are completed', async () => { + const allCompletedTodos: Todo[] = [ + { id: '1', content: 'Task one', status: 'completed', priority: 'high' }, + { id: '2', content: 'Task two', status: 'completed', priority: 'medium' }, + ] + useSessionTodos.getState().setTodos('session-1', allCompletedTodos) + + renderSessionDetail('session-1', 1) + + await waitFor(() => { + const headerRegion = screen.getByTestId('session-header-region') + expect(headerRegion).toBeInTheDocument() + }) + + expect(screen.queryByText(/Tasks:/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/stores/modelStore.ts b/frontend/src/stores/modelStore.ts index c8e389af..69108726 100644 --- a/frontend/src/stores/modelStore.ts +++ b/frontend/src/stores/modelStore.ts @@ -9,6 +9,7 @@ export interface ModelSelection { interface ModelStore { model: ModelSelection | null + agentModels: Record recentModels: ModelSelection[] favoriteModels: ModelSelection[] variants: Record @@ -16,6 +17,8 @@ interface ModelStore { setModel: (model: ModelSelection) => void setActiveModel: (model: ModelSelection) => void + setAgentModel: (agent: string, model: ModelSelection) => void + getAgentModel: (agent: string) => ModelSelection | null syncModelState: (state: { recent: ModelSelection[], favorite: ModelSelection[], variant: Record }) => void toggleFavorite: (model: ModelSelection) => void syncFromConfig: (configModel: string | undefined, force?: boolean) => void @@ -39,6 +42,7 @@ export const useModelStore = create()( persist( (set, get) => ({ model: null, + agentModels: {}, recentModels: [], favoriteModels: [], variants: {}, @@ -64,6 +68,20 @@ export const useModelStore = create()( set({ model }) }, + setAgentModel: (agent: string, model: ModelSelection) => { + set((state) => ({ + agentModels: { + ...state.agentModels, + [agent]: model, + }, + })) + }, + + getAgentModel: (agent: string) => { + const state = get() + return state.agentModels[agent] ?? null + }, + syncModelState: (modelState) => { set((state) => ({ recentModels: modelState.recent, @@ -182,6 +200,7 @@ export const useModelStore = create()( name: 'opencode-model-selection', partialize: (state) => ({ model: state.model, + agentModels: state.agentModels, recentModels: state.recentModels, favoriteModels: state.favoriteModels, variants: state.variants, diff --git a/frontend/src/stores/uiStateStore.ts b/frontend/src/stores/uiStateStore.ts index 933265a3..e6c8f65b 100644 --- a/frontend/src/stores/uiStateStore.ts +++ b/frontend/src/stores/uiStateStore.ts @@ -8,12 +8,14 @@ interface UIStateStore { activePromptFileBasePath: string | null pendingPromptCommand: { id: number; command: CommandType } | null pendingPromptFile: { id: number; path: string } | null + isMoreDrawerOpen: boolean setIsEditingMessage: (isEditing: boolean) => void setActivePromptFileBasePath: (basePath: string | null) => void selectPromptCommand: (command: CommandType) => void clearPendingPromptCommand: () => void selectPromptFile: (path: string) => void clearPendingPromptFile: () => void + setMoreDrawerOpen: (open: boolean) => void } export const useUIState = create((set) => ({ @@ -21,10 +23,12 @@ export const useUIState = create((set) => ({ activePromptFileBasePath: null, pendingPromptCommand: null, pendingPromptFile: null, + isMoreDrawerOpen: false, setIsEditingMessage: (isEditing: boolean) => set({ isEditingMessage: isEditing }), setActivePromptFileBasePath: (basePath: string | null) => set({ activePromptFileBasePath: basePath }), selectPromptCommand: (command: CommandType) => set({ pendingPromptCommand: { id: Date.now(), command } }), clearPendingPromptCommand: () => set({ pendingPromptCommand: null }), selectPromptFile: (path: string) => set({ pendingPromptFile: { id: Date.now(), path } }), clearPendingPromptFile: () => set({ pendingPromptFile: null }), + setMoreDrawerOpen: (open: boolean) => set({ isMoreDrawerOpen: open }), })) diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts index 56a0548c..6144ead4 100644 --- a/shared/src/schemas/repo.ts +++ b/shared/src/schemas/repo.ts @@ -19,6 +19,10 @@ export const RepoSchema = z.object({ isLocal: z.boolean().optional(), }) +export const InternalRepoListResponseSchema = z.object({ + repos: z.array(RepoSchema), +}) + export const CreateRepoRequestSchema = z.object({ repoUrl: z.string().url().optional(), localPath: z.string().optional(), @@ -61,6 +65,11 @@ export const AssistantModeStatusSchema = z.object({ repoId: z.number(), directory: z.string(), relativePath: z.literal('repos/assistant'), + warnings: z.array(z.object({ + code: z.string(), + path: z.string(), + message: z.string(), + })).optional(), files: z.object({ agentsMd: AssistantModeFileSchema, opencodeJson: AssistantModeFileSchema, @@ -68,22 +77,28 @@ export const AssistantModeStatusSchema = z.object({ internalToken: z.object({ path: z.string(), created: z.boolean(), - exists: z.boolean().optional(), }).optional(), schedulesSkill: z.object({ path: z.string(), created: z.boolean(), - exists: z.boolean().optional(), }).optional(), notificationsSkill: z.object({ path: z.string(), created: z.boolean(), - exists: z.boolean().optional(), }).optional(), settingsSkill: z.object({ path: z.string(), created: z.boolean(), - exists: z.boolean().optional(), + }).optional(), + repoManagementSkill: z.object({ + path: z.string(), + created: z.boolean(), + }).optional(), + defaultAgent: z.object({ + name: z.literal('assistant'), + path: z.string(), + exists: z.boolean(), + created: z.boolean(), }).optional(), }) diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index a788aebe..f88c6d31 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -12,6 +12,7 @@ import { } from '../schemas/settings' import { RepoSchema, + InternalRepoListResponseSchema, CreateRepoRequestSchema, DiscoverReposRequestSchema, DiscoverReposResponseSchema, @@ -57,6 +58,7 @@ export type UpdateOpenCodeConfigRequest = z.infer export type Repo = z.infer +export type InternalRepoListResponse = z.infer export type CreateRepoRequest = z.infer export type DiscoverReposRequest = z.infer export type DiscoverReposResponse = z.infer