claude-ws 0.3.97 → 0.3.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/locales/de.json +374 -12
- package/locales/en.json +374 -12
- package/locales/es.json +398 -11
- package/locales/fr.json +398 -11
- package/locales/ja.json +398 -11
- package/locales/ko.json +398 -11
- package/locales/vi.json +374 -12
- package/locales/zh.json +398 -11
- package/package.json +1 -1
- package/server.ts +283 -6
- package/src/app/[locale]/not-found.tsx +6 -3
- package/src/app/[locale]/page.tsx +14 -4
- package/src/app/api/attempts/[id]/workflow/route.ts +76 -0
- package/src/app/api/questions/answer/route.ts +58 -0
- package/src/app/api/questions/route.ts +68 -0
- package/src/app/api/tasks/[id]/compact/route.ts +62 -0
- package/src/components/access-anywhere/api-access-key-setup-modal.tsx +2 -2
- package/src/components/access-anywhere/tunnel-settings-dialog.tsx +6 -6
- package/src/components/access-anywhere/wizard-step-ctunnel.tsx +8 -8
- package/src/components/agent-factory/dependency-tree.tsx +5 -3
- package/src/components/agent-factory/discovery-dialog.tsx +26 -22
- package/src/components/agent-factory/plugin-detail-dialog.tsx +41 -38
- package/src/components/agent-factory/plugin-form-dialog.tsx +23 -20
- package/src/components/agent-factory/plugin-list.tsx +20 -17
- package/src/components/agent-factory/upload-dialog.tsx +17 -14
- package/src/components/auth/agent-provider-dialog.tsx +67 -65
- package/src/components/auth/api-key-dialog.tsx +14 -11
- package/src/components/auth/auth-error-message.tsx +6 -3
- package/src/components/editor/code-editor-with-inline-edit.tsx +4 -2
- package/src/components/editor/file-diff-resolver-modal.tsx +31 -26
- package/src/components/editor/inline-edit-dialog.tsx +9 -6
- package/src/components/editor/selection-mention-popup.tsx +3 -1
- package/src/components/header/project-selector.tsx +7 -4
- package/src/components/header.tsx +70 -4
- package/src/components/kanban/column.tsx +11 -0
- package/src/components/kanban/task-card.tsx +70 -4
- package/src/components/project-settings/component-selector.tsx +3 -1
- package/src/components/project-settings/plugin-upload-dialog.tsx +7 -5
- package/src/components/project-settings/project-settings-dialog.tsx +5 -3
- package/src/components/questions/questions-panel.tsx +136 -0
- package/src/components/settings/folder-browser-dialog.tsx +29 -25
- package/src/components/settings/settings-page.tsx +64 -18
- package/src/components/settings/setup-dialog.tsx +26 -23
- package/src/components/setup/unified-setup-wizard.tsx +12 -9
- package/src/components/sidebar/file-browser/file-create-buttons.tsx +7 -3
- package/src/components/sidebar/file-browser/file-tab-content.tsx +19 -15
- package/src/components/sidebar/file-browser/file-tabs-panel.tsx +7 -4
- package/src/components/sidebar/file-browser/file-tree.tsx +3 -1
- package/src/components/sidebar/git-changes/branch-checkout-modal.tsx +6 -4
- package/src/components/sidebar/git-changes/commit-details-modal.tsx +5 -3
- package/src/components/sidebar/git-changes/diff-tabs-panel.tsx +3 -1
- package/src/components/sidebar/git-changes/git-file-item.tsx +8 -6
- package/src/components/sidebar/git-changes/git-graph.tsx +8 -5
- package/src/components/sidebar/git-changes/git-panel.tsx +28 -27
- package/src/components/sidebar/git-changes/git-section.tsx +5 -3
- package/src/components/sidebar/shells/shell-panel.tsx +3 -1
- package/src/components/task/attachment-bar.tsx +4 -1
- package/src/components/task/attempt-item.tsx +7 -5
- package/src/components/task/conversation-view.tsx +21 -13
- package/src/components/task/floating-chat-window.tsx +14 -5
- package/src/components/task/interactive-command/checkpoint-list.tsx +5 -3
- package/src/components/task/interactive-command/confirm-dialog.tsx +9 -4
- package/src/components/task/interactive-command/interactive-command-overlay.tsx +23 -9
- package/src/components/task/interactive-command/question-prompt.tsx +12 -8
- package/src/components/task/pending-question-indicator.tsx +5 -3
- package/src/components/task/prompt-input.tsx +1 -1
- package/src/components/task/shell-log-view.tsx +3 -1
- package/src/components/task/status-line.tsx +84 -23
- package/src/components/task/task-detail-panel.tsx +27 -27
- package/src/components/task/task-shell-indicator.tsx +10 -6
- package/src/components/terminal/terminal-context-menu.tsx +6 -4
- package/src/components/terminal/terminal-instance.tsx +11 -3
- package/src/components/terminal/terminal-panel.tsx +6 -3
- package/src/components/terminal/terminal-shortcut-bar.tsx +3 -1
- package/src/components/terminal/terminal-tab-bar.tsx +5 -3
- package/src/components/workflow/workflow-panel.tsx +181 -0
- package/src/hooks/use-attempt-stream.ts +96 -3
- package/src/lib/agent-manager.ts +89 -3
- package/src/lib/db/index.ts +18 -0
- package/src/lib/db/schema.ts +29 -0
- package/src/lib/process-manager.ts +28 -7
- package/src/lib/session-manager.ts +60 -0
- package/src/lib/usage-tracker.ts +19 -19
- package/src/lib/workflow-tracker.ts +118 -20
- package/src/stores/questions-store.ts +76 -0
- package/src/stores/workflow-store.ts +71 -0
|
@@ -114,6 +114,7 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
114
114
|
activeQuestion,
|
|
115
115
|
answerQuestion,
|
|
116
116
|
cancelQuestion,
|
|
117
|
+
refetchQuestion,
|
|
117
118
|
} = useAttemptStream({
|
|
118
119
|
taskId: selectedTask?.id,
|
|
119
120
|
onComplete: handleTaskComplete,
|
|
@@ -311,7 +312,15 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
311
312
|
currentFiles={isRunning ? currentAttemptFiles : undefined}
|
|
312
313
|
isRunning={isRunning}
|
|
313
314
|
activeQuestion={activeQuestion}
|
|
314
|
-
onOpenQuestion={() =>
|
|
315
|
+
onOpenQuestion={() => {
|
|
316
|
+
if (activeQuestion) {
|
|
317
|
+
setShowQuestionPrompt(true);
|
|
318
|
+
} else {
|
|
319
|
+
// activeQuestion not yet loaded — re-fetch from server
|
|
320
|
+
// useEffect on activeQuestion will show the prompt when it arrives
|
|
321
|
+
refetchQuestion();
|
|
322
|
+
}
|
|
323
|
+
}}
|
|
315
324
|
/>
|
|
316
325
|
</div>
|
|
317
326
|
);
|
|
@@ -320,32 +329,23 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
320
329
|
<>
|
|
321
330
|
<Separator />
|
|
322
331
|
<div className="relative">
|
|
323
|
-
{showQuestionPrompt ? (
|
|
332
|
+
{showQuestionPrompt && activeQuestion ? (
|
|
324
333
|
<div className="border-t bg-muted/30">
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
/>
|
|
341
|
-
) : (
|
|
342
|
-
<div className="py-8 px-4 text-center">
|
|
343
|
-
<div className="inline-flex items-center gap-2 text-muted-foreground text-sm">
|
|
344
|
-
<div className="size-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
345
|
-
<span>Loading question...</span>
|
|
346
|
-
</div>
|
|
347
|
-
</div>
|
|
348
|
-
)}
|
|
334
|
+
<QuestionPrompt
|
|
335
|
+
key={activeQuestion.toolUseId}
|
|
336
|
+
questions={activeQuestion.questions}
|
|
337
|
+
onAnswer={(answers) => {
|
|
338
|
+
if (selectedTask?.status !== 'in_progress') {
|
|
339
|
+
moveTaskToInProgress(selectedTask.id);
|
|
340
|
+
}
|
|
341
|
+
answerQuestion(activeQuestion.questions, answers as Record<string, string>);
|
|
342
|
+
setShowQuestionPrompt(false);
|
|
343
|
+
}}
|
|
344
|
+
onCancel={() => {
|
|
345
|
+
cancelQuestion();
|
|
346
|
+
setShowQuestionPrompt(false);
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
349
|
</div>
|
|
350
350
|
) : shellPanelExpanded && currentProjectId ? (
|
|
351
351
|
<ShellExpandedPanel
|
|
@@ -458,7 +458,7 @@ export function TaskDetailPanel({ className }: TaskDetailPanelProps) {
|
|
|
458
458
|
variant="ghost"
|
|
459
459
|
size="icon-sm"
|
|
460
460
|
onClick={handleDetach}
|
|
461
|
-
title=
|
|
461
|
+
title={t('detachToFloating')}
|
|
462
462
|
>
|
|
463
463
|
<Minimize2 className="size-4" />
|
|
464
464
|
</Button>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
4
|
import { ChevronDown, ChevronUp, Terminal, Square, Circle } from 'lucide-react';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
5
6
|
import { Button } from '@/components/ui/button';
|
|
6
7
|
import { cn } from '@/lib/utils';
|
|
7
8
|
import { useShellStore, type ShellInfo } from '@/stores/shell-store';
|
|
@@ -15,6 +16,7 @@ interface ShellItemProps {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
function ShellItem({ shell, isSelected, onSelect, onStop }: ShellItemProps) {
|
|
19
|
+
const t = useTranslations('task');
|
|
18
20
|
const displayCommand =
|
|
19
21
|
shell.command.length > 40 ? shell.command.slice(0, 40) + '...' : shell.command;
|
|
20
22
|
|
|
@@ -53,7 +55,7 @@ function ShellItem({ shell, isSelected, onSelect, onStop }: ShellItemProps) {
|
|
|
53
55
|
e.stopPropagation();
|
|
54
56
|
onStop();
|
|
55
57
|
}}
|
|
56
|
-
title=
|
|
58
|
+
title={t('stopShell')}
|
|
57
59
|
>
|
|
58
60
|
<Square className="h-3 w-3" />
|
|
59
61
|
</Button>
|
|
@@ -72,6 +74,7 @@ interface ShellToggleBarProps {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
export function ShellToggleBar({ projectId, isExpanded, onToggle }: ShellToggleBarProps) {
|
|
77
|
+
const t = useTranslations('task');
|
|
75
78
|
const { shells, subscribeToProject } = useShellStore();
|
|
76
79
|
|
|
77
80
|
useEffect(() => {
|
|
@@ -103,7 +106,7 @@ export function ShellToggleBar({ projectId, isExpanded, onToggle }: ShellToggleB
|
|
|
103
106
|
)}
|
|
104
107
|
<Terminal className="h-3 w-3" />
|
|
105
108
|
<span>
|
|
106
|
-
|
|
109
|
+
{t('runningBackgroundTasks', { count: runningCount })}
|
|
107
110
|
</span>
|
|
108
111
|
</button>
|
|
109
112
|
);
|
|
@@ -119,6 +122,7 @@ interface ShellExpandedPanelProps {
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
export function ShellExpandedPanel({ projectId, onClose, className }: ShellExpandedPanelProps) {
|
|
125
|
+
const t = useTranslations('task');
|
|
122
126
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
123
127
|
const [viewingShellId, setViewingShellId] = useState<string | null>(null);
|
|
124
128
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -237,10 +241,10 @@ export function ShellExpandedPanel({ projectId, onClose, className }: ShellExpan
|
|
|
237
241
|
</div>
|
|
238
242
|
{/* Keyboard hints */}
|
|
239
243
|
<div className="text-[10px] text-muted-foreground/60 pt-2 flex gap-3 justify-center">
|
|
240
|
-
<span>↑↓
|
|
241
|
-
<span>⏎
|
|
242
|
-
<span>K
|
|
243
|
-
<span>Esc
|
|
244
|
+
<span>↑↓ {t('navigateHint')}</span>
|
|
245
|
+
<span>⏎ {t('viewLogsHint')}</span>
|
|
246
|
+
<span>K {t('killHint')}</span>
|
|
247
|
+
<span>Esc {t('closeHint')}</span>
|
|
244
248
|
</div>
|
|
245
249
|
</div>
|
|
246
250
|
);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ContextMenuShortcut,
|
|
11
11
|
} from '@/components/ui/context-menu';
|
|
12
12
|
import { useTerminalStore } from '@/stores/terminal-store';
|
|
13
|
+
import { useTranslations } from 'next-intl';
|
|
13
14
|
|
|
14
15
|
interface TerminalContextMenuProps {
|
|
15
16
|
terminalId: string;
|
|
@@ -17,6 +18,7 @@ interface TerminalContextMenuProps {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function TerminalContextMenu({ terminalId, children }: TerminalContextMenuProps) {
|
|
21
|
+
const tCommon = useTranslations('common');
|
|
20
22
|
const copySelection = useTerminalStore((s) => s.copySelection);
|
|
21
23
|
const pasteClipboard = useTerminalStore((s) => s.pasteClipboard);
|
|
22
24
|
const selectAll = useTerminalStore((s) => s.selectAll);
|
|
@@ -30,23 +32,23 @@ export function TerminalContextMenu({ terminalId, children }: TerminalContextMen
|
|
|
30
32
|
<ContextMenuContent className="w-52">
|
|
31
33
|
<ContextMenuItem onClick={() => copySelection(terminalId)}>
|
|
32
34
|
<Copy className="mr-2 h-4 w-4" />
|
|
33
|
-
|
|
35
|
+
{tCommon('copy')}
|
|
34
36
|
<ContextMenuShortcut>Ctrl+Shift+C</ContextMenuShortcut>
|
|
35
37
|
</ContextMenuItem>
|
|
36
38
|
<ContextMenuItem onClick={() => pasteClipboard(terminalId)}>
|
|
37
39
|
<ClipboardPaste className="mr-2 h-4 w-4" />
|
|
38
|
-
|
|
40
|
+
{tCommon('paste')}
|
|
39
41
|
<ContextMenuShortcut>Ctrl+Shift+V</ContextMenuShortcut>
|
|
40
42
|
</ContextMenuItem>
|
|
41
43
|
<ContextMenuSeparator />
|
|
42
44
|
<ContextMenuItem onClick={() => selectAll(terminalId)}>
|
|
43
45
|
<TextSelect className="mr-2 h-4 w-4" />
|
|
44
|
-
|
|
46
|
+
{tCommon('selectAll')}
|
|
45
47
|
</ContextMenuItem>
|
|
46
48
|
<ContextMenuSeparator />
|
|
47
49
|
<ContextMenuItem onClick={() => clearTerminal(terminalId)}>
|
|
48
50
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
49
|
-
|
|
51
|
+
{tCommon('clearTerminal')}
|
|
50
52
|
</ContextMenuItem>
|
|
51
53
|
</ContextMenuContent>
|
|
52
54
|
</ContextMenu>
|
|
@@ -5,6 +5,7 @@ import { useTerminalStore } from '@/stores/terminal-store';
|
|
|
5
5
|
import { getSocket } from '@/lib/socket-service';
|
|
6
6
|
import { useTheme } from 'next-themes';
|
|
7
7
|
import { toast } from 'sonner';
|
|
8
|
+
import { useTranslations } from 'next-intl';
|
|
8
9
|
|
|
9
10
|
interface TerminalInstanceProps {
|
|
10
11
|
terminalId: string;
|
|
@@ -70,6 +71,13 @@ export function TerminalInstance({ terminalId, isVisible, isMobile }: TerminalIn
|
|
|
70
71
|
|
|
71
72
|
const { sendInput, sendResize, panelHeight } = useTerminalStore();
|
|
72
73
|
const { resolvedTheme } = useTheme();
|
|
74
|
+
const tShells = useTranslations('shells');
|
|
75
|
+
const copiedMsgRef = useRef(tShells('copiedToClipboard'));
|
|
76
|
+
const failedCopyMsgRef = useRef(tShells('failedToCopy'));
|
|
77
|
+
const clipboardDeniedMsgRef = useRef(tShells('clipboardDenied'));
|
|
78
|
+
copiedMsgRef.current = tShells('copiedToClipboard');
|
|
79
|
+
failedCopyMsgRef.current = tShells('failedToCopy');
|
|
80
|
+
clipboardDeniedMsgRef.current = tShells('clipboardDenied');
|
|
73
81
|
|
|
74
82
|
// Initialize xterm on mount
|
|
75
83
|
useEffect(() => {
|
|
@@ -133,8 +141,8 @@ export function TerminalInstance({ terminalId, isVisible, isMobile }: TerminalIn
|
|
|
133
141
|
const sel = terminal.getSelection();
|
|
134
142
|
if (!sel) return;
|
|
135
143
|
const ok = await writeClipboard(sel);
|
|
136
|
-
if (ok) toast.success(
|
|
137
|
-
else toast.error(
|
|
144
|
+
if (ok) toast.success(copiedMsgRef.current);
|
|
145
|
+
else toast.error(failedCopyMsgRef.current);
|
|
138
146
|
};
|
|
139
147
|
|
|
140
148
|
const pasteFromClipboard = async () => {
|
|
@@ -142,7 +150,7 @@ export function TerminalInstance({ terminalId, isVisible, isMobile }: TerminalIn
|
|
|
142
150
|
const text = await navigator.clipboard.readText();
|
|
143
151
|
if (text) terminal.paste(text);
|
|
144
152
|
} catch {
|
|
145
|
-
toast.error(
|
|
153
|
+
toast.error(clipboardDeniedMsgRef.current);
|
|
146
154
|
}
|
|
147
155
|
};
|
|
148
156
|
|
|
@@ -11,8 +11,11 @@ import { TerminalTabBar } from './terminal-tab-bar';
|
|
|
11
11
|
import { TerminalInstance } from './terminal-instance';
|
|
12
12
|
import { TerminalShortcutBar } from './terminal-shortcut-bar';
|
|
13
13
|
import { TerminalContextMenu } from './terminal-context-menu';
|
|
14
|
+
import { useTranslations } from 'next-intl';
|
|
14
15
|
|
|
15
16
|
export function TerminalPanel() {
|
|
17
|
+
const tShells = useTranslations('shells');
|
|
18
|
+
const tCommon = useTranslations('common');
|
|
16
19
|
const isOpen = useTerminalStore((s) => s.isOpen);
|
|
17
20
|
const panelHeight = useTerminalStore((s) => s.panelHeight);
|
|
18
21
|
const setPanelHeight = useTerminalStore((s) => s.setPanelHeight);
|
|
@@ -140,14 +143,14 @@ export function TerminalPanel() {
|
|
|
140
143
|
{isCreating ? (
|
|
141
144
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
142
145
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
143
|
-
<span>
|
|
146
|
+
<span>{tShells('creatingTerminal')}</span>
|
|
144
147
|
</div>
|
|
145
148
|
) : createFailed ? (
|
|
146
149
|
<div className="flex flex-col items-center gap-3 text-sm">
|
|
147
|
-
<p className="text-muted-foreground">
|
|
150
|
+
<p className="text-muted-foreground">{tShells('failedToCreateTerminal')}</p>
|
|
148
151
|
<Button variant="outline" size="sm" onClick={handleRetryCreate}>
|
|
149
152
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
|
150
|
-
|
|
153
|
+
{tCommon('retry')}
|
|
151
154
|
</Button>
|
|
152
155
|
</div>
|
|
153
156
|
) : null}
|
|
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef, type PointerEvent as ReactPointerEvent }
|
|
|
4
4
|
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight, TextCursorInput, X, Copy, ClipboardPaste, TextSelect } from 'lucide-react';
|
|
5
5
|
import { cn } from '@/lib/utils';
|
|
6
6
|
import { useTerminalStore } from '@/stores/terminal-store';
|
|
7
|
+
import { useTranslations } from 'next-intl';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Mobile paste: always show dialog, pre-fill from clipboard if possible.
|
|
@@ -65,6 +66,7 @@ const SHORTCUT_KEYS: { label: string; input?: string; modifier?: 'ctrl' | 'alt';
|
|
|
65
66
|
];
|
|
66
67
|
|
|
67
68
|
export function TerminalShortcutBar() {
|
|
69
|
+
const t = useTranslations('shells');
|
|
68
70
|
const activeTabId = useTerminalStore((s) => s.activeTabId);
|
|
69
71
|
const sendInput = useTerminalStore((s) => s.sendInput);
|
|
70
72
|
const selectionMode = useTerminalStore((s) => activeTabId ? s.selectionMode[activeTabId] : false);
|
|
@@ -202,7 +204,7 @@ export function TerminalShortcutBar() {
|
|
|
202
204
|
onPointerDown={onDown}
|
|
203
205
|
onPointerUp={(e) => { if (isTap(e)) setSelectionMode(activeTabId, true); }}
|
|
204
206
|
className={cn(btnBase, 'bg-muted text-muted-foreground')}
|
|
205
|
-
title=
|
|
207
|
+
title={t('selectionMode')}
|
|
206
208
|
>
|
|
207
209
|
<TextCursorInput className="h-4 w-4" />
|
|
208
210
|
</button>
|
|
@@ -5,12 +5,14 @@ import { Plus, X, Terminal, ChevronDown, Square } from 'lucide-react';
|
|
|
5
5
|
import { Button } from '@/components/ui/button';
|
|
6
6
|
import { cn } from '@/lib/utils';
|
|
7
7
|
import { useTerminalStore } from '@/stores/terminal-store';
|
|
8
|
+
import { useTranslations } from 'next-intl';
|
|
8
9
|
|
|
9
10
|
interface TerminalTabBarProps {
|
|
10
11
|
projectId?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function TerminalTabBar({ projectId }: TerminalTabBarProps) {
|
|
15
|
+
const t = useTranslations('shells');
|
|
14
16
|
const { tabs, activeTabId, setActiveTab, createTerminal, closeTerminal, closePanel, renameTerminal, sendInput } =
|
|
15
17
|
useTerminalStore();
|
|
16
18
|
|
|
@@ -103,7 +105,7 @@ export function TerminalTabBar({ projectId }: TerminalTabBarProps) {
|
|
|
103
105
|
size="icon"
|
|
104
106
|
className="h-6 w-6 ml-1"
|
|
105
107
|
onClick={handleNewTerminal}
|
|
106
|
-
title=
|
|
108
|
+
title={t('newTerminal')}
|
|
107
109
|
>
|
|
108
110
|
<Plus className="h-3.5 w-3.5" />
|
|
109
111
|
</Button>
|
|
@@ -116,7 +118,7 @@ export function TerminalTabBar({ projectId }: TerminalTabBarProps) {
|
|
|
116
118
|
size="icon"
|
|
117
119
|
className="h-6 w-6 text-muted-foreground hover:text-red-500"
|
|
118
120
|
onClick={() => sendInput(activeTabId, '\x03')}
|
|
119
|
-
title=
|
|
121
|
+
title={t('sendCtrlC')}
|
|
120
122
|
>
|
|
121
123
|
<Square className="h-3 w-3 fill-current" />
|
|
122
124
|
</Button>
|
|
@@ -127,7 +129,7 @@ export function TerminalTabBar({ projectId }: TerminalTabBarProps) {
|
|
|
127
129
|
size="icon"
|
|
128
130
|
className="h-6 w-6"
|
|
129
131
|
onClick={closePanel}
|
|
130
|
-
title=
|
|
132
|
+
title={t('closePanel')}
|
|
131
133
|
>
|
|
132
134
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
133
135
|
</Button>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Network, X, ExternalLink } from 'lucide-react';
|
|
4
|
+
import { Badge } from '@/components/ui/badge';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { useWorkflowStore, type WorkflowEntry } from '@/stores/workflow-store';
|
|
7
|
+
import { useTaskStore } from '@/stores/task-store';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
import type { SubagentNode, AgentMessage } from '@/lib/workflow-tracker';
|
|
10
|
+
|
|
11
|
+
function formatDuration(ms: number): string {
|
|
12
|
+
if (ms < 1000) return `${ms}ms`;
|
|
13
|
+
const seconds = Math.floor(ms / 1000);
|
|
14
|
+
if (seconds < 60) return `${seconds}s`;
|
|
15
|
+
const minutes = Math.floor(seconds / 60);
|
|
16
|
+
const remainingSeconds = seconds % 60;
|
|
17
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatTimestamp(timestamp: number): string {
|
|
21
|
+
const date = new Date(timestamp);
|
|
22
|
+
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function StatusIcon({ status }: { status: SubagentNode['status'] }) {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case 'completed':
|
|
28
|
+
return <span className="text-green-500 text-xs shrink-0">✓</span>;
|
|
29
|
+
case 'in_progress':
|
|
30
|
+
return <span className="text-blue-500 text-xs shrink-0 animate-pulse">●</span>;
|
|
31
|
+
case 'failed':
|
|
32
|
+
return <span className="text-red-500 text-xs shrink-0">✗</span>;
|
|
33
|
+
case 'orphaned':
|
|
34
|
+
return <span className="text-yellow-500 text-xs shrink-0">⊘</span>;
|
|
35
|
+
default:
|
|
36
|
+
return <span className="text-muted-foreground text-xs shrink-0">●</span>;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function WorkflowEntryItem({ entry }: { entry: WorkflowEntry }) {
|
|
41
|
+
const { selectTask } = useTaskStore();
|
|
42
|
+
const { closePanel } = useWorkflowStore();
|
|
43
|
+
|
|
44
|
+
const handleGoToTask = () => {
|
|
45
|
+
selectTask(entry.taskId);
|
|
46
|
+
closePanel();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="border-b border-border last:border-b-0 px-4 py-3">
|
|
51
|
+
{/* Task header */}
|
|
52
|
+
<div className="flex items-center justify-between mb-2">
|
|
53
|
+
<span className="text-sm font-medium truncate">{entry.taskTitle}</span>
|
|
54
|
+
<button
|
|
55
|
+
onClick={handleGoToTask}
|
|
56
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 ml-2"
|
|
57
|
+
>
|
|
58
|
+
<ExternalLink className="size-3" />
|
|
59
|
+
Go to task
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Agent tree */}
|
|
64
|
+
<div className="space-y-1">
|
|
65
|
+
{entry.nodes.map((node) => (
|
|
66
|
+
<div
|
|
67
|
+
key={node.id}
|
|
68
|
+
className="flex items-center gap-1.5 text-xs"
|
|
69
|
+
style={{ paddingLeft: node.depth * 16 }}
|
|
70
|
+
>
|
|
71
|
+
<StatusIcon status={node.status} />
|
|
72
|
+
<span className="text-foreground truncate">
|
|
73
|
+
{node.name || node.type}
|
|
74
|
+
</span>
|
|
75
|
+
{node.teamName && (
|
|
76
|
+
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
|
|
77
|
+
{node.teamName}
|
|
78
|
+
</Badge>
|
|
79
|
+
)}
|
|
80
|
+
<span className="text-muted-foreground shrink-0 ml-auto">
|
|
81
|
+
{node.status === 'in_progress'
|
|
82
|
+
? 'running...'
|
|
83
|
+
: node.durationMs !== undefined
|
|
84
|
+
? formatDuration(node.durationMs)
|
|
85
|
+
: ''}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Messages */}
|
|
92
|
+
{entry.messages.length > 0 && (
|
|
93
|
+
<div className="mt-2 pt-2 border-t border-border/50 space-y-1">
|
|
94
|
+
{entry.messages.map((msg, idx) => (
|
|
95
|
+
<div key={idx} className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
|
96
|
+
<span className="shrink-0">
|
|
97
|
+
[{msg.fromType} → {msg.toType}]
|
|
98
|
+
</span>
|
|
99
|
+
<span className="truncate">"{msg.summary}"</span>
|
|
100
|
+
<span className="shrink-0 ml-auto text-[10px]">
|
|
101
|
+
{formatTimestamp(msg.timestamp)}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface WorkflowPanelProps {
|
|
112
|
+
className?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function WorkflowPanel({ className }: WorkflowPanelProps) {
|
|
116
|
+
const { isOpen, closePanel, workflows, getActiveAgentCount } = useWorkflowStore();
|
|
117
|
+
const entries = Array.from(workflows.values());
|
|
118
|
+
const activeAgentCount = getActiveAgentCount();
|
|
119
|
+
|
|
120
|
+
if (!isOpen) return null;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
{/* Overlay for mobile */}
|
|
125
|
+
<div
|
|
126
|
+
className="fixed inset-0 bg-black/50 z-40 sm:hidden"
|
|
127
|
+
onClick={closePanel}
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
{/* Sidebar */}
|
|
131
|
+
<div
|
|
132
|
+
className={cn(
|
|
133
|
+
'fixed right-0 top-0 h-full w-96 bg-background border-l shadow-lg z-50',
|
|
134
|
+
'flex flex-col',
|
|
135
|
+
className
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{/* Header */}
|
|
139
|
+
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
140
|
+
<div className="flex items-center gap-2">
|
|
141
|
+
<Network className="size-4 text-muted-foreground" />
|
|
142
|
+
<h2 className="font-semibold text-sm">Agent Workflow</h2>
|
|
143
|
+
{activeAgentCount > 0 && (
|
|
144
|
+
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
145
|
+
{activeAgentCount}
|
|
146
|
+
</Badge>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<Button
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="icon"
|
|
152
|
+
onClick={closePanel}
|
|
153
|
+
className="h-8 w-8"
|
|
154
|
+
>
|
|
155
|
+
<X className="h-4 w-4" />
|
|
156
|
+
</Button>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Content */}
|
|
160
|
+
<div className="flex-1 overflow-y-auto">
|
|
161
|
+
{entries.length === 0 ? (
|
|
162
|
+
<div className="px-4 py-12 text-center">
|
|
163
|
+
<Network className="size-10 text-muted-foreground/30 mx-auto mb-3" />
|
|
164
|
+
<p className="text-sm text-muted-foreground">No active workflows</p>
|
|
165
|
+
<p className="text-xs text-muted-foreground/70 mt-1">
|
|
166
|
+
Agent workflows will appear here when tasks use subagents
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
entries.map((entry) => (
|
|
171
|
+
<WorkflowEntryItem
|
|
172
|
+
key={entry.attemptId}
|
|
173
|
+
entry={entry}
|
|
174
|
+
/>
|
|
175
|
+
))
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -4,6 +4,8 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
|
|
4
4
|
import { io, Socket } from 'socket.io-client';
|
|
5
5
|
import type { ClaudeOutput, WsAttemptFinished } from '@/types';
|
|
6
6
|
import { useRunningTasksStore } from '@/stores/running-tasks-store';
|
|
7
|
+
import { useQuestionsStore } from '@/stores/questions-store';
|
|
8
|
+
import { useWorkflowStore } from '@/stores/workflow-store';
|
|
7
9
|
import { createLogger } from '@/lib/logger';
|
|
8
10
|
|
|
9
11
|
const log = createLogger('AttemptStreamHook');
|
|
@@ -44,6 +46,7 @@ interface UseAttemptStreamResult {
|
|
|
44
46
|
activeQuestion: ActiveQuestion | null;
|
|
45
47
|
answerQuestion: (questions: Question[], answers: Record<string, string>) => void;
|
|
46
48
|
cancelQuestion: () => void;
|
|
49
|
+
refetchQuestion: () => Promise<void>;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
export function useAttemptStream(
|
|
@@ -330,14 +333,94 @@ export function useAttemptStream(
|
|
|
330
333
|
|
|
331
334
|
socketInstance.on('question:ask', (data: any) => {
|
|
332
335
|
log.debug({ data }, 'Received question:ask event');
|
|
333
|
-
//
|
|
334
|
-
if (currentAttemptIdRef.current
|
|
336
|
+
// Only accept questions for the current attempt — reject if ref is null or mismatched
|
|
337
|
+
if (!currentAttemptIdRef.current || data.attemptId !== currentAttemptIdRef.current) {
|
|
335
338
|
log.debug({ receivedAttemptId: data.attemptId, currentAttemptId: currentAttemptIdRef.current }, 'Ignoring question from different attempt');
|
|
336
339
|
return;
|
|
337
340
|
}
|
|
338
341
|
setActiveQuestion({ attemptId: data.attemptId, toolUseId: data.toolUseId, questions: data.questions });
|
|
339
342
|
});
|
|
340
343
|
|
|
344
|
+
// Listen for global question events (for questions panel)
|
|
345
|
+
socketInstance.on('question:new', (data: any) => {
|
|
346
|
+
useQuestionsStore.getState().addQuestion({
|
|
347
|
+
attemptId: data.attemptId,
|
|
348
|
+
taskId: data.taskId,
|
|
349
|
+
taskTitle: data.taskTitle,
|
|
350
|
+
projectId: data.projectId,
|
|
351
|
+
toolUseId: data.toolUseId,
|
|
352
|
+
questions: data.questions,
|
|
353
|
+
timestamp: data.timestamp,
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
socketInstance.on('question:resolved', (data: { attemptId: string }) => {
|
|
358
|
+
useQuestionsStore.getState().removeQuestion(data.attemptId);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Listen for per-attempt workflow updates (full data including nodes/messages)
|
|
362
|
+
socketInstance.on('status:workflow', (data: { attemptId: string; nodes: unknown[]; messages: unknown[]; summary: { chain: string[]; completedCount: number; activeCount: number; totalCount: number } }) => {
|
|
363
|
+
// Feed workflow store with full node data for the workflow panel
|
|
364
|
+
useWorkflowStore.getState().updateWorkflow(data.attemptId, {
|
|
365
|
+
nodes: data.nodes as any,
|
|
366
|
+
messages: data.messages as any,
|
|
367
|
+
summary: data.summary,
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Listen for global workflow updates (for workflow panel cross-task tracking)
|
|
372
|
+
socketInstance.on('workflow:update', (data: { attemptId: string; taskId: string; taskTitle: string; summary: { chain: string[]; completedCount: number; activeCount: number; totalCount: number } }) => {
|
|
373
|
+
useWorkflowStore.getState().updateWorkflow(data.attemptId, {
|
|
374
|
+
taskId: data.taskId,
|
|
375
|
+
taskTitle: data.taskTitle,
|
|
376
|
+
summary: data.summary,
|
|
377
|
+
});
|
|
378
|
+
// Clean up entries with no active agents
|
|
379
|
+
if (data.summary.activeCount === 0 && data.summary.totalCount > 0) {
|
|
380
|
+
// Keep for a bit so user can see final state, then remove
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
const entry = useWorkflowStore.getState().workflows.get(data.attemptId);
|
|
383
|
+
if (entry && entry.summary.activeCount === 0) {
|
|
384
|
+
useWorkflowStore.getState().removeWorkflow(data.attemptId);
|
|
385
|
+
}
|
|
386
|
+
}, 30000);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Listen for stderr output (error messages from agent)
|
|
391
|
+
socketInstance.on('output:stderr', (data: { attemptId: string; content: string }) => {
|
|
392
|
+
if (currentAttemptIdRef.current && data.attemptId !== currentAttemptIdRef.current) return;
|
|
393
|
+
setMessages((prev) => [...prev, {
|
|
394
|
+
type: 'system' as any,
|
|
395
|
+
content: data.content,
|
|
396
|
+
isError: true,
|
|
397
|
+
_attemptId: data.attemptId,
|
|
398
|
+
_msgId: Math.random().toString(36),
|
|
399
|
+
}]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Listen for context compacting status
|
|
403
|
+
socketInstance.on('context:compacting', (data: { attemptId: string; taskId: string }) => {
|
|
404
|
+
setMessages((prev) => [...prev, {
|
|
405
|
+
type: 'system' as any,
|
|
406
|
+
content: 'Compacting conversation context...',
|
|
407
|
+
_attemptId: data.attemptId,
|
|
408
|
+
_msgId: Math.random().toString(36),
|
|
409
|
+
}]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// Listen for prompt-too-long error
|
|
413
|
+
socketInstance.on('context:prompt-too-long', (data: { attemptId: string; message: string }) => {
|
|
414
|
+
if (currentAttemptIdRef.current && data.attemptId !== currentAttemptIdRef.current) return;
|
|
415
|
+
setMessages((prev) => [...prev, {
|
|
416
|
+
type: 'system' as any,
|
|
417
|
+
content: data.message,
|
|
418
|
+
isError: true,
|
|
419
|
+
_attemptId: data.attemptId,
|
|
420
|
+
_msgId: Math.random().toString(36),
|
|
421
|
+
}]);
|
|
422
|
+
});
|
|
423
|
+
|
|
341
424
|
return () => {
|
|
342
425
|
socketInstance.close();
|
|
343
426
|
socketRef.current = null;
|
|
@@ -346,6 +429,10 @@ export function useAttemptStream(
|
|
|
346
429
|
|
|
347
430
|
// Clear messages and reset state when taskId changes
|
|
348
431
|
useEffect(() => {
|
|
432
|
+
// Unsubscribe from old attempt room to stop receiving its events
|
|
433
|
+
if (currentAttemptIdRef.current && socketRef.current) {
|
|
434
|
+
socketRef.current.emit('attempt:unsubscribe', { attemptId: currentAttemptIdRef.current });
|
|
435
|
+
}
|
|
349
436
|
// Clear previous task's messages
|
|
350
437
|
setMessages([]);
|
|
351
438
|
setCurrentAttemptId(null);
|
|
@@ -371,6 +458,12 @@ export function useAttemptStream(
|
|
|
371
458
|
}
|
|
372
459
|
}, []);
|
|
373
460
|
|
|
461
|
+
// Re-fetch pending question for current attempt (used by UI to recover stuck questions)
|
|
462
|
+
const refetchQuestion = useCallback(async () => {
|
|
463
|
+
if (!currentAttemptIdRef.current) return;
|
|
464
|
+
await fetchPendingQuestion(currentAttemptIdRef.current);
|
|
465
|
+
}, [fetchPendingQuestion]);
|
|
466
|
+
|
|
374
467
|
// Check for running attempt on mount/taskId change
|
|
375
468
|
useEffect(() => {
|
|
376
469
|
if (!taskId) return;
|
|
@@ -541,5 +634,5 @@ export function useAttemptStream(
|
|
|
541
634
|
startAttempt(taskId, prompt, displayPrompt, fileIds, model);
|
|
542
635
|
}, [isConnected, startAttempt]);
|
|
543
636
|
|
|
544
|
-
return { messages, isConnected, startAttempt, cancelAttempt, interruptAndSend, currentAttemptId, currentPrompt, isRunning, activeQuestion, answerQuestion, cancelQuestion };
|
|
637
|
+
return { messages, isConnected, startAttempt, cancelAttempt, interruptAndSend, currentAttemptId, currentPrompt, isRunning, activeQuestion, answerQuestion, cancelQuestion, refetchQuestion };
|
|
545
638
|
}
|