thepopebot 1.2.76-beta.2 → 1.2.76-beta.21

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 (128) hide show
  1. package/README.md +3 -3
  2. package/api/CLAUDE.md +11 -4
  3. package/api/index.js +56 -18
  4. package/bin/CLAUDE.md +7 -4
  5. package/bin/cli.js +25 -45
  6. package/config/CLAUDE.md +23 -4
  7. package/drizzle/0021_coding_agent_workspace.sql +1 -0
  8. package/drizzle/0022_organic_apocalypse.sql +16 -0
  9. package/drizzle/0023_needy_ender_wiggin.sql +1 -0
  10. package/drizzle/meta/0021_snapshot.json +639 -0
  11. package/drizzle/meta/0022_snapshot.json +743 -0
  12. package/drizzle/meta/0023_snapshot.json +750 -0
  13. package/drizzle/meta/_journal.json +21 -0
  14. package/lib/CLAUDE.md +2 -2
  15. package/lib/actions.js +9 -1
  16. package/lib/ai/CLAUDE.md +72 -57
  17. package/lib/ai/helper-llm.js +108 -0
  18. package/lib/ai/index.js +308 -438
  19. package/lib/ai/line-mappers.js +42 -24
  20. package/lib/ai/scope.js +26 -0
  21. package/lib/ai/sdk-adapters/CLAUDE.md +114 -0
  22. package/lib/ai/sdk-adapters/claude-code.js +120 -8
  23. package/lib/ai/system-prompt.js +34 -0
  24. package/lib/ai/workspace-setup.js +19 -35
  25. package/lib/channels/CLAUDE.md +14 -4
  26. package/lib/channels/base.js +6 -2
  27. package/lib/channels/commands/index.js +42 -0
  28. package/lib/channels/commands/session.js +53 -0
  29. package/lib/channels/commands/verify.js +18 -0
  30. package/lib/channels/telegram.js +79 -28
  31. package/lib/chat/CLAUDE.md +4 -4
  32. package/lib/chat/actions.js +270 -49
  33. package/lib/chat/api.js +185 -31
  34. package/lib/chat/components/CLAUDE.md +6 -2
  35. package/lib/chat/components/chat-input.js +77 -47
  36. package/lib/chat/components/chat-input.jsx +77 -40
  37. package/lib/chat/components/chat-page.js +2 -0
  38. package/lib/chat/components/chat-page.jsx +3 -0
  39. package/lib/chat/components/chat.js +62 -14
  40. package/lib/chat/components/chat.jsx +68 -10
  41. package/lib/chat/components/code-mode-toggle.js +141 -22
  42. package/lib/chat/components/code-mode-toggle.jsx +129 -20
  43. package/lib/chat/components/containers-page.js +58 -40
  44. package/lib/chat/components/containers-page.jsx +64 -25
  45. package/lib/chat/components/crons-page.js +17 -3
  46. package/lib/chat/components/crons-page.jsx +34 -6
  47. package/lib/chat/components/index.js +2 -2
  48. package/lib/chat/components/message.js +18 -3
  49. package/lib/chat/components/message.jsx +18 -3
  50. package/lib/chat/components/profile-page.js +182 -4
  51. package/lib/chat/components/profile-page.jsx +196 -1
  52. package/lib/chat/components/scope-picker.js +21 -0
  53. package/lib/chat/components/scope-picker.jsx +27 -0
  54. package/lib/chat/components/settings-chat-page.js +11 -11
  55. package/lib/chat/components/settings-chat-page.jsx +14 -18
  56. package/lib/chat/components/settings-coding-agents-page.js +110 -16
  57. package/lib/chat/components/settings-coding-agents-page.jsx +87 -3
  58. package/lib/chat/components/settings-github-page.js +5 -0
  59. package/lib/chat/components/settings-github-page.jsx +5 -0
  60. package/lib/chat/components/settings-layout.js +3 -3
  61. package/lib/chat/components/settings-layout.jsx +3 -3
  62. package/lib/chat/components/settings-secrets-layout.js +1 -2
  63. package/lib/chat/components/settings-secrets-layout.jsx +1 -2
  64. package/lib/chat/components/settings-secrets-page.js +180 -75
  65. package/lib/chat/components/settings-secrets-page.jsx +212 -66
  66. package/lib/chat/components/triggers-page.js +17 -3
  67. package/lib/chat/components/triggers-page.jsx +34 -6
  68. package/lib/chat/components/ui/combobox.js +18 -2
  69. package/lib/chat/components/ui/combobox.jsx +17 -1
  70. package/lib/chat/components/ui/dropdown-menu.js +23 -2
  71. package/lib/chat/components/ui/dropdown-menu.jsx +27 -2
  72. package/lib/chat/telegram-profile.js +33 -0
  73. package/lib/cluster/CLAUDE.md +9 -3
  74. package/lib/code/CLAUDE.md +11 -3
  75. package/lib/code/actions.js +47 -8
  76. package/lib/code/terminal-view.js +31 -21
  77. package/lib/code/terminal-view.jsx +32 -23
  78. package/lib/config.js +15 -4
  79. package/lib/containers/CLAUDE.md +16 -6
  80. package/lib/db/CLAUDE.md +5 -2
  81. package/lib/db/chats.js +9 -17
  82. package/lib/db/code-workspaces.js +8 -3
  83. package/lib/db/config.js +0 -1
  84. package/lib/db/index.js +12 -0
  85. package/lib/db/schema.js +24 -1
  86. package/lib/db/user-channels.js +129 -0
  87. package/lib/llm-providers.js +8 -0
  88. package/lib/maintenance.js +31 -21
  89. package/lib/tools/CLAUDE.md +12 -3
  90. package/lib/tools/assemblyai.js +17 -0
  91. package/lib/tools/create-agent-job.js +12 -8
  92. package/lib/tools/docker.js +34 -10
  93. package/lib/tools/github.js +34 -0
  94. package/lib/tools/telegram.js +106 -0
  95. package/lib/utils/render-md.js +44 -18
  96. package/package.json +8 -8
  97. package/setup/CLAUDE.md +11 -5
  98. package/setup/lib/providers.mjs +2 -1
  99. package/setup/lib/targets.mjs +13 -16
  100. package/setup/lib/telegram.mjs +8 -69
  101. package/templates/.env.example +0 -7
  102. package/templates/.github/workflows/rebuild-event-handler.yml +1 -1
  103. package/templates/.gitignore.template +1 -3
  104. package/templates/CLAUDE.md +1 -1
  105. package/templates/CLAUDE.md.template +29 -7
  106. package/templates/agent-job/CLAUDE.md.template +5 -3
  107. package/templates/agent-job/CRONS.json +16 -0
  108. package/templates/agent-job/SYSTEM.md +16 -11
  109. package/templates/agents/CLAUDE.md.template +17 -17
  110. package/templates/coding-workspace/CLAUDE.md.template +7 -0
  111. package/templates/data/CLAUDE.md.template +1 -1
  112. package/templates/docker-compose.custom.yml +1 -0
  113. package/templates/docker-compose.yml +1 -0
  114. package/templates/event-handler/CLAUDE.md.template +79 -0
  115. package/templates/event-handler/TRIGGERS.json +18 -2
  116. package/templates/skills/CLAUDE.md.template +20 -22
  117. package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/SKILL.md +2 -2
  118. package/lib/ai/agent.js +0 -65
  119. package/lib/ai/async-channel.js +0 -51
  120. package/lib/ai/model.js +0 -130
  121. package/lib/ai/tools.js +0 -164
  122. package/lib/tools/openai.js +0 -37
  123. package/setup/lib/telegram-verify.mjs +0 -63
  124. package/setup/setup-telegram.mjs +0 -260
  125. package/templates/agent-job/SOUL.md +0 -17
  126. /package/templates/{skills/active/.gitkeep → coding-workspace/SYSTEM.md} +0 -0
  127. /package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/agent-job-secrets.js +0 -0
  128. /package/templates/skills/{library/playwright-cli → playwright-cli}/SKILL.md +0 -0
