Skip to content

Commit ff69cd4

Browse files
committed
Decompose TaskPanel into focused sub-components
Extract 5 components from the 1472-line monolithic TaskPanel: - TaskTitleBar: title bar with drag, status dot, action buttons - TaskBranchInfoBar: branch/project/editor info bar - TaskNotesPanel: notes/plan tabs + changed files list - TaskShellSection: shell toolbar + terminal views - TaskAITerminal: AI agent terminal + exit badge + restart menu TaskPanel now serves as a coordinator (~300 lines), composing these sub-components and managing shared state (dialogs, focus, push status). Each extracted component owns its own signals and lifecycle. https://claude.ai/code/session_01QKHMqgf27nEbHkNnegfjHq
1 parent 4792390 commit ff69cd4

6 files changed

Lines changed: 1251 additions & 1105 deletions

File tree

src/components/TaskAITerminal.tsx

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { Show, For, createSignal, onMount, onCleanup } from 'solid-js';
2+
import {
3+
store,
4+
markAgentExited,
5+
restartAgent,
6+
switchAgent,
7+
setLastPrompt,
8+
markAgentOutput,
9+
getFontScale,
10+
registerFocusFn,
11+
setTaskFocusedPanel,
12+
} from '../store/store';
13+
import { ScalablePanel } from './ScalablePanel';
14+
import { InfoBar } from './InfoBar';
15+
import { TerminalView } from './TerminalView';
16+
import { theme } from '../lib/theme';
17+
import { sf } from '../lib/fontScale';
18+
import type { Task } from '../store/types';
19+
import type { PromptInputHandle } from './PromptInput';
20+
21+
interface TaskAITerminalProps {
22+
task: Task;
23+
isActive: boolean;
24+
promptHandle: PromptInputHandle | undefined;
25+
}
26+
27+
export function TaskAITerminal(props: TaskAITerminalProps) {
28+
const firstAgent = () => {
29+
const ids = props.task.agentIds;
30+
return ids.length > 0 ? store.agents[ids[0]] : undefined;
31+
};
32+
33+
return (
34+
<ScalablePanel panelId={`${props.task.id}:ai-terminal`}>
35+
<div
36+
class="focusable-panel shell-terminal-container"
37+
data-shell-focused={
38+
store.focusedPanel[props.task.id] === 'ai-terminal' ? 'true' : 'false'
39+
}
40+
style={{
41+
height: '100%',
42+
position: 'relative',
43+
background: theme.taskPanelBg,
44+
display: 'flex',
45+
'flex-direction': 'column',
46+
}}
47+
onClick={() => setTaskFocusedPanel(props.task.id, 'ai-terminal')}
48+
>
49+
<InfoBar
50+
title={
51+
props.task.lastPrompt ||
52+
(props.task.initialPrompt ? 'Waiting to send prompt…' : 'No prompts sent yet')
53+
}
54+
onDblClick={() => {
55+
if (props.task.lastPrompt && props.promptHandle && !props.promptHandle.getText())
56+
props.promptHandle.setText(props.task.lastPrompt);
57+
}}
58+
>
59+
<span style={{ opacity: props.task.lastPrompt ? 1 : 0.4 }}>
60+
{props.task.lastPrompt
61+
? `> ${props.task.lastPrompt}`
62+
: props.task.initialPrompt
63+
? '⏳ Waiting to send prompt…'
64+
: 'No prompts sent'}
65+
</span>
66+
</InfoBar>
67+
<div style={{ flex: '1', position: 'relative', overflow: 'hidden' }}>
68+
<Show when={firstAgent()}>
69+
{(a) => (
70+
<>
71+
<Show when={a().status === 'exited'}>
72+
<div
73+
class="exit-badge"
74+
title={a().lastOutput.length ? a().lastOutput.join('\n') : undefined}
75+
style={{
76+
position: 'absolute',
77+
top: '8px',
78+
right: '12px',
79+
'z-index': '10',
80+
'font-size': sf(11),
81+
color: a().exitCode === 0 ? theme.success : theme.error,
82+
background: 'color-mix(in srgb, var(--island-bg) 80%, transparent)',
83+
padding: '4px 12px',
84+
'border-radius': '8px',
85+
border: `1px solid ${theme.border}`,
86+
display: 'flex',
87+
'align-items': 'center',
88+
gap: '8px',
89+
}}
90+
>
91+
<span>
92+
{a().signal === 'spawn_failed'
93+
? 'Failed to start'
94+
: `Process exited (${a().exitCode ?? '?'})`}
95+
</span>
96+
<AgentRestartMenu agentId={a().id} agentDefId={a().def.id} />
97+
<Show when={a().def.resume_args?.length}>
98+
<button
99+
onClick={(e) => {
100+
e.stopPropagation();
101+
restartAgent(a().id, true);
102+
}}
103+
style={{
104+
background: theme.bgElevated,
105+
border: `1px solid ${theme.border}`,
106+
color: theme.fg,
107+
padding: '2px 8px',
108+
'border-radius': '4px',
109+
cursor: 'pointer',
110+
'font-size': sf(10),
111+
}}
112+
>
113+
Resume
114+
</button>
115+
</Show>
116+
</div>
117+
</Show>
118+
<Show when={`${a().id}:${a().generation}`} keyed>
119+
<TerminalView
120+
taskId={props.task.id}
121+
agentId={a().id}
122+
isFocused={
123+
props.isActive && store.focusedPanel[props.task.id] === 'ai-terminal'
124+
}
125+
command={a().def.command}
126+
args={[
127+
...(a().resumed && a().def.resume_args?.length
128+
? (a().def.resume_args ?? [])
129+
: a().def.args),
130+
...(props.task.skipPermissions && a().def.skip_permissions_args?.length
131+
? (a().def.skip_permissions_args ?? [])
132+
: []),
133+
]}
134+
cwd={props.task.worktreePath}
135+
dockerMode={props.task.dockerMode}
136+
dockerImage={props.task.dockerImage}
137+
onExit={(code) => markAgentExited(a().id, code)}
138+
onData={(data) => markAgentOutput(a().id, data, props.task.id)}
139+
onPromptDetected={(text) => setLastPrompt(props.task.id, text)}
140+
onReady={(focusFn) =>
141+
registerFocusFn(`${props.task.id}:ai-terminal`, focusFn)
142+
}
143+
fontSize={Math.round(13 * getFontScale(`${props.task.id}:ai-terminal`))}
144+
/>
145+
</Show>
146+
</>
147+
)}
148+
</Show>
149+
</div>
150+
</div>
151+
</ScalablePanel>
152+
);
153+
}
154+
155+
/** Restart/switch-agent dropdown menu shown on the exit badge. */
156+
function AgentRestartMenu(props: { agentId: string; agentDefId: string }) {
157+
const [showAgentMenu, setShowAgentMenu] = createSignal(false);
158+
let menuRef: HTMLSpanElement | undefined;
159+
160+
const handleClickOutside = (e: MouseEvent) => {
161+
if (menuRef && !menuRef.contains(e.target as Node)) {
162+
setShowAgentMenu(false);
163+
}
164+
};
165+
166+
onMount(() => document.addEventListener('mousedown', handleClickOutside));
167+
onCleanup(() => document.removeEventListener('mousedown', handleClickOutside));
168+
169+
return (
170+
<span style={{ position: 'relative', display: 'inline-flex' }} ref={(el) => (menuRef = el)}>
171+
<button
172+
onClick={(e) => {
173+
e.stopPropagation();
174+
restartAgent(props.agentId, false);
175+
}}
176+
style={{
177+
background: theme.bgElevated,
178+
border: `1px solid ${theme.border}`,
179+
color: theme.fg,
180+
padding: '2px 8px',
181+
'border-radius': '4px 0 0 4px',
182+
'border-right': 'none',
183+
cursor: 'pointer',
184+
'font-size': sf(10),
185+
}}
186+
>
187+
Restart
188+
</button>
189+
<button
190+
onClick={(e) => {
191+
e.stopPropagation();
192+
setShowAgentMenu(!showAgentMenu());
193+
}}
194+
style={{
195+
background: theme.bgElevated,
196+
border: `1px solid ${theme.border}`,
197+
color: theme.fg,
198+
padding: '2px 4px',
199+
'border-radius': '0 4px 4px 0',
200+
cursor: 'pointer',
201+
'font-size': sf(10),
202+
}}
203+
>
204+
205+
</button>
206+
<Show when={showAgentMenu()}>
207+
<div
208+
style={{
209+
position: 'absolute',
210+
top: '100%',
211+
right: '0',
212+
'margin-top': '4px',
213+
background: theme.bgElevated,
214+
border: `1px solid ${theme.border}`,
215+
'border-radius': '6px',
216+
padding: '4px 0',
217+
'z-index': '20',
218+
'min-width': '160px',
219+
'box-shadow': '0 4px 12px rgba(0,0,0,0.3)',
220+
}}
221+
>
222+
<div
223+
style={{
224+
padding: '4px 10px',
225+
'font-size': sf(9),
226+
color: theme.fgMuted,
227+
}}
228+
>
229+
Restart with…
230+
</div>
231+
<For each={store.availableAgents.filter((ag) => ag.available !== false)}>
232+
{(agentDef) => (
233+
<button
234+
title={agentDef.description}
235+
onClick={(e) => {
236+
e.stopPropagation();
237+
setShowAgentMenu(false);
238+
if (agentDef.id === props.agentDefId) {
239+
restartAgent(props.agentId, false);
240+
} else {
241+
switchAgent(props.agentId, agentDef);
242+
}
243+
}}
244+
style={{
245+
display: 'block',
246+
width: '100%',
247+
background:
248+
agentDef.id === props.agentDefId ? theme.bgSelected : 'transparent',
249+
border: 'none',
250+
color: theme.fg,
251+
padding: '5px 10px',
252+
cursor: 'pointer',
253+
'font-size': sf(10),
254+
'text-align': 'left',
255+
}}
256+
onMouseEnter={(e) => {
257+
if (agentDef.id !== props.agentDefId)
258+
e.currentTarget.style.background = theme.bgHover;
259+
}}
260+
onMouseLeave={(e) => {
261+
e.currentTarget.style.background =
262+
agentDef.id === props.agentDefId ? theme.bgSelected : 'transparent';
263+
}}
264+
>
265+
{agentDef.name}
266+
<Show when={agentDef.id === props.agentDefId}>
267+
{' '}
268+
<span style={{ opacity: 0.5 }}>(current)</span>
269+
</Show>
270+
</button>
271+
)}
272+
</For>
273+
</div>
274+
</Show>
275+
</span>
276+
);
277+
}

0 commit comments

Comments
 (0)