Skip to content

Commit d5af2c6

Browse files
feat: mirror task detail tabs in repo workspace right column
Bring the repository workspace's right column up to parity with the task detail view: Diff / Terminal / Scratch / Files / Browser tabs (Details intentionally omitted — repos have no task to describe). To support a task-less scope, lift persistence state out of `DiffViewer` and `BrowserPreview` so each tab's storage is the caller's choice. Task view continues to use the existing backend-backed `useDiffOptions` / `useBrowserUrl` hooks; the repo workspace gets new `useDiffOptionsLocal` / `useBrowserUrlLocal` hooks that key off `repoId` and persist to localStorage. Scratch already keyed by an arbitrary string, so it just receives `repo:<repoId>`. The shell-terminal logic in `TaskShellTerminal` is extracted into a generic `ShellTerminal` taking a synthetic `scopeId`; `TaskShellTerminal` becomes a thin wrapper that prefixes with `task-shell:` while the repo workspace uses `repo-shell:<repoId>`. The repo workspace's existing left-side terminal is unchanged — the right-column Terminal tab is a second, independent shell, mirroring how the task view's right column hosts a shell alongside the agent terminal on the left. Mobile gets the same five tabs (previously two: Terminal + Files). chore: bump version to 5.9.0
1 parent 25bbcf0 commit d5af2c6

16 files changed

Lines changed: 625 additions & 280 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"name": "fulcrum",
99
"source": "./plugins/fulcrum",
1010
"description": "Task orchestration for Claude Code",
11-
"version": "5.8.1"
11+
"version": "5.9.0"
1212
}
1313
]
1414
}

cli/src/mcp/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export async function runMcpServer(urlOverride?: string, portOverride?: string)
1212

1313
const server = new McpServer({
1414
name: 'fulcrum',
15-
version: '5.8.1',
15+
version: '5.9.0',
1616
})
1717

1818
registerTools(server, client)

