mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
rowboat driven mode uses the same assistant chat UI
This commit is contained in:
parent
89621b8bf0
commit
3384f0f38f
10 changed files with 655 additions and 173 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue