diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 2d9816d0..5ddfcf6e 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run | Property | Type | Notes | |---|---|---| -| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` | +| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` | | `sub_use_case` | string? | Refines `use_case` — see taxonomy table below | | `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` | | `model` | string | e.g. `claude-sonnet-4-6` | @@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | +| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) | ##### `live_note_agent` sub-use-case shape diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 35112709..2f81dedc 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -34,6 +34,13 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; +import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'; +import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js'; +import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; +import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; +import * as codeGit from '@x/core/dist/code-mode/git/service.js'; +import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; +import type { CodeSession } from '@x/shared/dist/code-sessions.js'; import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; @@ -375,6 +382,32 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro } } +async function requireCodeSession(sessionId: string): Promise { + const repo = container.resolve('codeSessionsRepo'); + const session = await repo.get(sessionId); + if (!session) { + throw new Error(`Unknown code session: ${sessionId}`); + } + return session; +} + +let codeSessionStatusWatcher: (() => void) | null = null; +export async function startCodeSessionStatusWatcher(): Promise { + if (codeSessionStatusWatcher) { + return; + } + const tracker = container.resolve('codeSessionStatusTracker'); + await tracker.start(); + codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('codeSession:status', { sessionId, status }); + } + } + }); +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -557,7 +590,7 @@ export function setupIpcHandlers() { return runsCore.createRun(args); }, 'runs:createMessage': async (_event, args) => { - return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); @@ -680,6 +713,103 @@ export function setupIpcHandlers() { 'codeMode:checkAgentStatus': async () => { return await checkCodeModeAgentStatus(); }, + 'codeProject:add': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + const project = await repo.add(args.path); + const git = await codeGit.repoInfo(project.path); + return { project, git }; + }, + 'codeProject:remove': async (_event, args) => { + const repo = container.resolve('codeProjectsRepo'); + await repo.remove(args.projectId); + return { success: true }; + }, + 'codeProject:list': async () => { + const repo = container.resolve('codeProjectsRepo'); + const projects = await repo.list(); + return { + projects: await Promise.all(projects.map(async (project) => ({ + project, + git: await codeGit.repoInfo(project.path), + }))), + }; + }, + 'codeSession:create': async (_event, args) => { + const service = container.resolve('codeSessionService'); + const session = await service.create(args); + return { session }; + }, + 'codeSession:list': async () => { + const repo = container.resolve('codeSessionsRepo'); + const tracker = container.resolve('codeSessionStatusTracker'); + return { sessions: await repo.list(), statuses: tracker.getStatuses() }; + }, + 'codeSession:update': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return { session: await service.update(args.sessionId, args.patch) }; + }, + 'codeSession:delete': async (_event, args) => { + const service = container.resolve('codeSessionService'); + await service.delete(args.sessionId, { + removeWorktree: args.removeWorktree, + deleteBranch: args.deleteBranch, + }); + return { success: true }; + }, + 'codeSession:sendMessage': async (_event, args) => { + const service = container.resolve('codeSessionService'); + // Intentionally not awaited: the turn can run for minutes and streams over + // runs:events. sendMessage validates synchronously enough that busy/unknown + // errors are reported via the run's error events instead. + const resultPromise = service.sendMessage(args.sessionId, args.text); + // Surface immediate rejections (busy session, unknown id) to the caller. + const result = await Promise.race([ + resultPromise, + new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)), + ]); + resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err)); + return result; + }, + 'codeSession:stop': async (_event, args) => { + const service = container.resolve('codeSessionService'); + await service.stop(args.sessionId); + return { success: true }; + }, + 'codeSession:gitStatus': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + const info = await codeGit.repoInfo(session.cwd); + if (!info.isGitRepo) { + return { isRepo: false, branch: null, hasCommits: false, files: [] }; + } + const files = await codeGit.status(session.cwd); + return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files }; + }, + 'codeSession:fileDiff': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return codeGit.fileDiff(session.cwd, args.path); + }, + 'codeSession:readdir': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return { entries: await readProjectDir(session.cwd, args.relPath) }; + }, + 'codeSession:readFile': async (_event, args) => { + const session = await requireCodeSession(args.sessionId); + return readProjectFile(session.cwd, args.relPath); + }, + 'codeSession:mergeBack': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return service.mergeBack(args.sessionId); + }, + 'codeSession:cleanupWorktree': async (_event, args) => { + const service = container.resolve('codeSessionService'); + try { + await service.cleanupWorktree(args.sessionId, args.deleteBranch); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to clean up worktree'; + return { success: false, error: message }; + } + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); @@ -830,6 +960,15 @@ export function setupIpcHandlers() { } return { path: result.filePaths[0] ?? null }; }, + 'dialog:openFiles': async (event, args) => { + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showOpenDialog(win!, { + title: args.title ?? 'Attach files', + ...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}), + properties: ['openFile', 'multiSelections'], + }); + return { paths: result.canceled ? [] : result.filePaths }; + }, // Knowledge version history handlers 'knowledge:history': async (_event, args) => { const commits = await versionHistory.getFileHistory(args.path); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 40e49e35..5615dd6e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, + startCodeSessionStatusWatcher, startServicesWatcher, startLiveNoteAgentWatcher, startBackgroundTaskAgentWatcher, @@ -352,6 +353,9 @@ app.whenReady().then(async () => { // start runs watcher startRunsWatcher(); + // start code-session status tracker (derives working/needs-you/idle + notifications) + startCodeSessionStatusWatcher(); + // start services watcher startServicesWatcher(); diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 67876189..3482b58e 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,7 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/language": "^6.12.3", + "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.12.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.1", "@eigenpal/docx-editor-react": "^1.0.3", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -42,6 +48,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror": "^6.0.2", "lucide-react": "^0.562.0", "mermaid": "^11.14.0", "motion": "^12.23.26", diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b850b57f..fcbc1ec7 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -34,6 +34,9 @@ import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; import { MeetingsView } from '@/components/meetings-view'; +import { CodeView, type ActiveCodeSession } from '@/components/code/code-view'; +import { CodeChat } from '@/components/code/code-chat'; +import { ResizableRightPane } from '@/components/code/resizable-right-pane'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -199,6 +202,7 @@ const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__' const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__' const HOME_TAB_PATH = '__rowboat_home__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' +const CODE_TAB_PATH = '__rowboat_code__' const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) @@ -336,6 +340,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH const isHomeTabPath = (path: string) => path === HOME_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH +const isCodeTabPath = (path: string) => path === CODE_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { const normalized = category?.trim().toLowerCase() @@ -589,6 +594,7 @@ type ViewState = | { type: 'knowledge-view'; folderPath?: string } | { type: 'chat-history' } | { type: 'home' } + | { type: 'code' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -652,6 +658,8 @@ function parseDeepLink(input: string): ViewState | null { return { type: 'chat-history' } case 'home': return { type: 'home' } + case 'code': + return { type: 'code' } default: return null } @@ -1034,7 +1042,7 @@ function App() { }, []) // Runs history state - type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } + type RunListItem = { id: string; title?: string; createdAt: string; agentId: string; useCase?: string } const [runs, setRuns] = useState([]) // Chat tab state @@ -1159,6 +1167,23 @@ function App() { const [activeFileTabId, setActiveFileTabId] = useState('home-tab') const activeFileTabIdRef = useRef(activeFileTabId) activeFileTabIdRef.current = activeFileTabId + // The Code section is tab-derived (no boolean to keep in sync with the other + // section flags): it is open exactly while its sentinel tab is active. + const isCodeOpen = React.useMemo(() => { + const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) + return activeTab ? isCodeTabPath(activeTab.path) : false + }, [fileTabs, activeFileTabId]) + // The code session that owns the right-hand chat pane: rowboat-mode sessions + // bind the assistant chat to their run; direct-mode sessions swap the pane + // for the direct-drive chat. + const [activeCodeSession, setActiveCodeSession] = useState(null) + // A file the code chat asked to review — consumed by the workspace pane. + const [codeDiffPath, setCodeDiffPath] = useState(null) + const boundCodeSessionRef = useRef(null) + // Composer locks for runs that are code sessions: the session's cwd + agent + // are frozen in the chat input (the backend pins them server-side anyway). + // Kept after the Code view unmounts — the chat tab stays bound to the run. + const [codeSessionLocks, setCodeSessionLocks] = useState>({}) const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(new Map()) const fileTabIdCounterRef = useRef(0) @@ -1175,6 +1200,7 @@ function App() { if (isKnowledgeViewTabPath(tab.path)) return 'Notes' if (isChatHistoryTabPath(tab.path)) return 'Chat history' if (isHomeTabPath(tab.path)) return 'Home' + if (isCodeTabPath(tab.path)) return 'Code' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -1807,8 +1833,8 @@ function App() { cursor = result.nextCursor } while (cursor) - // Filter for copilot runs only - const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot') + // Filter for copilot chats only (Code-section sessions live in the Code view) + const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session') setRuns(copilotRuns) } catch (err) { console.error('Failed to load runs:', err) @@ -2075,6 +2101,15 @@ function App() { setConversation(items) setRunId(id) setMessage('') + // Reconcile composer state with THIS run. Loading a run while another one + // is mid-turn (e.g. binding a code session steals the single chat tab) + // must not leave isProcessing/isStopping pointing at the old run — that + // wedges the composer: stop targets the new run (a no-op) while the old + // run's processing-end arrives flagged as non-active and clears nothing. + setIsProcessing(processingRunIdsRef.current.has(id)) + setIsStopping(false) + setStopClickedAt(null) + setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '') setPendingPermissionRequests(pendingPerms) setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) @@ -2145,6 +2180,11 @@ function App() { break case 'start': + // Run creation alone isn't a turn. Code-session runs are created when + // the session is (no message follows until the user sends one), so + // marking them processing here would never be cleared — and wedge the + // composer (Stop shown, send blocked) once the session binds a chat tab. + if (event.useCase === 'code_session') return setProcessingRunIds(prev => { if (prev.has(event.runId)) return prev const next = new Set(prev) @@ -2878,6 +2918,38 @@ function App() { } }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab]) + // A code session was selected (or changed mode/status) in the Code view. + // Rowboat-mode sessions take over the assistant chat pane by binding their + // run to a chat tab — the conversation IS the assistant chat, no copy. + // Direct-mode sessions render their own pane instead (see right-pane JSX). + const handleCodeSessionSelected = useCallback((active: ActiveCodeSession | null) => { + setActiveCodeSession(active) + if (active) { + const { id, cwd, agent } = active.session + setCodeSessionLocks((prev) => ( + prev[id]?.cwd === cwd && prev[id]?.agent === agent + ? prev + : { ...prev, [id]: { cwd, agent } } + )) + } + const rowboatSessionId = active && active.session.mode === 'rowboat' ? active.session.id : null + if (!rowboatSessionId) { + boundCodeSessionRef.current = null + return + } + if (boundCodeSessionRef.current === rowboatSessionId) return + boundCodeSessionRef.current = rowboatSessionId + const existingTab = chatTabsRef.current.find((t) => t.runId === rowboatSessionId) + if (existingTab) { + switchChatTab(existingTab.id) + return + } + setChatTabs((prev) => prev.map((t) => ( + t.id === activeChatTabIdRef.current ? { ...t, runId: rowboatSessionId } : t + ))) + loadRun(rowboatSessionId) + }, [switchChatTab, loadRun]) + const closeChatTab = useCallback((tabId: string) => { if (chatTabs.length <= 1) return const idx = chatTabs.findIndex(t => t.id === tabId) @@ -3147,6 +3219,14 @@ function App() { setIsHomeOpen(true) return } + if (isCodeTabPath(tab.path)) { + // isCodeOpen itself is derived from the active tab — just clear the rest. + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) @@ -3155,7 +3235,7 @@ function App() { const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -3548,10 +3628,11 @@ function App() { if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } + if (isCodeOpen) return { type: 'code' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3696,6 +3777,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureCodeFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isCodeTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: CODE_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const openEmailView = useCallback((threadId?: string) => { setSelectedPath(null) setIsGraphOpen(false) @@ -3751,6 +3843,18 @@ function App() { ensureMeetingsFileTab() }, [ensureMeetingsFileTab]) + const openCodeView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + ensureCodeFileTab() + }, [ensureCodeFileTab]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -3931,6 +4035,17 @@ function App() { setIsHomeOpen(true) ensureHomeFileTab() return + case 'code': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) + ensureCodeFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3959,7 +4074,7 @@ function App() { } return } - }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -4294,7 +4409,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -5300,7 +5415,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const nonChatPaneStyle = React.useMemo(() => { @@ -5369,12 +5484,14 @@ function App() { isHomeOpen ? 'home' : isEmailOpen ? 'email' : isMeetingsOpen ? 'meetings' + : isCodeOpen ? 'code' : (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge' : isBgTasksOpen ? 'agents' : isWorkspaceOpen ? 'workspaces' : null } onOpenMeetings={openMeetingsView} + onOpenCode={openCodeView} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} recentRuns={runs} @@ -5408,7 +5525,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : isFullScreenChat ? ( Version history )} - {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && ( + {!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && ( - {collapseLevel < 4 && ( + {collapseLevel < 4 && !isCodeLocked && ( - Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable + + {isCodeLocked + ? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}` + : `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`} + ) : (
@@ -1018,14 +1068,20 @@ function ChatInputInner({ - Code mode on — click to disable + + {isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'} + · @@ -1033,13 +1089,19 @@ function ChatInputInner({ - Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap + {isCodeLocked + ? `Coding agent fixed by the session: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}` + : `Coding agent: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap`}
@@ -1077,10 +1139,10 @@ function ChatInputInner({ More options - {workDir && collapseLevel >= 8 && ( - { void handleSetWorkDir() }}> - - {basename(workDir) || workDir} + {effectiveWorkDir && collapseLevel >= 8 && ( + { void handleSetWorkDir() }}> + {isCodeLocked ? : } + {basename(effectiveWorkDir) || effectiveWorkDir} )} {searchAvailable && collapseLevel >= 7 && ( @@ -1105,14 +1167,15 @@ function ChatInputInner({ {codeModeFeatureEnabled && collapseLevel >= 5 && ( <> e.preventDefault()} onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} > Code mode - {codeModeEnabled && ( - { e.preventDefault(); handleToggleCodingAgent() }}> + {(isCodeLocked || codeModeEnabled) && ( + { e.preventDefault(); handleToggleCodingAgent() }}> Coding agent {codingAgent === 'claude' ? 'Claude' : 'Codex'} @@ -1308,6 +1371,8 @@ export interface ChatInputWithMentionsProps { onSelectedModelChange?: (model: SelectedModel | null) => void workDir?: string | null onWorkDirChange?: (value: string | null) => void + /** Set when this chat is bound to a Code-section session — freezes workdir + agent. */ + codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null } export function ChatInputWithMentions({ @@ -1339,6 +1404,7 @@ export function ChatInputWithMentions({ onSelectedModelChange, workDir, onWorkDirChange, + codeSessionLock, }: ChatInputWithMentionsProps) { return ( @@ -1368,6 +1434,7 @@ export function ChatInputWithMentions({ onSelectedModelChange={onSelectedModelChange} workDir={workDir} onWorkDirChange={onWorkDirChange} + codeSessionLock={codeSessionLock} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 6300f4cc..0f89570f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react' +import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -155,6 +155,13 @@ interface ChatSidebarProps { onDraftChangeForTab?: (tabId: string, text: string) => void onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void workDirByTab?: Record + /** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */ + codeSessionLocks?: Record + /** + * Set while a Rowboat-mode code session owns this pane: the chat is pinned to + * the session, so the chat switcher / new-chat / history affordances hide. + */ + pinnedToCodeSession?: { title: string } | null onWorkDirChangeForTab?: (tabId: string, value: string | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] @@ -216,6 +223,8 @@ export function ChatSidebar({ onDraftChangeForTab, onSelectedModelChangeForTab, workDirByTab = {}, + codeSessionLocks = {}, + pinnedToCodeSession = null, onWorkDirChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), @@ -555,17 +564,34 @@ export function ChatSidebar({ transition: isMaximized ? 'padding-left 200ms linear' : undefined, }} > - { - const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) - return activeTab ? getChatTabTitle(activeTab) : 'New chat' - })()} - onNewChatTab={onNewChatTab} - recentRuns={recentRuns} - activeRunId={runId} - onSelectRun={onSelectRun} - onOpenChatHistory={onOpenChatHistory} - /> + {pinnedToCodeSession ? ( + + +
+ + {pinnedToCodeSession.title} + + Coding session + +
+
+ + This chat is pinned to the coding session — leave the Code view to switch chats. + +
+ ) : ( + { + const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) + return activeTab ? getChatTabTitle(activeTab) : 'New chat' + })()} + onNewChatTab={onNewChatTab} + recentRuns={recentRuns} + activeRunId={runId} + onSelectRun={onSelectRun} + onOpenChatHistory={onOpenChatHistory} + /> + )} @@ -646,9 +672,11 @@ export function ChatSidebar({ {!tabHasConversation ? ( ) : ( @@ -779,6 +807,7 @@ export function ChatSidebar({ onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} workDir={workDirByTab[tab.id] ?? null} onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined} + codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/apps/renderer/src/components/code/cm.ts b/apps/x/apps/renderer/src/components/code/cm.ts new file mode 100644 index 00000000..4b75c1b2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/cm.ts @@ -0,0 +1,84 @@ +import { EditorView, lineNumbers } from '@codemirror/view' +import { EditorState, type Extension } from '@codemirror/state' +import { + HighlightStyle, + LanguageDescription, + bracketMatching, + syntaxHighlighting, + defaultHighlightStyle, +} from '@codemirror/language' +import { languages } from '@codemirror/language-data' +import { tags } from '@lezer/highlight' + +// Shared CodeMirror setup for the Code section's read-only viewers +// (file viewer + diff viewer). Theming keys off the app's resolved theme +// instead of pulling in a theme package. + +const darkHighlight = HighlightStyle.define([ + { tag: tags.keyword, color: '#c678dd' }, + { tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: '#e06c75' }, + { tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' }, + { tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' }, + { tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' }, + { tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: '#e5c07b' }, + { tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: '#56b6c2' }, + { tag: [tags.meta, tags.comment], color: '#7d8799', fontStyle: 'italic' }, + { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' }, + { tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' }, + { tag: tags.invalid, color: '#ffffff' }, +]) + +export function cmBaseExtensions(isDark: boolean): Extension[] { + return [ + lineNumbers(), + bracketMatching(), + syntaxHighlighting(isDark ? darkHighlight : defaultHighlightStyle, { fallback: true }), + EditorView.lineWrapping, + EditorState.readOnly.of(true), + EditorView.editable.of(false), + EditorView.theme( + { + '&': { + backgroundColor: 'transparent', + fontSize: '12px', + height: '100%', + }, + '.cm-scroller': { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + overflow: 'auto', + }, + '.cm-gutters': { + backgroundColor: 'transparent', + border: 'none', + color: isDark ? '#6b7280' : '#9ca3af', + }, + '&.cm-focused': { outline: 'none' }, + // GitHub-style expander bar for folded unchanged regions (@codemirror/merge). + '.cm-collapsedLines': { + backgroundColor: isDark ? 'rgba(56, 139, 253, 0.15)' : 'rgba(9, 105, 218, 0.08)', + backgroundImage: 'none', + color: isDark ? '#79c0ff' : '#0969da', + padding: '4px 12px', + fontSize: '11px', + cursor: 'pointer', + }, + '.cm-collapsedLines:hover': { + backgroundColor: isDark ? 'rgba(56, 139, 253, 0.25)' : 'rgba(9, 105, 218, 0.15)', + }, + }, + { dark: isDark }, + ), + ] +} + +// Resolve a language extension from the filename (lazy-loaded; Vite splits +// each language into its own chunk). +export async function cmLanguageFor(filename: string): Promise { + const desc = LanguageDescription.matchFilename(languages, filename) + if (!desc) return null + try { + return await desc.load() + } catch { + return null + } +} diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx new file mode 100644 index 00000000..90b6772c --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -0,0 +1,350 @@ +import { useEffect, useRef, useState } from 'react' +import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react' +import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation' +import { MessageResponse } from '@/components/ai-elements/message' +import { Shimmer } from '@/components/ai-elements/shimmer' +import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool' +import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation' +import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run' +import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' +import { WebSearchResult } from '@/components/ai-elements/web-search-result' +import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat' + +const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } + +function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) { + const [open, setOpen] = useState(false) + const webSearch = getWebSearchCardData(item) + if (webSearch) { + return ( + + ) + } + if (item.name === 'code_agent_run') { + const agent = (item.result as { agent?: string } | undefined)?.agent + ?? (item.input as { agent?: string } | undefined)?.agent + return ( + + + + + + + ) + } + return ( +
+ {item.status === 'running' || item.status === 'pending' + ? + : } + {getToolDisplayName(item)} +
+ ) +} + +function ChatItem({ item, onOpenDiff }: { item: CodeChatItem; onOpenDiff: (path: string) => void }) { + if (isDirectTurn(item)) { + if (item.events.length === 0) return null + return ( +
+ +
+ ) + } + if (isChatErrorMessage(item)) { + return ( +
+ {item.message.split('\n')[0]} +
+ ) + } + if (isChatToolCall(item)) { + return + } + if (item.role === 'user') { + return ( +
+
+ {item.content} +
+
+ ) + } + return ( +
+ {item.content} +
+ ) +} + +// Direct-drive chat for one coding session, rendered in the right-side pane in +// place of the assistant chat. Messages go straight to the ACP agent — when the +// session is in Rowboat mode this component isn't used (the real assistant +// chat pane is, bound to the session's run). +export function CodeChat({ + session, + status, + onOpenDiff, +}: { + session: CodeSession + status: CodeSessionStatus + onOpenDiff: (path: string) => void +}) { + const { + items, liveText, isProcessing, pendingPermission, pendingToolPermissions, pendingAskHumans, + loading, send, stop, resolvePermission, respondToToolPermission, respondToAskHuman, + } = useCodeChat(session) + const [draft, setDraft] = useState('') + const [stopping, setStopping] = useState(false) + const textareaRef = useRef(null) + + const busy = isProcessing || status === 'working' || status === 'needs-you' + // Attached file PATHS — like dragging a file into the Claude Code CLI, the + // agent receives paths and reads the files itself with its own tools. + const [attachments, setAttachments] = useState([]) + + useEffect(() => { + setDraft('') + setAttachments([]) + setStopping(false) + textareaRef.current?.focus() + }, [session.id]) + + useEffect(() => { + if (!busy) setStopping(false) + }, [busy]) + + const addAttachments = (paths: string[]) => { + const cleaned = paths.filter(Boolean) + if (cleaned.length === 0) return + setAttachments((prev) => [...prev, ...cleaned.filter((p) => !prev.includes(p))]) + } + + const handlePickFiles = async () => { + const res = await window.ipc.invoke('dialog:openFiles', { + title: 'Attach files', + defaultPath: session.cwd, + }) + addAttachments(res.paths) + textareaRef.current?.focus() + } + + const handleDrop = (e: React.DragEvent) => { + if (!e.dataTransfer?.files?.length) return + e.preventDefault() + const paths = Array.from(e.dataTransfer.files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + addAttachments(paths) + } + + const canSend = (Boolean(draft.trim()) || attachments.length > 0) && !busy + + const handleSend = async () => { + if (!canSend) return + const text = draft.trim() + const files = attachments + // The agent gets paths, CLI-style; it reads them from disk on its own. + const message = files.length > 0 + ? `${text || 'Look at the attached files.'}\n\nAttached files (read them from disk):\n${files.map((p) => `- ${p}`).join('\n')}` + : text + setDraft('') + setAttachments([]) + const result = await send(message) + if (!result.ok && result.error) { + toast.error(result.error) + setDraft(text) + setAttachments(files) + } + } + + const handleStop = async () => { + setStopping(true) + await stop() + } + + const basename = (p: string) => p.split(/[\\/]/).pop() || p + + return ( +
{ if (e.dataTransfer?.types?.includes('Files')) e.preventDefault() }} + onDrop={handleDrop} + > + {/* Slim header — session controls live in the Code view's middle header */} +
+ +
+
{session.title}
+
{AGENT_LABEL[session.agent]} — direct
+
+
+ + {/* Conversation */} + + + {loading &&
Loading conversation…
} + {!loading && items.length === 0 && !busy && ( +
+
+ Talk directly to {AGENT_LABEL[session.agent]} +
+

+ Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here. +

+
+ )} + {items.map((item) => ( + + ))} + {liveText && ( +
+ {liveText.replace(/<\/?voice>/g, '')} +
+ )} + {pendingPermission && ( + void resolvePermission(d)} /> + )} + {Array.from(pendingToolPermissions.values()).map((request) => ( + void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve')} + onApproveSession={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'session')} + onApproveAlways={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'always')} + onDeny={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'deny')} + isProcessing={busy} + /> + ))} + {Array.from(pendingAskHumans.values()).map((request) => ( + void respondToAskHuman(request.toolCallId, request.subflow, response)} + isProcessing={busy} + /> + ))} + {busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && ( + + {stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`} + + )} +
+ +
+ + {/* Composer — mirrors the assistant chat input's look (rounded card, + borderless textarea, round primary send / destructive stop). */} +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((p) => ( + + + {basename(p)} + + + ))} +
+ )} +
+