rowboat driven mode uses the same assistant chat UI

This commit is contained in:
Arjun 2026-06-12 01:38:35 +05:30
parent 89621b8bf0
commit 3384f0f38f
10 changed files with 655 additions and 173 deletions

View file

@ -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<ActiveCodeSession | null>(null)
// A file the code chat asked to review — consumed by the workspace pane.
const [codeDiffPath, setCodeDiffPath] = useState<string | null>(null)
const boundCodeSessionRef = useRef<string | null>(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<Record<string, { cwd: string; agent: 'claude' | 'codex' }>>({})
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(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<React.CSSProperties>(() => {
@ -5635,7 +5693,11 @@ function App() {
</div>
) : isCodeOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<CodeView />
<CodeView
onSessionSelected={handleCodeSessionSelected}
openDiffPath={codeDiffPath}
onDiffOpened={() => setCodeDiffPath(null)}
/>
</div>
) : isLiveNotesOpen ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
@ -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() {
)}
</SidebarInset>
{/* 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' ? (
<aside
className="flex min-h-0 shrink-0 flex-col border-l bg-background"
style={{ width: DEFAULT_CHAT_PANE_WIDTH }}
onMouseDownCapture={() => setActiveShortcutPane('right')}
>
<CodeChat
key={activeCodeSession.session.id}
session={activeCodeSession.session}
status={activeCodeSession.status}
onOpenDiff={setCodeDiffPath}
/>
</aside>
) : isRightPaneContext && (
<ChatSidebar
placement={chatPanePlacement}
paneSize={chatPaneSize}
@ -6156,6 +6234,16 @@ function App() {
}}
workDirByTab={workDirByTab}
onWorkDirChangeForTab={setTabWorkDir}
codeSessionLocks={codeSessionLocks}
pinnedToCodeSession={
isCodeOpen
&& activeCodeSession?.session.mode === 'rowboat'
// Only while the pane is actually bound to the session — a
// palette-initiated fresh chat, for example, unbinds it.
&& chatTabs.find((t) => t.id === activeChatTabId)?.runId === activeCodeSession.session.id
? { title: activeCodeSession.session.title }
: null
}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}

View file

@ -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 (
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
@ -820,7 +842,7 @@ function ChatInputInner({
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">
{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'}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
@ -830,8 +852,21 @@ function ChatInputInner({
<span>Add files or photos</span>
</DropdownMenuItem>
{/* 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 ? (
<div
title={effectiveWorkDir ?? undefined}
className="flex h-auto items-center gap-2 rounded-[9px] px-2.5 py-2 text-muted-foreground"
>
<FolderCheck className="size-4 shrink-0" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm">{currentWorkDirLabel}</span>
<span className="truncate text-xs">Pinned by the coding session</span>
</span>
</div>
) : (
/* 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. */
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
<FolderCog className="size-4" />
@ -907,26 +942,31 @@ function ChatInputInner({
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
{workDir && collapseLevel < 8 && (
{effectiveWorkDir && collapseLevel < 8 && (
<Tooltip>
<TooltipTrigger asChild>
{/* Level 4: collapse to a square icon */}
<div className={cn(
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors",
!isCodeLocked && "hover:bg-muted hover:text-foreground",
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
)}>
<button
type="button"
onClick={handleSetWorkDir}
className="flex min-w-0 items-center gap-1.5"
disabled={isCodeLocked}
className={cn("flex min-w-0 items-center gap-1.5", isCodeLocked && "cursor-default")}
>
<FolderCog className="h-3.5 w-3.5 shrink-0" />
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
{isCodeLocked
? <Lock className="h-3 w-3 shrink-0" />
: <FolderCog className="h-3.5 w-3.5 shrink-0" />}
{collapseLevel < 4 && <span className="truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>}
</button>
{collapseLevel < 4 && (
{collapseLevel < 4 && !isCodeLocked && (
<button
type="button"
onClick={handleClearWorkDir}
@ -939,7 +979,9 @@ function ChatInputInner({
</div>
</TooltipTrigger>
<TooltipContent side="top">
Work directory: {workDir}
{isCodeLocked
? `Pinned by the coding session: ${effectiveWorkDir}`
: `Work directory: ${effectiveWorkDir}`}
</TooltipContent>
</Tooltip>
)}
@ -997,20 +1039,28 @@ function ChatInputInner({
</TooltipContent>
</Tooltip>
)}
{codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
{codeModeFeatureEnabled && collapseLevel < 5 && ((isCodeLocked || codeModeEnabled) ? (
collapseLevel >= 1 ? (
/* Level 1: collapse the pill to a single icon */
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/70"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<Terminal className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) click to disable</TooltipContent>
<TooltipContent side="top">
{isCodeLocked
? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
: `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`}
</TooltipContent>
</Tooltip>
) : (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
@ -1018,14 +1068,20 @@ function ChatInputInner({
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
disabled={isCodeLocked}
className={cn(
"flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<Terminal className="h-3.5 w-3.5" />
{isCodeLocked ? <Lock className="h-3 w-3" /> : <Terminal className="h-3.5 w-3.5" />}
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent>
<TooltipContent side="top">
{isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'}
</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip>
@ -1033,13 +1089,19 @@ function ChatInputInner({
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
disabled={isCodeLocked}
className={cn(
"flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors",
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
)}
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
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`}
</TooltipContent>
</Tooltip>
</div>
@ -1077,10 +1139,10 @@ function ChatInputInner({
<TooltipContent side="top">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" side="top" className="min-w-52">
{workDir && collapseLevel >= 8 && (
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
<FolderCog className="size-4" />
<span className="min-w-0 flex-1 truncate">{basename(workDir) || workDir}</span>
{effectiveWorkDir && collapseLevel >= 8 && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={() => { void handleSetWorkDir() }}>
{isCodeLocked ? <Lock className="size-4" /> : <FolderCog className="size-4" />}
<span className="min-w-0 flex-1 truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>
</DropdownMenuItem>
)}
{searchAvailable && collapseLevel >= 7 && (
@ -1105,14 +1167,15 @@ function ChatInputInner({
{codeModeFeatureEnabled && collapseLevel >= 5 && (
<>
<DropdownMenuCheckboxItem
checked={codeModeEnabled}
checked={isCodeLocked || codeModeEnabled}
disabled={isCodeLocked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
>
Code mode
</DropdownMenuCheckboxItem>
{codeModeEnabled && (
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
{(isCodeLocked || codeModeEnabled) && (
<DropdownMenuItem disabled={isCodeLocked} onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
<Terminal className="size-4" />
<span className="min-w-0 flex-1">Coding agent</span>
<span className="text-xs text-muted-foreground">{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
@ -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 (
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
@ -1368,6 +1434,7 @@ export function ChatInputWithMentions({
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}
codeSessionLock={codeSessionLock}
/>
</PromptInputProvider>
)

View file

@ -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<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
/**
* 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,
}}
>
<ChatHeader
activeTitle={(() => {
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 ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
Coding session
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
This chat is pinned to the coding session leave the Code view to switch chats.
</TooltipContent>
</Tooltip>
) : (
<ChatHeader
activeTitle={(() => {
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
})()}
onNewChatTab={onNewChatTab}
recentRuns={recentRuns}
activeRunId={runId}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
/>
)}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
@ -646,9 +672,11 @@ export function ChatSidebar({
{!tabHasConversation ? (
<ChatEmptyState
wide={isMaximized}
recentRuns={recentRuns}
onSelectRun={onSelectRun}
onOpenChatHistory={onOpenChatHistory}
// A pinned coding-session chat must not offer jumping
// to other conversations from the empty state either.
recentRuns={pinnedToCodeSession ? [] : recentRuns}
onSelectRun={pinnedToCodeSession ? undefined : onSelectRun}
onOpenChatHistory={pinnedToCodeSession ? undefined : onOpenChatHistory}
onPickPrompt={setLocalPresetMessage}
/>
) : (
@ -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}

View file

@ -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<string, string> = { claude: 'Claude Code', codex: 'Codex' }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
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 (
<WebSearchResult
query={webSearch.query}
results={webSearch.results}
status={item.status}
title={webSearch.title}
/>
)
}
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<HTMLTextAreaElement>(null)
@ -135,46 +139,13 @@ export function CodeChat({
return (
<div className="flex h-full min-h-0 flex-col">
{/* Session header */}
<div className="flex items-center gap-3 border-b px-4 py-2">
{/* Slim header — session controls live in the Code view's middle header */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{session.title}</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{AGENT_LABEL[session.agent]}</span>
<span>·</span>
<span className="truncate font-mono" title={session.cwd}>{session.cwd}</span>
{session.worktree && !session.worktree.removedAt && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<GitBranch className="size-3" />
{session.worktree.branch}
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground">{AGENT_LABEL[session.agent]} direct</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
{POLICY_LABEL[session.policy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
<DropdownMenuItem key={policy} onClick={() => onUpdateSession({ policy })}>
{POLICY_LABEL[policy]}
{session.policy === policy && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Bot className="size-3.5" />
Rowboat drives
<Switch
checked={session.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => onUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
</div>
{/* Conversation */}
@ -184,14 +155,10 @@ export function CodeChat({
{!loading && items.length === 0 && !busy && (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<div className="text-sm font-medium">
{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]}
</div>
<p className="max-w-sm text-xs text-muted-foreground">
{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.
</p>
</div>
)}
@ -206,9 +173,30 @@ export function CodeChat({
{pendingPermission && (
<CodeRunPermissionRequest ask={pendingPermission.ask} onDecide={(d) => void resolvePermission(d)} />
)}
{busy && !pendingPermission && (
{Array.from(pendingToolPermissions.values()).map((request) => (
<PermissionRequest
key={request.toolCall.toolCallId}
toolCall={request.toolCall}
permission={request.permission}
onApprove={() => 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) => (
<AskHumanRequest
key={request.toolCallId}
query={request.query}
options={request.options}
onResponse={(response) => void respondToAskHuman(request.toolCallId, request.subflow, response)}
isProcessing={busy}
/>
))}
{busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && (
<Shimmer className="text-sm">
{stopping ? 'Stopping…' : session.mode === 'direct' ? `${AGENT_LABEL[session.agent]} is working…` : 'Working…'}
{stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`}
</Shimmer>
)}
</ConversationContent>
@ -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({
</Button>
)}
</div>
<div className="mx-auto mt-1.5 flex w-full max-w-3xl items-center gap-1 text-[10px] text-muted-foreground">
<User className="size-3" />
{session.mode === 'direct'
? `Direct — messages go straight to ${AGENT_LABEL[session.agent]}`
: 'Rowboat orchestrates the coding agent for you'}
<div className="mx-auto mt-1.5 w-full max-w-3xl text-[10px] text-muted-foreground">
Direct messages go straight to {AGENT_LABEL[session.agent]}
</div>
</div>
</div>

View file

@ -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<string, string> = { claude: 'Claude Code', codex: 'Codex' }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
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<string | null>(null)
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
const [openDiffPath, setOpenDiffPath] = useState<string | null>(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 (
<div className="flex h-full min-h-0">
{/* Session rail */}
@ -97,23 +138,68 @@ export function CodeView() {
/>
</div>
{/* Chat */}
<div className="min-w-0 flex-1 border-r">
{/* Workspace: session header + diffs/files. The chat is in the right pane. */}
<div className="flex min-w-0 flex-1 flex-col">
{selectedSession ? (
<CodeChat
key={selectedSession.id}
session={selectedSession}
status={statusOf(selectedSession.id)}
onOpenDiff={setOpenDiffPath}
onUpdateSession={(patch) => void handleUpdateSession(patch)}
/>
<>
<div className="flex items-center gap-3 border-b px-4 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{AGENT_LABEL[selectedSession.agent]}</span>
<span>·</span>
<span className="truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<GitBranch className="size-3" />
{selectedSession.worktree.branch}
</span>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
{POLICY_LABEL[selectedSession.policy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
{POLICY_LABEL[policy]}
{selectedSession.policy === policy && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Bot className="size-3.5" />
Rowboat drives
<Switch
checked={selectedSession.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
</div>
<div className="min-h-0 flex-1">
<WorkspacePane
session={selectedSession}
status={selectedStatus}
openDiffPath={openDiffPath ?? null}
onDiffOpened={() => onDiffOpened?.()}
onSessionChanged={() => void refresh()}
/>
</div>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<Code2 className="size-10 text-muted-foreground/40" />
<div className="text-sm font-medium">Code with agents</div>
<p className="max-w-sm px-6 text-xs text-muted-foreground">
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.
</p>
{projects.length === 0 ? (
<Button size="sm" onClick={() => void handleAddProject()}>Add a project to get started</Button>
@ -124,19 +210,6 @@ export function CodeView() {
)}
</div>
{/* Workspace pane */}
{selectedSession && (
<div className="hidden w-[42%] min-w-[320px] shrink-0 lg:block">
<WorkspacePane
session={selectedSession}
status={statusOf(selectedSession.id)}
openDiffPath={openDiffPath}
onDiffOpened={() => setOpenDiffPath(null)}
onSessionChanged={() => void refresh()}
/>
</div>
)}
<NewSessionDialog
projectRow={newSessionProject}
open={newSessionProjectId !== null}

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { GitBranch, Loader2 } from 'lucide-react'
import type { CodeSession } from '@x/shared/src/code-sessions.js'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@ -24,6 +24,7 @@ import {
import type { ProjectRow } from './use-code-sessions'
type AgentStatus = { installed: boolean; signedIn: boolean }
type ModelOption = { provider: string; model: string }
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask every time',
@ -31,6 +32,38 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
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<ModelOption[]> {
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<string, unknown>
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<CodingAgent>('claude')
// Rowboat drives by default — direct CLI access is the power-user opt-in.
const [mode, setMode] = useState<CodeSessionMode>('rowboat')
const [policy, setPolicy] = useState<ApprovalPolicy>('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<ModelOption[]>([])
// '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({
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Who drives</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode('rowboat')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
mode === 'rowboat' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
)}
>
<div className="flex items-center gap-1.5 font-medium">
<Bot className="size-3.5" />
Rowboat
</div>
<div className="text-[11px] text-muted-foreground">
Full assistant chat Rowboat plans, runs the agent, and can use your knowledge.
</div>
</button>
<button
type="button"
onClick={() => setMode('direct')}
className={cn(
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
mode === 'direct' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
)}
>
<div className="flex items-center gap-1.5 font-medium">
<Terminal className="size-3.5" />
Direct
</div>
<div className="text-[11px] text-muted-foreground">
Talk straight to the coding agent no assistant in between.
</div>
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Where it works</label>
<div className="flex flex-col gap-2">
@ -191,6 +274,27 @@ export function NewSessionDialog({
</SelectContent>
</Select>
</div>
{modelOptions.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={modelKey} onValueChange={setModelKey}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default model</SelectItem>
{modelOptions.map((m) => {
const key = `${m.provider}/${m.model}`
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
})}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Used when Rowboat drives. Fixed once the session is created, like any chat.
</p>
</div>
)}
</div>
<DialogFooter>

View file

@ -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<PendingCodePermission | null>(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<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumans, setPendingAskHumans] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
const [loading, setLoading] = useState(false)
const seenMessageIdsRef = useRef<Set<string>>(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<string, ToolCall>()
const turnMap = new Map<string, DirectTurn>()
// 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<string, z.infer<typeof ToolPermissionRequestEvent>>()
const askHumans = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
for (const event of run.log as z.infer<typeof RunEvent>[]) {
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,
}
}

View file

@ -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<void> {
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<void> {
@ -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<void> {

View file

@ -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<typeof CreateRunOptions>): 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<string> {
// 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<ICodeSessionsRepo>('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<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');

View file

@ -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,