desktop/neutralino.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://raw.githubusercontent.com/neutralinojs/neutralinojs/main/schemas/neutralino.config.schema.json",
33
"applicationId": "io.fulcrum.desktop",
4-
"version": "5.8.1",
4+
"version": "5.9.0",
55
"defaultMode": "window",
66
"port": 0,
77
"documentRoot": "/resources/",
@@ -26,7 +26,7 @@
2626
],
2727
"globalVariables": {
2828
"APP_NAME": "Fulcrum",
29-
"APP_VERSION": "5.8.1"
29+
"APP_VERSION": "5.9.0"
3030
},
3131
"modes": {
3232
"window": {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { useEffect, useRef, useState, useCallback } from 'react'
2+
import type { Terminal as XTerm } from '@xterm/xterm'
3+
import { cn } from '@/lib/utils'
4+
import { useTerminalWS } from '@/hooks/use-terminal-ws'
5+
import { Terminal } from './terminal'
6+
import { HugeiconsIcon } from '@hugeicons/react'
7+
import { Loading03Icon } from '@hugeicons/core-free-icons'
8+
import { MobileTerminalControls } from './mobile-terminal-controls'
9+
import { useTheme } from 'next-themes'
10+
import { log } from '@/lib/logger'
11+
12+
interface ShellTerminalProps {
13+
/**
14+
* Synthetic tabId for this terminal. Used to bypass the server's duplicate-cwd
15+
* detection (which only applies to terminals without a tabId). Caller chooses
16+
* the prefix, e.g. `task-shell:{taskId}` or `repo-shell:{repoId}`.
17+
*/
18+
scopeId: string
19+
/** Display name for the terminal. */
20+
name: string
21+
/** Working directory for the shell. Renders an empty state when null. */
22+
cwd: string | null
23+
/** Optional taskId to associate with the terminal on the server side. */
24+
taskId?: string
25+
/** Message shown when cwd is null. */
26+
emptyMessage?: string
27+
className?: string
28+
/**
29+
* Reports the current terminal id to the parent. Used so the parent can wire
30+
* up things like ScratchEditor's "send to terminal" against this shell.
31+
*/
32+
onTerminalIdChange?: (terminalId: string | null) => void
33+
}
34+
35+
/**
36+
* A plain shell terminal scoped by a synthetic tabId.
37+
* Unlike the task agent terminal, this does NOT start an AI agent — it's just a shell.
38+
* Used by both the task detail view and the repository workspace.
39+
*/
40+
export function ShellTerminal({
41+
scopeId,
42+
name,
43+
cwd,
44+
taskId,
45+
emptyMessage = 'No working directory configured',
46+
className,
47+
onTerminalIdChange,
48+
}: ShellTerminalProps) {
49+
const [terminalId, setTerminalId] = useState<string | null>(null)
50+
const [isCreating, setIsCreating] = useState(false)
51+
const xtermRef = useRef<XTerm | null>(null)
52+
const containerRef = useRef<HTMLDivElement | null>(null)
53+
const createdRef = useRef(false)
54+
const attachedRef = useRef(false)
55+
const { resolvedTheme } = useTheme()
56+
const isDark = resolvedTheme === 'dark'
57+
58+
const {
59+
terminals,
60+
terminalsLoaded,
61+
connected,
62+
createTerminal,
63+
attachXterm,
64+
resizeTerminal,
65+
setupImagePaste,
66+
writeToTerminal,
67+
recreateTerminal,
68+
} = useTerminalWS()
69+
70+
const attachXtermRef = useRef(attachXterm)
71+
const setupImagePasteRef = useRef(setupImagePaste)
72+
useEffect(() => { attachXtermRef.current = attachXterm }, [attachXterm])
73+
useEffect(() => { setupImagePasteRef.current = setupImagePaste }, [setupImagePaste])
74+
75+
const currentTerminal = terminalId ? terminals.find((t) => t.id === terminalId) : null
76+
const terminalStatus = currentTerminal?.status
77+
78+
// Notify parent of id changes
79+
useEffect(() => {
80+
onTerminalIdChange?.(terminalId)
81+
}, [terminalId, onTerminalIdChange])
82+
83+
// Reset refs when scope changes (cwd or scopeId)
84+
useEffect(() => {
85+
createdRef.current = false
86+
attachedRef.current = false
87+
setTerminalId(null)
88+
setIsCreating(false)
89+
}, [cwd, scopeId])
90+
91+
// Find existing or create new shell terminal
92+
useEffect(() => {
93+
if (!connected || !cwd || !terminalsLoaded) return
94+
95+
const existing = terminals.find((t) => t.tabId === scopeId)
96+
if (existing) {
97+
log.taskTerminal.debug('Found existing shell terminal', { id: existing.id, scopeId })
98+
setTerminalId(existing.id)
99+
setIsCreating(false)
100+
return
101+
}
102+
103+
if (!createdRef.current && xtermRef.current) {
104+
log.taskTerminal.info('Creating shell terminal', { cwd, scopeId })
105+
createdRef.current = true
106+
setIsCreating(true)
107+
const { cols, rows } = xtermRef.current
108+
createTerminal({
109+
name,
110+
cols,
111+
rows,
112+
cwd,
113+
tabId: scopeId,
114+
...(taskId ? { taskId } : {}),
115+
})
116+
}
117+
}, [connected, cwd, terminalsLoaded, terminals, scopeId, name, taskId, createTerminal])
118+
119+
// Track terminal ID when it appears (optimistic tempId → realId)
120+
useEffect(() => {
121+
if (!cwd) return
122+
const match = terminals.find((t) => t.tabId === scopeId)
123+
if (!match) return
124+
125+
const currentExists = terminalId && terminals.some((t) => t.id === terminalId)
126+
if (!terminalId || !currentExists) {
127+
setTerminalId(match.id)
128+
setIsCreating(false)
129+
if (terminalId && !currentExists) {
130+
attachedRef.current = false
131+
}
132+
}
133+
}, [terminals, cwd, terminalId, scopeId])
134+
135+
// Attach xterm to terminal
136+
useEffect(() => {
137+
if (!terminalId || !xtermRef.current || !containerRef.current || attachedRef.current) return
138+
139+
const onAttached = () => {
140+
requestAnimationFrame(() => {
141+
// The Terminal component handles fitting internally
142+
})
143+
}
144+
145+
const cleanup = attachXtermRef.current(terminalId, xtermRef.current, { onAttached })
146+
const cleanupPaste = setupImagePasteRef.current(containerRef.current, terminalId)
147+
attachedRef.current = true
148+
149+
return () => {
150+
cleanup()
151+
cleanupPaste()
152+
attachedRef.current = false
153+
}
154+
}, [terminalId])
155+
156+
const handleReady = useCallback((term: XTerm) => {
157+
xtermRef.current = term
158+
}, [])
159+
160+
const handleResize = useCallback((cols: number, rows: number) => {
161+
if (terminalId) {
162+
resizeTerminal(terminalId, cols, rows)
163+
}
164+
}, [terminalId, resizeTerminal])
165+
166+
const handleContainerReady = useCallback((container: HTMLDivElement) => {
167+
containerRef.current = container
168+
}, [])
169+
170+
const handleMobileSend = useCallback((data: string) => {
171+
if (terminalId) {
172+
writeToTerminal(terminalId, data)
173+
}
174+
}, [terminalId, writeToTerminal])
175+
176+
const handleReset = useCallback(() => {
177+
if (terminalId) {
178+
attachedRef.current = false
179+
createdRef.current = false
180+
setTerminalId(null)
181+
recreateTerminal(terminalId)
182+
}
183+
}, [terminalId, recreateTerminal])
184+
185+
if (!cwd) {
186+
return (
187+
<div className={cn('flex h-full items-center justify-center text-muted-foreground text-sm bg-terminal-background', className)}>
188+
{emptyMessage}
189+
</div>
190+
)
191+
}
192+
193+
return (
194+
<div className="flex h-full min-h-0 flex-col">
195+
{!connected && (
196+
<div className="shrink-0 px-2 py-1 bg-muted-foreground/20 text-muted-foreground text-xs">
197+
Connecting to terminal server...
198+
</div>
199+
)}
200+
{terminalStatus === 'error' && (
201+
<div className="shrink-0 px-2 py-1 bg-destructive/20 text-destructive text-xs">
202+
Terminal failed to start. The directory may not exist.
203+
</div>
204+
)}
205+
{terminalStatus === 'exited' && (
206+
<div className="shrink-0 px-2 py-1 bg-muted text-muted-foreground text-xs">
207+
Terminal exited (code: {currentTerminal?.exitCode})
208+
</div>
209+
)}
210+
211+
<div className="relative min-h-0 min-w-0 flex-1">
212+
<Terminal
213+
className={cn('h-full w-full overflow-hidden p-2 bg-terminal-background', className)}
214+
onReady={handleReady}
215+
onResize={handleResize}
216+
onContainerReady={handleContainerReady}
217+
terminalId={terminalId ?? undefined}
218+
setupImagePaste={setupImagePaste}
219+
onSend={handleMobileSend}
220+
onReset={terminalId ? handleReset : undefined}
221+
/>
222+
223+
{isCreating && !terminalId && (
224+
<div className="absolute inset-0 flex items-center justify-center bg-terminal-background">
225+
<div className="flex flex-col items-center gap-3">
226+
<HugeiconsIcon
227+
icon={Loading03Icon}
228+
size={24}
229+
strokeWidth={2}
230+
className={cn('animate-spin', isDark ? 'text-white/50' : 'text-black/50')}
231+
/>
232+
<span className={cn('font-mono text-sm', isDark ? 'text-white/50' : 'text-black/50')}>
233+
Initializing terminal...
234+
</span>
235+
</div>
236+
</div>
237+
)}
238+
</div>
239+
240+
<div className="h-2 shrink-0 bg-terminal-background" />
241+
<MobileTerminalControls onSend={handleMobileSend} />
242+
</div>
243+
)
244+
}

0 commit comments

Comments
 (0)