package/lib/chat/api.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { auth } from '../auth/index.js';
2
2
  import { chatStream } from '../ai/index.js';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
- import { getConfig } from '../config.js';
5
4
 
6
5
  /**
7
6
  * POST handler for /stream/chat — streaming chat with session auth.
@@ -14,7 +13,7 @@ export async function POST(request) {
14
13
  }
15
14
 
16
15
  const body = await request.json();
17
- const { messages, chatId: rawChatId, trigger, codeMode, repo, branch, workspaceId, codeModeType } = body;
16
+ const { messages, chatId: rawChatId, trigger, codeMode, repo, branch, workspaceId, codeModeType, scope } = body;
18
17
 
19
18
  if (!messages?.length) {
20
19
  return Response.json({ error: 'No messages' }, { status: 400 });
@@ -87,6 +86,7 @@ export async function POST(request) {
87
86
  codeModeType: codeModeType || 'plan',
88
87
  };
89
88
  if (workspaceId) streamOptions.workspaceId = workspaceId;
89
+ if (scope) streamOptions.scope = scope;
90
90
  const chunks = chatStream(threadId, userText, attachments, streamOptions);
91
91
 
92
92
  // Signal start of assistant message
@@ -94,6 +94,28 @@ export async function POST(request) {
94
94
 
95
95
  let textStarted = false;
96
96
  let textId = uuidv4();
97
+ // Ephemeral thinking block state — tunneled as __thinking__ tool calls.
98
+ // Content is never persisted to DB (not a real tool-call/result pair in chatStream).
99
+ let thinkingId = null;
100
+ let thinkingText = '';
101
+ // Track which toolCallIds have had tool-input-start emitted.
102
+ //
103
+ // Two problems this solves:
104
+ //
105
+ // 1. The Claude Agent SDK emits tool-call twice per tool use: once at
106
+ // content_block_start (args: {}) and again at content_block_stop
107
+ // (args: complete). Sending tool-input-start twice for the same ID
108
+ // resets the AI SDK's internal part state to input-streaming and
109
+ // clears its stored input, causing a visual flicker. Deduplicate here.
110
+ //
111
+ // 2. When Claude Code resumes a session, the adapter skips assistant
112
+ // messages (to avoid duplicate UI) but still emits tool-result chunks
113
+ // for tool_result blocks in subsequent user messages. Those tool-result
114
+ // chunks have no matching tool-call in this stream, so tool-input-start
115
+ // is never sent for them. The AI SDK then throws
116
+ // "tool-output-error must be preceded by a tool-input-available"
117
+ // when tool-output-available arrives. Emit the open events defensively.
118
+ const openedToolCalls = new Set();
97
119
 
98
120
  for await (const chunk of chunks) {
99
121
  if (chunk.type === 'text') {
@@ -105,47 +127,49 @@ export async function POST(request) {
105
127
  writer.write({ type: 'text-delta', id: textId, delta: chunk.text });
106
128
 
107
129
  } else if (chunk.type === 'tool-call') {
108
- // Close any open text block before tool events
109
- if (textStarted) {
110
- writer.write({ type: 'text-end', id: textId });
111
- textStarted = false;
112
- }
113
- writer.write({
114
- type: 'tool-input-start',
115
- toolCallId: chunk.toolCallId,
116
- toolName: chunk.toolName,
117
- });
118
- // Enrich coding_agent input with active agent identity from config
119
- let input = chunk.args;
120
- if (chunk.toolName === 'coding_agent') {
121
- const agent = getConfig('CODING_AGENT') || 'claude-code';
122
- const providerKeys = {
123
- 'claude-code': 'CODING_AGENT_CLAUDE_CODE_BACKEND',
124
- 'pi-coding-agent': 'CODING_AGENT_PI_PROVIDER',
125
- 'gemini-cli': 'CODING_AGENT_GEMINI_CLI_PROVIDER',
126
- 'codex-cli': 'CODING_AGENT_CODEX_CLI_PROVIDER',
127
- 'opencode': 'CODING_AGENT_OPENCODE_PROVIDER',
128
- 'kimi-cli': 'CODING_AGENT_KIMI_CLI_PROVIDER',
129
- };
130
- const backendApi = getConfig(providerKeys[agent]) || 'anthropic';
131
- input = { ...chunk.args, codingAgent: agent, backendApi };
130
+ if (!openedToolCalls.has(chunk.toolCallId)) {
131
+ // First time seeing this ID — open the tool block
132
+ if (textStarted) {
133
+ writer.write({ type: 'text-end', id: textId });
134
+ textStarted = false;
135
+ }
136
+ writer.write({
137
+ type: 'tool-input-start',
138
+ toolCallId: chunk.toolCallId,
139
+ toolName: chunk.toolName,
140
+ });
141
+ openedToolCalls.add(chunk.toolCallId);
132
142
  }
143
+ // Always emit tool-input-available: first call shows empty args while
144
+ // streaming, second call (content_block_stop) updates to complete args
133
145
  writer.write({
134
146
  type: 'tool-input-available',
135
147
  toolCallId: chunk.toolCallId,
136
148
  toolName: chunk.toolName,
137
- input,
149
+ input: chunk.args,
138
150
  });
139
151
 
140
152
  } else if (chunk.type === 'tool-result') {
141
- // Update input with complete args (accumulated from all streaming chunks)
142
- if (chunk.args) {
153
+ if (!openedToolCalls.has(chunk.toolCallId)) {
154
+ // tool-result arrived with no preceding tool-call in this stream
155
+ // (session resume replays tool results from skipped assistant messages).
156
+ // Emit the required open events so the AI SDK does not throw.
157
+ if (textStarted) {
158
+ writer.write({ type: 'text-end', id: textId });
159
+ textStarted = false;
160
+ }
161
+ writer.write({
162
+ type: 'tool-input-start',
163
+ toolCallId: chunk.toolCallId,
164
+ toolName: chunk.toolName || 'unknown',
165
+ });
143
166
  writer.write({
144
167
  type: 'tool-input-available',
145
168
  toolCallId: chunk.toolCallId,
146
- toolName: chunk.toolName,
147
- input: chunk.args,
169
+ toolName: chunk.toolName || 'unknown',
170
+ input: chunk.args || {},
148
171
  });
172
+ openedToolCalls.add(chunk.toolCallId);
149
173
  }
150
174
  writer.write({
151
175
  type: 'tool-output-available',
@@ -153,9 +177,60 @@ export async function POST(request) {
153
177
  output: chunk.result,
154
178
  });
155
179
 
180
+ } else if (chunk.type === 'thinking-start') {
181
+ // Open a new ephemeral thinking block as a pseudo-tool
182
+ if (textStarted) {
183
+ writer.write({ type: 'text-end', id: textId });
184
+ textStarted = false;
185
+ }
186
+ thinkingId = uuidv4();
187
+ thinkingText = '';
188
+ writer.write({
189
+ type: 'tool-input-start',
190
+ toolCallId: thinkingId,
191
+ toolName: '__thinking__',
192
+ });
193
+
194
+ } else if (chunk.type === 'thinking') {
195
+ // Accumulate and stream thinking deltas progressively
196
+ if (thinkingId) {
197
+ thinkingText += chunk.delta;
198
+ writer.write({
199
+ type: 'tool-input-available',
200
+ toolCallId: thinkingId,
201
+ toolName: '__thinking__',
202
+ input: thinkingText,
203
+ });
204
+ }
205
+
206
+ } else if (chunk.type === 'thinking-end') {
207
+ // Close the thinking block — empty output marks it done
208
+ if (thinkingId) {
209
+ writer.write({
210
+ type: 'tool-output-available',
211
+ toolCallId: thinkingId,
212
+ output: '',
213
+ });
214
+ thinkingId = null;
215
+ thinkingText = '';
216
+ }
217
+
156
218
  } else if (chunk.type === 'meta' || chunk.type === 'result') {
157
219
  // Internal events — no SSE output needed
158
220
 
221
+ } else if (chunk.type === 'error') {
222
+ // Stream a typed data part so the client renders a red error message.
223
+ // Persisted by chatStream() as a JSON row — rehydrated in chat-page.jsx.
224
+ if (textStarted) {
225
+ writer.write({ type: 'text-end', id: textId });
226
+ textStarted = false;
227
+ }
228
+ writer.write({
229
+ type: 'data-error',
230
+ id: `error-${uuidv4().slice(0, 8)}`,
231
+ data: { message: chunk.message },
232
+ });
233
+
159
234
  } else if (chunk.type === 'unknown') {
160
235
  // Close any open text block before unknown event
161
236
  if (textStarted) {
@@ -449,6 +524,28 @@ export async function getRepositoriesHandler() {
449
524
  }
450
525
  }
451
526
 
527
+ /**
528
+ * POST handler for /code/repositories/create — create a new GitHub repository.
529
+ */
530
+ export async function createRepositoryHandler(request) {
531
+ const session = await auth();
532
+ if (!session?.user?.id) {
533
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
534
+ }
535
+ try {
536
+ const { name } = await request.json();
537
+ if (!name || typeof name !== 'string') {
538
+ return Response.json({ error: 'Repository name is required' }, { status: 400 });
539
+ }
540
+ const { createRepository } = await import('../tools/github.js');
541
+ const repo = await createRepository(name.trim());
542
+ return Response.json(repo);
543
+ } catch (err) {
544
+ const message = err.message || 'Failed to create repository';
545
+ return Response.json({ error: message }, { status: 422 });
546
+ }
547
+ }
548
+
452
549
  /**
453
550
  * GET handler for /code/branches?repo=owner/name — list branches with session auth.
454
551
  */
