diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index d529ddd0..e4a278d1 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -105,9 +105,13 @@ class ServerConnection { return (await res.json()) as T; } - async createSession(title?: string): Promise { - // No `directory` — the session lives in the fixed server's own project (its cwd). - const session = await this.postJson<{ id?: string }>('/session', title ? { title } : {}); + async createSession(directory?: string, title?: string): Promise { + // `directory` binds the session's working dir (e.g. an email account dir). Omit it for the general + // /chat, so the session lives in the fixed server's own project (its cwd). + const body: Record = {}; + if (directory) body.directory = directory; + if (title) body.title = title; + const session = await this.postJson<{ id?: string }>('/session', body); if (!session.id) throw new Error('opencode POST /session returned no id'); return session.id; } diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index d9fe9dbc..44571102 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -7,8 +7,9 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { ensureClaudeSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; -import { getHomeDirForRole } from '../../../servers/data-path'; -import { getUserSettings } from 'officerdb'; +import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path'; +import { getUserSettings, getEmailAccounts } from 'officerdb'; +import { mkdirSync } from 'node:fs'; import { logger } from './logger'; // Default model when no user preference is set @@ -56,6 +57,38 @@ export const resolveBaseCwd = (email: string, role: string, cwd?: string) => { return resolveCwd(email, role, cwd); }; +// The email chat runs from the selected account's storage dir: +// DATA_PATH//email_accounts/ +// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the +// owner's first enabled account. Falls back to the email_accounts root if there are no accounts. +async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?: string): Promise { + let account = accountEmail?.trim(); + if (!account) { + try { + const accounts = await getEmailAccounts(userId); + account = (accounts.find((a) => a.enabled) ?? accounts[0])?.email; + } catch (err) { + logger.error('Failed to resolve email account for chat cwd', { userId, error: String(err) }); + } + } + const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen +// pwd or the default claude_sessions dir; everything else (browser/project/dashboard) → the given cwd. +async function resolveChatCwd( + msg: { context?: string; contextId?: string; cwd?: string }, + email: string, + role: string, + userId: number, +): Promise { + if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId); + if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureClaudeSessionsCwd(email); + return resolveCwd(email, role, msg.cwd); +} + const wsToSessionMap = new WeakMap(); // Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets @@ -309,15 +342,7 @@ async function handleClaudeCodeChat( ): Promise { const { email, username, userId } = ws.data; - // The standalone /chat route runs from a chosen working directory (the pwd selector) or, by default, - // a dedicated `claude_sessions` dir — so transcripts form their own Claude "project" group per cwd. - // Other contexts (email/project panels) keep their own cwd. - const cwd = - msg.context === 'chat' - ? msg.cwd?.trim() - ? resolveCwd(email, ws.data.role, msg.cwd) - : ensureClaudeSessionsCwd(email) - : resolveCwd(email, ws.data.role, msg.cwd); + const cwd = await resolveChatCwd(msg, email, ws.data.role, userId); const groupSlug = msg.groupSlug || null; @@ -397,13 +422,11 @@ async function handleOpenCodeChat( ): Promise { const { email, username, userId } = ws.data; - // Same cwd resolution as the Claude path: /chat runs from a chosen pwd or the default dir. - const cwd = - msg.context === 'chat' - ? msg.cwd?.trim() - ? resolveCwd(email, ws.data.role, msg.cwd) - : ensureClaudeSessionsCwd(email) - : resolveCwd(email, ws.data.role, msg.cwd); + const cwd = await resolveChatCwd(msg, email, ws.data.role, userId); + + // OpenCode's working directory: a context-scoped dir (email account, project, …) for OpenCode to + // operate in; the general /chat runs in the fixed server's default project (home), so pass none. + const openCodeDir = msg.context && msg.context !== 'chat' ? cwd : undefined; const groupSlug = msg.groupSlug || null; @@ -446,7 +469,7 @@ async function handleOpenCodeChat( username, prompt: effectivePrompt, sessionKey: sessionId, - cwd, + cwd: openCodeDir, model, role: ws.data.role, resumeSessionId: msg.resumeSessionId, diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index c1a84ceb..92946f24 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -45,7 +45,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr (params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ?? params.resumeSessionId; if (!opencodeSessionId) { - opencodeSessionId = await conn.createSession(); + opencodeSessionId = await conn.createSession(params.cwd); } setOpenCodeSession(params.sessionKey, opencodeSessionId); const sessionId = opencodeSessionId;