From 3384f0f38f7be347a6a6f25d320e52050b2e939a Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:38:35 +0530 Subject: [PATCH] rowboat driven mode uses the same assistant chat UI --- apps/x/apps/renderer/src/App.tsx | 98 +++++++++++- .../components/chat-input-with-mentions.tsx | 143 +++++++++++++----- .../renderer/src/components/chat-sidebar.tsx | 59 ++++++-- .../src/components/code/code-chat.tsx | 131 +++++++--------- .../src/components/code/code-view.tsx | 139 +++++++++++++---- .../components/code/new-session-dialog.tsx | 110 +++++++++++++- .../src/components/code/use-code-chat.ts | 99 +++++++++++- .../core/src/code-mode/sessions/service.ts | 30 +++- apps/x/packages/core/src/runs/runs.ts | 15 ++ apps/x/packages/shared/src/ipc.ts | 4 + 10 files changed, 655 insertions(+), 173 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 417d2284..72bcb577 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -34,7 +34,8 @@ 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 } from '@/components/code/code-view'; +import { CodeView, type ActiveCodeSession } from '@/components/code/code-view'; +import { CodeChat } from '@/components/code/code-chat'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -1171,6 +1172,17 @@ function App() { 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) @@ -2088,6 +2100,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) @@ -2158,6 +2179,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) @@ -2891,6 +2917,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) @@ -5356,7 +5414,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(() => { @@ -5635,7 +5693,11 @@ function App() { ) : isCodeOpen ? (
- + setCodeDiffPath(null)} + />
) : isLiveNotesOpen ? (
@@ -6072,6 +6134,7 @@ function App() { presetMessage={isActive ? presetMessage : undefined} onPresetMessageConsumed={isActive ? () => setPresetMessage(undefined) : undefined} runId={tabState.runId} + codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null} initialDraft={chatDraftsRef.current.get(tab.id)} onDraftChange={(text) => setChatDraftForTab(tab.id, text)} onSelectedModelChange={(m) => { @@ -6106,8 +6169,23 @@ function App() { )} - {/* Chat pane - shown when viewing files/graph */} - {isRightPaneContext && ( + {/* Chat pane - shown when viewing files/graph. For a direct-mode + code session it swaps to the direct-drive chat; rowboat-mode + sessions use the regular assistant chat bound to their run. */} + {isRightPaneContext && isCodeOpen && activeCodeSession?.session.mode === 'direct' ? ( + + ) : isRightPaneContext && ( t.id === activeChatTabId)?.runId === activeCodeSession.session.id + ? { title: activeCodeSession.session.title } + : null + } pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0254cdfd..fcad45b4 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -18,6 +18,7 @@ import { Headphones, ImagePlus, LoaderIcon, + Lock, Mic, MoreHorizontal, Plus, @@ -237,6 +238,12 @@ interface ChatInputInnerProps { workDir?: string | null /** Fired when the user sets/changes/clears the work directory for this chat. */ onWorkDirChange?: (value: string | null) => void + /** + * Set when this chat is bound to a Code-section session: the work directory + * and coding agent come from the session and are FROZEN — the backend pins + * them server-side regardless, so the composer must not pretend otherwise. + */ + codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null } function ChatInputInner({ @@ -265,6 +272,7 @@ function ChatInputInner({ onSelectedModelChange, workDir = null, onWorkDirChange, + codeSessionLock = null, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -491,22 +499,33 @@ function ChatInputInner({ }) }, []) + // A chat bound to a Code-section session has its work directory and coding + // agent frozen to the session's — the backend pins them server-side, so the + // composer reflects that instead of offering controls that wouldn't apply. + const isCodeLocked = Boolean(codeSessionLock) + const effectiveWorkDir = codeSessionLock?.cwd ?? workDir + // Work directory is owned per-chat by the parent (App). This component only // drives the picker dialog and reports changes up via onWorkDirChange. Whenever // the work directory changes, load its persisted coding-agent preference. useEffect(() => { + if (codeSessionLock) { + setCodingAgent(codeSessionLock.agent) + return + } let cancelled = false loadCodingAgentFor(workDir).then((agent) => { if (!cancelled) setCodingAgent(agent) }) return () => { cancelled = true } - }, [workDir, loadCodingAgentFor]) + }, [workDir, loadCodingAgentFor, codeSessionLock]) useEffect(() => { - if (isActive && workDir) void rememberWorkDir(workDir) - }, [isActive, workDir, rememberWorkDir]) + if (isActive && workDir && !isCodeLocked) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir, isCodeLocked]) const handleSetWorkDir = useCallback(async () => { + if (isCodeLocked) return try { let defaultPath: string | undefined = workDir ?? undefined try { @@ -533,7 +552,7 @@ function ChatInputInner({ console.error('Failed to set work directory', err) toast.error('Failed to set work directory') } - }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) + }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor, isCodeLocked]) const handleSelectRecentWorkDir = useCallback(async (dir: string) => { onWorkDirChange?.(dir) @@ -543,12 +562,14 @@ function ChatInputInner({ }, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor]) const handleClearWorkDir = useCallback(() => { + if (isCodeLocked) return onWorkDirChange?.(null) setCodingAgent('claude') toast.success('Work directory cleared') - }, [onWorkDirChange]) + }, [onWorkDirChange, isCodeLocked]) const handleToggleCodingAgent = useCallback(async () => { + if (isCodeLocked) return const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude' setCodingAgent(next) // Persist only when scoped to a workdir; without one there's nothing to key on. @@ -561,7 +582,7 @@ function ChatInputInner({ // revert on failure setCodingAgent(codingAgent) } - }, [workDir, codingAgent, persistCodingAgent]) + }, [workDir, codingAgent, persistCodingAgent, isCodeLocked]) // Check search tool availability (exa or signed-in via gateway) useEffect(() => { @@ -647,15 +668,16 @@ function ChatInputInner({ const handleSubmit = useCallback(() => { if (!canSubmit) return - // codeMode is sticky per conversation — don't reset after send. - const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined + // codeMode is sticky per conversation — don't reset after send. A code + // session forces it (the backend pins the agent anyway). + const effectiveCodeMode = codeSessionLock ? codeSessionLock.agent : (codeModeEnabled ? codingAgent : undefined) onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode) controller.textInput.clear() controller.mentions.clearMentions() setAttachments([]) // Web search toggle stays on for the rest of the chat session; the user // turns it off explicitly. (Not persisted across app restarts.) - }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir]) + }, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir, codeSessionLock]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -697,8 +719,8 @@ function ChatInputInner({ const visibleRecentWorkDirs = recentWorkDirs .filter((entry) => entry.path !== workDir) .slice(0, MAX_VISIBLE_RECENT_WORK_DIRS) - const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set' - const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : '' + const currentWorkDirLabel = effectiveWorkDir ? basename(effectiveWorkDir) || effectiveWorkDir : 'Not set' + const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : '' return (
@@ -820,7 +842,7 @@ function ChatInputInner({ - {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + {isCodeLocked ? 'Add files' : workDir ? 'Add files or change work directory' : 'Add files or set work directory'} @@ -830,8 +852,21 @@ function ChatInputInner({ Add files or photos - {/* Working directory lives behind a submenu so the main menu stays to two - items. One hover/click away for power users; out of the way otherwise. */} + {/* A bound code session pins the directory — show it, no controls. */} + {isCodeLocked ? ( +
+ + + {currentWorkDirLabel} + Pinned by the coding session + +
+ ) : ( + /* Working directory lives behind a submenu so the main menu stays to two + items. One hover/click away for power users; out of the way otherwise. */ @@ -907,26 +942,31 @@ function ChatInputInner({ )} + )}
- {workDir && collapseLevel < 8 && ( + {effectiveWorkDir && collapseLevel < 8 && ( {/* Level 4: collapse to a square icon */}
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" )}> - {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/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx index 47c557e1..934f5f84 100644 --- a/apps/x/apps/renderer/src/components/code/code-chat.tsx +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -1,35 +1,36 @@ import { useEffect, useRef, useState } from 'react' -import { ArrowUp, Bot, GitBranch, Loader2, Square, User } from 'lucide-react' +import { ArrowUp, Loader2, Square, Terminal } from 'lucide-react' import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' -import type { ApprovalPolicy } from '@x/shared/src/code-mode.js' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' 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, type ToolCall } from '@/lib/chat-conversation' +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' } -const POLICY_LABEL: Record = { - ask: 'Ask every time', - 'auto-approve-reads': 'Auto-approve reads', - yolo: 'Auto-approve everything', -} 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 @@ -87,20 +88,23 @@ function ChatItem({ item, onOpenDiff }: { item: CodeChatItem; onOpenDiff: (path: ) } -// The chat surface for one coding session: direct messages go straight to the -// ACP agent; with "Rowboat drives" on they route through the copilot LLM. +// 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, - onUpdateSession, }: { session: CodeSession status: CodeSessionStatus onOpenDiff: (path: string) => void - onUpdateSession: (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => void }) { - const { items, liveText, isProcessing, pendingPermission, loading, send, stop, resolvePermission } = useCodeChat(session) + 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) @@ -135,46 +139,13 @@ export function CodeChat({ return (
- {/* Session header */} -
+ {/* Slim header — session controls live in the Code view's middle header */} +
+
{session.title}
-
- {AGENT_LABEL[session.agent]} - · - {session.cwd} - {session.worktree && !session.worktree.removedAt && ( - - - {session.worktree.branch} - - )} -
+
{AGENT_LABEL[session.agent]} — direct
- - - - - - {(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => ( - onUpdateSession({ policy })}> - {POLICY_LABEL[policy]} - {session.policy === policy && } - - ))} - - -
{/* Conversation */} @@ -184,14 +155,10 @@ export function CodeChat({ {!loading && items.length === 0 && !busy && (
- {session.mode === 'direct' - ? `Talk directly to ${AGENT_LABEL[session.agent]}` - : `Rowboat will drive ${AGENT_LABEL[session.agent]} for you`} + Talk directly to {AGENT_LABEL[session.agent]}

- {session.mode === 'direct' - ? 'Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here.' - : 'Describe the outcome you want — Rowboat plans the work, runs the coding agent, checks results, and reports back.'} + Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here.

)} @@ -206,9 +173,30 @@ export function CodeChat({ {pendingPermission && ( void resolvePermission(d)} /> )} - {busy && !pendingPermission && ( + {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…' : session.mode === 'direct' ? `${AGENT_LABEL[session.agent]} is working…` : 'Working…'} + {stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`} )} @@ -228,11 +216,7 @@ export function CodeChat({ void handleSend() } }} - placeholder={ - session.mode === 'direct' - ? `Message ${AGENT_LABEL[session.agent]}…` - : 'Tell Rowboat what to build or fix…' - } + placeholder={`Message ${AGENT_LABEL[session.agent]}…`} className="max-h-40 min-h-[44px] flex-1 resize-none" rows={1} /> @@ -259,11 +243,8 @@ export function CodeChat({ )}
-
- - {session.mode === 'direct' - ? `Direct — messages go straight to ${AGENT_LABEL[session.agent]}` - : 'Rowboat orchestrates the coding agent for you'} +
+ Direct — messages go straight to {AGENT_LABEL[session.agent]}
diff --git a/apps/x/apps/renderer/src/components/code/code-view.tsx b/apps/x/apps/renderer/src/components/code/code-view.tsx index f9b3d4b3..277d6836 100644 --- a/apps/x/apps/renderer/src/components/code/code-view.tsx +++ b/apps/x/apps/renderer/src/components/code/code-view.tsx @@ -1,9 +1,16 @@ -import { useCallback, useState } from 'react' -import { Code2 } from 'lucide-react' -import type { CodeSession } from '@x/shared/src/code-sessions.js' +import { useCallback, useEffect, useState } from 'react' +import { Bot, Code2, GitBranch } from 'lucide-react' +import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' import type { ApprovalPolicy } from '@x/shared/src/code-mode.js' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, @@ -17,22 +24,54 @@ import { import { useCodeSessions } from './use-code-sessions' import { SessionRail } from './session-rail' import { NewSessionDialog } from './new-session-dialog' -import { CodeChat } from './code-chat' import { WorkspacePane } from './workspace-pane' -// The Code section: projects → sessions → chat + workspace. Sessions run -// Claude Code / Codex directly (or via Rowboat), with diffs and files on the -// right. -export function CodeView() { +const AGENT_LABEL: Record = { claude: 'Claude Code', codex: 'Codex' } +const POLICY_LABEL: Record = { + ask: 'Ask every time', + 'auto-approve-reads': 'Auto-approve reads', + yolo: 'Auto-approve everything', +} + +export interface ActiveCodeSession { + session: CodeSession + status: CodeSessionStatus +} + +// The Code section's middle pane: session rail + workspace (diffs/files). +// The conversation lives in the RIGHT pane — the assistant chat bound to the +// session's run when Rowboat drives, or the direct-drive chat otherwise. +// App.tsx learns which via onSessionSelected and renders the right pane. +export function CodeView({ + onSessionSelected, + openDiffPath, + onDiffOpened, +}: { + onSessionSelected?: (active: ActiveCodeSession | null) => void + // A file path the chat asked to review (clicking a changed file in a tool call). + openDiffPath?: string | null + onDiffOpened?: () => void +}) { const { projects, sessions, statusOf, refresh } = useCodeSessions() const [selectedSessionId, setSelectedSessionId] = useState(null) const [newSessionProjectId, setNewSessionProjectId] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) - const [openDiffPath, setOpenDiffPath] = useState(null) const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null + const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle' const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null + // Tell App which session (and status) owns the right-hand chat pane. + useEffect(() => { + onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null) + }, [selectedSession, selectedStatus, onSessionSelected]) + + // Leaving the Code section unmounts this view — release the right pane. + useEffect(() => { + return () => onSessionSelected?.(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const handleAddProject = useCallback(async () => { const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose a project folder' }) const dir = res.path @@ -80,6 +119,8 @@ export function CodeView() { } }, [refresh, selectedSessionId]) + const busy = selectedStatus === 'working' || selectedStatus === 'needs-you' + return (
{/* Session rail */} @@ -97,23 +138,68 @@ export function CodeView() { />
- {/* Chat */} -
+ {/* Workspace: session header + diffs/files. The chat is in the right pane. */} +
{selectedSession ? ( - void handleUpdateSession(patch)} - /> + <> +
+
+
{selectedSession.title}
+
+ {AGENT_LABEL[selectedSession.agent]} + · + {selectedSession.cwd} + {selectedSession.worktree && !selectedSession.worktree.removedAt && ( + + + {selectedSession.worktree.branch} + + )} +
+
+ + + + + + {(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => ( + void handleUpdateSession({ policy })}> + {POLICY_LABEL[policy]} + {selectedSession.policy === policy && } + + ))} + + + +
+
+ onDiffOpened?.()} + onSessionChanged={() => void refresh()} + /> +
+ ) : (
Code with agents

- Run Claude Code or Codex on your projects — directly, or let Rowboat drive them. - Sessions stream the agent's plan, tool calls, and diffs, and you review changes on the right. + Run Claude Code or Codex on your projects — let Rowboat drive them, or talk to them + directly. The conversation happens in the chat pane on the right; changes and files + show here.

{projects.length === 0 ? ( @@ -124,19 +210,6 @@ export function CodeView() { )}
- {/* Workspace pane */} - {selectedSession && ( -
- setOpenDiffPath(null)} - onSessionChanged={() => void refresh()} - /> -
- )} - = { ask: 'Ask every time', @@ -31,6 +32,38 @@ const POLICY_LABEL: Record = { yolo: 'Auto-approve everything (YOLO)', } +// Models the user can pick for Rowboat-mode turns — mirrors the chat +// composer's loading: gateway list when signed in, models.json otherwise. +async function loadModelOptions(): Promise { + try { + const oauth = await window.ipc.invoke('oauth:getState', null) + const connected = oauth.config?.rowboat?.connected ?? false + if (connected) { + const listResult = await window.ipc.invoke('models:list', null) + const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined) + ?.find((p) => p.id === 'rowboat') + return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id })) + } + const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) + const parsed = JSON.parse(result.data) + const models: ModelOption[] = [] + if (parsed?.providers) { + for (const [flavor, entry] of Object.entries(parsed.providers)) { + const e = entry as Record + const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : [] + const singleModel = typeof e.model === 'string' ? e.model : '' + const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] + for (const model of allModels) { + if (model) models.push({ provider: flavor, model }) + } + } + } + return models + } catch { + return [] + } +} + export function NewSessionDialog({ projectRow, open, @@ -44,10 +77,15 @@ export function NewSessionDialog({ }) { const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null) const [agent, setAgent] = useState('claude') + // Rowboat drives by default — direct CLI access is the power-user opt-in. + const [mode, setMode] = useState('rowboat') const [policy, setPolicy] = useState('auto-approve-reads') const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo') const [title, setTitle] = useState('') const [creating, setCreating] = useState(false) + const [modelOptions, setModelOptions] = useState([]) + // 'default' = let the backend use the configured default model. + const [modelKey, setModelKey] = useState('default') const git = projectRow?.git const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits @@ -57,6 +95,9 @@ export function NewSessionDialog({ setTitle('') setCreating(false) setIsolation('in-repo') + setMode('rowboat') + setModelKey('default') + void loadModelOptions().then(setModelOptions) void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => { setAgentStatus(status) // Default to whichever agent is actually ready. @@ -77,13 +118,17 @@ export function NewSessionDialog({ if (!projectRow) return setCreating(true) try { + const picked = modelKey !== 'default' + ? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey) + : undefined const res = await window.ipc.invoke('codeSession:create', { projectId: projectRow.project.id, title: title.trim() || undefined, agent, - mode: 'direct', + mode, policy, isolation, + ...(picked ? { model: picked.model, provider: picked.provider } : {}), }) onOpenChange(false) onCreated(res.session) @@ -141,6 +186,44 @@ export function NewSessionDialog({
+
+ +
+ + +
+
+
@@ -191,6 +274,27 @@ export function NewSessionDialog({
+ + {modelOptions.length > 0 && ( +
+ + +

+ Used when Rowboat drives. Fixed once the session is created, like any chat. +

+
+ )}
diff --git a/apps/x/apps/renderer/src/components/code/use-code-chat.ts b/apps/x/apps/renderer/src/components/code/use-code-chat.ts index e8e08262..08468c49 100644 --- a/apps/x/apps/renderer/src/components/code/use-code-chat.ts +++ b/apps/x/apps/renderer/src/components/code/use-code-chat.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type z from 'zod' -import type { RunEvent } from '@x/shared/src/runs.js' +import type { RunEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js' import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js' import type { CodeSession } from '@x/shared/src/code-sessions.js' import { @@ -63,6 +63,10 @@ export function useCodeChat(session: CodeSession | null) { const [liveText, setLiveText] = useState('') const [isProcessing, setIsProcessing] = useState(false) const [pendingPermission, setPendingPermission] = useState(null) + // Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission + // requests and ask-human questions. Keyed by toolCallId. + const [pendingToolPermissions, setPendingToolPermissions] = useState>>(new Map()) + const [pendingAskHumans, setPendingAskHumans] = useState>>(new Map()) const [loading, setLoading] = useState(false) const seenMessageIdsRef = useRef>(new Set()) @@ -103,6 +107,8 @@ export function useCodeChat(session: CodeSession | null) { setItems([]) setLiveText('') setPendingPermission(null) + setPendingToolPermissions(new Map()) + setPendingAskHumans(new Map()) seenMessageIdsRef.current = new Set() void window.ipc.invoke('runs:fetch', { runId: sessionId }).then((run) => { @@ -110,6 +116,10 @@ export function useCodeChat(session: CodeSession | null) { const loaded: CodeChatItem[] = [] const toolCallMap = new Map() const turnMap = new Map() + // Rebuild copilot gates still waiting on the user (request without a + // matching response in the log) so reopening a blocked session shows them. + const toolPerms = new Map>() + const askHumans = new Map>() for (const event of run.log as z.infer[]) { const ts = event.ts ? new Date(event.ts).getTime() : Date.now() @@ -172,6 +182,22 @@ export function useCodeChat(session: CodeSession | null) { } break } + case 'tool-permission-request': + toolPerms.set(event.toolCall.toolCallId, event) + break + case 'tool-permission-response': + toolPerms.delete(event.toolCallId) + break + case 'ask-human-request': + askHumans.set(event.toolCallId, event) + break + case 'ask-human-response': + askHumans.delete(event.toolCallId) + break + case 'run-stopped': + toolPerms.clear() + askHumans.clear() + break case 'error': loaded.push({ id: `error-${loaded.length}`, kind: 'error', message: event.error, timestamp: ts }) break @@ -180,6 +206,8 @@ export function useCodeChat(session: CodeSession | null) { } } setItems(loaded) + setPendingToolPermissions(toolPerms) + setPendingAskHumans(askHumans) }).catch(() => { // Run log unreadable — show an empty conversation rather than crashing. }).finally(() => { @@ -220,6 +248,28 @@ export function useCodeChat(session: CodeSession | null) { case 'run-stopped': setIsProcessing(false) setPendingPermission(null) + setPendingToolPermissions(new Map()) + setPendingAskHumans(new Map()) + break + case 'tool-permission-request': + setPendingToolPermissions((prev) => new Map(prev).set(event.toolCall.toolCallId, event)) + break + case 'tool-permission-response': + setPendingToolPermissions((prev) => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) + break + case 'ask-human-request': + setPendingAskHumans((prev) => new Map(prev).set(event.toolCallId, event)) + break + case 'ask-human-response': + setPendingAskHumans((prev) => { + const next = new Map(prev) + next.delete(event.toolCallId) + return next + }) break case 'message': { const msg = event.message @@ -374,5 +424,50 @@ export function useCodeChat(session: CodeSession | null) { }) }, [pendingPermission]) - return { items, liveText, isProcessing, pendingPermission, loading, send, stop, resolvePermission } + // Rowboat-mode copilot gates — same IPC the main chat uses. + const respondToToolPermission = useCallback(async ( + toolCallId: string, + subflow: string[], + response: 'approve' | 'deny', + scope?: 'once' | 'session' | 'always', + ) => { + if (!sessionId) return + setPendingToolPermissions((prev) => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + await window.ipc.invoke('runs:authorizePermission', { + runId: sessionId, + authorization: { subflow, toolCallId, response, scope }, + }) + }, [sessionId]) + + const respondToAskHuman = useCallback(async (toolCallId: string, subflow: string[], response: string) => { + if (!sessionId) return + setPendingAskHumans((prev) => { + const next = new Map(prev) + next.delete(toolCallId) + return next + }) + await window.ipc.invoke('runs:provideHumanInput', { + runId: sessionId, + reply: { subflow, toolCallId, response }, + }) + }, [sessionId]) + + return { + items, + liveText, + isProcessing, + pendingPermission, + pendingToolPermissions, + pendingAskHumans, + loading, + send, + stop, + resolvePermission, + respondToToolPermission, + respondToAskHuman, + } } diff --git a/apps/x/packages/core/src/code-mode/sessions/service.ts b/apps/x/packages/core/src/code-mode/sessions/service.ts index f087c169..6eaf047b 100644 --- a/apps/x/packages/core/src/code-mode/sessions/service.ts +++ b/apps/x/packages/core/src/code-mode/sessions/service.ts @@ -1,4 +1,5 @@ import path from 'path'; +import fs from 'fs/promises'; import z from 'zod'; import { WorkDir } from '../../config/config.js'; import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js'; @@ -23,6 +24,9 @@ export interface CreateSessionArgs { mode: CodeSessionMode; policy: ApprovalPolicy; isolation: 'in-repo' | 'worktree'; + // LLM for Rowboat-mode turns; unset falls through to the configured default. + model?: string; + provider?: string; } export interface SendMessageResult { @@ -34,6 +38,19 @@ function worktreeRoot(projectId: string, sessionId: string): string { return path.join(WorkDir, 'code-mode', 'worktrees', projectId, sessionId); } +// The per-run work directory the copilot anchors its general context to +// (same file the chat composer writes for regular chats). Keeping it in sync +// with the session cwd means Rowboat-mode turns see the right "# User Work +// Directory" even for tools other than code_agent_run. +async function persistRunWorkDir(runId: string, cwd: string): Promise { + try { + const file = path.join(WorkDir, 'config', `workdir-${runId}.json`); + await fs.writeFile(file, JSON.stringify({ path: cwd }, null, 2)); + } catch { + // best effort — the session meta still pins cwd for code_agent_run + } +} + // Drives Code-section sessions. A session is a run (same id) whose JSONL holds // both modes' history: Rowboat turns are written by the agent runtime; direct // turns are written here. The direct path talks straight to the ACP engine — @@ -94,7 +111,12 @@ export class CodeSessionService { // The session is a real run so Rowboat mode (agent runtime) works on it // directly and the existing runs plumbing (fetch/events/stop) applies. const { createRun } = await import('../../runs/runs.js'); - const run = await createRun({ agentId: 'copilot', useCase: 'code_session' }); + const run = await createRun({ + agentId: 'copilot', + useCase: 'code_session', + ...(args.model ? { model: args.model } : {}), + ...(args.provider ? { provider: args.provider } : {}), + }); const sessionId = run.id; let cwd = project.path; @@ -123,6 +145,7 @@ export class CodeSessionService { createdAt: new Date().toISOString(), }; await this.codeSessionsRepo.save(session); + await persistRunWorkDir(sessionId, cwd); return session; } @@ -301,12 +324,14 @@ export class CodeSessionService { ...(deleteBranch ? { deleteBranch: session.worktree.branch } : {}), }); } + const nextCwd = project?.path ?? session.cwd; await this.codeSessionsRepo.save({ ...session, // The worktree is gone — fall back to working directly in the repo. - cwd: project?.path ?? session.cwd, + cwd: nextCwd, worktree: { ...session.worktree, removedAt: new Date().toISOString() }, }); + await persistRunWorkDir(sessionId, nextCwd); } async delete(sessionId: string, opts: { removeWorktree?: boolean; deleteBranch?: boolean } = {}): Promise { @@ -325,6 +350,7 @@ export class CodeSessionService { await clearStoredSession(sessionId); await this.codeSessionsRepo.remove(sessionId); await this.runsRepo.delete(sessionId).catch(() => {}); + await fs.rm(path.join(WorkDir, 'config', `workdir-${sessionId}.json`), { force: true }).catch(() => {}); } private async touch(session: CodeSession): Promise { diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index eeae02a4..80d7f7c9 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -3,6 +3,7 @@ import container from "../di/container.js"; import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js"; import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js"; import { IRunsRepo } from "./repo.js"; +import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; import { IBus } from "../application/lib/bus.js"; import { IAbortRegistry } from "./abort-registry.js"; @@ -41,6 +42,20 @@ export async function createRun(opts: z.infer): Promise } export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex', codeCwd?: string, codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo'): Promise { + // Code-section sessions carry their coding context in the session meta. + // Pin it here — not in the composer — so EVERY path into the run (assistant + // chat pane, voice, palette) drives the session's agent in its directory, + // and the session header stays the single source of truth. + try { + const sessionMeta = await container.resolve('codeSessionsRepo').get(runId); + if (sessionMeta) { + codeMode = sessionMeta.agent; + codeCwd = sessionMeta.cwd; + codePolicy = sessionMeta.policy; + } + } catch { + // sessions repo unavailable — treat as a regular chat run + } const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy); const runtime = container.resolve('agentRuntime'); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 1b0bf39f..8809dcfb 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -502,6 +502,10 @@ const ipcSchemas = { mode: CodeSessionMode, policy: ApprovalPolicy, isolation: z.enum(['in-repo', 'worktree']), + // LLM for Rowboat-mode turns. Unset = the configured default. Like any + // chat, the model is fixed once the session's run exists. + model: z.string().optional(), + provider: z.string().optional(), }), res: z.object({ session: CodeSession,