bosun 0.29.2 → 0.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/setup.mjs CHANGED
@@ -1500,6 +1500,15 @@ const EXECUTOR_PRESETS = {
1500
1500
  role: "primary",
1501
1501
  },
1502
1502
  ],
1503
+ "claude-only": [
1504
+ {
1505
+ name: "claude-default",
1506
+ executor: "CLAUDE",
1507
+ variant: "CLAUDE_OPUS_4",
1508
+ weight: 100,
1509
+ role: "primary",
1510
+ },
1511
+ ],
1503
1512
  triple: [
1504
1513
  {
1505
1514
  name: "copilot-claude",
@@ -1817,6 +1826,16 @@ function normalizeSetupConfiguration({
1817
1826
  enabled: executor.enabled !== false,
1818
1827
  }));
1819
1828
 
1829
+ // Derive PRIMARY_AGENT from executor config for SDK resolution
1830
+ {
1831
+ const primaryExec = configJson.executors.find((e) => e.role === "primary");
1832
+ if (primaryExec) {
1833
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
1834
+ env.PRIMARY_AGENT = env.PRIMARY_AGENT ||
1835
+ sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
1836
+ }
1837
+ }
1838
+
1820
1839
  configJson.failover = {
1821
1840
  strategy: normalizeEnum(
1822
1841
  configJson.failover?.strategy || env.FAILOVER_STRATEGY || "next-in-line",
@@ -2460,6 +2479,7 @@ async function main() {
2460
2479
  "Codex only",
2461
2480
  "Copilot + Codex (50/50 split)",
2462
2481
  "Copilot only (Claude Opus 4.6)",
2482
+ "Claude only (direct API)",
2463
2483
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2464
2484
  "Custom — I'll define my own executors",
2465
2485
  ]
@@ -2467,6 +2487,7 @@ async function main() {
2467
2487
  "Codex only",
2468
2488
  "Copilot + Codex (50/50 split)",
2469
2489
  "Copilot only (Claude Opus 4.6)",
2490
+ "Claude only (direct API)",
2470
2491
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2471
2492
  ];
2472
2493
 
@@ -2477,8 +2498,8 @@ async function main() {
2477
2498
  );
2478
2499
 
2479
2500
  const presetNames = isAdvancedSetup
2480
- ? ["codex-only", "copilot-codex", "copilot-only", "triple", "custom"]
2481
- : ["codex-only", "copilot-codex", "copilot-only", "triple"];
2501
+ ? ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple", "custom"]
2502
+ : ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple"];
2482
2503
  const presetKey = presetNames[presetIdx] || "codex-only";
2483
2504
 
2484
2505
  if (presetKey === "custom") {
@@ -2629,92 +2650,140 @@ async function main() {
2629
2650
  if (!wantCodexFallback) env.CODEX_SDK_DISABLED = "true";
2630
2651
  }
2631
2652
 
2632
- // ── Step 5: AI Provider ────────────────────────────────
2633
- headingStep(5, "AI / Codex Provider", markSetupProgress);
2653
+ // ── Step 5: AI Provider Keys ─────────────────────────────
2654
+ headingStep(5, "AI Provider Keys", markSetupProgress);
2634
2655
  console.log(
2635
- " Codex Monitor uses the Codex SDK for crash analysis & autofix.\n",
2656
+ " Configure API keys for the agent SDKs in your executor preset.\n",
2636
2657
  );
2637
2658
 
2638
- const providerIdx = await prompt.choose(
2639
- "Select AI provider:",
2640
- [
2641
- "OpenAI (default)",
2642
- "Azure OpenAI",
2643
- "Local model (Ollama, vLLM, etc.)",
2644
- "Other OpenAI-compatible endpoint",
2645
- "None disable AI features",
2646
- ],
2647
- 0,
2648
- );
2649
-
2650
- if (providerIdx < 4) {
2651
- env.OPENAI_API_KEY = await prompt.ask(
2652
- "API Key",
2653
- process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_API_KEY || "",
2654
- );
2659
+ // Determine which SDK families are needed
2660
+ const needsCodexSdk = usedSdks.has("CODEX") || env.CODEX_SDK_DISABLED !== "true";
2661
+ const needsCopilotSdk = usedSdks.has("COPILOT") || env.COPILOT_SDK_DISABLED !== "true";
2662
+ const needsClaudeSdk = usedSdks.has("CLAUDE") || env.CLAUDE_SDK_DISABLED !== "true";
2663
+
2664
+ // ── 5a. Copilot / GitHub Token ──────────────────────
2665
+ if (needsCopilotSdk) {
2666
+ console.log(chalk.bold(" Copilot SDK") + chalk.dim(" (uses GitHub token)\n"));
2667
+ const existingGhToken = process.env.COPILOT_CLI_TOKEN || process.env.GITHUB_TOKEN || "";
2668
+ if (existingGhToken) {
2669
+ info(`GitHub token detected (${existingGhToken.slice(0, 8)}…). Copilot SDK will use it.`);
2670
+ } else {
2671
+ const ghToken = await prompt.ask(
2672
+ "GitHub Token (GITHUB_TOKEN or COPILOT_CLI_TOKEN, blank to skip)",
2673
+ "",
2674
+ );
2675
+ if (ghToken) env.GITHUB_TOKEN = ghToken;
2676
+ }
2677
+ // Copilot permission defaults
2678
+ env.COPILOT_NO_ALLOW_ALL = env.COPILOT_NO_ALLOW_ALL || "false";
2679
+ env.COPILOT_ENABLE_ASK_USER = env.COPILOT_ENABLE_ASK_USER || "false";
2680
+ env.COPILOT_AGENT_MAX_REQUESTS = env.COPILOT_AGENT_MAX_REQUESTS || "500";
2655
2681
  }
2656
- if (providerIdx === 1) {
2657
- // Azure OpenAI also needs AZURE_OPENAI_API_KEY for adapters that check it directly
2658
- env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
2659
- env.OPENAI_BASE_URL = await prompt.ask(
2660
- "Azure endpoint URL",
2661
- process.env.OPENAI_BASE_URL || "",
2662
- );
2663
- env.CODEX_MODEL = await prompt.ask(
2664
- "Deployment/model name",
2665
- process.env.CODEX_MODEL || "",
2666
- );
2667
- } else if (providerIdx === 2) {
2668
- env.OPENAI_API_KEY = env.OPENAI_API_KEY || "ollama";
2669
- env.OPENAI_BASE_URL = await prompt.ask(
2670
- "Local API URL",
2671
- "http://localhost:11434/v1",
2672
- );
2673
- env.CODEX_MODEL = await prompt.ask("Model name", "codex");
2674
- } else if (providerIdx === 3) {
2675
- env.OPENAI_BASE_URL = await prompt.ask("API Base URL", "");
2676
- env.CODEX_MODEL = await prompt.ask("Model name", "");
2677
- } else if (providerIdx === 4) {
2678
- env.CODEX_SDK_DISABLED = "true";
2682
+
2683
+ // ── 5b. Claude / Anthropic Key ──────────────────────
2684
+ if (needsClaudeSdk) {
2685
+ console.log(chalk.bold("\n Claude SDK") + chalk.dim(" (uses Anthropic API key)\n"));
2686
+ const existingAnthropicKey = process.env.ANTHROPIC_API_KEY || "";
2687
+ if (existingAnthropicKey) {
2688
+ info(`Anthropic API key detected (${existingAnthropicKey.slice(0, 8)}…). Claude SDK will use it.`);
2689
+ } else {
2690
+ const anthropicKey = await prompt.ask(
2691
+ "Anthropic API Key (ANTHROPIC_API_KEY, blank to skip)",
2692
+ "",
2693
+ );
2694
+ if (anthropicKey) env.ANTHROPIC_API_KEY = anthropicKey;
2695
+ }
2696
+ // Claude always runs in bypass mode under Bosun
2697
+ env.CLAUDE_PERMISSION_MODE = env.CLAUDE_PERMISSION_MODE || "bypassPermissions";
2679
2698
  }
2680
2699
 
2681
- if (providerIdx < 4) {
2682
- const configureProfiles = await prompt.confirm(
2683
- "Configure model profiles (xl/m) for one-click switching?",
2684
- true,
2700
+ // ── 5c. Codex / OpenAI Key ──────────────────────────
2701
+ if (needsCodexSdk) {
2702
+ console.log(chalk.bold("\n Codex SDK") + chalk.dim(" (uses OpenAI API key)\n"));
2703
+
2704
+ const providerIdx = await prompt.choose(
2705
+ "Select AI provider for Codex:",
2706
+ [
2707
+ "OpenAI (default)",
2708
+ "Azure OpenAI",
2709
+ "Local model (Ollama, vLLM, etc.)",
2710
+ "Other OpenAI-compatible endpoint",
2711
+ "None — disable Codex SDK",
2712
+ ],
2713
+ 0,
2685
2714
  );
2686
- if (configureProfiles) {
2687
- const activeProfileIdx = await prompt.choose(
2688
- "Default active profile:",
2689
- ["xl (high quality)", "m (faster/cheaper)"],
2690
- 0,
2715
+
2716
+ if (providerIdx < 4) {
2717
+ env.OPENAI_API_KEY = await prompt.ask(
2718
+ "API Key",
2719
+ process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_API_KEY || "",
2691
2720
  );
2692
- env.CODEX_MODEL_PROFILE = activeProfileIdx === 0 ? "xl" : "m";
2693
- env.CODEX_MODEL_PROFILE_SUBAGENT = activeProfileIdx === 0 ? "m" : "xl";
2694
-
2695
- env.CODEX_MODEL_PROFILE_XL_MODEL = await prompt.ask(
2696
- "XL profile model",
2697
- process.env.CODEX_MODEL_PROFILE_XL_MODEL ||
2698
- process.env.CODEX_MODEL ||
2699
- "gpt-5.3-codex",
2721
+ }
2722
+ if (providerIdx === 1) {
2723
+ env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
2724
+ env.OPENAI_BASE_URL = await prompt.ask(
2725
+ "Azure endpoint URL",
2726
+ process.env.OPENAI_BASE_URL || "",
2700
2727
  );
2701
- env.CODEX_MODEL_PROFILE_M_MODEL = await prompt.ask(
2702
- "M profile model",
2703
- process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini",
2728
+ env.CODEX_MODEL = await prompt.ask(
2729
+ "Deployment/model name",
2730
+ process.env.CODEX_MODEL || "",
2704
2731
  );
2732
+ } else if (providerIdx === 2) {
2733
+ env.OPENAI_API_KEY = env.OPENAI_API_KEY || "ollama";
2734
+ env.OPENAI_BASE_URL = await prompt.ask(
2735
+ "Local API URL",
2736
+ "http://localhost:11434/v1",
2737
+ );
2738
+ env.CODEX_MODEL = await prompt.ask("Model name", "codex");
2739
+ } else if (providerIdx === 3) {
2740
+ env.OPENAI_BASE_URL = await prompt.ask("API Base URL", "");
2741
+ env.CODEX_MODEL = await prompt.ask("Model name", "");
2742
+ } else if (providerIdx === 4) {
2743
+ env.CODEX_SDK_DISABLED = "true";
2744
+ }
2745
+
2746
+ if (providerIdx < 4) {
2747
+ const configureProfiles = await prompt.confirm(
2748
+ "Configure model profiles (xl/m) for one-click switching?",
2749
+ true,
2750
+ );
2751
+ if (configureProfiles) {
2752
+ const activeProfileIdx = await prompt.choose(
2753
+ "Default active profile:",
2754
+ ["xl (high quality)", "m (faster/cheaper)"],
2755
+ 0,
2756
+ );
2757
+ env.CODEX_MODEL_PROFILE = activeProfileIdx === 0 ? "xl" : "m";
2758
+ env.CODEX_MODEL_PROFILE_SUBAGENT = activeProfileIdx === 0 ? "m" : "xl";
2759
+
2760
+ env.CODEX_MODEL_PROFILE_XL_MODEL = await prompt.ask(
2761
+ "XL profile model",
2762
+ process.env.CODEX_MODEL_PROFILE_XL_MODEL ||
2763
+ process.env.CODEX_MODEL ||
2764
+ "gpt-5.3-codex",
2765
+ );
2766
+ env.CODEX_MODEL_PROFILE_M_MODEL = await prompt.ask(
2767
+ "M profile model",
2768
+ process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini",
2769
+ );
2705
2770
 
2706
- const providerName =
2707
- providerIdx === 1 ? "azure" : providerIdx === 3 ? "compatible" : "openai";
2708
- env.CODEX_MODEL_PROFILE_XL_PROVIDER =
2709
- process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || providerName;
2710
- env.CODEX_MODEL_PROFILE_M_PROVIDER =
2711
- process.env.CODEX_MODEL_PROFILE_M_PROVIDER || providerName;
2771
+ const providerName =
2772
+ providerIdx === 1 ? "azure" : providerIdx === 3 ? "compatible" : "openai";
2773
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER =
2774
+ process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || providerName;
2775
+ env.CODEX_MODEL_PROFILE_M_PROVIDER =
2776
+ process.env.CODEX_MODEL_PROFILE_M_PROVIDER || providerName;
2712
2777
 
2713
- if (!env.CODEX_SUBAGENT_MODEL) {
2714
- env.CODEX_SUBAGENT_MODEL =
2715
- env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
2778
+ if (!env.CODEX_SUBAGENT_MODEL) {
2779
+ env.CODEX_SUBAGENT_MODEL =
2780
+ env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
2781
+ }
2716
2782
  }
2717
2783
  }
2784
+ } else {
2785
+ // Codex not needed — skip OpenAI key prompts entirely
2786
+ info("Codex SDK not in executor preset — skipping OpenAI configuration.");
2718
2787
  }
2719
2788
 
2720
2789
  // ── Step 6: Telegram ──────────────────────────────────
@@ -4568,6 +4637,13 @@ async function runNonInteractive({
4568
4637
  env.COPILOT_AGENT_MAX_REQUESTS =
4569
4638
  process.env.COPILOT_AGENT_MAX_REQUESTS || "500";
4570
4639
 
4640
+ // Claude SDK: permission mode and API key passthrough
4641
+ env.CLAUDE_PERMISSION_MODE =
4642
+ process.env.CLAUDE_PERMISSION_MODE || "bypassPermissions";
4643
+ if (process.env.ANTHROPIC_API_KEY) {
4644
+ env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
4645
+ }
4646
+
4571
4647
  // Parse EXECUTORS env if set, else use default preset
4572
4648
  if (process.env.EXECUTORS) {
4573
4649
  const entries = process.env.EXECUTORS.split(",").map((e) => e.trim());
@@ -4589,7 +4665,33 @@ async function runNonInteractive({
4589
4665
  }
4590
4666
  }
4591
4667
  if (!configJson.executors.length) {
4592
- configJson.executors = EXECUTOR_PRESETS["codex-only"];
4668
+ // Smart default: pick preset based on available API keys
4669
+ const hasOpenAI = !!process.env.OPENAI_API_KEY;
4670
+ const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
4671
+ const hasGitHub = !!process.env.GITHUB_TOKEN || !!process.env.COPILOT_CLI_TOKEN;
4672
+ const presetEnv = (process.env.EXECUTOR_PRESET || "").toLowerCase();
4673
+ if (presetEnv && EXECUTOR_PRESETS[presetEnv]) {
4674
+ configJson.executors = EXECUTOR_PRESETS[presetEnv];
4675
+ } else if (hasGitHub) {
4676
+ configJson.executors = hasOpenAI
4677
+ ? EXECUTOR_PRESETS["copilot-codex"]
4678
+ : EXECUTOR_PRESETS["copilot-only"];
4679
+ } else if (hasAnthropic) {
4680
+ configJson.executors = EXECUTOR_PRESETS["claude-only"];
4681
+ } else {
4682
+ configJson.executors = EXECUTOR_PRESETS["codex-only"];
4683
+ }
4684
+ }
4685
+
4686
+ // Derive PRIMARY_AGENT from executor preset's primary role
4687
+ {
4688
+ const primaryExec = (configJson.executors || []).find(
4689
+ (e) => e.role === "primary",
4690
+ );
4691
+ if (primaryExec) {
4692
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
4693
+ env.PRIMARY_AGENT = sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
4694
+ }
4593
4695
  }
4594
4696
 
4595
4697
  configJson.projectName = env.PROJECT_NAME;
@@ -4803,6 +4905,42 @@ async function writeConfigFiles({ env, configJson, repoRoot, configDir }) {
4803
4905
  warn(`Could not update Copilot MCP config: ${copilotMcpResult.error}`);
4804
4906
  }
4805
4907
 
4908
+ // ── Repo-level AI configs for all workspace repos ──────
4909
+ heading("Repo-Level AI Configs");
4910
+ try {
4911
+ const { ensureRepoConfigs, printRepoConfigSummary } = await import("./repo-config.mjs");
4912
+ // Apply to the primary repo
4913
+ const repoResult = ensureRepoConfigs(repoRoot);
4914
+ printRepoConfigSummary(repoResult, (msg) => console.log(msg));
4915
+
4916
+ // Also apply to all workspace repos under $BOSUN_DIR/workspaces/
4917
+ const bosunDir = env.BOSUN_DIR || configDir || resolve(homedir(), "bosun");
4918
+ const bosunConfigPath = resolve(bosunDir, "bosun.config.json");
4919
+ if (existsSync(bosunConfigPath)) {
4920
+ try {
4921
+ const bosunCfg = JSON.parse(readFileSync(bosunConfigPath, "utf8"));
4922
+ const wsDir = resolve(bosunDir, "workspaces");
4923
+ for (const ws of bosunCfg.workspaces || []) {
4924
+ for (const repo of ws.repos || []) {
4925
+ const wsRepoPath = resolve(wsDir, ws.id || ws.name || "", repo.name);
4926
+ if (wsRepoPath !== repoRoot && existsSync(wsRepoPath)) {
4927
+ const wsResult = ensureRepoConfigs(wsRepoPath);
4928
+ const anyChange = Object.values(wsResult).some((r) => r.created || r.updated);
4929
+ if (anyChange) {
4930
+ info(`Workspace repo: ${repo.name}`);
4931
+ printRepoConfigSummary(wsResult, (msg) => console.log(msg));
4932
+ }
4933
+ }
4934
+ }
4935
+ }
4936
+ } catch (wsErr) {
4937
+ warn(`Could not update workspace repo configs: ${wsErr.message}`);
4938
+ }
4939
+ }
4940
+ } catch (rcErr) {
4941
+ warn(`Could not apply repo-level configs: ${rcErr.message}`);
4942
+ }
4943
+
4806
4944
  // ── Codex CLI config.toml ─────────────────────────────
4807
4945
  heading("Codex CLI Config");
4808
4946
 
@@ -4814,10 +4952,24 @@ async function writeConfigFiles({ env, configJson, repoRoot, configDir }) {
4814
4952
  const kanbanIsVk =
4815
4953
  (env.KANBAN_BACKEND || "internal").toLowerCase() === "vk" ||
4816
4954
  ["vk", "hybrid"].includes((env.EXECUTOR_MODE || "internal").toLowerCase());
4955
+ // Derive primary SDK from executor configuration
4956
+ const primaryExecutor = (configJson.executors || []).find(
4957
+ (e) => e.role === "primary",
4958
+ );
4959
+ const executorToPrimarySdk = {
4960
+ CODEX: "codex",
4961
+ COPILOT: "copilot",
4962
+ CLAUDE: "claude",
4963
+ };
4964
+ const primarySdk = primaryExecutor
4965
+ ? executorToPrimarySdk[String(primaryExecutor.executor).toUpperCase()] || "codex"
4966
+ : "codex";
4967
+
4817
4968
  const tomlResult = ensureCodexConfig({
4818
4969
  vkBaseUrl,
4819
4970
  skipVk: !kanbanIsVk,
4820
4971
  dryRun: false,
4972
+ primarySdk,
4821
4973
  env: {
4822
4974
  ...process.env,
4823
4975
  ...env,
package/task-executor.mjs CHANGED
@@ -3717,6 +3717,7 @@ class TaskExecutor {
3717
3717
  "BOSUN_TASK_ID", "BOSUN_TASK_TITLE", "BOSUN_TASK_DESCRIPTION",
3718
3718
  "BOSUN_BRANCH_NAME", "BOSUN_WORKTREE_PATH", "BOSUN_SDK", "BOSUN_MANAGED",
3719
3719
  "BOSUN_REPO_ROOT", "BOSUN_REPO_SLUG", "BOSUN_WORKSPACE", "BOSUN_REPOSITORY",
3720
+ "BOSUN_AGENT_REPO_ROOT",
3720
3721
  ];
3721
3722
  const _savedEnv = {};
3722
3723
  for (const k of _savedEnvKeys) _savedEnv[k] = process.env[k];
@@ -3746,6 +3747,9 @@ class TaskExecutor {
3746
3747
  process.env.BOSUN_REPO_SLUG = executionRepoSlug;
3747
3748
  process.env.BOSUN_WORKSPACE = taskRepoContext.workspace || "";
3748
3749
  process.env.BOSUN_REPOSITORY = taskRepoContext.repository || "";
3750
+ // Enforce workspace isolation: agents must resolve repo root from the
3751
+ // workspace-scoped executionRepoRoot, NOT the developer's personal repo.
3752
+ process.env.BOSUN_AGENT_REPO_ROOT = executionRepoRoot;
3749
3753
 
3750
3754
  const attemptId = `${taskId}-${randomUUID()}`;
3751
3755
  const taskMeta = {
@@ -4,6 +4,7 @@
4
4
  * tracking, and pending/retry message support.
5
5
  * ────────────────────────────────────────────────────────────── */
6
6
  import { h } from "preact";
7
+ import { memo } from "preact/compat";
7
8
  import { useState, useEffect, useRef, useCallback, useMemo } from "preact/hooks";
8
9
  import htm from "htm";
9
10
  import { apiFetch } from "../modules/api.js";
@@ -151,8 +152,23 @@ function renderMarkdown(text) {
151
152
  return result;
152
153
  }
153
154
 
155
+ /* ─── Markdown render cache — avoids re-parsing identical content ─── */
156
+ const _mdCache = new Map();
157
+ const MD_CACHE_MAX = 500;
158
+ function cachedRenderMarkdown(text) {
159
+ if (_mdCache.has(text)) return _mdCache.get(text);
160
+ const html = renderMarkdown(text);
161
+ if (_mdCache.size >= MD_CACHE_MAX) {
162
+ // Evict oldest quarter when full
163
+ const keys = Array.from(_mdCache.keys()).slice(0, MD_CACHE_MAX >> 2);
164
+ for (const k of keys) _mdCache.delete(k);
165
+ }
166
+ _mdCache.set(text, html);
167
+ return html;
168
+ }
169
+
154
170
  /* ─── Code block copy button ─── */
155
- function CodeBlock({ code }) {
171
+ const CodeBlock = memo(function CodeBlock({ code }) {
156
172
  const [copied, setCopied] = useState(false);
157
173
  const handleCopy = useCallback(() => {
158
174
  try {
@@ -170,10 +186,10 @@ function CodeBlock({ code }) {
170
186
  <pre><code>${code}</code></pre>
171
187
  </div>
172
188
  `;
173
- }
189
+ });
174
190
 
175
191
  /* ─── Render message content with code block + markdown support ─── */
176
- function MessageContent({ text }) {
192
+ const MessageContent = memo(function MessageContent({ text }) {
177
193
  if (!text) return null;
178
194
  const parts = text.split(/(```[\s\S]*?```)/g);
179
195
  return html`${parts.map((part, i) => {
@@ -181,9 +197,9 @@ function MessageContent({ text }) {
181
197
  const code = part.slice(3, -3).replace(/^\w+\n/, "");
182
198
  return html`<${CodeBlock} key=${i} code=${code} />`;
183
199
  }
184
- return html`<div key=${i} class="md-rendered" dangerouslySetInnerHTML=${{ __html: renderMarkdown(part) }} />`;
200
+ return html`<div key=${i} class="md-rendered" dangerouslySetInnerHTML=${{ __html: cachedRenderMarkdown(part) }} />`;
185
201
  })}`;
186
- }
202
+ });
187
203
 
188
204
  /* ─── Stream helpers ─── */
189
205
  function categorizeMessage(msg) {
@@ -206,6 +222,42 @@ function formatMessageLine(msg) {
206
222
  return `[${timestamp}] ${String(kind).toUpperCase()}: ${content}`;
207
223
  }
208
224
 
225
+ /* ─── Memoized ChatBubble — only re-renders if msg identity changes ─── */
226
+ const ChatBubble = memo(function ChatBubble({ msg }) {
227
+ const isTool = msg.type === "tool_call" || msg.type === "tool_result";
228
+ const isError = msg.type === "error" || msg.type === "stream_error";
229
+ const role = msg.role ||
230
+ (isTool || isError ? "system" : msg.type === "system" ? "system" : "assistant");
231
+ const bubbleClass = isError
232
+ ? "error"
233
+ : isTool
234
+ ? "tool"
235
+ : role === "user"
236
+ ? "user"
237
+ : role === "system"
238
+ ? "system"
239
+ : "assistant";
240
+ const label =
241
+ isTool
242
+ ? msg.type === "tool_call" ? "TOOL CALL" : "TOOL RESULT"
243
+ : isError ? "ERROR" : null;
244
+ return html`
245
+ <div class="chat-bubble ${bubbleClass}">
246
+ ${role === "system" && !isTool
247
+ ? html`<div class="chat-system-text">${msg.content}</div>`
248
+ : html`
249
+ ${label ? html`<div class="chat-bubble-label">${label}</div>` : null}
250
+ <div class="chat-bubble-content">
251
+ <${MessageContent} text=${msg.content} />
252
+ </div>
253
+ <div class="chat-bubble-time">
254
+ ${msg.timestamp ? formatRelative(msg.timestamp) : ""}
255
+ </div>
256
+ `}
257
+ </div>
258
+ `;
259
+ }, (prev, next) => prev.msg === next.msg);
260
+
209
261
  /* ─── Chat View component ─── */
210
262
  export function ChatView({ sessionId, readOnly = false, embedded = false }) {
211
263
  const [input, setInput] = useState("");
@@ -406,7 +458,9 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
406
458
  const newMessages = nextCount > prevCount;
407
459
  lastMessageCount.current = nextCount;
408
460
  if (!paused && autoScroll) {
409
- el.scrollTop = el.scrollHeight;
461
+ // Use smooth scroll for small increments, instant for large jumps
462
+ const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
463
+ el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "instant" });
410
464
  return;
411
465
  }
412
466
  if (newMessages && !autoScroll) {
@@ -484,7 +538,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
484
538
  setInput(e.target.value);
485
539
  const el = e.target;
486
540
  el.style.height = 'auto';
487
- el.style.height = Math.min(el.scrollHeight, 100) + 'px';
541
+ el.style.height = Math.min(el.scrollHeight, 120) + 'px';
488
542
  }, []);
489
543
 
490
544
  const toggleFilter = useCallback((key) => {
@@ -754,50 +808,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
754
808
  </button>
755
809
  </div>
756
810
  `}
757
- ${visibleMessages.map((msg) => {
758
- const isTool =
759
- msg.type === "tool_call" || msg.type === "tool_result";
760
- const isError = msg.type === "error" || msg.type === "stream_error";
761
- const role = msg.role ||
762
- (isTool || isError ? "system" : msg.type === "system" ? "system" : "assistant");
763
- const bubbleClass = isError
764
- ? "error"
765
- : isTool
766
- ? "tool"
767
- : role === "user"
768
- ? "user"
769
- : role === "system"
770
- ? "system"
771
- : "assistant";
772
- const label =
773
- isTool
774
- ? msg.type === "tool_call"
775
- ? "TOOL CALL"
776
- : "TOOL RESULT"
777
- : isError
778
- ? "ERROR"
779
- : null;
780
- return html`
781
- <div
782
- key=${msg.id || msg.timestamp}
783
- class="chat-bubble ${bubbleClass}"
784
- >
785
- ${role === "system" && !isTool
786
- ? html`<div class="chat-system-text">${msg.content}</div>`
787
- : html`
788
- ${label
789
- ? html`<div class="chat-bubble-label">${label}</div>`
790
- : null}
791
- <div class="chat-bubble-content">
792
- <${MessageContent} text=${msg.content} />
793
- </div>
794
- <div class="chat-bubble-time">
795
- ${msg.timestamp ? formatRelative(msg.timestamp) : ""}
796
- </div>
797
- `}
798
- </div>
799
- `;
800
- })}
811
+ ${visibleMessages.map((msg) => html`
812
+ <${ChatBubble}
813
+ key=${msg.id || msg.timestamp}
814
+ msg=${msg}
815
+ />`
816
+ )}
801
817
 
802
818
  ${/* Pending messages (optimistic rendering) — use .peek() to avoid
803
819
  subscribing ChatView to pendingMessages signal. Pending messages
@@ -844,12 +860,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
844
860
  class="btn btn-primary btn-sm"
845
861
  onClick=${() => {
846
862
  const el = messagesRef.current;
847
- if (el) el.scrollTop = el.scrollHeight;
863
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
848
864
  setAutoScroll(true);
849
865
  setUnreadCount(0);
850
866
  }}
851
867
  >
852
- Jump to latest${unreadCount ? ` (${unreadCount})` : ""}
868
+ Jump to latest${unreadCount ? ` (${unreadCount})` : ""}
853
869
  </button>
854
870
  </div>
855
871
  `}
@@ -278,9 +278,9 @@ function SwipeableSessionItem({
278
278
  const x = e.clientX || e.touches?.[0]?.clientX || 0;
279
279
  currentX.current = x;
280
280
  const dx = x - startX.current;
281
- // Only allow left swipe (negative), cap at -120
281
+ // Only allow left swipe (negative), cap at -140
282
282
  if (dx < 0) {
283
- setOffset(Math.max(dx, -120));
283
+ setOffset(Math.max(dx, -140));
284
284
  } else {
285
285
  setOffset(0);
286
286
  }
@@ -290,7 +290,7 @@ function SwipeableSessionItem({
290
290
  swiping.current = false;
291
291
  if (offset < -50) {
292
292
  // Snap open to reveal actions
293
- setOffset(-120);
293
+ setOffset(-140);
294
294
  if (onToggleActions) onToggleActions(s.id);
295
295
  } else {
296
296
  setOffset(0);
@@ -440,7 +440,7 @@ function SwipeableSessionItem({
440
440
  e.stopPropagation();
441
441
  if (onToggleActions) {
442
442
  onToggleActions(s.id);
443
- setOffset(-120);
443
+ setOffset(-140);
444
444
  }
445
445
  }}
446
446
  >
@@ -206,6 +206,8 @@ export function Modal({ title, open = true, onClose, children, contentClassName
206
206
  }, []);
207
207
 
208
208
  const handleTouchStart = useCallback((e) => {
209
+ // Don't intercept touches on the close button
210
+ if (e.target.closest?.(".modal-close-btn")) return;
209
211
  const el = contentRef.current;
210
212
  if (!el) return;
211
213
  const rect = el.getBoundingClientRect();
@@ -244,6 +246,8 @@ export function Modal({ title, open = true, onClose, children, contentClassName
244
246
 
245
247
  const handlePointerDown = useCallback((e) => {
246
248
  if (e.pointerType === "touch") return;
249
+ // Don't intercept clicks on the close button
250
+ if (e.target.closest?.(".modal-close-btn")) return;
247
251
  const el = contentRef.current;
248
252
  if (!el) return;
249
253
  const rect = el.getBoundingClientRect();