@@ -469,6 +566,26 @@ export async function getBranchesHandler(request) {
469
566
  }
470
567
  }
471
568
 
569
+ /**
570
+ * GET handler for /code/default-branch?repo=owner/name — repo's default branch.
571
+ */
572
+ export async function getDefaultBranchHandler(request) {
573
+ const session = await auth();
574
+ if (!session?.user?.id) {
575
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
576
+ }
577
+ const url = new URL(request.url);
578
+ const repoFullName = url.searchParams.get('repo');
579
+ if (!repoFullName) return Response.json({ branch: null });
580
+ try {
581
+ const { getDefaultBranch } = await import('../tools/github.js');
582
+ const branch = await getDefaultBranch(repoFullName);
583
+ return Response.json({ branch });
584
+ } catch {
585
+ return Response.json({ branch: null });
586
+ }
587
+ }
588
+
472
589
  /**
473
590
  * GET handler for /chat/voice-token — AssemblyAI temporary token with session auth.
474
591
  */
@@ -493,6 +610,43 @@ export async function getVoiceTokenHandler(request) {
493
610
  return Response.json({ token: data.token });
494
611
  }
495
612
 
613
+ /**
614
+ * GET handler for /chat/scopes — list available agent scopes (subdirectories in agents/).
615
+ * Returns an array of { name, path } objects.
616
+ */
617
+ export async function getScopesHandler() {
618
+ const session = await auth();
619
+ if (!session?.user?.id) {
620
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
621
+ }
622
+ try {
623
+ const { readdirSync, statSync } = await import('fs');
624
+ const { join } = await import('path');
625
+ const { PROJECT_ROOT } = await import('../paths.js');
626
+ const agentsDir = join(PROJECT_ROOT, 'agents');
627
+
628
+ try {
629
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
630
+ const scopes = entries
631
+ .filter(e => e.isDirectory() || e.isSymbolicLink())
632
+ .filter(e => {
633
+ // Verify symlinks resolve to directories
634
+ if (e.isSymbolicLink()) {
635
+ try { return statSync(join(agentsDir, e.name)).isDirectory(); } catch { return false; }
636
+ }
637
+ return true;
638
+ })
639
+ .map(e => ({ name: e.name, path: `agents/${e.name}` }));
640
+ return Response.json(scopes);
641
+ } catch (err) {
642
+ if (err.code === 'ENOENT') return Response.json([]);
643
+ throw err;
644
+ }
645
+ } catch {
646
+ return Response.json([]);
647
+ }
648
+ }
649
+
496
650
  export async function finalizeChat(request) {
497
651
  const session = await auth();
498
652
  if (!session?.user?.id) {
@@ -8,8 +8,8 @@ Admin pages live under `/admin/` with two top-level sections:
8
8
 
9
9
  - **`/admin/event-handler/`** — Event handler config with pill-style sub-tabs via `SubTabLayout` (`settings-secrets-layout.jsx`):
10
10
  - `/admin/event-handler/llms` — LLM provider credentials
11
- - `/admin/event-handler/chat` — Chat LLM settings
12
11
  - `/admin/event-handler/coding-agents` — Multi-agent config (5 backends)
12
+ - `/admin/event-handler/helper-llm` — Helper LLM settings (provider/model used for chat titles, agent-job titles + summaries)
13
13
  - `/admin/event-handler/jobs` — Agent job custom secrets
14
14
  - `/admin/event-handler/telegram` — Telegram integration
15
15
  - `/admin/event-handler/voice` — Voice input (AssemblyAI)
@@ -29,7 +29,11 @@ Admin pages live under `/admin/` with two top-level sections:
29
29
 
30
30
  `tool-names.js` auto-generates display names from the tool's snake_case name (split on `_`, capitalize each word). No map to maintain — adding a new tool automatically gets a display name.
31
31
 
32
- This file is **UI-only** — it controls display text, not which tools are available. Tool-to-agent assignment lives in `lib/ai/agent.js`.
32
+ This file is **UI-only** — it controls display text. There is no host-side tool registry; every tool name in the UI comes from the coding agent's own stream (e.g. `Read`, `Bash`, `Edit`) plus the synthetic `workspace` setup tool emitted by `chatStream()`.
33
+
34
+ ## Error Messages
35
+
36
+ `chatStream()` emits `{ type: 'error', message }` on failure. `lib/chat/api.js` writes it as an AI SDK `data-error` part; `chat-page.jsx` rehydrates stored errors into the same part shape on refresh; `message.jsx` renders `data-error` parts as a red banner using `text-destructive` / `border-destructive/30 bg-destructive/5`. Errors persist to the `messages` table as JSON (`{"type":"error","message":"..."}`).
33
37
 
34
38
  ## Settings UI Standards
35
39
 
@@ -65,8 +65,10 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
65
65
  const fileInputRef = useRef(null);
66
66
  const [isDragging, setIsDragging] = useState(false);
67
67
  const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
68
+ const [agentPickerOpen, setAgentPickerOpen] = useState(false);
68
69
  const [partialText, setPartialText] = useState("");
69
70
  const dropdownRef = useRef(null);
71
+ const agentPickerRef = useRef(null);
70
72
  const isStreaming = status === "streaming" || status === "submitted";
71
73
  const volumeRef = useRef(0);
72
74
  const { voiceAvailable, isConnecting, isRecording, startRecording, stopRecording } = useVoiceInput({
@@ -106,6 +108,23 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
106
108
  document.addEventListener("mousedown", handleClickOutside);
107
109
  return () => document.removeEventListener("mousedown", handleClickOutside);
108
110
  }, [modeDropdownOpen]);
111
+ useEffect(() => {
112
+ if (!agentPickerOpen) return;
113
+ const handleClickOutside = (e) => {
114
+ if (agentPickerRef.current && !agentPickerRef.current.contains(e.target)) {
115
+ setAgentPickerOpen(false);
116
+ }
117
+ };
118
+ const handleKeyDown2 = (e) => {
119
+ if (e.key === "Escape") setAgentPickerOpen(false);
120
+ };
121
+ document.addEventListener("mousedown", handleClickOutside);
122
+ document.addEventListener("keydown", handleKeyDown2);
123
+ return () => {
124
+ document.removeEventListener("mousedown", handleClickOutside);
125
+ document.removeEventListener("keydown", handleKeyDown2);
126
+ };
127
+ }, [agentPickerOpen]);
109
128
  const handleFiles = useCallback((fileList) => {
110
129
  const newFiles = Array.from(fileList).filter(isAcceptedType);
111
130
  if (newFiles.length === 0) return;
@@ -127,6 +146,7 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
127
146
  if (e) e.preventDefault();
128
147
  if (disabled || !input.trim() && !partialText.trim() && files.length === 0 || isStreaming) return;
129
148
  if (canSendOverride !== void 0 && !canSendOverride) return;
149
+ if (isRecording) stopRecording();
130
150
  if (partialText) {
131
151
  const needsSpace = input && !input.endsWith(" ");
132
152
  setInput(input + (needsSpace ? " " : "") + partialText);
@@ -240,10 +260,10 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
240
260
  onClick: () => setModeDropdownOpen((prev) => !prev),
241
261
  className: cn(
242
262
  "inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors",
243
- codeModeSettings.mode === "code" ? "bg-green-500/15 text-green-500 hover:bg-green-500/25" : codeModeSettings.mode === "job" ? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25" : "bg-destructive/10 text-destructive hover:bg-destructive/20"
263
+ codeModeSettings.mode === "code" ? "bg-green-500/15 text-green-500 hover:bg-green-500/25" : "bg-destructive/10 text-destructive hover:bg-destructive/20"
244
264
  ),
245
265
  children: [
246
- codeModeSettings.mode === "code" ? "Code" : codeModeSettings.mode === "job" ? "Job" : "Plan",
266
+ codeModeSettings.mode === "code" ? "Code" : "Plan",
247
267
  " \u25BE"
248
268
  ]
249
269
  }
@@ -278,58 +298,68 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
278
298
  ),
279
299
  children: "Code"
280
300
  }
281
- ),
282
- !codeMode && /* @__PURE__ */ jsx(
301
+ )
302
+ ] })
303
+ ] }),
304
+ codeModeSettings && !codeModeSettings.isInteractiveActive && /* @__PURE__ */ jsxs("div", { className: "relative", ref: agentPickerRef, children: [
305
+ agentPickerOpen && codeModeSettings.availableAgents?.length > 1 && /* @__PURE__ */ jsxs("div", { className: "absolute bottom-full left-0 mb-1.5 z-50 min-w-[140px] rounded-md border border-border bg-background shadow-md py-1 overflow-hidden", children: [
306
+ /* @__PURE__ */ jsx("p", { className: "px-3 pt-0.5 pb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide", children: "Launch with" }),
307
+ codeModeSettings.availableAgents.map((agent) => /* @__PURE__ */ jsx(
283
308
  "button",
284
309
  {
285
310
  type: "button",
286
311
  onClick: () => {
287
- codeModeSettings.onModeChange("job");
288
- setModeDropdownOpen(false);
312
+ setAgentPickerOpen(false);
313
+ codeModeSettings.onInteractiveToggle(agent.value);
289
314
  },
290
- className: cn(
291
- "w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors",
292
- codeModeSettings.mode === "job" ? "text-blue-500 font-medium" : "text-foreground"
315
+ className: "w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-muted transition-colors",
316
+ children: agent.label
317
+ },
318
+ agent.value
319
+ ))
320
+ ] }),
321
+ /* @__PURE__ */ jsxs(
322
+ "button",
323
+ {
324
+ type: "button",
325
+ onClick: () => codeModeSettings.onInteractiveToggle(),
326
+ onContextMenu: (e) => {
327
+ e.preventDefault();
328
+ if (!codeModeSettings.togglingMode && codeModeSettings.availableAgents?.length > 1 && codeModeSettings.hasMessages) {
329
+ setAgentPickerOpen((prev) => !prev);
330
+ }
331
+ },
332
+ disabled: codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive,
333
+ title: codeModeSettings.availableAgents?.length > 1 && codeModeSettings.hasMessages ? "Left-click to launch \xB7 Right-click to pick agent" : void 0,
334
+ className: "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors",
335
+ children: [
336
+ codeModeSettings.togglingMode && /* @__PURE__ */ jsxs("svg", { className: "animate-spin h-3 w-3", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [
337
+ /* @__PURE__ */ jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }),
338
+ /* @__PURE__ */ jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })
339
+ ] }),
340
+ /* @__PURE__ */ jsx(
341
+ "span",
342
+ {
343
+ className: cn(
344
+ "relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200",
345
+ codeModeSettings.isInteractiveActive ? "bg-primary" : "bg-muted-foreground/30"
346
+ ),
347
+ children: /* @__PURE__ */ jsx(
348
+ "span",
349
+ {
350
+ className: cn(
351
+ "absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200",
352
+ codeModeSettings.isInteractiveActive && "translate-x-2.5"
353
+ )
354
+ }
355
+ )
356
+ }
293
357
  ),
294
- children: "Job"
295
- }
296
- )
297
- ] })
358
+ codeModeSettings.togglingMode ? "Launching..." : "Interactive"
359
+ ]
360
+ }
361
+ )
298
362
  ] }),
