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
package/server.ts CHANGED
@@ -113,6 +113,9 @@ app.prepare().then(async () => {
113
113
  pingTimeout: 10000,
114
114
  });
115
115
 
116
+ // Disconnect cleanup timers - keyed by attemptId
117
+ const disconnectTimers = new Map<string, NodeJS.Timeout>();
118
+
116
119
  // Socket.io connection handler
117
120
  io.on('connection', (socket) => {
118
121
  log.info(`Client connected: ${socket.id}`);
@@ -408,6 +411,14 @@ app.prepare().then(async () => {
408
411
  socket.on('attempt:subscribe', (data: { attemptId: string }) => {
409
412
  log.info(`[Server] Socket ${socket.id} subscribing to attempt:${data.attemptId}`);
410
413
  socket.join(`attempt:${data.attemptId}`);
414
+
415
+ // Clear disconnect timer if client reconnected
416
+ const timer = disconnectTimers.get(data.attemptId);
417
+ if (timer) {
418
+ clearTimeout(timer);
419
+ disconnectTimers.delete(data.attemptId);
420
+ log.info({ attemptId: data.attemptId }, '[Server] Cleared disconnect timer on reconnect');
421
+ }
411
422
  });
412
423
 
413
424
  // Unsubscribe from attempt logs
@@ -527,6 +538,48 @@ app.prepare().then(async () => {
527
538
  }
528
539
  );
529
540
 
541
+ // Handle manual compact request
542
+ socket.on('attempt:compact', async (data: { taskId: string }) => {
543
+ const { taskId: compactTaskId } = data;
544
+ log.info({ taskId: compactTaskId }, '[Server] Manual compact requested');
545
+
546
+ try {
547
+ const task = await db.query.tasks.findFirst({
548
+ where: eq(schema.tasks.id, compactTaskId),
549
+ });
550
+ if (!task) { socket.emit('error', { message: 'Task not found' }); return; }
551
+
552
+ const project = await db.query.projects.findFirst({
553
+ where: eq(schema.projects.id, task.projectId),
554
+ });
555
+ if (!project) { socket.emit('error', { message: 'Project not found' }); return; }
556
+
557
+ const conversationSummary = await sessionManager.getConversationSummary(compactTaskId);
558
+
559
+ const compactAttemptId = nanoid();
560
+ await db.insert(schema.attempts).values({
561
+ id: compactAttemptId,
562
+ taskId: compactTaskId,
563
+ prompt: 'Manual compact: summarize conversation context',
564
+ displayPrompt: 'Compacting conversation...',
565
+ status: 'running',
566
+ });
567
+
568
+ socket.join(`attempt:${compactAttemptId}`);
569
+ socket.emit('attempt:started', { attemptId: compactAttemptId, taskId: compactTaskId });
570
+ io.to(`attempt:${compactAttemptId}`).emit('context:compacting', { attemptId: compactAttemptId, taskId: compactTaskId });
571
+
572
+ agentManager.compact({
573
+ attemptId: compactAttemptId,
574
+ projectPath: project.path,
575
+ conversationSummary,
576
+ });
577
+ } catch (error) {
578
+ log.error({ error }, '[Server] Manual compact failed');
579
+ socket.emit('error', { message: 'Compact failed: ' + (error instanceof Error ? error.message : 'Unknown error') });
580
+ }
581
+ });
582
+
530
583
  // ========================================
531
584
  // Inline Edit Socket Handlers
532
585
  // ========================================
@@ -688,6 +741,49 @@ app.prepare().then(async () => {
688
741
 
689
742
  socket.on('disconnect', () => {
690
743
  log.info(`Client disconnected: ${socket.id}`);
744
+
745
+ // Find all attempt rooms this socket was in
746
+ // Socket.io automatically removes the socket from rooms on disconnect
747
+ // but we can check which rooms had this socket before disconnect
748
+ const socketRooms = Array.from(socket.rooms || []);
749
+ const attemptRooms = socketRooms
750
+ .filter(room => room.startsWith('attempt:'))
751
+ .map(room => room.replace('attempt:', ''));
752
+
753
+ for (const attemptId of attemptRooms) {
754
+ // Start grace timer for this attempt
755
+ if (disconnectTimers.has(attemptId)) continue; // Already has a timer
756
+
757
+ const timer = setTimeout(async () => {
758
+ disconnectTimers.delete(attemptId);
759
+
760
+ // Check if attempt room still has 0 clients
761
+ const room = io.sockets.adapter.rooms.get(`attempt:${attemptId}`);
762
+ if (room && room.size > 0) {
763
+ log.info({ attemptId, clients: room.size }, '[Server] Attempt room still has clients, skipping cleanup');
764
+ return;
765
+ }
766
+
767
+ // Check if attempt is still running
768
+ if (!agentManager.isRunning(attemptId)) return;
769
+
770
+ log.info({ attemptId }, '[Server] No clients for 30s, cancelling orphaned attempt');
771
+
772
+ // Cancel the agent
773
+ agentManager.cancel(attemptId);
774
+
775
+ // Mark subagents as orphaned in DB
776
+ try {
777
+ await db.update(schema.subagents)
778
+ .set({ status: 'orphaned', completedAt: Date.now() })
779
+ .where(eq(schema.subagents.attemptId, attemptId));
780
+ } catch (err) {
781
+ log.error({ err, attemptId }, '[Server] Failed to mark subagents as orphaned on disconnect');
782
+ }
783
+ }, 30000);
784
+
785
+ disconnectTimers.set(attemptId, timer);
786
+ }
691
787
  });
692
788
  });
693
789
 
@@ -857,21 +953,53 @@ app.prepare().then(async () => {
857
953
  });
858
954
 
859
955
  // Handle AskUserQuestion detection from AgentManager
860
- agentManager.on('question', ({ attemptId, toolUseId, questions }) => {
956
+ agentManager.on('question', async ({ attemptId, toolUseId, questions }) => {
861
957
  log.info({
862
958
  attemptId,
863
959
  toolUseId,
864
960
  questionCount: questions?.length,
865
961
  questions: questions?.map((q: any) => ({ header: q.header, question: q.question?.substring(0, 50) }))
866
962
  }, '[Server] AskUserQuestion detected');
963
+
964
+ // Emit to attempt room (existing behavior)
867
965
  io.to(`attempt:${attemptId}`).emit('question:ask', {
868
966
  attemptId,
869
967
  toolUseId,
870
968
  questions,
871
969
  });
970
+
971
+ // Emit global question:new event for the questions panel
972
+ // Look up taskId from the attempt
973
+ try {
974
+ const attempt = await db.query.attempts.findFirst({
975
+ where: eq(schema.attempts.id, attemptId),
976
+ });
977
+ if (attempt) {
978
+ const task = await db.query.tasks.findFirst({
979
+ where: eq(schema.tasks.id, attempt.taskId),
980
+ });
981
+ io.emit('question:new', {
982
+ attemptId,
983
+ taskId: attempt.taskId,
984
+ taskTitle: task?.title || '',
985
+ projectId: task?.projectId || '',
986
+ toolUseId,
987
+ questions,
988
+ timestamp: Date.now(),
989
+ });
990
+ }
991
+ } catch (err) {
992
+ log.error({ err }, '[Server] Failed to emit global question:new');
993
+ }
994
+
872
995
  log.info(`[Server] Emitted question:ask to attempt:${attemptId}`);
873
996
  });
874
997
 
998
+ // Handle question resolved from AgentManager (answered or cancelled, including via REST API)
999
+ agentManager.on('questionResolved', ({ attemptId }) => {
1000
+ io.emit('question:resolved', { attemptId });
1001
+ });
1002
+
875
1003
  // Handle background shell detection from AgentManager (Bash with run_in_background=true)
876
1004
  // NOTE: SDK spawns process but it dies when conversation ends.
877
1005
  // We spawn our own detached shell that survives.
@@ -1038,6 +1166,31 @@ app.prepare().then(async () => {
1038
1166
  }
1039
1167
  });
1040
1168
 
1169
+ // Handle "prompt too long" error - trigger auto-compact if enabled
1170
+ agentManager.on('promptTooLong', async ({ attemptId }) => {
1171
+ log.warn({ attemptId }, '[Server] Prompt too long detected');
1172
+
1173
+ try {
1174
+ const autoCompactSetting = await db
1175
+ .select()
1176
+ .from(schema.appSettings)
1177
+ .where(eq(schema.appSettings.key, 'auto_compact_enabled'))
1178
+ .limit(1);
1179
+
1180
+ const autoCompactEnabled = autoCompactSetting.length > 0 && autoCompactSetting[0].value === 'true';
1181
+
1182
+ io.to(`attempt:${attemptId}`).emit('context:prompt-too-long', {
1183
+ attemptId,
1184
+ autoCompactEnabled,
1185
+ message: autoCompactEnabled
1186
+ ? 'Context limit exceeded. Auto-compacting...'
1187
+ : 'Context limit exceeded. Use /compact to reduce context size, or start a new conversation.',
1188
+ });
1189
+ } catch (error) {
1190
+ log.error({ error }, '[Server] Failed to handle prompt-too-long');
1191
+ }
1192
+ });
1193
+
1041
1194
  // Register exit event handler
1042
1195
  agentManager.on('exit', async ({ attemptId, code }) => {
1043
1196
  // Get attempt to retrieve taskId and current status
@@ -1164,6 +1317,73 @@ app.prepare().then(async () => {
1164
1317
  if (attempt?.taskId) {
1165
1318
  io.emit('task:finished', { taskId: attempt.taskId, status });
1166
1319
  }
1320
+
1321
+ // Auto-compact check: if context exceeded threshold and auto-compact is enabled
1322
+ if (status === 'completed' && usageStats?.contextHealth?.shouldCompact && attempt?.taskId) {
1323
+ try {
1324
+ const autoCompactSetting = await db
1325
+ .select()
1326
+ .from(schema.appSettings)
1327
+ .where(eq(schema.appSettings.key, 'auto_compact_enabled'))
1328
+ .limit(1);
1329
+
1330
+ const autoCompactEnabled = autoCompactSetting.length > 0 && autoCompactSetting[0].value === 'true';
1331
+
1332
+ if (autoCompactEnabled) {
1333
+ const project = await db.query.projects.findFirst({
1334
+ where: eq(schema.projects.id, (await db.query.tasks.findFirst({ where: eq(schema.tasks.id, attempt.taskId) }))!.projectId),
1335
+ });
1336
+
1337
+ if (project) {
1338
+ const conversationSummary = await sessionManager.getConversationSummary(attempt.taskId);
1339
+
1340
+ const compactAttemptId = nanoid();
1341
+ await db.insert(schema.attempts).values({
1342
+ id: compactAttemptId,
1343
+ taskId: attempt.taskId,
1344
+ prompt: 'Auto-compact: summarize conversation context',
1345
+ displayPrompt: 'Auto-compacting conversation...',
1346
+ status: 'running',
1347
+ });
1348
+
1349
+ log.info({ attemptId: compactAttemptId, taskId: attempt.taskId }, '[Server] Auto-compacting conversation');
1350
+ io.to(`attempt:${attemptId}`).emit('context:compacting', { attemptId: compactAttemptId, taskId: attempt.taskId });
1351
+
1352
+ agentManager.compact({
1353
+ attemptId: compactAttemptId,
1354
+ projectPath: project.path,
1355
+ conversationSummary,
1356
+ });
1357
+ }
1358
+ }
1359
+ } catch (compactError) {
1360
+ log.error({ compactError }, '[Server] Auto-compact failed');
1361
+ }
1362
+ }
1363
+
1364
+ // Mark any remaining in-progress subagents as orphaned in DB
1365
+ const orphanedNodes = workflowTracker.markOrphaned(attemptId);
1366
+ if (orphanedNodes.length > 0) {
1367
+ log.info({ attemptId, count: orphanedNodes.length }, '[Server] Marking orphaned subagents');
1368
+ for (const node of orphanedNodes) {
1369
+ try {
1370
+ await db.update(schema.subagents)
1371
+ .set({
1372
+ status: 'orphaned',
1373
+ completedAt: node.completedAt || Date.now(),
1374
+ durationMs: node.durationMs || null,
1375
+ })
1376
+ .where(eq(schema.subagents.id, node.id));
1377
+ } catch (err) {
1378
+ log.error({ err, nodeId: node.id }, '[Server] Failed to mark subagent as orphaned');
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ // Clean up in-memory tracking data for this attempt to prevent unbounded growth
1384
+ usageTracker.clearSession(attemptId);
1385
+ workflowTracker.clearWorkflow(attemptId);
1386
+ gitStatsCache.clear(attemptId);
1167
1387
  });
1168
1388
 
1169
1389
  // Forward tracking module events to Socket.io clients
@@ -1177,14 +1397,71 @@ app.prepare().then(async () => {
1177
1397
  });
1178
1398
 
1179
1399
  // Workflow tracking (subagent execution chain)
1180
- workflowTracker.on('workflow-update', ({ attemptId, workflow }) => {
1181
- const summary = workflowTracker.getWorkflowSummary(attemptId);
1182
- if (summary) {
1183
- log.info({ attemptId, chain: summary.chain }, '[Server] Emitting status:workflow');
1400
+ workflowTracker.on('workflow-update', ({ attemptId }) => {
1401
+ const expanded = workflowTracker.getExpandedWorkflow(attemptId);
1402
+ if (expanded) {
1403
+ log.info({ attemptId, chain: expanded.summary.chain }, '[Server] Emitting status:workflow');
1184
1404
  io.to(`attempt:${attemptId}`).emit('status:workflow', {
1185
1405
  attemptId,
1186
- workflow: summary,
1406
+ nodes: expanded.nodes,
1407
+ messages: expanded.messages,
1408
+ summary: expanded.summary,
1187
1409
  });
1410
+
1411
+ // Also emit global workflow:update for cross-task awareness
1412
+ // Look up taskId and title for the attempt
1413
+ db.query.attempts.findFirst({
1414
+ where: eq(schema.attempts.id, attemptId),
1415
+ }).then(attempt => {
1416
+ if (attempt) {
1417
+ db.query.tasks.findFirst({
1418
+ where: eq(schema.tasks.id, attempt.taskId),
1419
+ }).then(task => {
1420
+ io.emit('workflow:update', {
1421
+ attemptId,
1422
+ taskId: attempt.taskId,
1423
+ taskTitle: task?.title || 'Unknown',
1424
+ summary: expanded.summary,
1425
+ });
1426
+ }).catch(() => {});
1427
+ }
1428
+ }).catch(() => {});
1429
+ }
1430
+ });
1431
+
1432
+ // Persist subagent start to DB
1433
+ workflowTracker.on('subagent-start', async ({ attemptId, node }) => {
1434
+ try {
1435
+ await db.insert(schema.subagents).values({
1436
+ id: node.id,
1437
+ attemptId,
1438
+ type: node.type,
1439
+ name: node.name || null,
1440
+ parentId: node.parentId,
1441
+ teamName: node.teamName || null,
1442
+ status: 'in_progress',
1443
+ depth: node.depth,
1444
+ startedAt: node.startedAt || Date.now(),
1445
+ });
1446
+ } catch (err) {
1447
+ log.error({ err, attemptId, nodeId: node.id }, '[Server] Failed to persist subagent start');
1448
+ }
1449
+ });
1450
+
1451
+ // Persist subagent end to DB
1452
+ workflowTracker.on('subagent-end', async ({ attemptId, node }) => {
1453
+ try {
1454
+ const dbStatus = node.status as 'in_progress' | 'completed' | 'failed' | 'orphaned';
1455
+ await db.update(schema.subagents)
1456
+ .set({
1457
+ status: dbStatus,
1458
+ completedAt: node.completedAt || Date.now(),
1459
+ durationMs: node.durationMs || null,
1460
+ error: node.error || null,
1461
+ })
1462
+ .where(eq(schema.subagents.id, node.id));
1463
+ } catch (err) {
1464
+ log.error({ err, attemptId, nodeId: node.id }, '[Server] Failed to persist subagent end');
1188
1465
  }
1189
1466
  });
1190
1467
 
@@ -3,13 +3,16 @@
3
3
  // Force dynamic rendering to avoid Next.js 16 Turbopack build bug
4
4
  export const dynamic = 'force-dynamic';
5
5
 
6
+ import { useTranslations } from 'next-intl';
7
+
6
8
  export default function NotFound() {
9
+ const t = useTranslations('notFound');
7
10
  return (
8
11
  <div style={{ padding: '2rem', fontFamily: 'system-ui', textAlign: 'center' }}>
9
- <h1>404 - Page Not Found</h1>
10
- <p>The page you're looking for doesn't exist.</p>
12
+ <h1>{t('title')}</h1>
13
+ <p>{t('description')}</p>
11
14
  <a href="/" style={{ color: '#0070f3', textDecoration: 'underline' }}>
12
- Go back home
15
+ {t('goHome')}
13
16
  </a>
14
17
  </div>
15
18
  );
@@ -11,6 +11,8 @@ import { SettingsPage } from '@/components/settings/settings-page';
11
11
  import { SetupDialog } from '@/components/settings/setup-dialog';
12
12
  import { SidebarPanel, FileTabsPanel, DiffTabsPanel } from '@/components/sidebar';
13
13
  import { RightSidebar } from '@/components/right-sidebar';
14
+ import { QuestionsPanel } from '@/components/questions/questions-panel';
15
+ import { WorkflowPanel } from '@/components/workflow/workflow-panel';
14
16
  import { PluginList } from '@/components/agent-factory/plugin-list';
15
17
  import { AccessAnywhereWizard } from '@/components/access-anywhere';
16
18
  import { TerminalPanel } from '@/components/terminal/terminal-panel';
@@ -24,8 +26,10 @@ import { useAgentFactoryUIStore } from '@/stores/agent-factory-ui-store';
24
26
  import { useSettingsUIStore } from '@/stores/settings-ui-store';
25
27
  import { useIsMobileViewport } from '@/hooks/use-mobile-viewport';
26
28
  import { useTerminalStore } from '@/stores/terminal-store';
29
+ import { useTranslations } from 'next-intl';
27
30
 
28
31
  function KanbanApp() {
32
+ const tCommon = useTranslations('common');
29
33
  const [createTaskOpen, setCreateTaskOpen] = useState(false);
30
34
  const [setupOpen, setSetupOpen] = useState(false);
31
35
  const [searchQuery, setSearchQuery] = useState('');
@@ -134,7 +138,7 @@ function KanbanApp() {
134
138
  const tab = openTabs.find(t => t.id === tabId);
135
139
  if (tab?.isDirty) {
136
140
  const fileName = tab.filePath.split('/').pop() || tab.filePath;
137
- if (!confirm(`"${fileName}" has unsaved changes. Close anyway?`)) {
141
+ if (!confirm(tCommon('unsavedChangesConfirm', { fileName }))) {
138
142
  return;
139
143
  }
140
144
  }
@@ -204,7 +208,7 @@ function KanbanApp() {
204
208
  <div className="flex h-screen items-center justify-center">
205
209
  <div className="flex items-center gap-3 text-muted-foreground">
206
210
  <img src="/logo.svg" alt="Logo" className="h-8 w-8 animate-spin" />
207
- <span>Loading to Claude<span style={{ color: '#d87756' }}>.</span>WS</span>
211
+ <span>{tCommon('loadingApp')}</span>
208
212
  </div>
209
213
  </div>
210
214
  );
@@ -236,12 +240,12 @@ function KanbanApp() {
236
240
  ) : (
237
241
  <div className="flex h-full items-center justify-center">
238
242
  <div className="text-center">
239
- <p className="text-muted-foreground mb-4">No projects configured</p>
243
+ <p className="text-muted-foreground mb-4">{tCommon('noProjectsConfigured')}</p>
240
244
  <button
241
245
  onClick={() => setSetupOpen(true)}
242
246
  className="text-primary underline hover:no-underline"
243
247
  >
244
- Set up a project
248
+ {tCommon('setUpProject')}
245
249
  </button>
246
250
  </div>
247
251
  </div>
@@ -283,6 +287,12 @@ function KanbanApp() {
283
287
  onCreateTask={() => setCreateTaskOpen(true)}
284
288
  />
285
289
 
290
+ {/* Questions Panel - pending questions sidebar */}
291
+ <QuestionsPanel />
292
+
293
+ {/* Workflow Panel - agent workflow sidebar */}
294
+ <WorkflowPanel />
295
+
286
296
  {/* Access Anywhere Wizard */}
287
297
  <AccessAnywhereWizard />
288
298
 
@@ -0,0 +1,76 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { db, schema } from '@/lib/db';
3
+ import { eq } from 'drizzle-orm';
4
+ import { workflowTracker } from '@/lib/workflow-tracker';
5
+
6
+ /**
7
+ * GET /api/attempts/[id]/workflow
8
+ *
9
+ * Returns the workflow tree for an attempt.
10
+ * - Running attempt: returns from workflowTracker in-memory state
11
+ * - Completed attempt: returns from DB subagents table
12
+ */
13
+ export async function GET(
14
+ _request: NextRequest,
15
+ { params }: { params: Promise<{ id: string }> }
16
+ ) {
17
+ const { id: attemptId } = await params;
18
+
19
+ // Try in-memory state first (for running attempts)
20
+ const expanded = workflowTracker.getExpandedWorkflow(attemptId);
21
+ if (expanded) {
22
+ return NextResponse.json({
23
+ source: 'live',
24
+ nodes: expanded.nodes,
25
+ messages: expanded.messages,
26
+ summary: expanded.summary,
27
+ });
28
+ }
29
+
30
+ // Fall back to DB (for completed attempts)
31
+ const subagents = await db.query.subagents.findMany({
32
+ where: eq(schema.subagents.attemptId, attemptId),
33
+ });
34
+
35
+ if (subagents.length === 0) {
36
+ return NextResponse.json({
37
+ source: 'db',
38
+ nodes: [],
39
+ messages: [],
40
+ summary: { chain: [], completedCount: 0, activeCount: 0, totalCount: 0 },
41
+ });
42
+ }
43
+
44
+ // Build summary from DB records
45
+ const rootNodes = subagents.filter(s => !s.parentId);
46
+ const chain = rootNodes.map(s => s.name || s.type);
47
+ const completedCount = subagents.filter(s => s.status === 'completed').length;
48
+ const activeCount = subagents.filter(s => s.status === 'in_progress').length;
49
+
50
+ // Map DB records to SubagentNode format
51
+ const nodes = subagents
52
+ .sort((a, b) => {
53
+ if (a.depth !== b.depth) return a.depth - b.depth;
54
+ return (a.startedAt || 0) - (b.startedAt || 0);
55
+ })
56
+ .map(s => ({
57
+ id: s.id,
58
+ type: s.type,
59
+ name: s.name,
60
+ status: s.status,
61
+ parentId: s.parentId,
62
+ depth: s.depth,
63
+ teamName: s.teamName,
64
+ startedAt: s.startedAt,
65
+ completedAt: s.completedAt,
66
+ durationMs: s.durationMs,
67
+ error: s.error,
68
+ }));
69
+
70
+ return NextResponse.json({
71
+ source: 'db',
72
+ nodes,
73
+ messages: [], // Messages are not persisted to DB
74
+ summary: { chain, completedCount, activeCount, totalCount: subagents.length },
75
+ });
76
+ }
@@ -0,0 +1,58 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { agentManager } from '@/lib/agent-manager';
3
+ import { db, schema } from '@/lib/db';
4
+ import { eq } from 'drizzle-orm';
5
+
6
+ // POST /api/questions/answer - Answer a pending question via REST API
7
+ // Used by the global questions panel (which doesn't have a socket connection to the attempt room)
8
+ export async function POST(request: NextRequest) {
9
+ try {
10
+ const { attemptId, toolUseId, questions, answers } = await request.json() as {
11
+ attemptId: string;
12
+ toolUseId: string;
13
+ questions: unknown[];
14
+ answers: Record<string, string>;
15
+ };
16
+
17
+ if (!agentManager.hasPendingQuestion(attemptId)) {
18
+ return NextResponse.json(
19
+ { error: 'No pending question for this attempt' },
20
+ { status: 404 }
21
+ );
22
+ }
23
+
24
+ const success = agentManager.answerQuestion(attemptId, toolUseId, questions, answers);
25
+
26
+ if (!success) {
27
+ return NextResponse.json(
28
+ { error: 'Failed to answer question' },
29
+ { status: 400 }
30
+ );
31
+ }
32
+
33
+ // Save answer to database for persistence
34
+ const answerText = Object.entries(answers)
35
+ .map(([question, answer]) => `${question}: **${answer}**`)
36
+ .join('\n');
37
+
38
+ await db.insert(schema.attemptLogs).values({
39
+ attemptId,
40
+ type: 'json',
41
+ content: JSON.stringify({
42
+ type: 'user_answer',
43
+ questions,
44
+ answers,
45
+ displayText: `✓ You answered:\n${answerText}`
46
+ }),
47
+ createdAt: Date.now(),
48
+ });
49
+
50
+ return NextResponse.json({ success: true });
51
+ } catch (error) {
52
+ console.error('Error answering question:', error);
53
+ return NextResponse.json(
54
+ { error: 'Failed to answer question' },
55
+ { status: 500 }
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,68 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { agentManager } from '@/lib/agent-manager';
3
+ import { db, schema } from '@/lib/db';
4
+ import { eq, inArray } from 'drizzle-orm';
5
+
6
+ // GET /api/questions - Get all pending questions across running attempts
7
+ // Optional query param: ?projectIds=id1,id2 to filter by project
8
+ export async function GET(request: NextRequest) {
9
+ try {
10
+ const allPending = agentManager.getAllPendingQuestions();
11
+
12
+ if (allPending.length === 0) {
13
+ return NextResponse.json({ questions: [] });
14
+ }
15
+
16
+ // Look up attemptId → taskId → projectId from DB
17
+ const attemptIds = allPending.map((p) => p.attemptId);
18
+ const attempts = await db.query.attempts.findMany({
19
+ where: inArray(schema.attempts.id, attemptIds),
20
+ });
21
+
22
+ const attemptMap = new Map(attempts.map((a) => [a.id, a]));
23
+
24
+ // Get task info for all relevant tasks
25
+ const taskIds = [...new Set(attempts.map((a) => a.taskId))];
26
+ const tasks = taskIds.length > 0
27
+ ? await db.query.tasks.findMany({
28
+ where: inArray(schema.tasks.id, taskIds),
29
+ })
30
+ : [];
31
+ const taskMap = new Map(tasks.map((t) => [t.id, t]));
32
+
33
+ // Filter by projectIds if provided
34
+ const projectIdsParam = request.nextUrl.searchParams.get('projectIds');
35
+ const filterProjectIds = projectIdsParam ? projectIdsParam.split(',').filter(Boolean) : null;
36
+
37
+ const questions = allPending
38
+ .map((pending) => {
39
+ const attempt = attemptMap.get(pending.attemptId);
40
+ if (!attempt) return null;
41
+ const task = taskMap.get(attempt.taskId);
42
+ if (!task) return null;
43
+
44
+ return {
45
+ attemptId: pending.attemptId,
46
+ taskId: task.id,
47
+ taskTitle: task.title,
48
+ projectId: task.projectId,
49
+ toolUseId: pending.toolUseId,
50
+ questions: pending.questions,
51
+ timestamp: pending.timestamp,
52
+ };
53
+ })
54
+ .filter((q): q is NonNullable<typeof q> => {
55
+ if (!q) return false;
56
+ if (filterProjectIds && !filterProjectIds.includes(q.projectId)) return false;
57
+ return true;
58
+ });
59
+
60
+ return NextResponse.json({ questions });
61
+ } catch (error) {
62
+ console.error('Error getting pending questions:', error);
63
+ return NextResponse.json(
64
+ { error: 'Failed to get pending questions' },
65
+ { status: 500 }
66
+ );
67
+ }
68
+ }