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.
Files changed (86) hide show
  1. package/locales/de.json +374 -12
  2. package/locales/en.json +374 -12
  3. package/locales/es.json +398 -11
  4. package/locales/fr.json +398 -11
  5. package/locales/ja.json +398 -11
  6. package/locales/ko.json +398 -11
  7. package/locales/vi.json +374 -12
  8. package/locales/zh.json +398 -11
  9. package/package.json +1 -1
  10. package/server.ts +283 -6
  11. package/src/app/[locale]/not-found.tsx +6 -3
  12. package/src/app/[locale]/page.tsx +14 -4
  13. package/src/app/api/attempts/[id]/workflow/route.ts +76 -0
  14. package/src/app/api/questions/answer/route.ts +58 -0
  15. package/src/app/api/questions/route.ts +68 -0
  16. package/src/app/api/tasks/[id]/compact/route.ts +62 -0
  17. package/src/components/access-anywhere/api-access-key-setup-modal.tsx +2 -2
  18. package/src/components/access-anywhere/tunnel-settings-dialog.tsx +6 -6
  19. package/src/components/access-anywhere/wizard-step-ctunnel.tsx +8 -8
  20. package/src/components/agent-factory/dependency-tree.tsx +5 -3
  21. package/src/components/agent-factory/discovery-dialog.tsx +26 -22
  22. package/src/components/agent-factory/plugin-detail-dialog.tsx +41 -38
  23. package/src/components/agent-factory/plugin-form-dialog.tsx +23 -20
  24. package/src/components/agent-factory/plugin-list.tsx +20 -17
  25. package/src/components/agent-factory/upload-dialog.tsx +17 -14
  26. package/src/components/auth/agent-provider-dialog.tsx +67 -65
  27. package/src/components/auth/api-key-dialog.tsx +14 -11
  28. package/src/components/auth/auth-error-message.tsx +6 -3
  29. package/src/components/editor/code-editor-with-inline-edit.tsx +4 -2
  30. package/src/components/editor/file-diff-resolver-modal.tsx +31 -26
  31. package/src/components/editor/inline-edit-dialog.tsx +9 -6
  32. package/src/components/editor/selection-mention-popup.tsx +3 -1
  33. package/src/components/header/project-selector.tsx +7 -4
  34. package/src/components/header.tsx +70 -4
  35. package/src/components/kanban/column.tsx +11 -0
  36. package/src/components/kanban/task-card.tsx +70 -4
  37. package/src/components/project-settings/component-selector.tsx +3 -1
  38. package/src/components/project-settings/plugin-upload-dialog.tsx +7 -5
  39. package/src/components/project-settings/project-settings-dialog.tsx +5 -3
  40. package/src/components/questions/questions-panel.tsx +136 -0
  41. package/src/components/settings/folder-browser-dialog.tsx +29 -25
  42. package/src/components/settings/settings-page.tsx +64 -18
  43. package/src/components/settings/setup-dialog.tsx +26 -23
  44. package/src/components/setup/unified-setup-wizard.tsx +12 -9
  45. package/src/components/sidebar/file-browser/file-create-buttons.tsx +7 -3
  46. package/src/components/sidebar/file-browser/file-tab-content.tsx +19 -15
  47. package/src/components/sidebar/file-browser/file-tabs-panel.tsx +7 -4
  48. package/src/components/sidebar/file-browser/file-tree.tsx +3 -1
  49. package/src/components/sidebar/git-changes/branch-checkout-modal.tsx +6 -4
  50. package/src/components/sidebar/git-changes/commit-details-modal.tsx +5 -3
  51. package/src/components/sidebar/git-changes/diff-tabs-panel.tsx +3 -1
  52. package/src/components/sidebar/git-changes/git-file-item.tsx +8 -6
  53. package/src/components/sidebar/git-changes/git-graph.tsx +8 -5
  54. package/src/components/sidebar/git-changes/git-panel.tsx +28 -27
  55. package/src/components/sidebar/git-changes/git-section.tsx +5 -3
  56. package/src/components/sidebar/shells/shell-panel.tsx +3 -1
  57. package/src/components/task/attachment-bar.tsx +4 -1
  58. package/src/components/task/attempt-item.tsx +7 -5
  59. package/src/components/task/conversation-view.tsx +21 -13
  60. package/src/components/task/floating-chat-window.tsx +14 -5
  61. package/src/components/task/interactive-command/checkpoint-list.tsx +5 -3
  62. package/src/components/task/interactive-command/confirm-dialog.tsx +9 -4
  63. package/src/components/task/interactive-command/interactive-command-overlay.tsx +23 -9
  64. package/src/components/task/interactive-command/question-prompt.tsx +12 -8
  65. package/src/components/task/pending-question-indicator.tsx +5 -3
  66. package/src/components/task/prompt-input.tsx +1 -1
  67. package/src/components/task/shell-log-view.tsx +3 -1
  68. package/src/components/task/status-line.tsx +84 -23
  69. package/src/components/task/task-detail-panel.tsx +27 -27
  70. package/src/components/task/task-shell-indicator.tsx +10 -6
  71. package/src/components/terminal/terminal-context-menu.tsx +6 -4
  72. package/src/components/terminal/terminal-instance.tsx +11 -3
  73. package/src/components/terminal/terminal-panel.tsx +6 -3
  74. package/src/components/terminal/terminal-shortcut-bar.tsx +3 -1
  75. package/src/components/terminal/terminal-tab-bar.tsx +5 -3
  76. package/src/components/workflow/workflow-panel.tsx +181 -0
  77. package/src/hooks/use-attempt-stream.ts +96 -3
  78. package/src/lib/agent-manager.ts +89 -3
  79. package/src/lib/db/index.ts +18 -0
  80. package/src/lib/db/schema.ts +29 -0
  81. package/src/lib/process-manager.ts +28 -7
  82. package/src/lib/session-manager.ts +60 -0
  83. package/src/lib/usage-tracker.ts +19 -19
  84. package/src/lib/workflow-tracker.ts +118 -20
  85. package/src/stores/questions-store.ts +76 -0
  86. 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={() => setShowQuestionPrompt(true)}
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
- {activeQuestion ? (
326
- <QuestionPrompt
327
- key={activeQuestion.toolUseId}
328
- questions={activeQuestion.questions}
329
- onAnswer={(answers) => {
330
- if (selectedTask?.status !== 'in_progress') {
331
- moveTaskToInProgress(selectedTask.id);
332
- }
333
- answerQuestion(activeQuestion.questions, answers as Record<string, string>);
334
- setShowQuestionPrompt(false);
335
- }}
336
- onCancel={() => {
337
- cancelQuestion();
338
- setShowQuestionPrompt(false);
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="Detach to floating window"
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="Stop shell (K)"
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
- <span className="text-green-500 font-medium">{runningCount}</span> running background task{runningCount !== 1 ? 's' : ''}
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>↑↓ navigate</span>
241
- <span>⏎ view logs</span>
242
- <span>K kill</span>
243
- <span>Esc close</span>
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
- Copy
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
- Paste
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
- Select All
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
- Clear Terminal
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('Copied to clipboard');
137
- else toast.error('Failed to copy');
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('Clipboard access denied — use the shortcut bar Paste button on mobile');
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>Creating terminal...</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">Failed to create terminal</p>
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
- Retry
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="Selection mode"
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="New Terminal"
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="Send Ctrl+C (SIGINT)"
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="Close Panel"
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">&#10003;</span>;
29
+ case 'in_progress':
30
+ return <span className="text-blue-500 text-xs shrink-0 animate-pulse">&#9679;</span>;
31
+ case 'failed':
32
+ return <span className="text-red-500 text-xs shrink-0">&#10007;</span>;
33
+ case 'orphaned':
34
+ return <span className="text-yellow-500 text-xs shrink-0">&#8856;</span>;
35
+ default:
36
+ return <span className="text-muted-foreground text-xs shrink-0">&#9679;</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} &rarr; {msg.toType}]
98
+ </span>
99
+ <span className="truncate">&quot;{msg.summary}&quot;</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
- // Filter by current attemptId to prevent cross-task question leaking
334
- if (currentAttemptIdRef.current && data.attemptId !== 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
  }