299
- codeModeSettings && !codeModeSettings.isInteractiveActive && /* @__PURE__ */ jsxs(
300
- "button",
301
- {
302
- type: "button",
303
- onClick: codeModeSettings.onInteractiveToggle,
304
- disabled: codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive,
305
- className: "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors",
306
- children: [
307
- codeModeSettings.togglingMode && /* @__PURE__ */ jsxs("svg", { className: "animate-spin h-3 w-3", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [
308
- /* @__PURE__ */ jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }),
309
- /* @__PURE__ */ jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })
310
- ] }),
311
- /* @__PURE__ */ jsx(
312
- "span",
313
- {
314
- className: cn(
315
- "relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200",
316
- codeModeSettings.isInteractiveActive ? "bg-primary" : "bg-muted-foreground/30"
317
- ),
318
- children: /* @__PURE__ */ jsx(
319
- "span",
320
- {
321
- className: cn(
322
- "absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200",
323
- codeModeSettings.isInteractiveActive && "translate-x-2.5"
324
- )
325
- }
326
- )
327
- }
328
- ),
329
- codeModeSettings.togglingMode ? "Launching..." : "Interactive"
330
- ]
331
- }
332
- ),
333
363
  /* @__PURE__ */ jsx(
334
364
  "input",
335
365
  {
@@ -46,8 +46,10 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
46
46
  const fileInputRef = useRef(null);
47
47
  const [isDragging, setIsDragging] = useState(false);
48
48
  const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
49
+ const [agentPickerOpen, setAgentPickerOpen] = useState(false);
49
50
  const [partialText, setPartialText] = useState('');
50
51
  const dropdownRef = useRef(null);
52
+ const agentPickerRef = useRef(null);
51
53
  const isStreaming = status === 'streaming' || status === 'submitted';
52
54
  const volumeRef = useRef(0);
53
55
 
@@ -94,6 +96,25 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
94
96
  return () => document.removeEventListener('mousedown', handleClickOutside);
95
97
  }, [modeDropdownOpen]);
96
98
 
99
+ // Close agent picker on outside click or Escape
100
+ useEffect(() => {
101
+ if (!agentPickerOpen) return;
102
+ const handleClickOutside = (e) => {
103
+ if (agentPickerRef.current && !agentPickerRef.current.contains(e.target)) {
104
+ setAgentPickerOpen(false);
105
+ }
106
+ };
107
+ const handleKeyDown = (e) => {
108
+ if (e.key === 'Escape') setAgentPickerOpen(false);
109
+ };
110
+ document.addEventListener('mousedown', handleClickOutside);
111
+ document.addEventListener('keydown', handleKeyDown);
112
+ return () => {
113
+ document.removeEventListener('mousedown', handleClickOutside);
114
+ document.removeEventListener('keydown', handleKeyDown);
115
+ };
116
+ }, [agentPickerOpen]);
117
+
97
118
  const handleFiles = useCallback((fileList) => {
98
119
  const newFiles = Array.from(fileList).filter(isAcceptedType);
99
120
  if (newFiles.length === 0) return;
@@ -119,6 +140,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
119
140
  if (e) e.preventDefault();
120
141
  if (disabled || (!input.trim() && !partialText.trim() && files.length === 0) || isStreaming) return;
121
142
  if (canSendOverride !== undefined && !canSendOverride) return;
143
+ if (isRecording) stopRecording();
122
144
  if (partialText) {
123
145
  const needsSpace = input && !input.endsWith(' ');
124
146
  setInput(input + (needsSpace ? ' ' : '') + partialText);
@@ -247,7 +269,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
247
269
  <PaperclipIcon size={16} />
248
270
  </button>
249
271
 
250
- {/* Plan/Code/Job dropdown */}
272
+ {/* Plan/Code dropdown */}
251
273
  {codeModeSettings && (
252
274
  <div className="relative" ref={dropdownRef}>
253
275
  <button
@@ -257,12 +279,10 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
257
279
  'inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors',
258
280
  codeModeSettings.mode === 'code'
259
281
  ? 'bg-green-500/15 text-green-500 hover:bg-green-500/25'
260
- : codeModeSettings.mode === 'job'
261
- ? 'bg-blue-500/15 text-blue-500 hover:bg-blue-500/25'
262
- : 'bg-destructive/10 text-destructive hover:bg-destructive/20'
282
+ : 'bg-destructive/10 text-destructive hover:bg-destructive/20'
263
283
  )}
264
284
  >
265
- {codeModeSettings.mode === 'code' ? 'Code' : codeModeSettings.mode === 'job' ? 'Job' : 'Plan'} &#9662;
285
+ {codeModeSettings.mode === 'code' ? 'Code' : 'Plan'} &#9662;
266
286
  </button>
267
287
  {modeDropdownOpen && (
268
288
  <div className="absolute bottom-full left-0 mb-1 rounded-lg border border-border bg-background shadow-lg py-1 min-w-[100px] z-50">
@@ -286,52 +306,69 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
286
306
  >
287
307
  Code
288
308
  </button>
289
- {!codeMode && (
290
- <button
291
- type="button"
292
- onClick={() => { codeModeSettings.onModeChange('job'); setModeDropdownOpen(false); }}
293
- className={cn(
294
- 'w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors',
295
- codeModeSettings.mode === 'job' ? 'text-blue-500 font-medium' : 'text-foreground'
296
- )}
297
- >
298
- Job
299
- </button>
300
- )}
301
309
  </div>
302
310
  )}
303
311
  </div>
304
312
  )}
305
313
 
306
- {/* Interactive toggle */}
314
+ {/* Interactive toggle — left-click to launch with default agent,
315
+ right-click to pick a specific agent (when multiple are available) */}
307
316
  {codeModeSettings && !codeModeSettings.isInteractiveActive && (
308
- <button
309
- type="button"
310
- onClick={codeModeSettings.onInteractiveToggle}
311
- disabled={codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive}
312
- className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
313
- >
314
- {codeModeSettings.togglingMode && (
315
- <svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
316
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
317
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
318
- </svg>
317
+ <div className="relative" ref={agentPickerRef}>
318
+ {/* Agent picker popup — appears above the toggle on right-click */}
319
+ {agentPickerOpen && codeModeSettings.availableAgents?.length > 1 && (
320
+ <div className="absolute bottom-full left-0 mb-1.5 z-50 min-w-[140px] rounded-md border border-border bg-background shadow-md py-1 overflow-hidden">
321
+ <p className="px-3 pt-0.5 pb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Launch with</p>
322
+ {codeModeSettings.availableAgents.map(agent => (
323
+ <button
324
+ key={agent.value}
325
+ type="button"
326
+ onClick={() => {
327
+ setAgentPickerOpen(false);
328
+ codeModeSettings.onInteractiveToggle(agent.value);
329
+ }}
330
+ className="w-full text-left px-3 py-1.5 text-xs text-foreground hover:bg-muted transition-colors"
331
+ >
332
+ {agent.label}
333
+ </button>
334
+ ))}
335
+ </div>
319
336
  )}
320
- <span
321
- className={cn(
322
- 'relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200',
323
- codeModeSettings.isInteractiveActive ? 'bg-primary' : 'bg-muted-foreground/30'
324
- )}
337
+ <button
338
+ type="button"
339
+ onClick={() => codeModeSettings.onInteractiveToggle()}
340
+ onContextMenu={(e) => {
341
+ e.preventDefault();
342
+ if (!codeModeSettings.togglingMode && codeModeSettings.availableAgents?.length > 1 && codeModeSettings.hasMessages) {
343
+ setAgentPickerOpen(prev => !prev);
344
+ }
345
+ }}
346
+ disabled={codeModeSettings.togglingMode || codeModeSettings.isInteractiveActive}
347
+ title={codeModeSettings.availableAgents?.length > 1 && codeModeSettings.hasMessages ? 'Left-click to launch · Right-click to pick agent' : undefined}
348
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
325
349
  >
350
+ {codeModeSettings.togglingMode && (
351
+ <svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
352
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
353
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
354
+ </svg>
355
+ )}
326
356
  <span
327
357
  className={cn(
328
- 'absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200',
329
- codeModeSettings.isInteractiveActive && 'translate-x-2.5'
358
+ 'relative inline-flex h-3.5 w-6 shrink-0 rounded-full transition-colors duration-200',
359
+ codeModeSettings.isInteractiveActive ? 'bg-primary' : 'bg-muted-foreground/30'
330
360
  )}
331
- />
332
- </span>
333
- {codeModeSettings.togglingMode ? 'Launching...' : 'Interactive'}
334
- </button>
361
+ >
362
+ <span
363
+ className={cn(
364
+ 'absolute top-0.5 left-0.5 h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200',
365
+ codeModeSettings.isInteractiveActive && 'translate-x-2.5'
366
+ )}
367
+ />
368
+ </span>
369
+ {codeModeSettings.togglingMode ? 'Launching...' : 'Interactive'}
370
+ </button>
371
+ </div>
335
372
  )}
336
373
 
337
374
  <input