From bf06479359419dd957a157c3f30b8bc12763c8b0 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 01:34:39 +0800 Subject: [PATCH 01/31] Open HTML files from tool panels in the browser; quiet session usage on older daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turns list opened an HTML file as a file:// URL, which the embedded browser refuses (http/https only): the panel stayed on 输入网址开始浏览. It now goes through the loopback preview server like chat links do. A daemon without settled usage answered session_usage with unknown op, which showed as an error in every chat; it is now skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/daemon.ts | 6 +++++- src/routes/+page.svelte | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/daemon.ts b/src/lib/daemon.ts index 1ff0e2c..352032a 100644 --- a/src/lib/daemon.ts +++ b/src/lib/daemon.ts @@ -301,7 +301,11 @@ export class DaemonClient { state.dirty = false; let reply: Frame; try { reply = await this.request({ op: 'session_usage', session }); } - catch (error) { reply = { type: 'session_usage', session, billing_error: String(error) }; } + catch (error) { + // A daemon from before settled usage: nothing to show, nothing to retry. + if (/unknown op/i.test(String(error))) { this.#forgetUsage(session); return; } + reply = { type: 'session_usage', session, billing_error: String(error) }; + } if (this.#usage.get(session) !== state) return; // detached, reopened or disconnected state.inFlight = false; const desktopId = this.#toDesktop.get(session); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index bc7b9a9..9b8f77c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1010,7 +1010,12 @@ const abs = rel.startsWith('/') ? rel : `${cwd.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`; const ext = abs.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; if ((ext === 'html' || ext === 'htm') && prefs.htmlOpenInBrowser) { - browser.open(`file://${abs}`); + // The embedded browser loads http(s) only (a file:// URL was refused + // and left it on its empty page): serve the file from the loopback + // preview server, as chat links do, so its relative assets load too. + invoke('preview_url', { path: abs }) + .then((url) => browser.open(url)) + .catch((e) => toast.error(t('chat.fileOpenFailed', { path: rel, error: String(e) }))); } else { editorStore.open(abs, cwd).catch((e) => console.error('open file', e)); } From 60ecac5e38abcce3c73f10f142748df520217627 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 02:16:09 +0800 Subject: [PATCH 02/31] =?UTF-8?q?Show=20the=20version=20on=20Windows;=20op?= =?UTF-8?q?en=20the=20logs=20folder=20from=20=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 更新 page read the version through the app plugin's getVersion, which the window's capability does not grant on every platform: on Windows it showed … . The version (with the bundled engine's) now comes from an app command, which the feedback form and the required-update check use too. 更新 also gets 打开日志文件夹 for ~/.jucode (engine and daemon logs). Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/lib.rs | 29 +++++++++++++++++++++++++++++ src/lib/FeedbackDialog.svelte | 4 ++-- src/lib/about.ts | 29 +++++++++++++++++++++++++++++ src/lib/i18n/messages/settings.ts | 6 ++++++ src/lib/settings/UpdateCard.svelte | 18 +++++++++++++++--- src/lib/updater.svelte.ts | 4 ++-- src/lib/updater.test.ts | 2 +- 7 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/lib/about.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf86652..11d13ce 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -841,6 +841,33 @@ fn submit_feedback(ticket: serde_json::Value) -> Result serde_json::Value { + let jucode = jucode_dir(); + serde_json::json!({ + "version": app.package_info().version.to_string(), + "cli": app_cli::app_cli_version(), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "logs": jucode.join("logs").display().to_string(), + "daemon_log": jucode.join("daemon").join("daemon.log").display().to_string(), + }) +} + +/// Opens the folder holding the engine and daemon logs (made when missing). +#[tauri::command] +fn open_logs_folder(app: AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let dir = jucode_dir(); + std::fs::create_dir_all(dir.join("logs")).map_err(|e| e.to_string())?; + app.opener() + .open_path(dir.display().to_string(), None::<&str>) + .map_err(|e| e.to_string()) +} + /// Anonymous usage counts (src/lib/telemetry.svelte.ts), with this app's /// version and platform; no login needed. #[tauri::command(async)] @@ -3305,6 +3332,8 @@ pub fn run() { native_import::native_sessions, app_cli::install_cli_command, app_cli::app_cli_version, + about_app, + open_logs_folder, app_cli::replace_daemon, #[cfg(desktop)] app_update::update_check, diff --git a/src/lib/FeedbackDialog.svelte b/src/lib/FeedbackDialog.svelte index 2aa554f..36caaa5 100644 --- a/src/lib/FeedbackDialog.svelte +++ b/src/lib/FeedbackDialog.svelte @@ -9,7 +9,7 @@ import ImageIcon from 'phosphor-svelte/lib/ImageIcon'; import XIcon from 'phosphor-svelte/lib/XIcon'; import CircleNotchIcon from 'phosphor-svelte/lib/CircleNotchIcon'; - import { getVersion } from '@tauri-apps/api/app'; + import { appVersion } from '$lib/about'; import Modal from '$lib/ui/Modal.svelte'; import Button from '$lib/ui/Button.svelte'; import Notice from '$lib/ui/Notice.svelte'; @@ -90,7 +90,7 @@ sending = true; error = ''; try { - const version = await getVersion().catch(() => ''); + const version = await appVersion(); const attachments: { name: string; type: string; data_url: string }[] = shots.map((s) => ({ name: s.name, type: s.url.slice(5, s.url.indexOf(';')), diff --git a/src/lib/about.ts b/src/lib/about.ts new file mode 100644 index 0000000..a543476 --- /dev/null +++ b/src/lib/about.ts @@ -0,0 +1,29 @@ +// This app's version, the engine it ships and where the logs are, from the +// app's own command (the `app` plugin's getVersion can be left out of a +// capability, which showed the version as … on Windows). +import { invoke } from '@tauri-apps/api/core'; + +export interface AboutApp { + version: string; + cli: string | null; + os: string; + arch: string; + logs: string; + daemon_log: string; +} + +let cached: Promise | null = null; + +export function aboutApp(): Promise { + cached ??= invoke('about_app').catch(() => null); + return cached; +} + +/** The app version ('' when unknown). */ +export async function appVersion(): Promise { + return (await aboutApp())?.version ?? ''; +} + +export function openLogsFolder(): Promise { + return invoke('open_logs_folder'); +} diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 6caae98..7e5ae17 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -343,6 +343,9 @@ const settings = { footHint: '更改会自动保存,对新建的会话生效。', update: { groupLabel: '应用更新', + engineVersion: '引擎版本', + logs: '日志', + openLogs: '打开日志文件夹', currentVersion: '当前版本', check: '检查更新', checking: '检查中…', @@ -787,6 +790,9 @@ const settings = { footHint: 'Changes save automatically and apply to new sessions.', update: { groupLabel: 'App updates', + engineVersion: 'Engine version', + logs: 'Logs', + openLogs: 'Open logs folder', currentVersion: 'Current version', check: 'Check for updates', checking: 'Checking…', diff --git a/src/lib/settings/UpdateCard.svelte b/src/lib/settings/UpdateCard.svelte index 26b67cf..2a8c388 100644 --- a/src/lib/settings/UpdateCard.svelte +++ b/src/lib/settings/UpdateCard.svelte @@ -3,7 +3,9 @@ import CheckCircleIcon from 'phosphor-svelte/lib/CheckCircleIcon'; import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon'; import ArrowClockwiseIcon from 'phosphor-svelte/lib/ArrowClockwiseIcon'; - import { getVersion } from '@tauri-apps/api/app'; + import FolderOpenIcon from 'phosphor-svelte/lib/FolderOpenIcon'; + import { aboutApp, openLogsFolder, type AboutApp } from '$lib/about'; + import { toast } from '$lib/ui/toast.svelte'; import Button from '$lib/ui/Button.svelte'; import Notice from '$lib/ui/Notice.svelte'; import { updater } from '$lib/updater.svelte'; @@ -11,10 +13,14 @@ import SettingsSection from './SettingsSection.svelte'; import SettingsRow from './SettingsRow.svelte'; - let current = $state(''); + let about = $state(null); + const current = $derived(about?.version ?? ''); onMount(async () => { - current = await getVersion().catch(() => ''); + about = await aboutApp(); }); + function showLogs() { + openLogsFolder().catch((e) => toast.error(String(e))); + } @@ -46,6 +52,12 @@ {/if} + {#if about?.cli} + + {/if} + + + diff --git a/src/lib/ui/Select.svelte b/src/lib/ui/Select.svelte index ede9407..e66cdf3 100644 --- a/src/lib/ui/Select.svelte +++ b/src/lib/ui/Select.svelte @@ -127,7 +127,7 @@ border-radius: var(--r-lg); box-shadow: var(--shadow-pop); transform-origin: bottom left; - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .opt { display: flex; diff --git a/src/lib/ui/Toaster.svelte b/src/lib/ui/Toaster.svelte index 442973d..6275e78 100644 --- a/src/lib/ui/Toaster.svelte +++ b/src/lib/ui/Toaster.svelte @@ -8,7 +8,8 @@ import WarningIcon from 'phosphor-svelte/lib/WarningIcon'; import XIcon from 'phosphor-svelte/lib/XIcon'; import { fly } from 'svelte/transition'; - import { T_FAST, T_MED } from './motion'; + import { cubicOut } from 'svelte/easing'; + import { T_FAST, T_MED, motionMs } from './motion'; import { toast, type ToastTone } from './toast.svelte'; import { t } from '$lib/i18n'; @@ -45,8 +46,8 @@ style:z-index={toast.items.length - i} style:transform="translate(-50%, {offset(i)}px) scale({expanded ? 1 : 1 - i * 0.04})" style:opacity={!expanded && i > 2 ? 0 : 1} - in:fly={{ y: -12, duration: T_MED }} - out:fly={{ y: -8, duration: T_FAST }} + in:fly={{ y: -8, duration: motionMs(T_MED), easing: cubicOut }} + out:fly={{ y: -6, duration: motionMs(T_FAST), easing: cubicOut }} > {it.message} @@ -91,7 +92,7 @@ font-size: var(--fs-sm); transform-origin: top center; transition: - transform var(--t-med) var(--ease-spring), + transform var(--t-med) var(--ease-out), opacity var(--t-med) var(--ease-out); } /* Errors tint the card; other tones colour only their icon. */ diff --git a/src/lib/ui/motion.ts b/src/lib/ui/motion.ts index 8b8fce9..cfc3e6d 100644 --- a/src/lib/ui/motion.ts +++ b/src/lib/ui/motion.ts @@ -1,7 +1,9 @@ // Svelte transitions on the app's motion tokens (app.css: --t-*, --ease-*), for // elements that also need an exit animation. CSS-only entrances use the -// keyframes in app.css. -import { cubicOut, backOut } from 'svelte/easing'; +// keyframes in app.css. Everything here fades and moves a few pixels: no +// scale, no overshoot, nothing longer than T_MED; reduced motion makes them +// instant. +import { cubicOut } from 'svelte/easing'; import type { TransitionConfig } from 'svelte/transition'; export const T_FAST = 140; @@ -9,16 +11,63 @@ export const T_MED = 220; const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; +/** `ms`, or 0 when the user asked for reduced motion (for svelte/transition's own). */ +export const motionMs = (ms: number) => (reduced() ? 0 : ms); + /** Backdrops. */ export function scrim(_node: Element, { duration = T_FAST } = {}): TransitionConfig { - return { duration: reduced() ? 0 : duration, easing: cubicOut, css: (t) => `opacity: ${t}` }; + return { duration: motionMs(duration), easing: cubicOut, css: (t) => `opacity: ${t}` }; +} + +/** Modals and sheets: rise in, sink out. */ +export function sheet(_node: Element, { y = 6, duration = T_MED } = {}): TransitionConfig { + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; } -/** Modals, sheets and floating cards: rise and settle in, sink out. */ -export function sheet(_node: Element, { y = 10, duration = T_MED } = {}): TransitionConfig { +/** A page or section coming in (settings sections): fade and rise a little. */ +export function paneIn(_node: Element, { y = 4, duration = T_MED } = {}): TransitionConfig { return { - duration: reduced() ? 0 : duration, - easing: backOut, - css: (t, u) => `opacity: ${Math.min(1, t * 1.6)}; transform: translateY(${u * y}px) scale(${0.985 + 0.015 * t})` + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; +} + +/** A page leaving (settings, home, project page): fade and sink a little. */ +export function paneOut(_node: Element, { y = 4, duration = T_FAST } = {}): TransitionConfig { + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateY(${u * y}px)` + }; +} + +/** Menus and popovers closing: a quick fade. */ +export function popOut(_node: Element, { duration = 110 } = {}): TransitionConfig { + return { duration: motionMs(duration), easing: cubicOut, css: (t) => `opacity: ${t}` }; +} + +// Rows that arrive in the same update: a list loading or filling in, which +// appears at once instead of sliding row by row. +let arriving = 0; + +/** A list row arriving (a new session in the sidebar): slide in from the left. */ +export function rowIn(_node: Element, { x = -6, duration = 180, skip = false } = {}): () => TransitionConfig { + arriving++; + // Svelte calls a returned function in the next microtask, after every + // row of this update has counted itself. + return () => { + const together = arriving; + queueMicrotask(() => (arriving = 0)); + if (skip || together > 2) return { duration: 0 }; + return { + duration: motionMs(duration), + easing: cubicOut, + css: (t, u) => `opacity: ${t}; transform: translateX(${u * x}px)` + }; }; } diff --git a/src/lib/workbench/TabChromePopover.svelte b/src/lib/workbench/TabChromePopover.svelte index 24ab76c..bdac01f 100644 --- a/src/lib/workbench/TabChromePopover.svelte +++ b/src/lib/workbench/TabChromePopover.svelte @@ -242,7 +242,7 @@ background: var(--panel); border-radius: var(--r-lg); box-shadow: var(--shadow-pop); - animation: pop-in var(--t-med) var(--ease-spring); + animation: pop-in var(--t-med) var(--ease-out); } .field { display: flex; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2fc7aeb..0b59b9e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -21,6 +21,20 @@ prefs.init(); // Persist detection + reflect the active locale on for a11y. setLocale(getLocale()); + // Resizing or zooming the window: layout jumps, nothing animates along + // (app.css .win-resizing), until 150 ms after the last resize event. + const root = document.documentElement; + let settle: ReturnType | undefined; + const onResize = () => { + root.classList.add('win-resizing'); + clearTimeout(settle); + settle = setTimeout(() => root.classList.remove('win-resizing'), 150); + }; + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + clearTimeout(settle); + }; }); diff --git a/src/routes/remote/+page.svelte b/src/routes/remote/+page.svelte index 26dfcdb..a4cae71 100644 --- a/src/routes/remote/+page.svelte +++ b/src/routes/remote/+page.svelte @@ -850,7 +850,7 @@ max-width: min(340px, calc(100vw - 32px)); text-align: left; transform-origin: top right; - animation: drop-in var(--t-med) var(--ease-spring); + animation: drop-in var(--t-med) var(--ease-out); } .switch-menu.left { right: auto; From db9fc308891c98a58b065332cdbb5f5e4038f214 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 11:31:01 +0800 Subject: [PATCH 05/31] Motion: pages fade out, settings sections cross-fade, new sidebar rows slide in Settings, the workbench page, the home page and a project page fade and sink 4px as they close (they rose in already). Settings sections cross-fade in one grid cell: the leaving section fades out while the next rises in. A session row that joins the sidebar slides in 6px from the left; a list that fills at once (load, clearing a search) appears without motion. Chat tiles keep their pane-in when a conversation is switched in. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/DeskPage.svelte | 3 ++- src/lib/ProjectPage.svelte | 3 ++- src/lib/Sidebar.svelte | 2 ++ src/lib/settings/SettingsPage.svelte | 19 +++++++++++++++---- src/lib/ui/motion.ts | 12 ++++-------- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/lib/DeskPage.svelte b/src/lib/DeskPage.svelte index b25ee5f..577286d 100644 --- a/src/lib/DeskPage.svelte +++ b/src/lib/DeskPage.svelte @@ -1,4 +1,5 @@ -
+
{#if shownReq}
{#key shownReq.id} diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte index 8f1ca41..cd7a65d 100644 --- a/src/lib/Sidebar.svelte +++ b/src/lib/Sidebar.svelte @@ -19,6 +19,7 @@ import CheckIcon from 'phosphor-svelte/lib/CheckIcon'; import PushPinIcon from 'phosphor-svelte/lib/PushPinIcon'; import PushPinSlashIcon from 'phosphor-svelte/lib/PushPinSlashIcon'; + import { rowIn } from '$lib/ui/motion'; import Button from '$lib/ui/Button.svelte'; import { t } from '$lib/i18n'; import { withShortcut } from '$lib/shortcuts'; @@ -317,6 +318,7 @@ {@const req = s.chat.sessionId ? reqs.bySession.get(s.chat.sessionId) : undefined}
+ +
{#key current} -
+

{t(`settings.section.${current}`)}

{#if JUCODE_ONLY.has(current)}

{t('settings.page.jucodeOnly')}

{/if} @@ -924,6 +928,7 @@
{/key} +
From fef766a091b3edef19ac8280b2db7da9f992bd3a Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 12:12:35 +0800 Subject: [PATCH 08/31] Show a turn's todos and subagents at the top right of the chat The progress card lists the plan's steps (the current one highlighted) and the subagents of the turn with their model, running time and latest action; a click opens the subagent's own conversation in the agents tile. It folds to a pill (kept per session), folds by itself a few seconds after the turn, and replaces the old subagent strip. The agent trace's single subagent view moves into AgentTranscript, shared with the drawer to come. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/AgentRunsPanel.svelte | 104 ++------ src/lib/ChatPane.svelte | 74 ++---- src/lib/agentProgress.test.ts | 5 + src/lib/agentProgress.ts | 9 + src/lib/agents/AgentTranscript.svelte | 130 ++++++++++ src/lib/agents/Elapsed.svelte | 29 +++ src/lib/agents/ProgressCard.svelte | 360 ++++++++++++++++++++++++++ src/lib/agents/StateIcon.svelte | 49 ++++ src/lib/i18n/messages/dock.ts | 4 +- 9 files changed, 625 insertions(+), 139 deletions(-) create mode 100644 src/lib/agents/AgentTranscript.svelte create mode 100644 src/lib/agents/Elapsed.svelte create mode 100644 src/lib/agents/ProgressCard.svelte create mode 100644 src/lib/agents/StateIcon.svelte diff --git a/src/lib/AgentRunsPanel.svelte b/src/lib/AgentRunsPanel.svelte index 7b6ed8e..358a8bf 100644 --- a/src/lib/AgentRunsPanel.svelte +++ b/src/lib/AgentRunsPanel.svelte @@ -1,5 +1,5 @@ + +{#if transcript?.error} +

{transcript.error}

+{:else if !transcript} +

{t('dock.agents.loading')}

+{:else} + {#if transcript.task} +
+ +
+
+ {/if} + {#if transcript.messages.length} +
+ {}} + onRewind={() => {}} + /> +
+ {:else} +

{live ? t('dock.agents.loading') : t('dock.agents.noMessages')}

+ {/if} +{/if} + + diff --git a/src/lib/agents/Elapsed.svelte b/src/lib/agents/Elapsed.svelte new file mode 100644 index 0000000..65d63f1 --- /dev/null +++ b/src/lib/agents/Elapsed.svelte @@ -0,0 +1,29 @@ + + +{#if ms > 0 || live}{shortElapsed(ms)}{/if} + + diff --git a/src/lib/agents/ProgressCard.svelte b/src/lib/agents/ProgressCard.svelte new file mode 100644 index 0000000..5559682 --- /dev/null +++ b/src/lib/agents/ProgressCard.svelte @@ -0,0 +1,360 @@ + + +{#if mode === 'pill'} + +{:else if mode === 'card'} +
+
+ {t('chat.progress.label')} + {#if showPlan}{t('chat.progress.count', { done: plan.done, total: plan.total })}{/if} + + +
+
+ {#if showPlan} +
{t('chat.progress.todo')}
+
    + {#each plan.steps as s, i (i)} +
  1. + + {s.text} +
  2. + {/each} +
+ {/if} + {#if rows.length} +
{t('chat.progress.agents')}{rows.length}
+
    + {#each rows as r (r.id)} +
  • + +
  • + {/each} +
+ {/if} +
+
+{/if} + + diff --git a/src/lib/agents/StateIcon.svelte b/src/lib/agents/StateIcon.svelte new file mode 100644 index 0000000..5cb255d --- /dev/null +++ b/src/lib/agents/StateIcon.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index 69e0255..d7bdf78 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -28,7 +28,7 @@ const dock = { toolCalls: '{n} 次工具调用', progress: '{done}/{total} 个智能体', empty: '还没有子智能体运行', - emptyHint: '开启 Ultracode,或让 Claude 调用 Agent 工具后,这里会显示每个智能体的运行轨迹、耗时和 token 消耗。', + emptyHint: '智能体派出子智能体(或开启 Claude 的 Ultracode)后,这里会显示每个子智能体的运行轨迹、耗时和 token 消耗。', col: { agent: '智能体', timeline: '时间线', time: '耗时', tokens: 'Tokens', tools: '工具' }, state: { running: '运行中', done: '已完成', failed: '失败', stopped: '已停止', queued: '排队中' }, unit: { s: '秒', m: '分', h: '小时' }, @@ -238,7 +238,7 @@ const dock = { toolCalls: '{n} tool calls', progress: '{done}/{total} agents', empty: 'No subagents have run', - emptyHint: 'Turn on Ultracode, or have Claude use the Agent tool, to see each agent’s path, time and tokens here.', + emptyHint: 'Once the agent starts subagents (or Claude runs Ultracode), each one’s path, time and tokens show here.', col: { agent: 'Agent', timeline: 'Timeline', time: 'Time', tokens: 'Tokens', tools: 'Tools' }, state: { running: 'Running', done: 'Done', failed: 'Failed', stopped: 'Stopped', queued: 'Queued' }, unit: { s: 's', m: 'm', h: 'h' }, From 0c035451e0f27aa1f562763634010029e94a518f Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 12:18:22 +0800 Subject: [PATCH 09/31] Subagent calls render as subagent cards in the conversation spawn_agent (and claude's Task / Agent) shows the agent it started: name, the task's first line, state, running time and latest action; wait_agent shows the agents it waits on with their states. Both open the subagent's own conversation. A subagent call keeps its arguments once its output replaces them, so the card can name the agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ChatPane.svelte | 2 +- src/lib/MessageList.svelte | 17 +- src/lib/agents/SubagentCard.svelte | 248 +++++++++++++++++++++++++++++ src/lib/chat.svelte.ts | 11 +- src/lib/chat.test.ts | 8 + src/lib/toolSummary.ts | 10 ++ 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 src/lib/agents/SubagentCard.svelte diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 2ca4c28..af70658 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -1139,7 +1139,7 @@
- autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} /> + autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} />
{#if chat.booting && chat.engineState !== 'exited'}
diff --git a/src/lib/MessageList.svelte b/src/lib/MessageList.svelte index 9dd2009..ecf30ca 100644 --- a/src/lib/MessageList.svelte +++ b/src/lib/MessageList.svelte @@ -7,6 +7,8 @@ import Markdown from '$lib/Markdown.svelte'; import Collapse from '$lib/ui/Collapse.svelte'; import ToolCard from '$lib/ToolCard.svelte'; + import SubagentCard from '$lib/agents/SubagentCard.svelte'; + import { SPAWN_TOOLS, WAIT_TOOLS, type AgentRow } from '$lib/agentProgress'; import DeliveryNotice from '$lib/DeliveryNotice.svelte'; import { parseDelivery } from '$lib/delivery'; import { parseToolOutput, toolIcon, toolTarget, toolVerb } from '$lib/toolSummary'; @@ -47,6 +49,8 @@ backend = '', onErrorAction, traceOf, + agents, + onOpenAgent, loadImage, mark = $bindable(-1) }: { @@ -83,6 +87,11 @@ onErrorAction?: (action: ErrorAction) => void; /** A tool call whose run the agent trace shows (claude's Agent / Workflow). */ traceOf?: (m: Msg) => { label: string; run: () => void } | null; + /** The conversation's subagents: spawn and wait calls render as their + * cards (absent: plain tool rows). */ + agents?: AgentRow[]; + /** Shows a subagent's own conversation. */ + onOpenAgent?: (row: AgentRow) => void; /** Reads a sent image where the desktop can't open its path (the remote page). */ loadImage?: (path: string) => Promise; /** Ordinal of the user message in view: at or above the upper third. */ @@ -399,13 +408,17 @@ }; for (const m of messages) { if (!hasContent(m)) continue; - if (m.kind === 'tool') run.push(m); + if (m.kind === 'tool' && !isAgentCall(m)) run.push(m); else flush(); } flush(); return { members, headOf }; }); const openGroups = new SvelteSet(); + /** A spawn or wait call that renders as a subagent card. */ + function isAgentCall(m: Msg): boolean { + return !!agents && m.kind === 'tool' && (SPAWN_TOOLS.has(m.name) || WAIT_TOOLS.has(m.name)); + } function shown(m: Msg): boolean { return hasContent(m) && (!toolGroups.headOf.has(m) || toolGroups.members.has(m)); } @@ -530,6 +543,8 @@ {/each}
{/if} + {:else if agents && isAgentCall(m)} + {:else} {/if} diff --git a/src/lib/agents/SubagentCard.svelte b/src/lib/agents/SubagentCard.svelte new file mode 100644 index 0000000..215bec2 --- /dev/null +++ b/src/lib/agents/SubagentCard.svelte @@ -0,0 +1,248 @@ + + +{#if wait} +
+
+ + {waitingOn.length ? t('chat.subagentCard.waitN', { n: waitingOn.length }) : t('chat.subagentCard.wait')} + {#if timedOut}{t('chat.subagentCard.timedOut')}{/if} + {#if running}{/if} +
+ {#if waitingOn.length} +
+ {#each waitingOn as w (w.id)} + + {/each} +
+ {/if} +
+{:else} + +{/if} + + diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 2c972cd..8e15a05 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -75,6 +75,9 @@ export type Msg = isError: boolean; /** The Task subagent that made this call (claude). */ subagent?: string; + /** A subagent call's arguments as the engine showed them before its + * output replaced them (the agent's name and task). */ + args?: string; } | { kind: 'system'; text: string } | { kind: 'error'; text: string } @@ -182,6 +185,9 @@ export interface SubagentInfo { /** The lifecycle words of an agent that has stopped for good. */ const FINAL_AGENT = ['completed', 'done', 'failed', 'errored', 'stopped', 'interrupted', 'closed', 'killed', 'cancelled']; +/** Calls that start a subagent: their arguments name it. */ +const SUBAGENT_CALLS = new Set(['spawn_agent', 'Task', 'Agent']); + /** A proposed plan's status as the engine words it. */ const PLAN_STATUS = ['pending', 'approved', 'revising']; const planStatus = (v: unknown) => (typeof v === 'string' && PLAN_STATUS.includes(v) ? v : 'pending'); @@ -1151,7 +1157,10 @@ export class ChatState { break; case 'tool_update': { const t = this.#tool(str(ev.call_id)); - if (t) t.output = str(ev.output); + if (t) { + t.output = str(ev.output); + if (SUBAGENT_CALLS.has(t.name)) t.args = t.output; + } break; } case 'tool_output': { diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index 9ac4796..569ab01 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -671,6 +671,14 @@ describe('claude session extras', () => { expect(c.messages).toEqual([]); }); + it('keeps a subagent call’s arguments once its result replaces them', () => { + const c = new ChatState(); + c.handle({ type: 'tool_start', call_id: 't1', name: 'Task' }); + c.handle({ type: 'tool_update', call_id: 't1', output: '{"description":"Scan auth","prompt":"Find it"}' }); + c.handle({ type: 'tool_output', call_id: 't1', name: 'Task', output: 'Found it in auth.ts', is_error: false }); + expect(c.messages.at(-1)).toMatchObject({ output: 'Found it in auth.ts', args: '{"description":"Scan auth","prompt":"Find it"}' }); + }); + it('reads a JuCode subagent from its spawn call and its lifecycle', () => { const c = new ChatState(); c.handle({ type: 'connecting' }); diff --git a/src/lib/toolSummary.ts b/src/lib/toolSummary.ts index 03ff9c2..91c5a2d 100644 --- a/src/lib/toolSummary.ts +++ b/src/lib/toolSummary.ts @@ -32,6 +32,10 @@ const VERBS: Record = { web_fetch: 'Fetched', generate_image: 'Generated image', spawn_agent: 'Started subagent', + wait_agent: 'Waited for subagents', + list_agents: 'Listed subagents', + send_message: 'Messaged subagent', + close_agent: 'Closed subagent', agent_wait: 'Waited for subagents', agent_sendInput: 'Messaged subagent', agent_sendMessage: 'Messaged subagent', @@ -59,6 +63,12 @@ const ICONS: Record = { generate_image: ImageIcon, Task: RobotIcon, Agent: RobotIcon, + spawn_agent: RobotIcon, + wait_agent: RobotIcon, + list_agents: RobotIcon, + send_message: RobotIcon, + close_agent: RobotIcon, + agent_wait: RobotIcon, TodoWrite: ListChecksIcon, update_plan: ListChecksIcon }; From 9d2d159a49b673b91d6e86028ca934f995c5a1fa Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 12:23:40 +0800 Subject: [PATCH 10/31] Plan mode: the proposed plan as a card, approve or revise it A proposed plan shows as a card: title, the Markdown clipped with a fade and a button for the full plan, copy, and a status tag once approved or revising. While it waits as the latest message, approve and run it in a mode picked beside the button (the mode before plan mode, else auto-edit), or revise it: the next message goes as approve_plan feedback, with a chip above the composer. Markdown tables get compact cells, a tinted header, zebra rows and a box that scrolls a wide table sideways. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ChatPane.svelte | 87 ++++++++++++- src/lib/Markdown.svelte | 38 +++++- src/lib/MessageList.svelte | 13 ++ src/lib/PlanCard.svelte | 249 +++++++++++++++++++++++++++++++++++++ src/lib/markdown.test.ts | 10 ++ src/lib/markdown.ts | 5 +- 6 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 src/lib/PlanCard.svelte diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index af70658..e9f2db3 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -33,7 +33,10 @@ import CircleNotchIcon from 'phosphor-svelte/lib/CircleNotchIcon'; import { open } from '@tauri-apps/plugin-dialog'; import { treeRows } from '$lib/tree'; - import { buildSetApprovalModeOp, needsClaudeYoloRespawn, type ApprovalMode, type ApproveOp } from '$lib/approval'; + import { buildSetApprovalModeOp, needsClaudeYoloRespawn, toEngineMode, type ApprovalMode, type ApproveOp } from '$lib/approval'; + import type { PlanAction } from '$lib/PlanCard.svelte'; + import XIcon from 'phosphor-svelte/lib/XIcon'; + import ClipboardTextIcon from 'phosphor-svelte/lib/ClipboardTextIcon'; import { processVideo, sessionHistory, @@ -487,6 +490,25 @@ openTrace(row.workflow ? null : row.id); } + // Plan mode: a proposed plan runs once approved, in the mode picked on its + // card; revising it sends the next message as the user's feedback. + const planMode = $derived( + chat.modeBeforePlan && chat.modeBeforePlan !== 'plan' ? chat.modeBeforePlan : 'edits' + ); + function planAction(id: string, action: PlanAction) { + const plan = chat.messages.find((m) => m.kind === 'plan' && m.id === id); + if (action.decision === 'approve') { + const mode = toEngineMode(action.mode) as Exclude, 'plan'>; + send({ op: 'approve_plan', id, decision: 'approve', mode }); + chat.setApprovalMode(action.mode); + if (plan?.kind === 'plan') plan.status = 'approved'; + chat.planRevising = null; + return; + } + chat.planRevising = id; + composerEl?.focus(); + } + // pickers (tree / model / resume) — this pane's session const pickerTitle = $derived( chat.picker?.kind === 'tree' @@ -726,6 +748,19 @@ content = content.replace(re, () => `\n\n${block}\n\n`); } content = content.replace(/\n{3,}/g, '\n\n').trim(); + // Revising a proposed plan: the message is the user's feedback on it. + const revising = chat.planRevising; + if (revising && !files.length && !videos.length && !imagePaths.length) { + const plan = chat.messages.find((m) => m.kind === 'plan' && m.id === revising); + if (plan?.kind === 'plan') plan.status = 'revising'; + chat.planRevising = null; + if (!chat.busy || chat.restarting) chat.optimisticUser(content); + send({ op: 'approve_plan', id: revising, decision: 'revise', feedback: content }); + input = ''; + webRefs = []; + quotes = []; + return; + } if (files.length) content += `${content ? '\n\n' : ''}Attached files (read these):\n${files.join('\n')}`; for (const v of videos) { @@ -1139,7 +1174,7 @@
- autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} /> + autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} onPlan={planAction} {planMode} />
{#if chat.booting && chat.engineState !== 'exited'}
@@ -1213,6 +1248,17 @@ + {#if chat.planRevising} +
+
+ + {t('chat.planCard.revising')} + {t('chat.planCard.revisingHint')} + +
+
+ {/if} + {#if gate && !chat.pendingApproval}
@@ -1442,6 +1488,43 @@ font-weight: 500; } + .revising { + display: flex; + align-items: center; + gap: 8px; + width: fit-content; + max-width: 100%; + padding: 5px 6px 5px 12px; + border-radius: var(--r-full); + background: var(--surface2); + color: var(--text); + font-size: var(--fs-xs); + } + .rv-label { + flex: none; + font-weight: 500; + } + .rv-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dim); + } + .rv-x { + display: inline-flex; + flex: none; + padding: 3px; + border: none; + border-radius: var(--r-full); + background: none; + color: var(--dim); + cursor: pointer; + } + .rv-x:hover { + background: var(--surface2); + color: var(--text); + } .interm { margin: 0 0 8px; font-size: var(--fs-xs); diff --git a/src/lib/Markdown.svelte b/src/lib/Markdown.svelte index 7915e32..bc8e4ca 100644 --- a/src/lib/Markdown.svelte +++ b/src/lib/Markdown.svelte @@ -189,20 +189,52 @@ background: var(--panel); color: var(--text); } + /* Tables: compact cells on hairlines, a tinted header, zebra rows; a wide + one scrolls sideways in its own box (markdown.ts wraps it). */ + .md :global(.table-wrap) { + max-width: 100%; + margin: 0 0 12px; + overflow-x: auto; + border-radius: var(--r-md); + box-shadow: inset 0 0 0 1px var(--hairline); + } .md :global(table) { + width: max-content; + min-width: 100%; border-collapse: collapse; - margin: 0 0 12px; - font-size: 0.95em; + font-size: 0.92em; + line-height: 1.45; + font-variant-numeric: tabular-nums; } .md :global(th), .md :global(td) { + padding: 6px 12px; border: none; border-bottom: 1px solid var(--hairline); - padding: 5px 10px; text-align: left; + vertical-align: top; + } + .md :global(th[align='center']), + .md :global(td[align='center']) { + text-align: center; + } + .md :global(th[align='right']), + .md :global(td[align='right']) { + text-align: right; } .md :global(th) { background: var(--surface2); + font-weight: 600; + white-space: nowrap; + } + .md :global(tbody tr:nth-child(even)) { + background: var(--surface); + } + .md :global(tbody tr:last-child td) { + border-bottom: none; + } + .md :global(td code) { + white-space: nowrap; } /* highlight.js tokens mapped to design tokens (adapt to light/dark) */ diff --git a/src/lib/MessageList.svelte b/src/lib/MessageList.svelte index ecf30ca..16ba85d 100644 --- a/src/lib/MessageList.svelte +++ b/src/lib/MessageList.svelte @@ -8,6 +8,8 @@ import Collapse from '$lib/ui/Collapse.svelte'; import ToolCard from '$lib/ToolCard.svelte'; import SubagentCard from '$lib/agents/SubagentCard.svelte'; + import PlanCard, { type PlanAction } from '$lib/PlanCard.svelte'; + import type { ApprovalMode } from '$lib/approval'; import { SPAWN_TOOLS, WAIT_TOOLS, type AgentRow } from '$lib/agentProgress'; import DeliveryNotice from '$lib/DeliveryNotice.svelte'; import { parseDelivery } from '$lib/delivery'; @@ -51,6 +53,8 @@ traceOf, agents, onOpenAgent, + onPlan, + planMode = 'edits', loadImage, mark = $bindable(-1) }: { @@ -92,6 +96,10 @@ agents?: AgentRow[]; /** Shows a subagent's own conversation. */ onOpenAgent?: (row: AgentRow) => void; + /** Approves or revises a proposed plan (absent: plans have no actions). */ + onPlan?: (id: string, action: PlanAction) => void; + /** The mode a plan's approval offers first. */ + planMode?: ApprovalMode; /** Reads a sent image where the desktop can't open its path (the remote page). */ loadImage?: (path: string) => Promise; /** Ordinal of the user message in view: at or above the upper third. */ @@ -387,6 +395,7 @@ // Meta/status notices render in the collapsible status strip, not inline. if (m.kind === 'system') return false; if (m.kind === 'tool') return !!(m.name || m.output); + if (m.kind === 'plan') return !!(m.title || m.text); return !!m.text && m.text.trim().length > 0; } @@ -415,6 +424,8 @@ return { members, headOf }; }); const openGroups = new SvelteSet(); + // The last message with something to show (a plan's actions show there only). + const lastShown = $derived(messages.findLast((m) => hasContent(m))); /** A spawn or wait call that renders as a subagent card. */ function isAgentCall(m: Msg): boolean { return !!agents && m.kind === 'tool' && (SPAWN_TOOLS.has(m.name) || WAIT_TOOLS.has(m.name)); @@ -548,6 +559,8 @@ {:else} {/if} + {:else if m.kind === 'plan'} + {:else if m.kind === 'error'} onDismiss(m) : undefined} /> {/if} diff --git a/src/lib/PlanCard.svelte b/src/lib/PlanCard.svelte new file mode 100644 index 0000000..5d8bdfa --- /dev/null +++ b/src/lib/PlanCard.svelte @@ -0,0 +1,249 @@ + + + + +
+
+ + {t('chat.planCard.label')} + {#if status === 'approved' || status === 'revising'} + {t(`chat.planCard.status.${status}`)} + {/if} + + +
+ {#if title}

{title}

{/if} +
+
+
+ {#if long} +
+ +
+ {/if} +
+{#if actionable && status === 'pending' && onAction} +
+ + + + + {#if menuOpen} + ((mode = k as ApprovalMode), (menuOpen = false))} + onClose={() => (menuOpen = false)} + /> + {/if} + + + +
+{/if} + + diff --git a/src/lib/markdown.test.ts b/src/lib/markdown.test.ts index 910c346..f1241b6 100644 --- a/src/lib/markdown.test.ts +++ b/src/lib/markdown.test.ts @@ -28,3 +28,13 @@ describe('renderMarkdown file references', () => { expect(html).not.toContain(''); }); }); + +describe('renderMarkdown tables', () => { + it('wraps a table so a wide one scrolls in its own box', () => { + const html = renderMarkdown('| 项 | 状态 |\n| --- | :-: |\n| 解析 | ✅ |\n| 渲染 | ⏳ |'); + expect(html.startsWith('
')).toBe(true); + expect(html.trim().endsWith('
')).toBe(true); + expect(html).toContain('状态'); + expect(html.match(//g)).toHaveLength(3); + }); +}); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 77a8680..e930094 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -114,5 +114,8 @@ marked.use({ }); export function renderMarkdown(text: string): string { - return marked.parse(text, { async: false }) as string; + // A table scrolls sideways in its own box when it is wider than the column. + return (marked.parse(text, { async: false }) as string) + .replace(//g, '
') + .replace(/<\/table>/g, '
'); } From 631ced7d01d4f2d8b3f2143827a95bb72e26428b Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 12:26:39 +0800 Subject: [PATCH 11/31] Agent trace: list subagents an engine reports by lifecycle only A JuCode engine before agent_runs reports its subagents through subagent_lifecycle and the spawn calls; the agents tile lists those, with their state, model and time, so a click from the progress card always lands on its agent. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/AgentRunsPanel.svelte | 37 ++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/src/lib/AgentRunsPanel.svelte b/src/lib/AgentRunsPanel.svelte index 358a8bf..08924af 100644 --- a/src/lib/AgentRunsPanel.svelte +++ b/src/lib/AgentRunsPanel.svelte @@ -36,8 +36,31 @@ const units = $derived({ s: t('dock.agents.unit.s'), m: t('dock.agents.unit.m'), h: t('dock.agents.unit.h') }); const dur = (ms: number) => formatDuration(ms, units); + // An engine without the trace (JuCode before agent_runs) reports its + // subagents by their lifecycle only: listed as runs of their own. + const agents = $derived.by((): AgentRun[] => { + if (chat.agentRuns.agents.length || chat.agentRunsSeen) return chat.agentRuns.agents; + return Object.entries(chat.subagents).map(([id, a]) => ({ + id, + label: shortPath(a.label || id), + phase: 0, + model: a.model ?? '', + state: a.status, + startedAt: a.startedAt ?? 0, + durationMs: a.endedAt && a.startedAt ? a.endedAt - a.startedAt : 0, + tokens: 0, + toolCalls: 0, + prompt: '', + result: '', + error: '', + type: '', + toolUseId: a.toolUseId ?? '', + activity: a.message, + effort: '' + })); + }); let now = $state(Date.now()); - const running = $derived(anyRunning(chat.agentRuns.workflows, chat.agentRuns.agents)); + const running = $derived(anyRunning(chat.agentRuns.workflows, agents)); $effect(() => { if (!running) return; const id = setInterval(() => (now = Date.now()), 1000); @@ -52,7 +75,7 @@ open[w.id] ?? (runState(w.status) === 'running' || i === chat.agentRuns.workflows.length - 1); const totals = $derived.by(() => { - const all = [...chat.agentRuns.workflows, ...chat.agentRuns.agents]; + const all = [...chat.agentRuns.workflows, ...agents]; return { tokens: all.reduce((s, r) => s + r.tokens, 0) }; }); @@ -156,13 +179,13 @@ {#key chat.agentFocus} {/key} - {:else if chat.agentRuns.workflows.length || chat.agentRuns.agents.length} + {:else if chat.agentRuns.workflows.length || agents.length}
{t('dock.agents.title')} {t('dock.agents.summary', { w: chat.agentRuns.workflows.length, - a: chat.agentRuns.agents.length, + a: agents.length, tokens: formatCount(totals.tokens) })} @@ -211,8 +234,8 @@ {/each} - {#if chat.agentRuns.agents.length} - {@const line = timeline(chat.agentRuns.agents, now)} + {#if agents.length} + {@const line = timeline(agents, now)}
{t('dock.agents.subagents')} @@ -225,7 +248,7 @@ {t('dock.agents.col.tokens')} {t('dock.agents.col.tools')}
- {#each [...chat.agentRuns.agents].reverse() as a, ai (a.id || ai)} + {#each [...agents].reverse() as a, ai (a.id || ai)} {@render agentRow(a, line.bars.get(a.id))} {/each}
From 81eb7c36684e95368fbb54471c23fe35a68fff07 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 16:59:04 +0800 Subject: [PATCH 12/31] =?UTF-8?q?=E8=BF=98=E5=8E=9F=E6=AD=A4=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20undoes=20only=20the=20agent's=20edits;=20the=20user?= =?UTF-8?q?'s=20stay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Changes panel's revert ran git restore / git clean on the whole file, which also threw away the user's own uncommitted changes to it. The session now keeps each agent edit's diff, and revert reverse-applies exactly those (revert_agent_edits: one file, inside the project, checked before it writes); the user's changes elsewhere in the file stay. When the file changed too much since, the full revert is offered with a warning that says the user's own changes go too. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/lib.rs | 66 +++++++++++++++++++++++++++++++++++ src/lib/ChangesPanel.svelte | 42 ++++++++++++++++++++-- src/lib/chat.svelte.ts | 10 ++++++ src/lib/i18n/messages/dock.ts | 8 +++-- src/lib/protocol.ts | 4 +++ src/routes/+page.svelte | 2 +- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11d13ce..a6594d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2769,6 +2769,71 @@ fn git(args: Vec, cwd: Option) -> Result { } } +/// Undoes the agent's own edits to one file: `diffs` are the edit tools' +/// unified diffs for it, oldest first; they are reverse-applied newest first +/// against the working tree by their context, so the user's own changes +/// elsewhere in the file stay (a hunk whose context changed refuses). Every diff +/// must touch only `path` (inside `cwd`). Nothing is written unless all of +/// them apply (`git apply --check` first). +#[tauri::command(async)] +fn revert_agent_edits(cwd: String, path: String, diffs: Vec) -> Result<(), String> { + use std::io::Write; + let dir = PathBuf::from(&cwd); + if path.is_empty() || Path::new(&path).is_absolute() || path.split(['/', '\\']).any(|part| part == "..") { + return Err(format!("not a project path: {path}")); + } + if diffs.is_empty() || diffs.len() > 200 { + return Err("no agent edits to undo".to_string()); + } + // Each diff names its file in `diff --git a/

b/

` / `+++ b/

` + // headers; any other file name is refused. + for diff in &diffs { + for line in diff.lines() { + let named = line + .strip_prefix("+++ b/") + .or_else(|| line.strip_prefix("--- a/")) + .map(str::trim); + if let Some(named) = named { + if named != path && named != "/dev/null" { + return Err(format!("edit touches another file: {named}")); + } + } + } + } + let patch: String = diffs + .iter() + .rev() + .map(|diff| if diff.ends_with('\n') { diff.clone() } else { format!("{diff}\n") }) + .collect(); + let run = |check: bool| -> Result<(), String> { + let mut cmd = Command::new("git"); + no_window(&mut cmd); + cmd.current_dir(&dir).env("GIT_TERMINAL_PROMPT", "0").args(["apply", "-R", "--whitespace=nowarn"]); + if check { + cmd.arg("--check"); + } + cmd.args(["--include", &path, "-"]); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| format!("failed to run git: {e}"))?; + child + .stdin + .take() + .ok_or("git stdin unavailable")? + .write_all(patch.as_bytes()) + .map_err(|e| e.to_string())?; + let output = child.wait_with_output().map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } + }; + run(true)?; + run(false) +} + /// Runs a fixed git plumbing command in `dir` (optionally with an isolated index /// file). A stable checkpoint identity is set so `commit-tree` works even in a /// repo without a configured user. Returns trimmed stdout on success. @@ -3303,6 +3368,7 @@ pub fn run() { put_cloud_settings, diagnostic_logs, submit_feedback, + revert_agent_edits, send_telemetry, fetch_jucode_models, fetch_jucode_groups, diff --git a/src/lib/ChangesPanel.svelte b/src/lib/ChangesPanel.svelte index 51b099a..ff2c10e 100644 --- a/src/lib/ChangesPanel.svelte +++ b/src/lib/ChangesPanel.svelte @@ -7,12 +7,25 @@ import Modal from '$lib/ui/Modal.svelte'; import Notice from '$lib/ui/Notice.svelte'; import { confirm } from '$lib/ui/confirm.svelte'; - import { git } from '$lib/protocol'; + import { git, revertAgentEdits } from '$lib/protocol'; import { editorStore } from '$lib/editor/editorStore.svelte'; import { t } from '$lib/i18n'; // `files` is the session-tracked set of agent-edited paths; onRevert removes one. - let { cwd = '', files = [], onRevert }: { cwd?: string; files?: string[]; onRevert?: (p: string) => void } = $props(); + // `agentDiffs`: the agent's own edits per project-relative path, so a + // revert undoes only them and keeps the user's changes. + let { + cwd = '', + files = [], + agentDiffs = {}, + onRevert + }: { cwd?: string; files?: string[]; agentDiffs?: Record; onRevert?: (p: string) => void } = $props(); + /** The recorded diffs for a listed path (absolute or project-relative). */ + function diffsOf(path: string): string[] { + const root = cwd.replace(/\/+$/, ''); + const rel = root && path.startsWith(root + '/') ? path.slice(root.length + 1) : path; + return agentDiffs[rel] ?? []; + } const dir = () => cwd || undefined; let stats = $state>({}); @@ -76,6 +89,31 @@ } async function revert(path: string) { + const diffs = diffsOf(path); + // The agent's own edits are known: undo exactly them, keep the user's. + if (diffs.length && cwd) { + const ok = await confirm({ + title: t('dock.changes.revertTitle'), + message: t('dock.changes.revertAgentConfirm', { path }), + confirmLabel: t('dock.changes.revert'), + danger: true + }); + if (!ok) return; + busy = true; + error = ''; + try { + const root = cwd.replace(/\/+$/, ''); + const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; + await revertAgentEdits(cwd, rel, diffs); + onRevert?.(path); + return; + } catch (e) { + // The file changed too much since: fall through to the full revert. + error = t('dock.changes.revertAgentFailed', { error: String(e) }); + } finally { + busy = false; + } + } const ok = await confirm({ title: t('dock.changes.revertTitle'), message: t('dock.changes.revertConfirm', { path }), diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 8e15a05..c98f538 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -461,6 +461,9 @@ export class ChatState { // Per user-turn file edits (path → added/removed line counts), keyed by the // 0-based user-turn index — drives the turn-level diff timeline. turnEdits = $state>>({}); + /** The agent's own edits per file (the edit tools' unified diffs, oldest + * first), so 还原 can undo exactly them and keep the user's changes. */ + agentDiffs: Record = {}; // Approval policy, enforced engine-side ('ask' ↔ read-only, 'edits' ↔ // auto-edit, 'all' ↔ full-auto). The engine is the source of truth: this // mirrors its `approval_mode` events, except right after engine startup where @@ -1193,6 +1196,13 @@ export class ChatState { const out = JSON.parse(str(ev.output)) as Record; const paths = [out.path, ...(Array.isArray(out.paths) ? out.paths : [])]; const edited: string[] = []; + // One file's edit: keep its diff (bounded) for an exact undo, + // by the project-relative name its diff header carries. + const label = /^\+\+\+ b\/(.+)$/m.exec(str(out.diff))?.[1]; + if (label && typeof out.path === 'string' && !Array.isArray(out.paths)) { + const list = (this.agentDiffs[label] ??= []); + if (list.length < 200) list.push(str(out.diff)); + } for (const p of paths) { if (typeof p === 'string' && p) { edited.push(p); diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index d7bdf78..c0aed6f 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -182,7 +182,9 @@ const dock = { emptyHint: 'AI 编辑文件后会在此列出', revert: '还原', revertFile: '还原此文件', - revertConfirm: '还原「{path}」到改动前?此操作不可撤销。', + revertConfirm: '把「{path}」还原到上次提交的版本?你自己在这个文件里没提交的改动也会一起丢掉,此操作不可撤销。', + revertAgentConfirm: '撤销智能体对「{path}」的修改?你自己的改动会保留。', + revertAgentFailed: '无法只撤销智能体的修改(文件之后又改动过):{error}', revertTitle: '还原文件', newFileDiff: '(新文件或无可显示的 diff)' }, @@ -392,7 +394,9 @@ const dock = { emptyHint: 'Files the AI edits will appear here', revert: 'Revert', revertFile: 'Revert this file', - revertConfirm: 'Revert "{path}" to before the changes? This cannot be undone.', + revertConfirm: 'Restore "{path}" to its last commit? Your own uncommitted changes to it are lost too. This cannot be undone.', + revertAgentConfirm: 'Undo the agent\'s edits to "{path}"? Your own changes stay.', + revertAgentFailed: 'Could not undo only the agent\'s edits (the file changed since): {error}', revertTitle: 'Revert file', newFileDiff: '(New file or no diff to display)' }, diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 71dbbf2..66fae28 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -540,6 +540,10 @@ export function listProviders(): Promise { export function git(args: string[], cwd?: string): Promise { return invoke('git', { args, cwd }); } +/** Undoes only the agent's edits to `path` (its recorded diffs), keeping the user's. */ +export function revertAgentEdits(cwd: string, path: string, diffs: string[]): Promise { + return invoke('revert_agent_edits', { cwd, path, diffs }); +} // 并行任务 worktree 的容器目录(/.jucode-worktrees/)。 export function worktreeBase(cwd: string): Promise { return invoke('worktree_base', { cwd }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9b8f77c..819acf2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1467,7 +1467,7 @@ {:else if tab.panel === 'plan'} {:else if tab.panel === 'goal'} {:else if tab.panel === 'agents'}{#if chat && activeId}{#key activeId} activeId && dispatch(activeId, op)} />{/key}{/if} - {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> + {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> {:else if tab.panel === 'turns'} {:else if tab.panel === 'files'} {:else if tab.panel === 'git'} openTaskProject(path, meta)} onTaskRemoved={closeTaskProject} /> From 8647fa90f4157e1a495a3a0310e850944850c87f Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 17:05:36 +0800 Subject: [PATCH 13/31] Streaming reply renders each finished paragraph once; reasoning streams as plain text The finished part of a streaming reply was one Markdown block that re-parsed, re-highlighted and re-sanitized everything before it whenever a paragraph completed (quadratic in the reply's length). It is now a keyed list of paragraph chunks, each rendered once; open reasoning streams as plain text and renders as Markdown when it ends. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/MessageList.svelte | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/lib/MessageList.svelte b/src/lib/MessageList.svelte index 16ba85d..7ff9cab 100644 --- a/src/lib/MessageList.svelte +++ b/src/lib/MessageList.svelte @@ -362,6 +362,33 @@ return i < 0 ? 0 : i + 2; } + // The finished part of a streaming reply as stable chunks: each paragraph + // (or fenced block) is one chunk whose text never changes once complete, so + // a keyed {#each} renders it once. Rendering the whole finished prefix as one + // Markdown re-parsed, re-highlighted and re-sanitized everything before it + // whenever a paragraph completed: quadratic in the reply's length. + function stableBlocks(done: string): { key: string; text: string }[] { + const out: { key: string; text: string }[] = []; + let start = 0; + let open = false; + const lines = done.split('\n'); + let pos = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (/^ {0,3}(`{3,}|~{3,})/.test(line)) open = !open; + pos += line.length + 1; + // A blank line outside a fence ends a chunk. + if (!open && line.trim() === '' && pos - start > 1) { + const text = done.slice(start, pos); + if (text.trim()) out.push({ key: `${start}:${text.length}`, text }); + start = pos; + } + } + const rest = done.slice(start); + if (rest.trim()) out.push({ key: `${start}:${rest.length}`, text: rest }); + return out; + } + // A user message's text split into runs of "> " quote lines (quoted // passages, shown as quotes) and the text between. function userBlocks(text: string): { text: string; quote: boolean }[] { @@ -488,7 +515,7 @@ {#if m === streamingMsg} {@const rt = revealed(m)} {@const si = splitIdx(rt)} - {#if si > 0}{/if} + {#each stableBlocks(rt.slice(0, si)) as block (block.key)}{/each}

{rt.slice(si)}
{:else} @@ -519,10 +546,9 @@ - {@const rt = revealed(m)} - {@const si = splitIdx(rt)} - {#if si > 0}{/if} - + +
{revealed(m)}
{:else} {/if} From 54ff32a7b140383e89e00d3b2646b218172dffd8 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 18:24:11 +0800 Subject: [PATCH 14/31] Windows blank chat: guard the pane, log UI errors; fixed column width - A conversation that hit a render error left an empty pane. Each chat pane now has an error boundary that says so with a retry, and every UI error (pane or window) is appended to ~/.jucode/logs/desktop-ui.log so the cause can be read from the logs folder. - The transcript's stacked edge-blur layers run only on macOS: WebView2 can paint masked backdrop filters over a scroller as an opaque layer, which blanks the conversation on Windows. - The conversation column has one fixed width (768px); the drag handles to resize it are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/lib.rs | 23 ++++++++++ src/lib/ChatPane.svelte | 79 +++------------------------------- src/lib/i18n/messages/shell.ts | 4 ++ src/routes/+page.svelte | 78 +++++++++++++++++++++++++++------ 4 files changed, 96 insertions(+), 88 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a6594d7..6bf0e28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -857,6 +857,28 @@ fn about_app(app: AppHandle) -> serde_json::Value { }) } +/// A UI error, appended to ~/.jucode/logs/desktop-ui.log (bounded), so a +/// blank pane on a machine nobody here can see still says what broke. +#[tauri::command] +fn log_ui_error(message: String) { + use std::io::Write; + let dir = jucode_dir().join("logs"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("desktop-ui.log"); + // Keep it small: start over past 512 KB. + if std::fs::metadata(&path).map(|m| m.len() > 512 * 1024).unwrap_or(false) { + let _ = std::fs::remove_file(&path); + } + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + let line: String = message.chars().take(4000).collect(); + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + let _ = writeln!(file, "{stamp} {} {}", std::env::consts::OS, line.replace('\n', " | ")); + } +} + /// Opens the folder holding the engine and daemon logs (made when missing). #[tauri::command] fn open_logs_folder(app: AppHandle) -> Result<(), String> { @@ -3369,6 +3391,7 @@ pub fn run() { diagnostic_logs, submit_feedback, revert_agent_edits, + log_ui_error, send_telemetry, fetch_jucode_models, fetch_jucode_groups, diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index e9f2db3..baf2ece 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -199,41 +199,15 @@ let mark = $state(-1); const marks = $derived(chat.messages.flatMap((m) => (m.kind === 'user' && !parseDelivery(m.text) ? [m.text] : []))); - // The conversation column's width: dragging either edge resizes it about - // the centre; the width is kept once the drag ends. - // Narrow enough to read, wide enough for the composer's bar on one line. - const CHAT_MIN = 640; - const EDGE = 10; // px from the text to the drag strip's centre + // The conversation column has one fixed width (as in ChatGPT and Claude): + // narrow enough to read, wide enough for the composer's bar on one line. + const CHAT_W = 768; let wrapW = $state(0); - let dragW = $state(null); - let hot = $state<{ edge: string; y: number } | null>(null); - const chatW = $derived(Math.max(CHAT_MIN, dragW ?? prefs.chatWidth)); + const chatW = CHAT_W; // The least room beside the column (more with the rail at the left edge), // and the room it has: main's padding-inline. const chatPad = $derived(marks.length > 1 ? 56 : 32); const pad = $derived(Math.max(chatPad, (wrapW - chatW) / 2)); - function startResize(e: PointerEvent) { - e.preventDefault(); - const el = e.currentTarget as HTMLElement; - const box = el.parentElement!.getBoundingClientRect(); - const centre = box.left + box.width / 2; - el.setPointerCapture(e.pointerId); - const move = (ev: PointerEvent) => { - dragW = Math.max(CHAT_MIN, Math.min(box.width - 2 * chatPad, 2 * (Math.abs(ev.clientX - centre) - EDGE))); - }; - const end = () => { - if (dragW !== null) prefs.setChatWidth(dragW); - dragW = null; - el.removeEventListener('pointermove', move); - }; - el.addEventListener('pointermove', move); - el.addEventListener('pointerup', end, { once: true }); - el.addEventListener('pointercancel', end, { once: true }); - } - function trackEdge(edge: string, e: PointerEvent) { - const box = (e.currentTarget as HTMLElement).getBoundingClientRect(); - hot = { edge, y: e.clientY - box.top }; - } // In-conversation find (⌘F). The raw input updates per keystroke; the actual // scan (findHits) keys off the debounced `findQuery` so the O(n) message scan @@ -1170,7 +1144,7 @@ /> {/if} -
+
@@ -1200,21 +1174,6 @@ {#if marks.length > 1} messageList?.jumpToMark(n)} /> {/if} - {#each ['left', 'right'] as edge (edge)} - - {/each}
{#if !atBottom} @@ -1551,34 +1510,6 @@ centres the column. */ padding-inline: max(var(--chat-pad), calc((100% - var(--chat-w)) / 2)); } - /* The column's edge: hovering shows a short bar at the pointer, dragging - resizes the column. */ - .edge { - position: absolute; - top: 0; - bottom: 0; - width: 12px; - transform: translateX(-50%); - cursor: col-resize; - z-index: 4; - } - .edge span { - position: absolute; - left: 5px; - width: 2px; - height: 120px; - border-radius: 1px; - transform: translateY(-50%); - background: linear-gradient(transparent, var(--dim), transparent); - pointer-events: none; - } - .edge.active span { - background: linear-gradient(transparent, var(--text), transparent); - } - .resizing { - cursor: col-resize; - user-select: none; - } .welcome { margin: auto; display: flex; diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index 75f1faf..6b1ec12 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -98,6 +98,8 @@ const shell = { // header / navigator toggleSidebar: '会话列表 · ⌘B', chatGone: '该对话已关闭', + paneError: '这个对话显示出错了,已记录到日志。', + paneErrorRetry: '重新显示', // workspaces workspace: { @@ -998,6 +1000,8 @@ const shell = { // header / navigator toggleSidebar: 'Session list · ⌘B', chatGone: 'This conversation is closed', + paneError: 'This conversation failed to display; it was logged.', + paneErrorRetry: 'Show again', // workspaces workspace: { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 819acf2..0d3828c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -900,6 +900,13 @@ store.removeProject(p); } + /** A pane's render error: logged with the session for the logs folder. */ + function reportUiError(error: unknown, sid: string) { + telemetry.track('error:pane'); + const text = error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error); + invoke('log_ui_error', { message: `pane ${sid}: ${text}` }).catch(() => {}); + } + async function removeSession(id: string) { const s = sessionMap.get(id); // A draft that never started has nothing to lose. @@ -1071,7 +1078,13 @@ duration: 15000, action: { label: t('settings.help.noticeAction'), run: () => openSettings('general') } }); - const onUiError = () => telemetry.track('error:ui'); + const onUiError = (e: Event) => { + // (window-level errors; a pane's own are caught by its boundary) + telemetry.track('error:ui'); + const err = e instanceof ErrorEvent ? e.error ?? e.message : (e as PromiseRejectionEvent).reason; + const text = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err); + invoke('log_ui_error', { message: text }).catch(() => {}); + }; window.addEventListener('error', onUiError); window.addEventListener('unhandledrejection', onUiError); const savedSb = Number(localStorage.getItem('jucode-sidebar-width')); @@ -1447,19 +1460,30 @@ /> {/if} {:else} - openPanelTile('agents')} - /> + + reportUiError(e, sid)}> + openPanelTile('agents')} + /> + {#snippet failed(error, reset)} +
+

{t('shell.paneError')}

+ {String((error as Error)?.message ?? error).slice(0, 300)} + +
+ {/snippet} +
{/if} {:else}
{t('shell.chatGone')}
@@ -1868,4 +1892,30 @@ border-color: var(--accent); background: var(--surface2); } + .pane-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + height: 100%; + padding: 24px; + color: var(--dim); + text-align: center; + } + .pane-error code { + max-width: 560px; + color: var(--dim2); + font-size: var(--fs-xs); + word-break: break-word; + } + .pane-error button { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--panel); + color: var(--text); + font: inherit; + cursor: pointer; + } From 0c927e0e5ba6d9306539bb7fb9d0061edec4c7c4 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 18:27:09 +0800 Subject: [PATCH 15/31] Approvals: answer with 1/2/3 (y/a/n, Esc); notify when one waits in the background - The allow / deny card takes 1 (allow once), 2 (always), 3 or Esc (deny), shown on the buttons; only the active pane's card listens, never while typing in a field or within a second of the last keystroke, and not for questions, per-hunk edits or plans. - With the window in the background, a new action to allow or a plan to approve raises a system notification. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ApprovalCard.svelte | 49 +++++++++++++++++++++++++++++++--- src/lib/ChatPane.svelte | 2 +- src/lib/i18n/messages/shell.ts | 4 +++ src/routes/+page.svelte | 12 +++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/lib/ApprovalCard.svelte b/src/lib/ApprovalCard.svelte index bed0500..c656b18 100644 --- a/src/lib/ApprovalCard.svelte +++ b/src/lib/ApprovalCard.svelte @@ -27,7 +27,8 @@ let { approval, onRespond, - ruleScopes = false + ruleScopes = false, + keys = true }: { approval: { callId: string; @@ -45,6 +46,8 @@ onRespond: (op: ApproveOp) => void; /** 始终允许 asks where to keep the rule (caps.ruleScopes). */ ruleScopes?: boolean; + /** Answers the 1/2/3 keys (only the active pane's card). */ + keys?: boolean; } = $props(); // An MCP server asking the user (claude elicitation): accept / decline, @@ -61,6 +64,34 @@ desc: t(`shell.alwaysScope.${key}Desc`) })) ); + // Keys for the plain allow / deny card (not questions, hunks or plans): + // 1 / y allow once, 2 / a always, 3 / n / Esc deny. Never while the user + // types somewhere, nor within a second of the last keystroke (a sentence + // in the composer must not answer a card that just appeared). + let lastTyped = 0; + function onKey(e: KeyboardEvent) { + if (!keys || e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return; + const el = document.activeElement as HTMLElement | null; + const typing = !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); + if (typing) { + lastTyped = Date.now(); + if (e.key !== 'Escape') return; + } + if (Date.now() - lastTyped < 1000 || questions || approval.hunks?.length || isPlan) return; + const key = e.key.toLowerCase(); + let op: ApproveOp | null = null; + if (key === '1' || key === 'y') op = buildApproveOp(approval.callId, 'allow'); + else if ((key === '2' || key === 'a') && !isElicitation) { + e.preventDefault(); + if (scopeKeys) scopeOpen = true; + else allowAlways(); + return; + } else if (key === '3' || key === 'n' || (key === 'escape' && !typing && !scopeOpen)) + op = buildApproveOp(approval.callId, 'deny'); + if (!op) return; + e.preventDefault(); + onRespond(op); + } function allowAlways(scope?: AlwaysScope) { scopeOpen = false; onRespond(buildApproveOp(approval.callId, 'allow', { always: true, scope })); @@ -148,6 +179,7 @@ import Checkbox from '$lib/ui/Checkbox.svelte'; +
{#if questions} @@ -283,7 +315,7 @@ variant="primary" size="sm" onclick={() => onRespond(buildApproveOp(approval.callId, 'allow'))} - >{isElicitation ? t('shell.elicitAccept') : t('shell.allowOnce')}{isElicitation ? t('shell.elicitAccept') : t('shell.allowOnce')}1 {#if !isElicitation} @@ -291,7 +323,7 @@ variant="secondary" size="sm" onclick={() => (scopeKeys ? (scopeOpen = !scopeOpen) : allowAlways())} - >{scopeKeys ? t('shell.allowAlwaysScoped') : t('shell.allowAlways')}{scopeKeys ? t('shell.allowAlwaysScoped') : t('shell.allowAlways')}2 {#if scopeOpen} onRespond(buildApproveOp(approval.callId, 'deny'))} - >{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}{isElicitation ? '2' : '3'}
{/if} @@ -571,4 +603,13 @@ .diff .ctx { display: block; } + .approval-actions :global(kbd) { + margin-left: 6px; + padding: 0 4px; + border-radius: 3px; + background: color-mix(in oklab, currentColor 14%, transparent); + font: inherit; + font-size: 10px; + opacity: 0.75; + } diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index baf2ece..ae0e205 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -1193,7 +1193,7 @@ {#if chat.pendingApproval && !chat.pendingApproval.questions?.length}
{#key chat.pendingApproval.callId} - + {/key}
{/if} diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index 6b1ec12..a90974e 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -4,6 +4,8 @@ const shell = { zh: { // notifications / runtime notifyDone: '对话完成:{title}', + notifyApproval: '等你批准:{what}', + notifyPlan: '计划待批准:{title}', cacheMiss: { message: '「{title}」缓存未命中:本次请求输入 {input} token,命中缓存 {cached}。5 秒内不操作将继续执行。', stop: '停止任务' @@ -906,6 +908,8 @@ const shell = { en: { // notifications / runtime notifyDone: 'Conversation done: {title}', + notifyApproval: 'Waiting for your approval: {what}', + notifyPlan: 'Plan to approve: {title}', cacheMiss: { message: '{title}: prompt cache miss. {cached} of {input} input tokens were cached. Continuing in 5 seconds.', stop: 'Stop task' diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0d3828c..93d8294 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1103,6 +1103,8 @@ const s = sessionMap.get(sessionId); if (!s) return; const wasBusy = s.chat.busy; + const hadApproval = s.chat.pendingApproval?.callId ?? null; + const hadPlan = s.chat.messages.findLast((m) => m.kind === 'plan' && m.status === 'pending'); // Capture the raw frame for the diagnostics trace so a mis-parsed or // dropped tool frame is inspectable after the fact. s.chat.captureFrame(data); @@ -1155,6 +1157,16 @@ s.chat.unseen = true; notifyDone(s.chat.title); } + // Something waits on the user while the window is in the + // background: an action to allow, or a plan to approve. + if (!document.hasFocus()) { + const ask = s.chat.pendingApproval; + if (ask && ask.callId !== hadApproval && !ask.questions?.length) + void notify(s.chat.title, t('shell.notifyApproval', { what: ask.summary.slice(0, 120) })); + const plan = s.chat.messages.findLast((m) => m.kind === 'plan' && m.status === 'pending'); + if (plan && plan !== hadPlan && plan.kind === 'plan') + void notify(s.chat.title, t('shell.notifyPlan', { title: plan.title })); + } // This session's tile (if any) sticks to the bottom while streaming. panes.get(s.id)?.scrollToEnd(); }; From 920e301392c8c046558842619dcd68f15f1c2bdb Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 18:49:17 +0800 Subject: [PATCH 16/31] Memory: a tool card keeps a long output's head and tail (64 KB) A build log or a big file read stayed whole in every session's state for as long as the app ran. Cards now keep at most 64 KB of an output (head and tail; JSON stays parseable, an image's pixels stay whole); the engine's session still has all of it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/chat.svelte.ts | 29 +++++++++++++++++++++++++++-- src/lib/chat.test.ts | 21 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index c98f538..4eefd58 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -275,6 +275,31 @@ export interface TurnDiff { * `delayMs` from `at` (ms), because of `reason` (the provider's error). */ /** What an automatic retry sends when the failed turn is kept (autoRetry.ts). * Recognised again in a reloaded transcript, where it shows as a note. */ +/** What a tool card keeps of an output in memory: a long one (a build log, + * a big file read) keeps its head and tail. Structured (JSON) outputs are + * kept whole when they fit, else their string fields are cut the same way, + * so a card still parses them. The engine's session keeps the full output. */ +const TOOL_OUTPUT_MAX = 64 * 1024; +export function boundedOutput(output: string): string { + if (output.length <= TOOL_OUTPUT_MAX) return output; + const cut = (text: string, max: number) => + text.length <= max ? text : `${text.slice(0, max / 2)}\n… (${text.length - max} characters not shown) …\n${text.slice(-max / 2)}`; + try { + const value = JSON.parse(output) as unknown; + if (value && typeof value === 'object' && !Array.isArray(value)) { + const out: Record = {}; + // An image's pixels are kept whole (cut, they would not decode). + for (const [k, v] of Object.entries(value as Record)) + out[k] = typeof v === 'string' && k !== 'base64' ? cut(v, TOOL_OUTPUT_MAX / 2) : v; + const text = JSON.stringify(out); + if (text.length <= TOOL_OUTPUT_MAX * 2 || typeof out.base64 === 'string') return text; + } + } catch { + /* plain text */ + } + return cut(output, TOOL_OUTPUT_MAX); +} + export const AUTO_CONTINUE = '继续(连接中断后自动重试)'; export interface RetryState { @@ -1161,7 +1186,7 @@ export class ChatState { case 'tool_update': { const t = this.#tool(str(ev.call_id)); if (t) { - t.output = str(ev.output); + t.output = boundedOutput(str(ev.output)); if (SUBAGENT_CALLS.has(t.name)) t.args = t.output; } break; @@ -1175,7 +1200,7 @@ export class ChatState { this.#segStart = Date.now(); const t = this.#tool(str(ev.call_id)); if (t) { - t.output = str(ev.output); + t.output = boundedOutput(str(ev.output)); t.running = false; t.isError = ev.is_error === true; } diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index 569ab01..0ea1e34 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -802,3 +802,24 @@ describe('gateway session costs', () => { expect(last?.kind === 'assistant' && last.turn?.billing?.gateway_cost).toBe(0); }); }); + +import { boundedOutput } from './chat.svelte'; +describe('boundedOutput', () => { + it('keeps a short output, cuts a long one to its head and tail', () => { + expect(boundedOutput('ok')).toBe('ok'); + const long = 'a'.repeat(50_000) + 'MIDDLE' + 'z'.repeat(50_000); + const out = boundedOutput(long); + expect(out.length).toBeLessThan(70_000); + expect(out.startsWith('aaa')).toBe(true); + expect(out.endsWith('zzz')).toBe(true); + expect(out).toContain('characters not shown'); + }); + it('keeps JSON parseable and an image whole', () => { + const json = JSON.stringify({ stdout: 'x'.repeat(200_000), exit_code: 0 }); + const out = JSON.parse(boundedOutput(json)); + expect(out.exit_code).toBe(0); + expect(out.stdout.length).toBeLessThan(40_000); + const image = JSON.stringify({ kind: 'image', base64: 'A'.repeat(300_000) }); + expect(JSON.parse(boundedOutput(image)).base64.length).toBe(300_000); + }); +}); From d8a6cddac283cf55ab5b70257387666c627b74b9 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 23:24:50 +0800 Subject: [PATCH 17/31] Windows: the browser opens files; file links with Windows paths open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_open built its window inside a synchronous command, which deadlocks WebView2: the browser panel stayed blank and every later click waited behind it. It is async now. Links marked percent-encodes (a Windows backslash as %5C, Chinese names as %E9...) are decoded, file:///C:/ keeps its drive, and C:\ or \\server paths count as absolute instead of being joined to the project again. A resolved path drops the \\?\ prefix canonicalize adds on Windows. 反馈问题 also attaches desktop-ui.log, where the UI writes what broke. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/browser.rs | 6 ++++-- src-tauri/src/lib.rs | 27 ++++++++++++++++++++++++--- src/lib/ChatPane.svelte | 7 +++---- src/lib/RemoteSession.svelte | 5 ++--- src/lib/fileRefs.test.ts | 23 ++++++++++++++++++++++- src/lib/fileRefs.ts | 31 ++++++++++++++++++++++++++++++- src/routes/+page.svelte | 7 ++++--- 7 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 1cdd77d..cfb92d0 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -78,8 +78,10 @@ fn place(app: &AppHandle, child: &WebviewWindow, x: f64, y: f64, width: f64, hei } /// Creates the child browser window at the given panel bounds (or navigates the -/// existing one) and loads `url`. -#[tauri::command] +/// existing one) and loads `url`. Async: building a webview window inside a +/// synchronous command deadlocks on Windows (WebView2), leaving the panel blank +/// and every later browser command waiting behind it. +#[tauri::command(async)] pub fn browser_open( app: AppHandle, url: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6bf0e28..69b6bd3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -818,6 +818,7 @@ fn diagnostic_logs() -> Vec { [ ("jucode.log", dir.join("logs").join("jucode.log")), ("daemon.log", dir.join("daemon").join("daemon.log")), + ("desktop-ui.log", dir.join("logs").join("desktop-ui.log")), ] .into_iter() .filter_map(|(name, path)| { @@ -2092,16 +2093,28 @@ fn resolve_file_ref(root: String, rel: String) -> Option { let rel = Path::new(&rel); let direct = root.join(rel); if direct.is_file() { - return Some(direct.display().to_string()); + return Some(ui_path(&direct)); } let mut found = Vec::new(); find_ref(&root, rel, 0, &mut found); match found.as_slice() { - [one] => one.canonicalize().ok().filter(|p| p.starts_with(&root)).map(|p| p.display().to_string()), + [one] => one.canonicalize().ok().filter(|p| p.starts_with(&root)).map(|p| ui_path(&p)), _ => None, } } +/// A canonical path as the UI opens it: on Windows `canonicalize` adds a +/// `\\?\` prefix the UI does not read as absolute, so it joined the path to +/// the project root again and the file did not open. +fn ui_path(path: &Path) -> String { + let text = path.display().to_string(); + match text.strip_prefix(r"\\?\") { + Some(unc) if unc.starts_with(r"UNC\") => format!(r"\\{}", &unc[4..]), + Some(local) => local.to_string(), + None => text, + } +} + const REF_DEPTH: usize = 4; fn find_ref(dir: &Path, rel: &Path, depth: usize, out: &mut Vec) { @@ -3468,7 +3481,15 @@ pub fn run() { #[cfg(test)] mod tests { - use super::{read_json_strict, resolve_file_ref, valid_app_data_name}; + use super::{read_json_strict, resolve_file_ref, ui_path, valid_app_data_name}; + use std::path::Path; + + #[test] + fn ui_path_drops_the_windows_verbatim_prefix() { + assert_eq!(ui_path(Path::new(r"\\?\C:\p\a.html")), r"C:\p\a.html"); + assert_eq!(ui_path(Path::new(r"\\?\UNC\srv\share\a.ts")), r"\\srv\share\a.ts"); + assert_eq!(ui_path(Path::new("/Users/me/a.ts")), "/Users/me/a.ts"); + } #[test] fn resolve_file_ref_finds_a_unique_nested_match() { diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index ae0e205..4e229cc 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -70,7 +70,7 @@ import ProgressCard from '$lib/agents/ProgressCard.svelte'; import { agentRows, type AgentRow } from '$lib/agentProgress'; import { parseToolOutput, toolTarget, toolVerb } from '$lib/toolSummary'; - import { parseFileHref } from '$lib/fileRefs'; + import { isAbsolutePath, joinPath, parseFileHref, pathExt } from '$lib/fileRefs'; import type { Msg } from '$lib/chat.svelte'; import type { SessionSwitch } from '$lib/composer/SessionSwitches.svelte'; import Modal from '$lib/ui/Modal.svelte'; @@ -1023,9 +1023,8 @@ const ref = parseFileHref(href); const rel = ref.path; if (!rel) return; - const joined = `${cwd.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`; - const abs = rel.startsWith('/') ? rel : ((await resolveFileRef(cwd, rel).catch(() => null)) ?? joined); - const ext = abs.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; + const abs = isAbsolutePath(rel) ? rel : ((await resolveFileRef(cwd, rel).catch(() => null)) ?? joinPath(cwd, rel)); + const ext = pathExt(abs); if ((ext === 'html' || ext === 'htm') && prefs.htmlOpenInBrowser) { browser.open(`file://${abs}`); } else { diff --git a/src/lib/RemoteSession.svelte b/src/lib/RemoteSession.svelte index 164bbf8..15fbdc6 100644 --- a/src/lib/RemoteSession.svelte +++ b/src/lib/RemoteSession.svelte @@ -27,7 +27,7 @@ import { loadSession, saveSession } from '$lib/remote/cache'; import ModelMenu from '$lib/remote/ModelMenu.svelte'; import TaskStrip from '$lib/TaskStrip.svelte'; - import { parseFileHref } from '$lib/fileRefs'; + import { joinPath, parseFileHref } from '$lib/fileRefs'; import AgentRunsPanel from '$lib/AgentRunsPanel.svelte'; import TreeStructureIcon from 'phosphor-svelte/lib/TreeStructureIcon'; import type { Msg } from '$lib/chat.svelte'; @@ -508,8 +508,7 @@ function openReplyFile(href: string) { const ref = parseFileHref(href); if (!ref.path) return; - const base = (cwd ?? '').replace(/\/+$/, ''); - onFile?.(ref.path.startsWith('/') || !base ? ref.path : `${base}/${ref.path.replace(/^\.?\//, '')}`, ref.line); + onFile?.(joinPath(cwd ?? '', ref.path), ref.line); } // The agent trace sheet (AgentRunsPanel). let traceOpen = $state(false); diff --git a/src/lib/fileRefs.test.ts b/src/lib/fileRefs.test.ts index 69633c6..5b0a52f 100644 --- a/src/lib/fileRefs.test.ts +++ b/src/lib/fileRefs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { codeFileRef, fileHref, parseFileHref, splitFileRefs } from './fileRefs'; +import { codeFileRef, fileHref, isAbsolutePath, joinPath, parseFileHref, pathExt, splitFileRefs } from './fileRefs'; const refs = (text: string) => splitFileRefs(text).filter((p) => typeof p !== 'string'); @@ -38,4 +38,25 @@ describe('file references', () => { expect(parseFileHref('file:///abs/b.rs?x=1#L3C2')).toEqual({ path: '/abs/b.rs', line: 3, col: 2 }); expect(parseFileHref('docs/c.md')).toEqual({ path: 'docs/c.md' }); }); + + it('reads the links marked percent-encodes, Windows paths included', () => { + expect(parseFileHref('/Users/me/%E9%B9%88%E9%B9%95.html')).toEqual({ path: '/Users/me/鹈鹕.html' }); + expect(parseFileHref('C:%5CUsers%5Cme%5Ca.html#L3')).toEqual({ path: 'C:\\Users\\me\\a.html', line: 3 }); + expect(parseFileHref('file:///C:/Users/me/a.html')).toEqual({ path: 'C:/Users/me/a.html' }); + expect(parseFileHref('docs/100%.md')).toEqual({ path: 'docs/100%.md' }); + }); + + it('joins and reads paths with either separator', () => { + for (const p of ['/abs/a.ts', 'C:\\p\\a.ts', 'c:/p/a.ts', '\\\\server\\share\\a.ts']) { + expect(isAbsolutePath(p)).toBe(true); + expect(joinPath('C:\\proj', p)).toBe(p); + } + expect(isAbsolutePath('src/a.ts')).toBe(false); + expect(joinPath('C:\\proj\\', './src/a.ts')).toBe('C:\\proj/src/a.ts'); + expect(joinPath('/proj/', 'src/a.ts')).toBe('/proj/src/a.ts'); + expect(joinPath('', 'src/a.ts')).toBe('src/a.ts'); + expect(pathExt('C:\\p\\site.v2\\Index.HTML')).toBe('html'); + expect(pathExt('C:\\p.d\\README')).toBe(''); + expect(pathExt('/p/.env')).toBe(''); + }); }); diff --git a/src/lib/fileRefs.ts b/src/lib/fileRefs.ts index e947da1..e55e62a 100644 --- a/src/lib/fileRefs.ts +++ b/src/lib/fileRefs.ts @@ -69,10 +69,39 @@ export function fileHref(ref: FileRef): string { export function parseFileHref(href: string): FileRef { const clean = href.replace(/^file:\/\//, ''); const [main, hash = ''] = clean.split('#'); - const path = main.split('?')[0].trim(); + // marked percent-encodes a link: 鹈鹕 and a Windows `\` arrive as %XX. + let path = decodePath(main.split('?')[0].trim()); + // `file:///C:/x` leaves `/C:/x`; on Windows the drive starts the path. + if (/^\/[A-Za-z]:[\\/]/.test(path)) path = path.slice(1); const m = /^L(\d+)(?::(\d+)|C(\d+))?$/.exec(hash); if (!m) return { path }; const line = Number(m[1]); const col = Number(m[2] ?? m[3]); return { path, ...(line > 0 ? { line } : {}), ...(col > 0 ? { col } : {}) }; } + +function decodePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; // a literal `%` that is no escape + } +} + +/** An absolute path on any OS: `/x`, `C:\x`, `C:/x` or `\\server\share`. */ +export function isAbsolutePath(path: string): boolean { + return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path); +} + +/** `path` under `root`, unless it is absolute already. */ +export function joinPath(root: string, path: string): string { + if (isAbsolutePath(path) || !root) return path; + return `${root.replace(/[\\/]+$/, '')}/${path.replace(/^\.?[\\/]/, '')}`; +} + +/** A path's extension, lowercased; '' when it has none. Either separator. */ +export function pathExt(path: string): string { + const name = path.split(/[\\/]/).pop() ?? ''; + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 93d8294..e116ce2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,6 +8,7 @@ import ChatCircleTextIcon from 'phosphor-svelte/lib/ChatCircleTextIcon'; import SessionMark from '$lib/SessionMark.svelte'; import { sessionStatus } from '$lib/sessionStatus'; + import { joinPath, parseFileHref, pathExt } from '$lib/fileRefs'; import PlusIcon from 'phosphor-svelte/lib/PlusIcon'; import ListChecksIcon from 'phosphor-svelte/lib/ListChecksIcon'; import TargetIcon from 'phosphor-svelte/lib/TargetIcon'; @@ -1012,10 +1013,10 @@ function openActiveFile(href: string) { const cwd = activeProject?.path; if (!cwd) return; - const rel = href.replace(/^file:\/\//, '').split(/[?#]/)[0].trim(); + const rel = parseFileHref(href).path; if (!rel) return; - const abs = rel.startsWith('/') ? rel : `${cwd.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`; - const ext = abs.split('/').pop()?.split('.').pop()?.toLowerCase() ?? ''; + const abs = joinPath(cwd, rel); + const ext = pathExt(abs); if ((ext === 'html' || ext === 'htm') && prefs.htmlOpenInBrowser) { // The embedded browser loads http(s) only (a file:// URL was refused // and left it on its empty page): serve the file from the loopback From b9648e7c5da37cf9b811c72be25c62e7d02466cb Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Thu, 8 Oct 2026 23:58:53 +0800 Subject: [PATCH 18/31] test: resolve_file_ref expects the UI path on Windows too Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69b6bd3..f9025ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3502,8 +3502,9 @@ mod tests { } let r = root.display().to_string(); let canon = root.canonicalize().unwrap(); - assert_eq!(resolve_file_ref(r.clone(), "top.ts".into()), Some(canon.join("top.ts").display().to_string())); - assert_eq!(resolve_file_ref(r.clone(), "src/x.ts".into()), Some(canon.join("a/repo/src/x.ts").display().to_string())); + // As the UI gets it: without the `\\?\` prefix canonicalize adds on Windows. + assert_eq!(resolve_file_ref(r.clone(), "top.ts".into()), Some(ui_path(&canon.join("top.ts")))); + assert_eq!(resolve_file_ref(r.clone(), "src/x.ts".into()), Some(ui_path(&canon.join("a/repo/src/x.ts")))); assert_eq!(resolve_file_ref(r.clone(), "src/y.ts".into()), None); assert_eq!(resolve_file_ref(r.clone(), "src/z.ts".into()), None); std::fs::remove_dir_all(&root).unwrap(); From b2c66ac6d727e9fa9133ff85348afe76dae95e9a Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 01:06:06 +0800 Subject: [PATCH 19/31] =?UTF-8?q?New=20chat=20from=20the=20sidebar's=20+?= =?UTF-8?q?=20no=20longer=20shows=20=E8=BF=99=E4=B8=AA=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=87=BA=E9=94=99=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + next to the session list passed its click event as the first message; the chat then called .trim() on an Event. The button now calls onNewChat() with nothing, and only text starts a chat. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/session.svelte.ts | 4 +++- src/lib/session.test.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 692fe0d..6d4909b 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -334,7 +334,9 @@ export class SessionStore { // At the start: the sidebar lists a project's first sessions only. project.sessions.unshift(s); this.activeId = s.id; - if (firstMessage) { + // Only text starts the chat: a click handler wired straight through passes + // its event here, which showed as a broken first message. + if (typeof firstMessage === 'string' && firstMessage) { s.chat.optimisticUser(firstMessage); dispatch(s.id, { op: 'user_message', content: firstMessage }); } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index fdf3075..617a87b 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -65,6 +65,15 @@ describe('SessionStore lifecycle', () => { expect(p.sessions[0].chat.sessionId).toBe(`conv-${id}`); }); + it('a click event handed in as the first message starts an empty draft', () => { + const store = new SessionStore(); + const p = proj(); + store.projects.push(p); + store.addSession(p, new Event('click') as unknown as string); + expect(p.sessions[0].chat.messages).toEqual([]); + expect(p.sessions[0].draft).toBe(true); + }); + it('a draft records backend, model and effort, and applies them when it starts', async () => { const store = new SessionStore(); const p = proj(); From 0a8373c009e3e466bca0c533b50e51d9b9d72775 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 01:13:51 +0800 Subject: [PATCH 20/31] =?UTF-8?q?Cache-miss=20notice:=20no=20digit=20right?= =?UTF-8?q?=20before=20=E3=80=825=20=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/i18n/messages/shell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/i18n/messages/shell.ts b/src/lib/i18n/messages/shell.ts index a90974e..68234a7 100644 --- a/src/lib/i18n/messages/shell.ts +++ b/src/lib/i18n/messages/shell.ts @@ -7,7 +7,7 @@ const shell = { notifyApproval: '等你批准:{what}', notifyPlan: '计划待批准:{title}', cacheMiss: { - message: '「{title}」缓存未命中:本次请求输入 {input} token,命中缓存 {cached}。5 秒内不操作将继续执行。', + message: '「{title}」缓存未命中:本次输入 {input} token,只命中 {cached} token。不操作的话,5 秒后继续执行。', stop: '停止任务' }, untitled: '未命名', From 29f5b863f8db4fa12e9d83ceebcb32548e2d8145 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 01:51:47 +0800 Subject: [PATCH 21/31] Marketplace: sources as the catalog lists them (Anthropic and community repositories) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/Marketplace.svelte | 13 +++++++++---- src/lib/i18n/messages/settings.ts | 4 ++-- src/lib/protocol.ts | 10 +++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/lib/Marketplace.svelte b/src/lib/Marketplace.svelte index 2d00a9f..7b06fb0 100644 --- a/src/lib/Marketplace.svelte +++ b/src/lib/Marketplace.svelte @@ -24,7 +24,11 @@ let installDir = $state(''); let query = $state(''); let tag = $state(''); - let source = $state<'all' | 'jucode' | 'anthropic' | 'installed'>('all'); + /** 'all', 'installed' or a source key (`anthropic`, `owner/repo`). */ + let source = $state('all'); + const sourceName = (s: MarketSkill) => s.sourceName || s.source; + /** The sources the catalog has, in its order (Anthropic first). */ + const sources = $derived([...new Map(skills.map((s) => [s.source, sourceName(s)])).entries()]); let installing = $state>({}); const tags = $derived([...new Set(skills.flatMap((s) => s.tags))].sort()); @@ -92,8 +96,9 @@
(source = 'all')}>{t('settings.marketplace.all')} - (source = 'jucode')}>JuCode - (source = 'anthropic')}>Anthropic + {#each sources as [key, name] (key)} + (source = key)}>{name} + {/each} (source = 'installed')}>{t('settings.marketplace.installed')}
@@ -123,7 +128,7 @@
{s.name} - {s.source === 'anthropic' ? 'Anthropic' : 'JuCode'} + {sourceName(s)} {#if s.isDefault}{t('settings.account.default')}{/if}

{s.description}

diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index dc39137..44a782d 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -387,7 +387,7 @@ const settings = { }, marketplace: { title: '扩展市场', - subtitle: '从 JuCode 与 Anthropic 安装技能扩展。', + subtitle: '从 Anthropic 和社区仓库(Ikaleio、Superpowers、Composio)安装技能。', search: '搜索扩展…', all: '全部', allTags: '全部标签', @@ -838,7 +838,7 @@ const settings = { }, marketplace: { title: 'Marketplace', - subtitle: 'Install skills from JuCode and Anthropic.', + subtitle: 'Install skills from Anthropic and community repositories (Ikaleio, Superpowers, Composio).', search: 'Search extensions…', all: 'All', allTags: 'All tags', diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 66fae28..868d021 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -222,15 +222,19 @@ export function removeAuthKey(provider: string): Promise { return invoke('remove_auth_key', { provider }); } -// Skills marketplace: the daemon combines JuCode with github.com/anthropics/skills -// and installs into the backend's personal skills directory. -export type SkillSource = 'jucode' | 'anthropic'; +// Skills marketplace: the daemon combines github.com/anthropics/skills with +// community skill repositories and installs into the backend's personal skills +// directory. +/** `anthropic`, or a community repository's `owner/repo`. */ +export type SkillSource = string; export interface MarketSkill { id: string; name: string; description: string; tags: string[]; source: SkillSource; + /** The source as shown: Anthropic, Ikaleio, Superpowers… */ + sourceName?: string; isDefault: boolean; installed: boolean; license: string; From b75373f8a127be2daf2dcbcb409d149129dabe56 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 02:02:16 +0800 Subject: [PATCH 22/31] Plans read in a page of their own beside the chat A proposed plan opens a page to the right of its chat, as the browser does: one tab per plan, closed one by one. A pending plan comes in block by block; an approved one shows whole; approve / revise sit at its bottom. In the chat the plan is one row (title, its first lines, open), so the clipped card that twitched when it could not unfold is gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/ChatPane.svelte | 11 +- src/lib/MessageList.svelte | 5 +- src/lib/PlanActions.svelte | 99 +++++++++++++ src/lib/PlanCard.svelte | 257 ++++++++++------------------------ src/lib/ProposalPane.svelte | 172 +++++++++++++++++++++++ src/lib/i18n/messages/chat.ts | 4 + src/lib/planPages.svelte.ts | 45 ++++++ src/lib/planPages.test.ts | 17 +++ src/routes/+page.svelte | 55 +++++++- 9 files changed, 480 insertions(+), 185 deletions(-) create mode 100644 src/lib/PlanActions.svelte create mode 100644 src/lib/ProposalPane.svelte create mode 100644 src/lib/planPages.svelte.ts create mode 100644 src/lib/planPages.test.ts diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 4e229cc..8fe48ef 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -1,10 +1,13 @@ @@ -75,6 +80,7 @@ import type { SessionSwitch } from '$lib/composer/SessionSwitches.svelte'; import Modal from '$lib/ui/Modal.svelte'; import { toast } from '$lib/ui/toast.svelte'; + import { planPages } from '$lib/planPages.svelte'; import Picker from '$lib/shell/Picker.svelte'; import Segmented from '$lib/ui/Segmented.svelte'; import type { SectionKey } from '$lib/settings/nav'; @@ -1078,7 +1084,8 @@ stop: () => { if (chat.busy) stop(); }, - openModelMenu: () => composerRef?.openModelMenu() + openModelMenu: () => composerRef?.openModelMenu(), + planAction }; $effect(() => { onRegister?.(session.id, api); @@ -1147,7 +1154,7 @@
- autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} onPlan={planAction} {planMode} /> + autoRetry.now(chat)} onAutoRetryCancel={() => autoRetry.cancel(chat)} {findActive} {scroller} onEdit={editMessage} onCite={citeText} onNote={noteRequirement} onRewind={rewindToMessage} onFile={openChatFile} onDismiss={(m) => (chat.messages = chat.messages.filter((x) => x !== m))} backend={chat.backendId} onErrorAction={fixError} traceOf={traceable ? traceOf : undefined} agents={allAgents} onOpenAgent={traceable ? openAgent : undefined} onPlan={planAction} {planMode} onOpenPlan={(id) => planPages.open(session.id, id)} />
{#if chat.booting && chat.engineState !== 'exited'}
diff --git a/src/lib/MessageList.svelte b/src/lib/MessageList.svelte index 7ff9cab..7af5ea8 100644 --- a/src/lib/MessageList.svelte +++ b/src/lib/MessageList.svelte @@ -54,6 +54,7 @@ agents, onOpenAgent, onPlan, + onOpenPlan, planMode = 'edits', loadImage, mark = $bindable(-1) @@ -99,6 +100,8 @@ /** Approves or revises a proposed plan (absent: plans have no actions). */ onPlan?: (id: string, action: PlanAction) => void; /** The mode a plan's approval offers first. */ + /** Shows a plan in its page beside the chat. */ + onOpenPlan?: (id: string) => void; planMode?: ApprovalMode; /** Reads a sent image where the desktop can't open its path (the remote page). */ loadImage?: (path: string) => Promise; @@ -586,7 +589,7 @@ {/if} {:else if m.kind === 'plan'} - + {:else if m.kind === 'error'} onDismiss(m) : undefined} /> {/if} diff --git a/src/lib/PlanActions.svelte b/src/lib/PlanActions.svelte new file mode 100644 index 0000000..eebcb48 --- /dev/null +++ b/src/lib/PlanActions.svelte @@ -0,0 +1,99 @@ + + +
+ + + + + {#if menuOpen} + ((mode = k as ApprovalMode), (menuOpen = false))} + onClose={() => (menuOpen = false)} + /> + {/if} + + + +
+ + diff --git a/src/lib/PlanCard.svelte b/src/lib/PlanCard.svelte index 5d8bdfa..abb7702 100644 --- a/src/lib/PlanCard.svelte +++ b/src/lib/PlanCard.svelte @@ -4,20 +4,14 @@
-
- - {t('chat.planCard.label')} - {#if status === 'approved' || status === 'revising'} - {t(`chat.planCard.status.${status}`)} - {/if} - - -
- {#if title}

{title}

{/if} -
-
-
- {#if long} -
- -
- {/if} -
-{#if actionable && status === 'pending' && onAction} -
- - - - - {#if menuOpen} - ((mode = k as ApprovalMode), (menuOpen = false))} - onClose={() => (menuOpen = false)} - /> + -
+ + + +{#if actionable && status === 'pending' && onAction} +
{/if} diff --git a/src/lib/ProposalPane.svelte b/src/lib/ProposalPane.svelte new file mode 100644 index 0000000..6249453 --- /dev/null +++ b/src/lib/ProposalPane.svelte @@ -0,0 +1,172 @@ + + +
+ {#if plan} +
+ + {t('chat.planCard.label')} + {#if plan.status === 'approved' || plan.status === 'revising'} + {t(`chat.planCard.status.${plan.status}`)} + {/if} + + +
+
+
+ {#if plan.title}

{plan.title}

{/if} + {#each blocks.slice(0, shown) as block, i (i)} +
+ {/each} +
+
+ {#if actionable && plan.status === 'pending' && onAction} +
+ {/if} + {:else} +

{t('chat.planCard.gone')}

+ {/if} +
+ + diff --git a/src/lib/i18n/messages/chat.ts b/src/lib/i18n/messages/chat.ts index 3e9f47b..e2eaab5 100644 --- a/src/lib/i18n/messages/chat.ts +++ b/src/lib/i18n/messages/chat.ts @@ -285,6 +285,8 @@ const chat = { planCard: { label: '计划', viewFull: '查看完整计划', + openPage: '在右侧查看计划', + gone: '这份计划已不在对话中', fold: '收起', approve: '批准并执行', modeTitle: '执行时的审批模式', @@ -586,6 +588,8 @@ const chat = { planCard: { label: 'Plan', viewFull: 'View the full plan', + openPage: 'Open the plan beside the chat', + gone: 'This plan is no longer in the conversation', fold: 'Fold', approve: 'Approve and run', modeTitle: 'Approval mode for the work', diff --git a/src/lib/planPages.svelte.ts b/src/lib/planPages.svelte.ts new file mode 100644 index 0000000..9f98072 --- /dev/null +++ b/src/lib/planPages.svelte.ts @@ -0,0 +1,45 @@ +// A plan the agent proposes opens in a page of its own beside the chat, as +// the embedded browser does: one tab per plan, kept until the user closes it. +// The tab's panel names the session and the plan (`proposal::`). + +const PREFIX = 'proposal:'; + +export const proposalPanel = (sessionId: string, planId: string) => `${PREFIX}${sessionId}:${planId}`; + +/** The session and plan a `proposal:` panel shows; null for any other panel. */ +export function proposalOf(panel: string): { sessionId: string; planId: string } | null { + if (!panel.startsWith(PREFIX)) return null; + const rest = panel.slice(PREFIX.length); + const at = rest.indexOf(':'); + return at > 0 ? { sessionId: rest.slice(0, at), planId: rest.slice(at + 1) } : null; +} + +/** Markdown in blocks (blank-line separated, a code fence kept whole), so a + * page can bring a plan in one block at a time. */ +export function planBlocks(text: string): string[] { + const blocks: string[] = []; + let current: string[] = []; + let fenced = false; + for (const line of text.split('\n')) { + if (/^\s*(```|~~~)/.test(line)) fenced = !fenced; + if (!fenced && line.trim() === '' && current.length) { + blocks.push(current.join('\n')); + current = []; + continue; + } + if (line.trim() !== '' || current.length) current.push(line); + } + if (current.length) blocks.push(current.join('\n')); + return blocks; +} + +class PlanPages { + /** The last page asked for; the page shell opens (or shows) its tab. */ + request = $state<{ sessionId: string; planId: string; n: number } | null>(null); + + open(sessionId: string, planId: string) { + this.request = { sessionId, planId, n: (this.request?.n ?? 0) + 1 }; + } +} + +export const planPages = new PlanPages(); diff --git a/src/lib/planPages.test.ts b/src/lib/planPages.test.ts new file mode 100644 index 0000000..5efc6cd --- /dev/null +++ b/src/lib/planPages.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { planBlocks, proposalOf, proposalPanel } from './planPages.svelte'; + +describe('plan pages', () => { + it('names a session and a plan in the panel and reads them back', () => { + expect(proposalOf(proposalPanel('s1', 'plan-2'))).toEqual({ sessionId: 's1', planId: 'plan-2' }); + expect(proposalOf(proposalPanel('s1', 'a:b'))).toEqual({ sessionId: 's1', planId: 'a:b' }); + expect(proposalOf('plan')).toBeNull(); + expect(proposalOf('browser')).toBeNull(); + }); + + it('splits a plan into blocks and keeps a code fence whole', () => { + const text = '## Goal\n\nDo it.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\n- one\n- two\n'; + expect(planBlocks(text)).toEqual(['## Goal', 'Do it.', '```ts\nconst a = 1;\n\nconst b = 2;\n```', '- one\n- two']); + expect(planBlocks('')).toEqual([]); + }); +}); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e116ce2..3805749 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -67,6 +67,7 @@ leavesOf, openTab, serializeLayout, + splitLeaf, type TileLayout, type TileTab } from '$lib/workbench/tiles'; @@ -110,6 +111,9 @@ import { agentDirectory, agentsOfWorkspace } from '$lib/agents.svelte'; import { CHATS_ENABLED, type Project, type WorktreeMeta } from '$lib/types'; import PlanPanel from '$lib/PlanPanel.svelte'; + import ProposalPane from '$lib/ProposalPane.svelte'; + import { planPages, proposalOf, proposalPanel } from '$lib/planPages.svelte'; + import type { ApprovalMode } from '$lib/approval'; import GoalPanel from '$lib/GoalPanel.svelte'; import ChangesPanel from '$lib/ChangesPanel.svelte'; import TurnsPanel from '$lib/TurnsPanel.svelte'; @@ -424,6 +428,8 @@ const tui = tuiBackendOf(tab.panel); if (tui) return tuiTabTitle(tui); if (tab.panel === 'audit') return t('editor.title'); + const proposal = proposalOf(tab.panel); + if (proposal) return planOf(proposal.sessionId, proposal.planId)?.title || t('chat.planCard.label'); return (ALL_PANELS as readonly string[]).includes(tab.panel) ? t(`dock.tabs.${tab.panel}`) : tab.panel; } function tuiReady(sid: string): boolean { @@ -558,6 +564,35 @@ else applyTiles(openTab(tiles, focusedLeaf, { id: newTabId(), panel: kind })); } + /** The approval mode a plan runs in once approved: the one before plan mode. */ + function planModeOf(sessionId: string): ApprovalMode { + const before = sessionMap.get(sessionId)?.chat.modeBeforePlan; + return before && before !== 'plan' ? before : 'edits'; + } + /** A plan message of a session, for its page. */ + function planOf(sessionId: string, planId: string) { + const m = sessionMap.get(sessionId)?.chat.messages.find((x) => x.kind === 'plan' && x.id === planId); + return m?.kind === 'plan' ? m : undefined; + } + /** A plan's page beside its session's chat: shown again if open, else a + * new tab (one per plan, closed one by one). */ + function openPlanPage(sessionId: string, planId: string) { + const panel = proposalPanel(sessionId, planId); + const existing = findPanelTab(panel); + if (existing) return applyTiles(activateTab(tiles, existing.id)); + const tab = { id: newTabId(), panel }; + // Plan pages share one column right of the chat, as tabs. + const plans = leavesOf(tiles.root).find((leaf) => leaf.tabs.some((x) => x.panel.startsWith('proposal:'))); + if (plans) return applyTiles(openTab(tiles, plans.id, tab)); + const chat = leafOfTab(tiles.root, chatPanel(sessionId)); + applyTiles(chat ? splitLeaf(tiles, chat.id, 'right', tab).layout : openTab(tiles, focusedLeaf, tab)); + } + $effect(() => { + const request = planPages.request; + if (!request || !tilesReady) return; + untrack(() => openPlanPage(request.sessionId, request.planId)); + }); + // The workbench-active session always has a chat tile: activating a session // (sidebar click, ⌘N, resume, deep link…) opens or focuses it. $effect(() => { @@ -1106,6 +1141,7 @@ const wasBusy = s.chat.busy; const hadApproval = s.chat.pendingApproval?.callId ?? null; const hadPlan = s.chat.messages.findLast((m) => m.kind === 'plan' && m.status === 'pending'); + const lastPlan = s.chat.messages.findLast((m) => m.kind === 'plan'); // Capture the raw frame for the diagnostics trace so a mis-parsed or // dropped tool frame is inspectable after the fact. s.chat.captureFrame(data); @@ -1168,7 +1204,11 @@ if (plan && plan !== hadPlan && plan.kind === 'plan') void notify(s.chat.title, t('shell.notifyPlan', { title: plan.title })); } - // This session's tile (if any) sticks to the bottom while streaming. + // A plan just proposed opens in its page beside this session's chat. + const newPlan = s.chat.messages.findLast((m) => m.kind === 'plan'); + if (newPlan && newPlan !== lastPlan && newPlan.kind === 'plan' && newPlan.status === 'pending' && tilesReady && chatSessionsIn(tiles).includes(s.id)) + openPlanPage(s.id, newPlan.id); + // This session's tile (if any) sticks to the bottom while streaming. panes.get(s.id)?.scrollToEnd(); }; daemon.onFrame = deliver; @@ -1502,6 +1542,19 @@
{t('shell.chatGone')}
{/if} {:else if tab.panel === 'plan'} + {:else if proposalOf(tab.panel)} + {@const p = proposalOf(tab.panel)!} + {@const plan = planOf(p.sessionId, p.planId)} + {@const lastPlan = sessionMap.get(p.sessionId)?.chat.messages.findLast((m) => m.kind === 'plan')} + { + panes.get(p.sessionId)?.planAction(id, action); + if (action.decision === 'revise') panes.get(p.sessionId)?.focusComposer(); + }} + /> {:else if tab.panel === 'goal'} {:else if tab.panel === 'agents'}{#if chat && activeId}{#key activeId} activeId && dispatch(activeId, op)} />{/key}{/if} {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> From 069c948567d8b51e5f27291f5c0971f3ab7a7191 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 02:28:51 +0800 Subject: [PATCH 23/31] The plan page opens when the model starts writing and grows as it streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan_draft events (JuCode CLI) grow one plan message marked 撰写中; its page opens beside the chat at the first fragment and shows the text as it comes, with a caret; the finished proposed_plan completes the same message. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/PlanCard.svelte | 2 +- src/lib/ProposalPane.svelte | 25 +++++++++++++++++++++---- src/lib/chat.svelte.ts | 15 +++++++++++++++ src/lib/chat.test.ts | 13 +++++++++++++ src/lib/i18n/messages/chat.ts | 4 ++-- src/routes/+page.svelte | 5 +++-- 6 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/lib/PlanCard.svelte b/src/lib/PlanCard.svelte index abb7702..df6bb65 100644 --- a/src/lib/PlanCard.svelte +++ b/src/lib/PlanCard.svelte @@ -52,7 +52,7 @@ {title || t('chat.planCard.label')} - {#if status === 'approved' || status === 'revising'} + {#if status === 'approved' || status === 'revising' || status === 'drafting'} {t(`chat.planCard.status.${status}`)} {/if} diff --git a/src/lib/ProposalPane.svelte b/src/lib/ProposalPane.svelte index 6249453..be93e49 100644 --- a/src/lib/ProposalPane.svelte +++ b/src/lib/ProposalPane.svelte @@ -30,8 +30,8 @@ /** ms between blocks while a pending plan comes in. */ const STEP = 70; const blocks = $derived(planBlocks(plan?.text ?? '')); - // A plan that is not waiting when its page opens is read, not watched: it - // shows whole. + // A plan already written when its page opens comes in block by block; one + // being written grows as it streams; an approved one shows whole. let shown = $state(untrack(() => (plan?.status === 'pending' ? 0 : Number.MAX_SAFE_INTEGER))); $effect(() => { if (shown >= blocks.length) return; @@ -53,7 +53,7 @@
{t('chat.planCard.label')} - {#if plan.status === 'approved' || plan.status === 'revising'} + {#if plan.status === 'approved' || plan.status === 'revising' || plan.status === 'drafting'} {t(`chat.planCard.status.${plan.status}`)} {/if} @@ -67,6 +67,7 @@ {#each blocks.slice(0, shown) as block, i (i)}
{/each} + {#if plan.status === 'drafting'}{/if}
{#if actionable && plan.status === 'pending' && onAction} @@ -155,8 +156,24 @@ transform: translateY(4px); } } + /* While the model writes the plan. */ + .caret { + display: inline-block; + width: 7px; + height: 1.1em; + margin-top: 6px; + border-radius: 1px; + background: var(--accent); + animation: blink 1s steps(2, start) infinite; + } + @keyframes blink { + to { + visibility: hidden; + } + } @media (prefers-reduced-motion: reduce) { - .block { + .block, + .caret { animation: none; } } diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 4eefd58..963d022 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -1533,6 +1533,21 @@ export class ChatState { }; break; } + // The plan as the model writes it: one message grows, and the + // proposed_plan with the same id completes it. + case 'plan_draft': { + const id = str(ev.id); + const known = this.messages.find((m) => m.kind === 'plan' && m.id === id); + if (known?.kind === 'plan') { + if (str(ev.title)) known.title = str(ev.title); + known.text += str(ev.append); + } else { + this.#collapseReasoning(); + this.#closeSegment(Date.now()); + this.messages.push({ kind: 'plan', id, title: str(ev.title), text: str(ev.append), status: 'drafting' }); + } + break; + } case 'proposed_plan': { const id = str(ev.id); const plan = { kind: 'plan' as const, id, title: str(ev.title), text: str(ev.markdown), status: planStatus(ev.status) }; diff --git a/src/lib/chat.test.ts b/src/lib/chat.test.ts index 0ea1e34..7392fbe 100644 --- a/src/lib/chat.test.ts +++ b/src/lib/chat.test.ts @@ -728,6 +728,19 @@ describe('claude session extras', () => { expect(c.messages).toEqual([{ kind: 'plan', id: 'p2', title: 'T', text: 'body', status: 'approved' }]); }); + it('grows one plan while the model writes it, and completes it in place', () => { + const c = new ChatState(); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Sna', append: '' }); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Snake', append: '## Go' }); + c.handle({ type: 'plan_draft', id: 'p1', title: 'Snake', append: 'al' }); + expect(c.messages).toEqual([{ kind: 'plan', id: 'p1', title: 'Snake', text: '## Goal', status: 'drafting' }]); + const draft = c.messages[0]; + c.handle({ type: 'proposed_plan', id: 'p1', title: 'Snake', markdown: '## Goal\n1. a', status: 'pending' }); + expect(c.messages).toHaveLength(1); + expect(c.messages[0]).toBe(draft); + expect(c.messages[0]).toMatchObject({ text: '## Goal\n1. a', status: 'pending' }); + }); + it('remembers the mode before plan mode and when the plan changed', () => { const c = new ChatState(); c.setApprovalMode('edits'); diff --git a/src/lib/i18n/messages/chat.ts b/src/lib/i18n/messages/chat.ts index e2eaab5..6603920 100644 --- a/src/lib/i18n/messages/chat.ts +++ b/src/lib/i18n/messages/chat.ts @@ -294,7 +294,7 @@ const chat = { revising: '正在修改计划', revisingHint: '说说要怎么改,发送后计划会按你的意见重写', cancelRevise: '取消修改', - status: { approved: '已批准', revising: '修改中' } + status: { approved: '已批准', revising: '修改中', drafting: '撰写中' } }, // Indicator phaseConnecting: '连接中', @@ -597,7 +597,7 @@ const chat = { revising: 'Revising the plan', revisingHint: 'Say what to change; the plan is rewritten when you send', cancelRevise: 'Cancel revising', - status: { approved: 'Approved', revising: 'Revising' } + status: { approved: 'Approved', revising: 'Revising', drafting: 'Writing' } }, // Indicator phaseConnecting: 'Connecting', diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3805749..861b2b3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1204,9 +1204,10 @@ if (plan && plan !== hadPlan && plan.kind === 'plan') void notify(s.chat.title, t('shell.notifyPlan', { title: plan.title })); } - // A plan just proposed opens in its page beside this session's chat. + // A plan the model starts writing (or proposes whole) opens in its + // page beside this session's chat. const newPlan = s.chat.messages.findLast((m) => m.kind === 'plan'); - if (newPlan && newPlan !== lastPlan && newPlan.kind === 'plan' && newPlan.status === 'pending' && tilesReady && chatSessionsIn(tiles).includes(s.id)) + if (newPlan && newPlan !== lastPlan && newPlan.kind === 'plan' && (newPlan.status === 'drafting' || newPlan.status === 'pending') && tilesReady && chatSessionsIn(tiles).includes(s.id)) openPlanPage(s.id, newPlan.id); // This session's tile (if any) sticks to the bottom while streaming. panes.get(s.id)?.scrollToEnd(); From f3316d18251591d8e254acf3dbf0f1e9d5500091 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 02:48:58 +0800 Subject: [PATCH 24/31] Import from other agents, host side: one scan, one apply import_scan lists what Claude Code, Codex, opencode, zcode and omp have on this machine: conversations in every folder (Claude's folder read from its rows), skills (their skills folders and installed plugins) and MCP servers as JuCode mcp_servers entries. A server JuCode could not start as written (SSE, a command relative to the tool's folder, a ${...} only its app fills in) is left out. import_apply brings the picked conversations and skills over. Claude Code and Codex go through native_import's cleaned copies. opencode and zcode (SQLite, opened read-only) and omp (a JSONL tree) become JuCode sessions: prompts, assistant text, and each tool call with its result; reasoning, step markers and compactions are dropped. They are recorded in native-imports.json, so importing again reopens the first copy. Skills are copied into ~/.jucode/skills unless JuCode has one of that name. Checked on this Mac: the scan finds 25 Claude Code, 34 Codex, 3 opencode and 85 zcode conversations, 86 skills and 3 MCP servers in 1.4 s; copies of real opencode and zcode sessions list and resume in `jucode serve`. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/Cargo.lock | 69 ++ src-tauri/Cargo.toml | 4 + src-tauri/src/agent_import/mcp.rs | 461 +++++++++ src-tauri/src/agent_import/mod.rs | 367 +++++++ src-tauri/src/agent_import/sessions.rs | 1226 ++++++++++++++++++++++++ src-tauri/src/agent_import/skills.rs | 232 +++++ src-tauri/src/claude_history.rs | 4 +- src-tauri/src/lib.rs | 3 + src-tauri/src/native_import.rs | 50 +- 9 files changed, 2389 insertions(+), 27 deletions(-) create mode 100644 src-tauri/src/agent_import/mcp.rs create mode 100644 src-tauri/src/agent_import/mod.rs create mode 100644 src-tauri/src/agent_import/sessions.rs create mode 100644 src-tauri/src/agent_import/skills.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3080497..2e4e43e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -888,6 +900,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "portable-pty", + "rusqlite", "serde", "serde_json", "tauri", @@ -901,6 +914,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "toml 0.8.2", "ureq", "window-vibrancy 0.5.3", ] @@ -1157,6 +1171,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1693,6 +1719,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1709,6 +1738,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2355,6 +2393,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3582,6 +3631,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -5311,6 +5374,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7daf1ec..ca09542 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,10 @@ ureq = { version = "2", features = ["json"] } chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc", "getrandom"] } getrandom = "0.2" tauri-plugin-clipboard-manager = "2" +# 一键导入:读 opencode / zcode 的会话库(只读)和 Codex 的 config.toml。 +# bundled 自带 SQLite,不依赖系统库。 +rusqlite = { version = "0.32", features = ["bundled"] } +toml = "0.8" # 仅桌面端的插件(updater / 单实例 / 进程重启) [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] diff --git a/src-tauri/src/agent_import/mcp.rs b/src-tauri/src/agent_import/mcp.rs new file mode 100644 index 0000000..48f63df --- /dev/null +++ b/src-tauri/src/agent_import/mcp.rs @@ -0,0 +1,461 @@ +//! MCP servers the other agents are configured with, as JuCode +//! `mcp_servers` entries: {name, transport: "stdio" | "http", command, args, +//! env, url, headers, enabled, timeout_seconds} (JuCode-CLI config.rs). +//! +//! Read from Claude Code's `~/.claude.json` (`mcpServers`, also per project) +//! and its plugins' `.mcp.json`; Codex's `config.toml` (`[mcp_servers.*]`) +//! and its enabled plugins; opencode's `opencode.json(c)` (`mcp`); zcode's +//! config and plugins; omp's `~/.omp/agent/mcp.json`. A server JuCode could +//! not start as written is left out: an SSE endpoint, a command relative to +//! the tool's own folder, or a `${…}` placeholder only its app fills in. + +use super::{codex_config, plugins, Roots}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::Path; + +#[derive(Serialize, Debug, Clone)] +pub struct Found { + pub source: String, + /// Where it is set when not in the tool's own settings: the plugin it + /// comes with, the project folder it is set for. + pub from: String, + pub name: String, + pub transport: &'static str, + pub command: Option, + pub args: Option>, + pub url: Option, + /// JuCode already has a server of this name. + pub present: bool, + /// The `mcp_servers` entry to add. + pub entry: Value, +} + +/// JuCode-CLI `DEFAULT_MCP_TIMEOUT_SECONDS`. +const DEFAULT_TIMEOUT: u64 = 60; + +/// A server as some tool writes it, before it is checked. +#[derive(Default, Debug)] +struct Raw { + kind: Option, + command: Option, + args: Vec, + env: BTreeMap, + url: Option, + headers: BTreeMap, + disabled: bool, + timeout_s: Option, +} + +fn strings(v: &Value) -> Vec { + v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() +} + +fn string_map(v: &Value) -> BTreeMap { + v.as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), v.as_str().map_or_else(|| v.to_string(), str::to_string))) + .collect() +} + +fn text(v: &Value) -> Option { + v.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string) +} + +fn millis(v: &Value) -> Option { + v.as_f64().filter(|ms| *ms > 0.0).map(|ms| (ms / 1000.0).ceil() as u64) +} + +/// The `mcpServers` shape Claude Code, omp and plugin `.mcp.json` files use. +fn standard(v: &Value) -> Raw { + let (command, args) = match &v["command"] { + // Some write the whole command line as a list. + Value::Array(_) => { + let mut all = strings(&v["command"]); + let first = (!all.is_empty()).then(|| all.remove(0)); + all.extend(strings(&v["args"])); + (first, all) + } + other => (text(other), strings(&v["args"])), + }; + Raw { + kind: text(&v["type"]).or_else(|| text(&v["transport"])), + command, + args, + env: string_map(&v["env"]), + url: text(&v["url"]), + headers: string_map(&v["headers"]), + disabled: v["disabled"] == json!(true) || v["enabled"] == json!(false), + timeout_s: millis(&v["timeout"]).or_else(|| millis(&v["timeoutMs"])), + } +} + +/// Codex `[mcp_servers.]`. +fn codex(v: &Value) -> Raw { + Raw { + kind: text(&v["type"]), + command: text(&v["command"]), + args: strings(&v["args"]), + env: string_map(&v["env"]), + url: text(&v["url"]), + headers: string_map(&v["http_headers"]), + disabled: v["enabled"] == json!(false), + timeout_s: v["tool_timeout_sec"].as_f64().filter(|s| *s > 0.0).map(|s| s.ceil() as u64), + } +} + +/// opencode `mcp.`: `local` runs `command` (a list), `remote` is a URL. +fn opencode(v: &Value) -> Raw { + let mut all = strings(&v["command"]); + let command = (!all.is_empty()).then(|| all.remove(0)); + Raw { + kind: text(&v["type"]), + command, + args: all, + env: string_map(&v["environment"]), + url: text(&v["url"]), + headers: string_map(&v["headers"]), + disabled: v["enabled"] == json!(false), + timeout_s: millis(&v["timeout"]), + } +} + +/// A server name JuCode accepts (`^[A-Za-z0-9_-]+$`). +fn clean_name(name: &str) -> Option { + let name: String = name + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }) + .collect(); + let name = name.trim_matches('-').to_string(); + (!name.is_empty()).then_some(name) +} + +/// The JuCode entry for a server, or None when it could not run as written. +/// `plugin` fills in the plugin-folder placeholders of a plugin's servers. +fn entry(name: &str, mut raw: Raw, plugin: Option<&Path>) -> Option { + let name = clean_name(name)?; + if let Some(dir) = plugin { + let dir = dir.display().to_string(); + let fill = |s: &mut String| { + for key in ["${CLAUDE_PLUGIN_ROOT}", "${ZCODE_PLUGIN_ROOT}", "${PLUGIN_ROOT}"] { + *s = s.replace(key, &dir); + } + }; + raw.command.iter_mut().for_each(fill); + raw.url.iter_mut().for_each(fill); + raw.args.iter_mut().for_each(fill); + raw.env.values_mut().for_each(fill); + raw.headers.values_mut().for_each(fill); + } + let unfilled = |s: &String| s.contains("${"); + if raw.command.iter().chain(&raw.url).chain(&raw.args).chain(raw.env.values()).chain(raw.headers.values()).any(unfilled) { + return None; + } + let http = match raw.kind.as_deref() { + Some("stdio" | "local") => false, + Some("http" | "streamable-http" | "streamableHttp" | "streamable_http" | "remote") => true, + // JuCode speaks streamable HTTP, not the older SSE transport. + Some(_) => return None, + None => raw.command.is_none() && raw.url.is_some(), + }; + let timeout = raw.timeout_s.unwrap_or(DEFAULT_TIMEOUT).clamp(1, 3600); + if http { + let url = raw.url.filter(|u| u.starts_with("http://") || u.starts_with("https://"))?; + Some(json!({ + "name": name, "transport": "http", "command": "", "args": [], "env": {}, + "url": url, "headers": raw.headers, "enabled": !raw.disabled, "timeout_seconds": timeout, + })) + } else { + let command = raw.command?; + // Relative to the tool's own folder (`./bin/server`): not found from here. + if command.starts_with("./") || command.starts_with("../") || command.starts_with(".\\") { + return None; + } + Some(json!({ + "name": name, "transport": "stdio", "command": command, "args": raw.args, "env": raw.env, + "url": "", "headers": {}, "enabled": !raw.disabled, "timeout_seconds": timeout, + })) + } +} + +/// The servers of a `.mcp.json`: under `mcpServers`, or the file itself. +fn server_map(v: &Value) -> Option<&serde_json::Map> { + v["mcpServers"].as_object().or_else(|| { + let map = v.as_object()?; + map.values().all(|s| s.get("command").is_some() || s.get("url").is_some()).then_some(map) + }) +} + +/// JSON with comments and trailing commas (opencode.jsonc). +fn strip_jsonc(text: &str) -> String { + let mut plain = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + let mut in_string = false; + let mut escaped = false; + while let Some(c) = chars.next() { + if in_string { + plain.push(c); + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + continue; + } + match (c, chars.peek()) { + ('"', _) => { + in_string = true; + plain.push(c); + } + ('/', Some('/')) => { + while chars.peek().is_some_and(|n| *n != '\n') { + chars.next(); + } + } + ('/', Some('*')) => { + chars.next(); + let mut prev = ' '; + for n in chars.by_ref() { + if prev == '*' && n == '/' { + break; + } + prev = n; + } + } + _ => plain.push(c), + } + } + // Trailing commas, outside strings. + let mut out = String::with_capacity(plain.len()); + let chars: Vec = plain.chars().collect(); + let (mut in_string, mut escaped) = (false, false); + for (i, &c) in chars.iter().enumerate() { + if in_string { + match c { + _ if escaped => escaped = false, + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + } else if c == '"' { + in_string = true; + } else if c == ',' && chars[i + 1..].iter().find(|n| !n.is_whitespace()).is_some_and(|n| matches!(n, '}' | ']')) { + continue; + } + out.push(c); + } + out +} + +fn read_jsonc(path: &Path) -> Value { + fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&strip_jsonc(&t)).ok()).unwrap_or_default() +} + +/// Names JuCode's config already has. +fn configured(roots: &Roots) -> HashSet { + let config = crate::read_json(&roots.jucode.join("config.json")); + config["mcp_servers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|s| s["name"].as_str().map(str::to_string)) + .collect() +} + +pub fn scan(roots: &Roots) -> Vec { + // (source, from, name, raw server, plugin folder) + let mut all: Vec<(&'static str, String, String, Raw, Option)> = Vec::new(); + + let claude = crate::read_json(&roots.home.join(".claude.json")); + for (name, v) in claude["mcpServers"].as_object().into_iter().flatten() { + all.push(("claude", String::new(), name.clone(), standard(v), None)); + } + for (project, p) in claude["projects"].as_object().into_iter().flatten() { + for (name, v) in p["mcpServers"].as_object().into_iter().flatten() { + all.push(("claude", project.clone(), name.clone(), standard(v), None)); + } + } + + let config = codex_config(roots); + for (name, v) in config["mcp_servers"].as_object().into_iter().flatten() { + all.push(("codex", String::new(), name.clone(), codex(v), None)); + } + + // opencode.jsonc overrides opencode.json, server by server. + let dir = roots.config_home.join("opencode"); + let mut servers: BTreeMap = BTreeMap::new(); + for file in ["opencode.json", "opencode.jsonc"] { + let v = read_jsonc(&dir.join(file)); + for (name, s) in v["mcp"].as_object().into_iter().flatten() { + servers.insert(name.clone(), s.clone()); + } + } + for (name, v) in &servers { + all.push(("opencode", String::new(), name.clone(), opencode(v), None)); + } + + let zcode = crate::read_json(&roots.home.join(".zcode").join("cli").join("config.json")); + for (name, v) in zcode["mcp"].as_object().into_iter().flatten() { + all.push(("zcode", String::new(), name.clone(), opencode(v), None)); + } + for (name, v) in zcode["mcpServers"].as_object().into_iter().flatten() { + all.push(("zcode", String::new(), name.clone(), standard(v), None)); + } + + for file in ["mcp.json", ".mcp.json"] { + let v = read_jsonc(&roots.omp_agent().join(file)); + let off: HashSet = strings(&v["disabledServers"]).into_iter().collect(); + for (name, s) in server_map(&v).into_iter().flatten() { + let mut raw = standard(s); + raw.disabled |= off.contains(name); + all.push(("omp", String::new(), name.clone(), raw, None)); + } + } + + for plugin in plugins(roots) { + let v = read_jsonc(&plugin.dir.join(".mcp.json")); + for (name, s) in server_map(&v).into_iter().flatten() { + all.push((plugin.source, plugin.name.clone(), name.clone(), standard(s), Some(plugin.dir.clone()))); + } + } + + let have = configured(roots); + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for (source, from, name, raw, plugin) in all { + let Some(entry) = entry(&name, raw, plugin.as_deref()) else { continue }; + let name = entry["name"].as_str().unwrap_or_default().to_string(); + // The same server set for several projects is offered once. + if !seen.insert((source, name.clone())) { + continue; + } + let stdio = entry["transport"] == "stdio"; + out.push(Found { + source: source.to_string(), + from, + present: have.contains(&name), + transport: if stdio { "stdio" } else { "http" }, + command: stdio.then(|| entry["command"].as_str().unwrap_or_default().to_string()), + args: stdio.then(|| strings(&entry["args"])), + url: (!stdio).then(|| entry["url"].as_str().unwrap_or_default().to_string()), + name, + entry, + }); + } + let rank = |s: &str| ["claude", "codex", "opencode", "zcode", "omp"].iter().position(|x| *x == s); + out.sort_by(|a, b| (rank(&a.source), &a.from, &a.name).cmp(&(rank(&b.source), &b.from, &b.name))); + out +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + #[test] + fn maps_each_tools_format() { + let roots = testutil::roots("mcp"); + testutil::write( + &roots.home.join(".claude.json"), + &json!({ + "mcpServers": { + "files": {"type": "stdio", "command": "npx", "args": ["-y", "server-files"], "env": {"ROOT": "/tmp"}}, + "docs api": {"type": "http", "url": "https://mcp.example.com/mcp", "headers": {"Authorization": "Bearer x"}}, + "old": {"type": "sse", "url": "https://sse.example.com"}, + "needs-env": {"command": "srv", "env": {"TOKEN": "${MY_TOKEN}"}} + }, + "projects": {"/p": {"mcpServers": {"files": {"command": "other"}, "proj": {"command": "proj-srv"}}}} + }) + .to_string(), + ); + testutil::write( + &roots.codex.join("config.toml"), + "[mcp_servers.node_repl]\ncommand = \"/bin/node_repl\"\nargs = []\ntool_timeout_sec = 120\n\ + [mcp_servers.node_repl.env]\nA = \"1\"\n\ + [mcp_servers.computer-use]\ntype = \"stdio\"\ncommand = \"./App.app/client\"\nenabled = false\n\ + [mcp_servers.remote]\nurl = \"https://r.example.com/mcp\"\nenabled = false\n\ + [plugins.\"notion@curated\"]\nenabled = true\n", + ); + testutil::write( + &roots.codex.join("plugins/cache/curated/notion/1dc19589/.mcp.json"), + &json!({"mcpServers": {"notion": {"type": "http", "url": "https://mcp.notion.com/mcp"}}}).to_string(), + ); + testutil::write( + &roots.config_home.join("opencode/opencode.jsonc"), + "{\n // mine\n \"mcp\": {\n \"jira\": {\"type\": \"local\", \"command\": [\"bun\", \"x\", \"jira-mcp\"], \"environment\": {\"K\": \"v\"}, \"timeout\": 5000,},\n \"web\": {\"type\": \"remote\", \"url\": \"https://w.example.com/mcp\", \"enabled\": false},\n },\n}\n", + ); + let plugin = roots.zcode_plugins().join("official/android/0.1.0"); + testutil::write( + &plugin.join(".mcp.json"), + &json!({"mcpServers": { + "android": {"command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/dist/server.js"]}, + "img": {"type": "http", "url": "${ZCODE_BASE_URL}/mcp"} + }}) + .to_string(), + ); + testutil::write( + &roots.omp_agent().join("mcp.json"), + &json!({"mcpServers": {"gh": {"command": "gh-mcp"}}, "disabledServers": ["gh"]}).to_string(), + ); + testutil::write(&roots.jucode.join("config.json"), &json!({"mcp_servers": [{"name": "files"}]}).to_string()); + + let found = scan(&roots); + let by = |source: &str, name: &str| { + found.iter().find(|f| f.source == source && f.name == name).unwrap_or_else(|| panic!("{source} {name}")) + }; + let names: Vec<(&str, &str)> = found.iter().map(|f| (f.source.as_str(), f.name.as_str())).collect(); + assert_eq!( + names, + [ + ("claude", "docs-api"), + ("claude", "files"), + ("claude", "proj"), + ("codex", "node_repl"), + ("codex", "remote"), + ("codex", "notion"), + ("opencode", "jira"), + ("opencode", "web"), + ("zcode", "android"), + ("omp", "gh"), + ], + "SSE, unfilled placeholders and folder-relative commands are left out; a name set again for a project is offered once" + ); + let files = by("claude", "files"); + assert!(files.present); + assert_eq!( + files.entry, + json!({"name": "files", "transport": "stdio", "command": "npx", "args": ["-y", "server-files"], "env": {"ROOT": "/tmp"}, + "url": "", "headers": {}, "enabled": true, "timeout_seconds": 60}) + ); + assert_eq!(by("claude", "proj").from, "/p"); + let docs = by("claude", "docs-api"); + assert_eq!((docs.transport, docs.url.as_deref()), ("http", Some("https://mcp.example.com/mcp"))); + assert_eq!(docs.entry["headers"], json!({"Authorization": "Bearer x"})); + assert!(!docs.present); + let repl = &by("codex", "node_repl").entry; + assert_eq!((repl["timeout_seconds"].as_u64(), repl["env"].clone()), (Some(120), json!({"A": "1"}))); + assert_eq!(by("codex", "remote").entry["enabled"], false); + let jira = &by("opencode", "jira").entry; + assert_eq!((jira["command"].as_str(), jira["args"].clone()), (Some("bun"), json!(["x", "jira-mcp"]))); + assert_eq!((jira["env"].clone(), jira["timeout_seconds"].as_u64()), (json!({"K": "v"}), Some(5))); + assert_eq!(by("opencode", "web").entry["enabled"], false); + let android = by("zcode", "android"); + assert_eq!(android.from, "android"); + assert_eq!(android.entry["args"], json!([format!("{}/dist/server.js", plugin.display())])); + assert_eq!(by("omp", "gh").entry["enabled"], false); + assert_eq!(by("codex", "notion").from, "notion"); + } + + #[test] + fn jsonc_comments_and_trailing_commas() { + let text = "{\"a\": \"x // not a comment\", /* gone */ \"b\": [1, 2,], // end\n}"; + assert_eq!(serde_json::from_str::(&strip_jsonc(text)).unwrap(), json!({"a": "x // not a comment", "b": [1, 2]})); + assert_eq!(clean_name("my server.v2"), Some("my-server-v2".into())); + assert_eq!(clean_name("中文"), None); + } +} diff --git a/src-tauri/src/agent_import/mod.rs b/src-tauri/src/agent_import/mod.rs new file mode 100644 index 0000000..01974e3 --- /dev/null +++ b/src-tauri/src/agent_import/mod.rs @@ -0,0 +1,367 @@ +//! 一键导入: the conversations, skills and MCP servers of the other coding +//! agents on this machine (Claude Code, Codex, opencode, zcode, omp), listed +//! in one scan and imported in one go. Their own folders are only read. +//! +//! - Conversations: Claude Code and Codex ones become a cleaned copy in the +//! engine's own format (native_import) and continue on that engine. +//! opencode, zcode and omp have no engine here, so theirs are converted +//! into JuCode sessions (sessions.rs). Either way +//! `~/.jucode/native-imports.json` records the import, and importing again +//! reuses the first copy. +//! - Skills are copied into `~/.jucode/skills` (skills.rs). +//! - MCP servers are mapped to `mcp_servers` entries (mcp.rs). The desktop +//! adds them through the daemon (`mcp_set`), as Settings → MCP does, so +//! open sessions pick them up. + +mod mcp; +mod sessions; +mod skills; + +use crate::native_import::{self, Homes}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Where everything is read from and written to (injectable so tests run +/// against a fixture home). +pub struct Roots { + pub home: PathBuf, + /// `~/.jucode`: sessions, skills, config.json and the import registry. + pub jucode: PathBuf, + pub claude: PathBuf, + pub codex: PathBuf, + /// `$XDG_DATA_HOME` or `~/.local/share` (opencode's database). + pub data_home: PathBuf, + /// `$XDG_CONFIG_HOME` or `~/.config` (opencode's config and skills). + pub config_home: PathBuf, +} + +impl Roots { + fn real() -> Roots { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_default(); + let xdg = |var: &str, fallback: &str| { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(fallback)) + }; + let native = native_import::homes(); + Roots { + jucode: crate::jucode_dir(), + claude: native.claude, + codex: native.codex, + data_home: xdg("XDG_DATA_HOME", ".local/share"), + config_home: xdg("XDG_CONFIG_HOME", ".config"), + home, + } + } + + fn native(&self) -> Homes { + Homes { claude: self.claude.clone(), codex: self.codex.clone(), registry: self.registry() } + } + + fn registry(&self) -> PathBuf { + self.jucode.join("native-imports.json") + } + + fn opencode_db(&self) -> PathBuf { + self.data_home.join("opencode").join("opencode.db") + } + + fn zcode_db(&self) -> PathBuf { + self.home.join(".zcode").join("cli").join("db").join("db.sqlite") + } + + fn zcode_plugins(&self) -> PathBuf { + self.home.join(".zcode").join("cli").join("plugins").join("cache") + } + + fn omp_agent(&self) -> PathBuf { + self.home.join(".omp").join("agent") + } +} + +/// A plugin another agent has installed: skills and MCP servers come with +/// it (`skills/`, `.mcp.json`). +pub(crate) struct Plugin { + pub source: &'static str, + pub name: String, + pub dir: PathBuf, +} + +/// Codex's `config.toml` as JSON (null when missing or unreadable). +fn codex_config(roots: &Roots) -> serde_json::Value { + std::fs::read_to_string(roots.codex.join("config.toml")) + .ok() + .and_then(|text| text.parse::().ok()) + .and_then(|table| serde_json::to_value(table).ok()) + .unwrap_or_default() +} + +/// The newest version folder of a plugin cache entry (`//`). +fn latest_version(dir: &Path) -> Option { + let key = |p: &PathBuf| -> Vec<(u64, String)> { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name.split(['.', '-', '+']) + .map(|part| part.parse::().map_or((0, part.to_string()), |n| (n, String::new()))) + .collect() + }; + std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir() && !p.file_name().and_then(|n| n.to_str()).unwrap_or(".").starts_with('.')) + .max_by(|a, b| key(a).cmp(&key(b))) +} + +/// Installed plugins: Claude Code's registry (less the ones turned off in its +/// settings), the ones Codex's config enables, and every one in zcode's cache. +fn plugins(roots: &Roots) -> Vec { + let mut out = Vec::new(); + let installed = crate::read_json(&roots.claude.join("plugins").join("installed_plugins.json")); + let enabled = crate::read_json(&roots.claude.join("settings.json")); + for (key, entry) in installed["plugins"].as_object().into_iter().flatten() { + if enabled["enabledPlugins"][key] == serde_json::json!(false) { + continue; + } + // One install (older registries) or one per scope. + let entries = entry.as_array().cloned().unwrap_or_else(|| vec![entry.clone()]); + let dir = entries.iter().rev().filter_map(|e| e["installPath"].as_str()).map(PathBuf::from).find(|p| p.is_dir()); + if let Some(dir) = dir { + out.push(Plugin { source: "claude", name: key.split('@').next().unwrap_or(key).to_string(), dir }); + } + } + let config = codex_config(roots); + for (key, entry) in config["plugins"].as_object().into_iter().flatten() { + let Some((name, market)) = key.split_once('@') else { continue }; + if entry["enabled"] == serde_json::json!(false) || name.contains(['/', '\\']) || market.contains(['/', '\\']) { + continue; + } + if let Some(dir) = latest_version(&roots.codex.join("plugins").join("cache").join(market).join(name)) { + out.push(Plugin { source: "codex", name: name.to_string(), dir }); + } + } + for market in std::fs::read_dir(roots.zcode_plugins()).into_iter().flatten().flatten() { + for plugin in std::fs::read_dir(market.path()).into_iter().flatten().flatten() { + if let Some(dir) = latest_version(&plugin.path()) { + out.push(Plugin { source: "zcode", name: plugin.file_name().to_string_lossy().into_owned(), dir }); + } + } + } + out +} + +#[derive(Serialize, Debug)] +pub struct Scan { + pub sessions: Vec, + pub skills: Vec, + pub mcp: Vec, +} + +fn scan(roots: &Roots) -> Scan { + Scan { sessions: sessions::scan(roots), skills: skills::scan(roots), mcp: mcp::scan(roots) } +} + +#[derive(Deserialize, Debug)] +pub struct SessionPick { + pub source: String, + pub id: String, + /// The folder the scan found it in (Claude Code and Codex copies are + /// written per folder). + #[serde(default)] + pub cwd: String, +} + +#[derive(Deserialize, Debug)] +pub struct SkillPick { + pub source: String, + pub path: String, +} + +#[derive(Deserialize, Debug, Default)] +pub struct Selection { + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub skills: Vec, +} + +/// What became of one picked item. +#[derive(Serialize, Debug)] +pub struct Outcome { + /// "session" or "skill". + pub kind: &'static str, + pub source: String, + /// The item as the scan named it: a session id, a skill folder. + pub key: String, + /// "imported"; "existing" when an earlier import (or a skill of that + /// name) is already there; "failed". + pub status: &'static str, + /// Sessions: the copy to open. Skills: the folder it was copied to. + pub target: Option, + pub title: Option, + /// Sessions: the folder the conversation belongs to, and the engine its + /// copy continues on ("jucode", "claude", "codex"). + pub cwd: Option, + pub engine: Option, + /// Whether `cwd` is still a folder on this machine (only then can it be a + /// project). + pub cwd_exists: bool, + pub error: Option, +} + +impl Outcome { + fn failed(kind: &'static str, source: &str, key: &str, error: String) -> Outcome { + Outcome { + kind, + source: source.to_string(), + key: key.to_string(), + status: "failed", + target: None, + title: None, + cwd: None, + engine: None, + cwd_exists: false, + error: Some(error), + } + } +} + +fn apply(roots: &Roots, selection: Selection) -> Vec { + // One import at a time, here or from a history picker: each extends the + // registry. + let _one = native_import::IMPORTING.lock().unwrap_or_else(|e| e.into_inner()); + let mut out = Vec::new(); + for pick in &selection.sessions { + out.push(match sessions::import(roots, pick) { + Ok(done) => Outcome { + kind: "session", + source: pick.source.clone(), + key: pick.id.clone(), + status: if done.reused { "existing" } else { "imported" }, + target: Some(done.id), + title: Some(done.title), + cwd_exists: Path::new(&done.cwd).is_dir(), + cwd: Some(done.cwd), + engine: Some(done.engine.to_string()), + error: None, + }, + Err(e) => Outcome::failed("session", &pick.source, &pick.id, e), + }); + } + if !selection.skills.is_empty() { + let found = skills::scan(roots); + for pick in &selection.skills { + out.push(match skills::import(roots, &found, &pick.source, &pick.path) { + Ok((copied, dest)) => Outcome { + kind: "skill", + source: pick.source.clone(), + key: pick.path.clone(), + status: if copied { "imported" } else { "existing" }, + target: Some(dest.display().to_string()), + title: None, + cwd: None, + engine: None, + cwd_exists: false, + error: None, + }, + Err(e) => Outcome::failed("skill", &pick.source, &pick.path, e), + }); + } + } + out +} + +// --- commands ---------------------------------------------------------------- + +/// Everything the other agents on this machine have that JuCode can take +/// over: conversations (all folders), skills and MCP servers. +#[tauri::command(async)] +pub fn import_scan() -> Scan { + scan(&Roots::real()) +} + +/// Imports the picked conversations and skills (MCP servers go through the +/// daemon, see the module docs); one outcome per item, in order. +#[tauri::command(async)] +pub fn import_apply(selection: Selection) -> Vec { + apply(&Roots::real(), selection) +} + +#[cfg(test)] +pub(crate) mod testutil { + use super::Roots; + use std::fs; + use std::path::{Path, PathBuf}; + + /// A fresh fake home for one test. + pub fn roots(name: &str) -> Roots { + let home = std::env::temp_dir().join(format!("jucode-agent-import-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(&home).unwrap(); + Roots { + jucode: home.join(".jucode"), + claude: home.join(".claude"), + codex: home.join(".codex"), + data_home: home.join(".local/share"), + config_home: home.join(".config"), + home, + } + } + + pub fn write(path: &Path, text: &str) -> PathBuf { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + path.to_path_buf() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Scans this machine's real agents (read-only) and converts a few of the + /// opencode / zcode / omp conversations into a temporary JuCode home. + /// `cargo test --lib real_machine -- --ignored --nocapture` + #[test] + #[ignore] + fn real_machine_scan_and_convert() { + let real = Roots::real(); + let started = std::time::Instant::now(); + let found = scan(&real); + println!("scan took {:?}", started.elapsed()); + // For a look at the page with this machine's data (tests/browser). + if let Some(path) = std::env::var_os("IMPORT_SCAN_JSON") { + std::fs::write(path, serde_json::to_string(&found).unwrap()).unwrap(); + } + let count = |s: &str| found.sessions.iter().filter(|x| x.source == s).count(); + for source in ["claude", "codex", "opencode", "zcode", "omp"] { + println!("{source}: {} conversations", count(source)); + } + for s in found.sessions.iter().take(3) { + println!(" e.g. {} {} {:?} {} prompts", s.source, s.id, s.title, s.messages); + } + println!("skills: {}", found.skills.len()); + for s in &found.skills { + println!(" {} {} {} present={}", s.source, s.plugin.as_deref().unwrap_or("-"), s.name, s.present); + } + println!("mcp: {}", found.mcp.len()); + for m in &found.mcp { + println!(" {} {} {} present={} {}", m.source, m.from, m.name, m.present, m.entry); + } + // Converted copies go to a temporary JuCode home, never ~/.jucode. + let tmp = testutil::roots("real"); + let into = Roots { jucode: tmp.jucode.clone(), ..Roots::real() }; + for source in ["opencode", "zcode", "omp"] { + for s in found.sessions.iter().filter(|x| x.source == source).take(3) { + let pick = SessionPick { source: s.source.clone(), id: s.id.clone(), cwd: s.cwd.clone() }; + let done = sessions::import(&into, &pick).expect("converts"); + let dir = sessions::jucode_session_dir(&into.jucode, &done.cwd); + let journal = std::fs::read_to_string(dir.join(format!("{}.jsonl", done.id))).unwrap(); + println!("{source} {} → {} ({} journal lines) in {}", s.id, done.id, journal.lines().count(), dir.display()); + } + } + } +} diff --git a/src-tauri/src/agent_import/sessions.rs b/src-tauri/src/agent_import/sessions.rs new file mode 100644 index 0000000..c99bdee --- /dev/null +++ b/src-tauri/src/agent_import/sessions.rs @@ -0,0 +1,1226 @@ +//! Conversations of the other agents, across every folder. +//! +//! Claude Code and Codex: listed from their session files and imported with +//! native_import (a cleaned copy resumed on that engine). +//! +//! opencode, zcode and omp: converted into a JuCode session, the journal and +//! summary `jucode` itself writes (JuCode-CLI agent-core session.rs): +//! `~/.jucode/sessions//.jsonl` and `.json`. +//! What a model needs to continue comes along: the prompts, the assistant's +//! text, and each tool call with its result (a `function_call` item and a +//! `tool_output` entry). Reasoning, step markers and compactions are dropped: +//! reasoning only replays on the account that wrote it, and a compaction's +//! summary stands for history the copy has in full. +//! +//! opencode and zcode (an opencode fork) keep conversations in SQLite — +//! `session`, `message` (one row per message, JSON `data`) and `part` (its +//! text, tool calls, reasoning …) — read here read-only. omp (oh-my-pi) keeps +//! a JSONL tree per conversation under `~/.omp/agent/sessions//`; the +//! active branch runs from the last entry back through `parentId`. + +use super::{Roots, SessionPick}; +use crate::claude_history::{is_session_id, is_synthetic, preview_of, row_text, truncate_chars}; +use crate::native_import::{ + claude_copy_path, codex_first_prompt, codex_names, codex_threads, existing_copy, import_claude, + import_codex, is_conversation, load_registry, mtime_ms, now_ms, save_registry, ImportRecord, + JUCODE_ORIGINATOR, MAX_TITLE_CHARS, +}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// A conversation offered for import. +#[derive(Serialize, Debug, Clone)] +pub struct Found { + /// "claude", "codex", "opencode", "zcode" or "omp". + pub source: String, + pub id: String, + pub title: String, + /// The folder it ran in. + pub cwd: String, + pub updated_at_ms: u64, + /// Prompts the user sent. + pub messages: usize, + /// Imported before: importing again opens that copy. + pub imported: bool, +} + +pub fn scan(roots: &Roots) -> Vec { + let registry = load_registry(&roots.registry()); + let mut out = scan_claude(roots, ®istry); + out.extend(scan_codex(roots, ®istry)); + out.extend(scan_db(roots, Db::Opencode, ®istry)); + out.extend(scan_db(roots, Db::Zcode, ®istry)); + out.extend(scan_omp(roots, ®istry)); + out.sort_by_key(|s| std::cmp::Reverse(s.updated_at_ms)); + out +} + +pub struct Imported { + pub id: String, + pub title: String, + pub cwd: String, + pub engine: &'static str, + /// An earlier copy was reused. + pub reused: bool, +} + +pub fn import(roots: &Roots, pick: &SessionPick) -> Result { + match pick.source.as_str() { + "claude" | "codex" => { + let h = roots.native(); + // An import that writes a copy records it; one that finds a copy does not. + let before = load_registry(&h.registry).len(); + let (engine, done) = if pick.source == "claude" { + ("claude", import_claude(&h, &pick.cwd, &pick.id)?) + } else { + ("codex", import_codex(&h, &pick.cwd, &pick.id)?) + }; + Ok(Imported { + id: done.id, + title: done.title, + cwd: folder_key(&pick.cwd), + engine, + reused: load_registry(&h.registry).len() == before, + }) + } + "opencode" => import_converted(roots, "opencode", &pick.id, load_db(roots, Db::Opencode, &pick.id)?), + "zcode" => import_converted(roots, "zcode", &pick.id, load_db(roots, Db::Zcode, &pick.id)?), + "omp" => import_converted(roots, "omp", &pick.id, load_omp(roots, &pick.id)?), + other => Err(format!("unknown source: {other}")), + } +} + +fn copies_of<'a>(registry: &'a [ImportRecord], source: &str) -> HashSet<&'a str> { + registry.iter().filter(|r| r.source == source).map(|r| r.to.as_str()).collect() +} + +/// Calls `each` with every line of a file that contains one of `needles`, +/// parsed. Lines are searched before they are parsed: session files reach +/// 100 MB and most lines are not wanted. +fn matching_rows(path: &Path, needles: &[&str], mut each: impl FnMut(Value)) { + let Ok(file) = fs::File::open(path) else { return }; + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + loop { + line.clear(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let Ok(text) = std::str::from_utf8(&line) else { continue }; + if !needles.iter().any(|n| text.contains(n)) { + continue; + } + if let Ok(v) = serde_json::from_str::(text) { + each(v); + } + } +} + +// --- Claude Code ------------------------------------------------------------- + +fn scan_claude(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let h = roots.native(); + let copies = copies_of(registry, "claude"); + let mut out = Vec::new(); + let Ok(dirs) = fs::read_dir(h.claude.join("projects")) else { return out }; + for dir in dirs.flatten().map(|e| e.path()).filter(|p| p.is_dir()) { + let Ok(files) = fs::read_dir(&dir) else { continue }; + for path in files.flatten().map(|e| e.path()) { + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Some(id) = path.file_stem().and_then(|s| s.to_str()).filter(|s| is_session_id(s)) else { continue }; + if copies.contains(id) { + continue; + } + // The folder comes from the rows (the directory name is lossy). + let mut cwd: Option = None; + let mut prompts = 0; + matching_rows(&path, &["\"type\":\"user\"", "\"cwd\":"], |v| { + if cwd.is_none() { + cwd = v["cwd"].as_str().map(str::to_string); + } + let tool_result = v.pointer("/message/content").and_then(Value::as_array).is_some_and(|c| { + c.iter().any(|b| b["type"].as_str() == Some("tool_result")) + }); + if v["type"].as_str() == Some("user") && !tool_result && !is_synthetic(&v) && row_text(&v).is_some() { + prompts += 1; + } + }); + let Some(cwd) = cwd.filter(|_| prompts > 0) else { continue }; + // native_import finds the file again by folder; one it cannot find + // (a folder renamed since) is not offered. + if claude_copy_path(&h, &cwd, id) != path { + continue; + } + out.push(Found { + source: "claude".into(), + id: id.into(), + title: preview_of(&path), + imported: existing_copy(registry, "claude", id, |to| claude_copy_path(&h, &cwd, to).exists()).is_some(), + cwd, + updated_at_ms: mtime_ms(&path), + messages: prompts, + }); + } + } + out +} + +// --- Codex ------------------------------------------------------------------- + +fn codex_prompts(path: &Path) -> usize { + let mut n = 0; + matching_rows(path, &["\"user_message\""], |v| { + let p = &v["payload"]; + let text = p["message"].as_str().unwrap_or("").trim(); + if v["type"].as_str() == Some("event_msg") + && p["type"].as_str() == Some("user_message") + && !text.is_empty() + && !text.starts_with('<') + { + n += 1; + } + }); + n +} + +fn scan_codex(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let threads = codex_threads(&roots.codex); + if threads.is_empty() { + return Vec::new(); + } + let copies = copies_of(registry, "codex"); + let names = codex_names(&roots.codex); + let mut out = Vec::new(); + for (id, segs) in &threads { + if copies.contains(id.as_str()) + || !is_conversation(segs) + || segs[0].meta["originator"].as_str() == Some(JUCODE_ORIGINATOR) + { + continue; + } + let Some(cwd) = segs[0].meta["cwd"].as_str() else { continue }; + let live: Vec<_> = segs.iter().filter(|s| !s.archived).collect(); + // A fork's own files: its prompts since the fork. + let prompts = live.iter().map(|s| codex_prompts(&s.path)).sum(); + let title = names + .get(id) + .map(|n| truncate_chars(n, MAX_TITLE_CHARS)) + .unwrap_or_else(|| codex_first_prompt(&segs[0].path)); + if prompts == 0 && title.is_empty() { + continue; + } + out.push(Found { + source: "codex".into(), + id: id.clone(), + title, + cwd: cwd.to_string(), + updated_at_ms: live.iter().map(|s| mtime_ms(&s.path)).max().unwrap_or(0), + messages: prompts, + imported: existing_copy(registry, "codex", id, |to| threads.contains_key(to)).is_some(), + }); + } + out +} + +// --- converted conversations ------------------------------------------------- + +/// One step of a conversation, as a JuCode session entry records it. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Turn { + User(String), + Assistant(String), + Call { call_id: String, name: String, arguments: String }, + Output { call_id: String, name: String, output: String }, +} + +impl Turn { + /// The entry `kind` (JuCode-CLI session.rs `entry_to_json`). + fn kind(&self) -> Value { + match self { + Turn::User(text) => json!({ "type": "user", "content": text }), + Turn::Assistant(text) => json!({ + "type": "response_item", + "item": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": text }] } + }), + Turn::Call { call_id, name, arguments } => json!({ + "type": "response_item", + "item": { "type": "function_call", "call_id": call_id, "name": name, "arguments": arguments } + }), + Turn::Output { call_id, name, output } => json!({ + "type": "tool_output", "call_id": call_id, "name": name, "output": output, "image": null + }), + } + } +} + +/// A conversation read from another agent, ready to write. +pub(super) struct Conversation { + /// Its title there; empty when it had none. + pub title: String, + pub cwd: String, + pub updated_s: u64, + /// Each with the second it happened (0: unknown). + pub turns: Vec<(u64, Turn)>, +} + +/// Longest tool output kept; the model saw the rest once, and a resumed +/// session would carry it in every request. +const MAX_OUTPUT_CHARS: usize = 32_000; +/// The session name the CLI derives from the first prompt (session.rs +/// `SESSION_LABEL_MAX_CHARS`). +const LABEL_CHARS: usize = 72; + +/// A tool-call id the Responses API accepts. +fn clean_call_id(raw: &str) -> String { + let id: String = raw.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')).take(64).collect(); + if id.is_empty() { "call".to_string() } else { id } +} + +/// A function name the APIs accept (`^[A-Za-z0-9_-]{1,64}$`). +fn clean_tool_name(raw: &str) -> String { + let name: String = raw + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '-') { c } else { '_' }) + .take(64) + .collect(); + if name.is_empty() { "tool".to_string() } else { name } +} + +fn clip(text: String) -> String { + match text.char_indices().nth(MAX_OUTPUT_CHARS) { + Some((cut, _)) => format!("{}\n[truncated on import]", &text[..cut]), + None => text, + } +} + +/// Makes the turns a valid request history: ids cleaned and unique, every +/// call followed by its output (a call without one, or an output without its +/// call, is dropped — the API rejects either), empty text gone and an +/// assistant's consecutive texts joined. +pub(super) fn tidy(turns: Vec<(u64, Turn)>) -> Vec<(u64, Turn)> { + let mut out: Vec<(u64, Turn)> = Vec::with_capacity(turns.len()); + let mut used: HashSet = HashSet::new(); + // A call's id as written there → the id it gets here, until answered. + let mut open: HashMap = HashMap::new(); + let mut answered: HashSet = HashSet::new(); + for (at, turn) in turns { + match turn { + Turn::User(text) => { + let text = text.trim(); + if !text.is_empty() { + out.push((at, Turn::User(text.to_string()))); + } + } + Turn::Assistant(text) => { + let text = text.trim(); + if text.is_empty() { + continue; + } + if let Some((_, Turn::Assistant(prev))) = out.last_mut() { + prev.push_str("\n\n"); + prev.push_str(text); + } else { + out.push((at, Turn::Assistant(text.to_string()))); + } + } + Turn::Call { call_id, name, arguments } => { + let base = clean_call_id(&call_id); + let mut id = base.clone(); + let mut n = 2; + while !used.insert(id.clone()) { + id = format!("{base}_{n}"); + n += 1; + } + open.insert(call_id, id.clone()); + out.push((at, Turn::Call { call_id: id, name: clean_tool_name(&name), arguments })); + } + Turn::Output { call_id, name, output } => { + let Some(id) = open.remove(&call_id) else { continue }; + answered.insert(id.clone()); + out.push((at, Turn::Output { call_id: id, name: clean_tool_name(&name), output: clip(output) })); + } + } + } + out.retain(|(_, t)| !matches!(t, Turn::Call { call_id, .. } if !answered.contains(call_id))); + out +} + +fn first_prompt(turns: &[(u64, Turn)]) -> String { + turns + .iter() + .find_map(|(_, t)| match t { + Turn::User(text) => text.lines().map(str::trim).find(|l| !l.is_empty()), + _ => None, + }) + .map(|l| truncate_chars(l, MAX_TITLE_CHARS)) + .unwrap_or_default() +} + +/// The folder as the desktop and the daemon name it: its real path while it +/// exists (projects are added canonicalized), else as recorded. +pub(super) fn folder_key(cwd: &str) -> String { + let trimmed = if cwd.len() > 1 { cwd.trim_end_matches(['/', '\\']) } else { cwd }; + match fs::canonicalize(trimmed) { + Ok(real) => { + let real = real.to_string_lossy().into_owned(); + // Windows canonicalizes to `\\?\C:\...`; sessions are keyed by `C:\...`. + match real.strip_prefix(r"\\?\") { + Some(plain) if !plain.starts_with("UNC\\") => plain.to_string(), + _ => real, + } + } + Err(_) => trimmed.to_string(), + } +} + +/// `sessions/` (session.rs `hash_path`). +pub(super) fn jucode_session_dir(jucode: &Path, cwd: &str) -> PathBuf { + let mut hash = 0xcbf29ce484222325u64; + for byte in cwd.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + jucode.join("sessions").join(format!("{hash:016x}")) +} + +/// session.rs `single_line_with_limit`: what the CLI shows as a name. +fn label_of(text: &str) -> String { + let compact = text.split_whitespace().collect::>().join(" "); + let mut chars = compact.chars(); + let prefix: String = chars.by_ref().take(LABEL_CHARS).collect(); + if chars.next().is_some() { format!("{prefix}...") } else { prefix } +} + +fn write_file(path: &Path, text: &str) -> Result<(), String> { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("session"); + let tmp = path.with_file_name(format!(".{name}.import.tmp")); + fs::write(&tmp, text).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +/// Writes the turns as a new JuCode session in `dir`, the way a first save +/// writes one (entries, then the state); returns its id. The summary goes +/// last: a listing never sees a session without its journal. +pub(super) fn write_session(dir: &Path, title: &str, updated_s: u64, turns: &[(u64, Turn)]) -> Result { + fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + // `s{secs:x}{nanos:08x}` like the CLI's; bumped past any taken one. + let mut nanos = now.subsec_nanos(); + let sid = loop { + let sid = format!("s{:x}{nanos:08x}", now.as_secs()); + if !dir.join(format!("{sid}.json")).exists() && !dir.join(format!("{sid}.jsonl")).exists() { + break sid; + } + nanos = nanos.wrapping_add(1); + }; + let created = turns.iter().map(|(at, _)| *at).find(|at| *at > 0).unwrap_or(updated_s); + let updated = updated_s.max(created); + let mut journal = String::new(); + for (i, (at, turn)) in turns.iter().enumerate() { + let id = i as u64 + 1; + let entry = json!({ + "id": id, + "parent_id": (id > 1).then(|| id - 1), + "created_at": if *at > 0 { *at } else { created }, + "kind": turn.kind(), + }); + journal.push_str(&json!({ "type": "entry", "entry": entry }).to_string()); + journal.push('\n'); + } + let leaf = turns.len() as u64; + let state = json!({ + "version": 1, + "id": sid, + "created_at": created, + "updated_at": updated, + "leaf_id": leaf, + "next_id": leaf + 1, + "branch_heads": { "root": leaf }, + "resume_summary": null, + "resume_status": null, + "resume_summary_updated_at": null, + "goal": null, + }); + journal.push_str(&json!({ "type": "state", "state": state }).to_string()); + journal.push('\n'); + let summary = json!({ + "version": 3, + "id": sid, + "created_at": created, + "updated_at": updated, + "leaf_id": leaf, + "next_id": leaf + 1, + "branch_heads": { "root": leaf }, + "entries_count": turns.len(), + // The daemon lists a session by this; its title there reads better + // than the first prompt the CLI would derive. + "label": label_of(title), + "resume_summary": null, + "resume_status": null, + "resume_summary_updated_at": null, + "goal": null, + "journal": format!("{sid}.jsonl"), + }); + write_file(&dir.join(format!("{sid}.jsonl")), &journal)?; + let text = serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())? + "\n"; + write_file(&dir.join(format!("{sid}.json")), &text)?; + Ok(sid) +} + +fn import_converted(roots: &Roots, source: &str, id: &str, conv: Conversation) -> Result { + let cwd = folder_key(&conv.cwd); + let dir = jucode_session_dir(&roots.jucode, &cwd); + let mut registry = load_registry(&roots.registry()); + let title = if conv.title.is_empty() { first_prompt(&conv.turns) } else { conv.title }; + if let Some(to) = existing_copy(®istry, source, id, |to| dir.join(format!("{to}.json")).exists()) { + return Ok(Imported { id: to, title, cwd, engine: "jucode", reused: true }); + } + let turns = tidy(conv.turns); + if !turns.iter().any(|(_, t)| matches!(t, Turn::User(_))) { + return Err("this conversation has no messages to import".to_string()); + } + let sid = write_session(&dir, &title, conv.updated_s, &turns)?; + registry.push(ImportRecord { source: source.into(), from: id.into(), to: sid.clone(), at_ms: now_ms() }); + save_registry(&roots.registry(), ®istry)?; + Ok(Imported { id: sid, title, cwd, engine: "jucode", reused: false }) +} + +fn converted_imported(roots: &Roots, registry: &[ImportRecord], source: &str, id: &str, cwd: &str) -> bool { + let dir = jucode_session_dir(&roots.jucode, &folder_key(cwd)); + existing_copy(registry, source, id, |to| dir.join(format!("{to}.json")).exists()).is_some() +} + +// --- opencode / zcode -------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Db { + Opencode, + Zcode, +} + +impl Db { + fn source(self) -> &'static str { + match self { + Db::Opencode => "opencode", + Db::Zcode => "zcode", + } + } + + fn path(self, roots: &Roots) -> PathBuf { + match self { + Db::Opencode => roots.opencode_db(), + Db::Zcode => roots.zcode_db(), + } + } +} + +fn open_db(path: &Path) -> Option { + if !path.is_file() { + return None; + } + let conn = + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX).ok()?; + // The app may be writing; wait for it rather than fail. + let _ = conn.busy_timeout(Duration::from_secs(3)); + Some(conn) +} + +fn columns(conn: &Connection, table: &str) -> HashSet { + let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { return HashSet::new() }; + stmt.query_map([], |row| row.get::<_, String>(1)) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default() +} + +/// The app's own title, when it is one: opencode names an untitled session +/// "New session - ", zcode marks a placeholder with `title_source`. +fn real_title(title: &str, title_source: Option<&str>) -> String { + let t = title.trim(); + if t.is_empty() + || title_source == Some("default") + || t.starts_with("New session - ") + || t.starts_with("Child session - ") + { + return String::new(); + } + truncate_chars(t, MAX_TITLE_CHARS) +} + +/// Text the user wrote: not the app's own additions (`synthetic`) or parts +/// it left out (`ignored`). +fn written(part: &Value) -> Option<&str> { + (part["type"].as_str() == Some("text") && part["synthetic"] != json!(true) && part["ignored"] != json!(true)) + .then(|| part["text"].as_str()) + .flatten() + .filter(|t| !t.trim().is_empty()) +} + +struct DbSession { + id: String, + cwd: String, + title: String, + updated_ms: u64, + prompts: usize, +} + +fn db_sessions(conn: &Connection) -> rusqlite::Result> { + let cols = columns(conn, "session"); + // Sub-agent sessions resume only through their parent. + let mut filter = "s.parent_id IS NULL".to_string(); + if cols.contains("task_type") { + filter.push_str(" AND s.task_type = 'interactive'"); + } + let title_source = if cols.contains("title_source") { "s.title_source" } else { "NULL" }; + let sql = format!( + "SELECT s.id, s.directory, s.title, {title_source}, s.time_updated, + (SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id AND json_extract(m.data, '$.role') = 'user' + AND NOT EXISTS (SELECT 1 FROM part p WHERE p.message_id = m.id + AND json_extract(p.data, '$.type') = 'compaction')) + FROM session s WHERE {filter}" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |row| { + let title: String = row.get(2)?; + let title_source: Option = row.get(3)?; + Ok(DbSession { + id: row.get(0)?, + cwd: row.get(1)?, + title: real_title(&title, title_source.as_deref()), + updated_ms: row.get::<_, i64>(4)?.max(0) as u64, + prompts: row.get::<_, i64>(5)?.max(0) as usize, + }) + })?; + rows.collect() +} + +/// The first thing the user wrote in a session (a title for an untitled one). +fn db_first_prompt(conn: &Connection, id: &str) -> String { + let sql = "SELECT p.data FROM part p JOIN message m ON m.id = p.message_id + WHERE m.session_id = ?1 AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + ORDER BY m.time_created, m.id, p.id LIMIT 20"; + let Ok(mut stmt) = conn.prepare(sql) else { return String::new() }; + let Ok(rows) = stmt.query_map(params![id], |row| row.get::<_, String>(0)) else { return String::new() }; + for data in rows.flatten() { + let Ok(part) = serde_json::from_str::(&data) else { continue }; + if let Some(line) = written(&part).and_then(|t| t.lines().map(str::trim).find(|l| !l.is_empty())) { + return truncate_chars(line, MAX_TITLE_CHARS); + } + } + String::new() +} + +fn scan_db(roots: &Roots, db: Db, registry: &[ImportRecord]) -> Vec { + let Some(conn) = open_db(&db.path(roots)) else { return Vec::new() }; + let Ok(list) = db_sessions(&conn) else { return Vec::new() }; + list.into_iter() + .filter(|s| s.prompts > 0) + .map(|s| Found { + source: db.source().into(), + title: if s.title.is_empty() { db_first_prompt(&conn, &s.id) } else { s.title }, + imported: converted_imported(roots, registry, db.source(), &s.id, &s.cwd), + id: s.id, + cwd: s.cwd, + updated_at_ms: s.updated_ms, + messages: s.prompts, + }) + .collect() +} + +fn text_of(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// How an attached file is mentioned in the prompt it came with. +fn file_label(part: &Value) -> String { + let local = part["source"]["path"] + .as_str() + .or_else(|| part["url"].as_str().filter(|u| u.starts_with('/'))) + .or_else(|| part["url"].as_str().and_then(|u| u.strip_prefix("file://"))); + let name = local.or_else(|| part["filename"].as_str()).unwrap_or("attachment"); + format!("[file: {name}]") +} + +/// A tool part's call and its result. +fn db_tool(part: &Value, at: u64) -> [(u64, Turn); 2] { + let call_id = part["callID"].as_str().unwrap_or("").to_string(); + let name = part["tool"].as_str().unwrap_or("tool").to_string(); + let state = &part["state"]; + let arguments = match &state["input"] { + Value::Null => "{}".to_string(), + input => input.to_string(), + }; + let output = match state["status"].as_str() { + Some("completed") => text_of(&state["output"]), + Some("error") => format!("Error: {}", text_of(&state["error"])), + _ => "[the tool did not finish]".to_string(), + }; + [(at, Turn::Call { call_id: call_id.clone(), name: name.clone(), arguments }), (at, Turn::Output { call_id, name, output })] +} + +/// Messages (in order) and their parts as turns. +pub(super) fn convert_db(messages: &[(String, Value)], parts: &HashMap>) -> Vec<(u64, Turn)> { + let none = Vec::new(); + let mut turns = Vec::new(); + for (id, data) in messages { + let parts = parts.get(id).unwrap_or(&none); + // A compaction: its marker, and the summary standing for the history + // before it (opencode: an assistant message with `summary: true`; + // zcode: a user message carrying a compaction part). + if parts.iter().any(|p| p["type"].as_str() == Some("compaction")) || data["summary"] == json!(true) { + continue; + } + let at = data["time"]["created"].as_u64().unwrap_or(0) / 1000; + match data["role"].as_str() { + Some("user") => { + let mut text = Vec::new(); + for p in parts { + if let Some(t) = written(p) { + text.push(t.to_string()); + } else if p["type"].as_str() == Some("file") { + text.push(file_label(p)); + } + } + turns.push((at, Turn::User(text.join("\n\n")))); + } + Some("assistant") => { + for p in parts { + match p["type"].as_str() { + Some("text") => { + if let Some(t) = written(p) { + turns.push((at, Turn::Assistant(t.to_string()))); + } + } + Some("tool") => turns.extend(db_tool(p, at)), + // reasoning, step-start/-finish, patch, snapshot, timeline … + _ => {} + } + } + } + _ => {} + } + } + turns +} + +fn load_db(roots: &Roots, db: Db, id: &str) -> Result { + let conn = open_db(&db.path(roots)).ok_or_else(|| format!("{} has no conversations here", db.source()))?; + let err = |e: rusqlite::Error| format!("cannot read {}: {e}", db.path(roots).display()); + let title_source = if columns(&conn, "session").contains("title_source") { "title_source" } else { "NULL" }; + let head: Option<(String, String, Option, i64)> = conn + .query_row( + &format!("SELECT directory, title, {title_source}, time_updated FROM session WHERE id = ?1"), + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional() + .map_err(err)?; + let (cwd, title, title_source, updated_ms) = + head.ok_or_else(|| format!("{} conversation {id} not found", db.source()))?; + // zcode numbers messages and parts; opencode's ids sort by time. + let order = |table: &str, by: &str| { + if columns(&conn, table).contains("sequence") { format!("{by}sequence, time_created, id") } else { format!("{by}time_created, id") } + }; + let mut messages = Vec::new(); + { + let mut stmt = conn + .prepare(&format!("SELECT id, data FROM message WHERE session_id = ?1 ORDER BY {}", order("message", ""))) + .map_err(err)?; + let rows = stmt.query_map(params![id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))).map_err(err)?; + for row in rows { + let (mid, data) = row.map_err(err)?; + messages.push((mid, serde_json::from_str::(&data).unwrap_or(Value::Null))); + } + } + let mut parts: HashMap> = HashMap::new(); + { + let part_order = if columns(&conn, "part").contains("sequence") { "message_id, sequence, id" } else { "message_id, id" }; + let mut stmt = conn + .prepare(&format!("SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY {part_order}")) + .map_err(err)?; + let rows = stmt.query_map(params![id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))).map_err(err)?; + for row in rows { + let (mid, data) = row.map_err(err)?; + if let Ok(v) = serde_json::from_str::(&data) { + parts.entry(mid).or_default().push(v); + } + } + } + Ok(Conversation { + title: real_title(&title, title_source.as_deref()), + cwd, + updated_s: updated_ms.max(0) as u64 / 1000, + turns: convert_db(&messages, &parts), + }) +} + +// --- omp --------------------------------------------------------------------- + +struct OmpFile { + id: String, + cwd: String, + title: String, + entries: Vec, +} + +fn omp_files(roots: &Roots) -> Vec { + let mut out = Vec::new(); + let Ok(dirs) = fs::read_dir(roots.omp_agent().join("sessions")) else { return out }; + for dir in dirs.flatten().map(|e| e.path()).filter(|p| p.is_dir()) { + let Ok(files) = fs::read_dir(&dir) else { continue }; + out.extend( + files + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("jsonl")), + ); + } + out +} + +/// A session file: an optional fixed-width `title` slot, the `session` +/// header, then the entries. +fn read_omp(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let mut slot_title = String::new(); + let mut header: Option = None; + let mut entries = Vec::new(); + for line in text.lines() { + let Ok(v) = serde_json::from_str::(line) else { continue }; + match (&header, v["type"].as_str()) { + (None, Some("title")) => slot_title = v["title"].as_str().unwrap_or("").to_string(), + (None, Some("session")) => header = Some(v), + (Some(_), _) => entries.push(v), + _ => {} + } + } + let header = header?; + // A sub-agent's or a fork's session names the one it came from; the + // conversation itself is the parent's. + if header["parentSession"].as_str().is_some_and(|p| !p.is_empty()) { + return None; + } + let title = Some(slot_title.trim()) + .filter(|t| !t.is_empty()) + .or_else(|| header["title"].as_str().map(str::trim)) + .map(|t| truncate_chars(t, MAX_TITLE_CHARS)) + .unwrap_or_default(); + Some(OmpFile { id: header["id"].as_str()?.to_string(), cwd: header["cwd"].as_str()?.to_string(), title, entries }) +} + +/// The active branch: from the last entry back to the root (omp rebuilds +/// its leaf from the last physical entry). +fn omp_branch(entries: &[Value]) -> Vec<&Value> { + let by_id: HashMap<&str, &Value> = entries.iter().filter_map(|e| Some((e["id"].as_str()?, e))).collect(); + let mut path = Vec::new(); + let mut seen = HashSet::new(); + let mut current = entries.iter().rev().find_map(|e| e["id"].as_str()); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + let Some(entry) = by_id.get(id) else { break }; + path.push(*entry); + current = entry["parentId"].as_str(); + } + path.reverse(); + path +} + +/// Text of a message's content (a string, or text and image blocks). +fn omp_text(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(blocks) => blocks + .iter() + .filter_map(|b| match b["type"].as_str() { + Some("text") => b["text"].as_str().map(str::to_string), + Some("image") => Some("[image]".to_string()), + _ => None, + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub(super) fn convert_omp(entries: &[Value]) -> Vec<(u64, Turn)> { + let mut turns = Vec::new(); + for entry in omp_branch(entries) { + // compaction, branch_summary, model_change, custom … carry no turns. + if entry["type"].as_str() != Some("message") { + continue; + } + let m = &entry["message"]; + let at = m["timestamp"].as_u64().unwrap_or(0) / 1000; + match m["role"].as_str() { + Some("user") if m["synthetic"] != json!(true) => turns.push((at, Turn::User(omp_text(&m["content"])))), + Some("assistant") => { + for block in m["content"].as_array().into_iter().flatten() { + match block["type"].as_str() { + Some("text") => { + turns.push((at, Turn::Assistant(block["text"].as_str().unwrap_or("").to_string()))) + } + Some("toolCall") => turns.push(( + at, + Turn::Call { + call_id: block["id"].as_str().unwrap_or("").to_string(), + name: block["name"].as_str().unwrap_or("tool").to_string(), + arguments: match &block["arguments"] { + Value::Null => "{}".to_string(), + Value::String(s) => s.clone(), + args => args.to_string(), + }, + }, + )), + // thinking, redactedThinking + _ => {} + } + } + } + Some("toolResult") => { + let text = omp_text(&m["content"]); + turns.push(( + at, + Turn::Output { + call_id: m["toolCallId"].as_str().unwrap_or("").to_string(), + name: m["toolName"].as_str().unwrap_or("tool").to_string(), + output: if m["isError"] == json!(true) { format!("Error: {text}") } else { text }, + }, + )) + } + // developer, bashExecution, fileMention … + _ => {} + } + } + turns +} + +fn scan_omp(roots: &Roots, registry: &[ImportRecord]) -> Vec { + let mut out = Vec::new(); + for path in omp_files(roots) { + let Some(file) = read_omp(&path) else { continue }; + let turns = convert_omp(&file.entries); + let prompts = turns.iter().filter(|(_, t)| matches!(t, Turn::User(s) if !s.trim().is_empty())).count(); + if prompts == 0 { + continue; + } + out.push(Found { + source: "omp".into(), + title: if file.title.is_empty() { first_prompt(&turns) } else { file.title }, + imported: converted_imported(roots, registry, "omp", &file.id, &file.cwd), + id: file.id, + cwd: file.cwd, + updated_at_ms: mtime_ms(&path), + messages: prompts, + }); + } + out +} + +fn load_omp(roots: &Roots, id: &str) -> Result { + for path in omp_files(roots) { + let Some(file) = read_omp(&path) else { continue }; + if file.id != id { + continue; + } + return Ok(Conversation { + title: file.title, + cwd: file.cwd, + updated_s: mtime_ms(&path) / 1000, + turns: convert_omp(&file.entries), + }); + } + Err(format!("omp conversation {id} not found")) +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + fn call(id: &str) -> Turn { + Turn::Call { call_id: id.into(), name: "bash".into(), arguments: "{}".into() } + } + fn output(id: &str) -> Turn { + Turn::Output { call_id: id.into(), name: "bash".into(), output: "ok".into() } + } + + #[test] + fn tidy_pairs_calls_and_cleans_ids() { + let turns = vec![ + (1, Turn::User(" hi ".into())), + (1, Turn::Assistant("one".into())), + (1, Turn::Assistant("two".into())), + (2, call("call:1/x")), + (2, output("call:1/x")), + (3, call("lost")), + (3, output("never-called")), + (4, call("dup")), + (4, output("dup")), + (4, call("dup")), + (4, output("dup")), + (5, Turn::Assistant(" ".into())), + ]; + let got = tidy(turns); + assert_eq!( + got.iter().map(|(_, t)| t.clone()).collect::>(), + vec![ + Turn::User("hi".into()), + Turn::Assistant("one\n\ntwo".into()), + call("call1x"), + output("call1x"), + call("dup"), + output("dup"), + call("dup_2"), + output("dup_2"), + ] + ); + let long = tidy(vec![(0, call("a")), (0, Turn::Output { call_id: "a".into(), name: "mcp.x y".into(), output: "x".repeat(MAX_OUTPUT_CHARS + 5) })]); + match &long[1].1 { + Turn::Output { name, output, .. } => { + assert_eq!(name, "mcp_x_y"); + assert!(output.ends_with("[truncated on import]")); + } + other => panic!("{other:?}"), + } + } + + fn opencode_db(path: &Path, zcode: bool) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let conn = Connection::open(path).unwrap(); + let extra = if zcode { ", task_type text not null default 'interactive', title_source text" } else { "" }; + let seq = if zcode { ", sequence integer" } else { "" }; + conn.execute_batch(&format!( + "CREATE TABLE session (id text primary key, project_id text, parent_id text, directory text not null, + title text not null, time_created integer not null, time_updated integer not null{extra}); + CREATE TABLE message (id text primary key, session_id text not null, time_created integer not null, + time_updated integer not null, data text not null{seq}); + CREATE TABLE part (id text primary key, message_id text not null, session_id text not null, + time_created integer not null, time_updated integer not null, data text not null{seq});" + )) + .unwrap(); + let session = |id: &str, parent: Option<&str>, title: &str| { + conn.execute( + "INSERT INTO session (id, project_id, parent_id, directory, title, time_created, time_updated) VALUES (?1, 'p', ?2, '/proj/oc', ?3, 1000, 1790000000000)", + params![id, parent, title], + ) + .unwrap(); + }; + session("ses_main", None, "New session - 2026-08-18T10:34:46.692Z"); + session("ses_child", Some("ses_main"), "child"); + let message = |id: &str, session: &str, at: i64, data: Value| { + conn.execute( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?3, ?4)", + params![id, session, at, data.to_string()], + ) + .unwrap(); + }; + let part = |id: &str, msg: &str, data: Value| { + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, 'ses_main', 0, 0, ?3)", + params![id, msg, data.to_string()], + ) + .unwrap(); + }; + message("msg_1", "ses_main", 1_790_000_000_000, json!({"role": "user", "time": {"created": 1_790_000_000_000u64}})); + part("prt_1", "msg_1", json!({"type": "text", "text": "fix the bug"})); + part("prt_2", "msg_1", json!({"type": "text", "text": "Called the Read tool", "synthetic": true})); + part("prt_3", "msg_1", json!({"type": "file", "filename": "a.png", "url": "data:image/png;base64,xx"})); + message("msg_2", "ses_main", 1_790_000_001_000, json!({"role": "assistant", "time": {"created": 1_790_000_001_000u64}})); + part("prt_4", "msg_2", json!({"type": "step-start"})); + part("prt_5", "msg_2", json!({"type": "reasoning", "text": "hmm"})); + part("prt_6", "msg_2", json!({"type": "tool", "callID": "call-1", "tool": "bash", "state": {"status": "completed", "input": {"command": "ls"}, "output": "a.txt"}})); + part("prt_7", "msg_2", json!({"type": "tool", "callID": "call-2", "tool": "read", "state": {"status": "error", "input": {"filePath": "x"}, "error": "no such file"}})); + part("prt_8", "msg_2", json!({"type": "step-finish"})); + message("msg_3", "ses_main", 1_790_000_002_000, json!({"role": "assistant", "time": {"created": 1_790_000_002_000u64}})); + part("prt_9", "msg_3", json!({"type": "text", "text": "Done."})); + // A compaction: the trigger and its summary are left out. + message("msg_4", "ses_main", 1_790_000_003_000, json!({"role": "user", "time": {"created": 1_790_000_003_000u64}})); + part("prt_10", "msg_4", json!({"type": "compaction", "auto": true})); + message("msg_5", "ses_main", 1_790_000_004_000, json!({"role": "assistant", "summary": true, "time": {"created": 1_790_000_004_000u64}})); + part("prt_11", "msg_5", json!({"type": "text", "text": "Summary of everything"})); + message("msg_6", "ses_main", 1_790_000_005_000, json!({"role": "user", "time": {"created": 1_790_000_005_000u64}})); + part("prt_12", "msg_6", json!({"type": "text", "text": "thanks"})); + message("msg_c", "ses_child", 1_790_000_000_000, json!({"role": "user"})); + } + + #[test] + fn opencode_converts_and_reimport_reuses() { + let roots = testutil::roots("opencode"); + opencode_db(&roots.opencode_db(), false); + let found = scan(&roots); + assert_eq!(found.len(), 1, "the sub-agent session is not offered"); + let s = &found[0]; + assert_eq!((s.source.as_str(), s.id.as_str(), s.messages), ("opencode", "ses_main", 2)); + assert_eq!(s.title, "fix the bug", "the placeholder title gives way to the first prompt"); + assert!(!s.imported); + + let pick = SessionPick { source: "opencode".into(), id: "ses_main".into(), cwd: String::new() }; + let done = import(&roots, &pick).unwrap(); + assert!(!done.reused && done.engine == "jucode" && done.cwd == "/proj/oc"); + let dir = jucode_session_dir(&roots.jucode, "/proj/oc"); + let lines: Vec = fs::read_to_string(dir.join(format!("{}.jsonl", done.id))) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let kinds: Vec = lines.iter().filter(|l| l["type"] == "entry").map(|l| l["entry"]["kind"].clone()).collect(); + assert_eq!( + kinds, + vec![ + json!({"type": "user", "content": "fix the bug\n\n[file: a.png]"}), + json!({"type": "response_item", "item": {"type": "function_call", "call_id": "call-1", "name": "bash", "arguments": "{\"command\":\"ls\"}"}}), + json!({"type": "tool_output", "call_id": "call-1", "name": "bash", "output": "a.txt", "image": null}), + json!({"type": "response_item", "item": {"type": "function_call", "call_id": "call-2", "name": "read", "arguments": "{\"filePath\":\"x\"}"}}), + json!({"type": "tool_output", "call_id": "call-2", "name": "read", "output": "Error: no such file", "image": null}), + json!({"type": "response_item", "item": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Done."}]}}), + json!({"type": "user", "content": "thanks"}), + ] + ); + // Entries chain one after another; the state closes the journal. + let entries: Vec<&Value> = lines.iter().filter(|l| l["type"] == "entry").map(|l| &l["entry"]).collect(); + assert_eq!(entries[0]["parent_id"], Value::Null); + assert!(entries.windows(2).all(|w| w[1]["parent_id"] == w[0]["id"])); + assert_eq!(entries[0]["created_at"], 1_790_000_000u64); + let state = &lines.last().unwrap()["state"]; + assert_eq!(lines.last().unwrap()["type"], "state"); + assert_eq!((state["leaf_id"].as_u64(), state["next_id"].as_u64()), (Some(7), Some(8))); + assert_eq!(state["branch_heads"], json!({"root": 7})); + let summary: Value = serde_json::from_str(&fs::read_to_string(dir.join(format!("{}.json", done.id))).unwrap()).unwrap(); + assert_eq!(summary["version"], 3); + assert_eq!(summary["entries_count"], 7); + assert_eq!(summary["label"], "fix the bug"); + assert_eq!(summary["journal"], format!("{}.jsonl", done.id)); + assert_eq!(summary["updated_at"], 1_790_000_000u64); + assert!(done.id.starts_with('s') && done.id.len() > 9); + + let again = import(&roots, &pick).unwrap(); + assert!(again.reused); + assert_eq!(again.id, done.id); + assert!(scan(&roots)[0].imported); + assert!(import(&roots, &SessionPick { source: "opencode".into(), id: "nope".into(), cwd: String::new() }).is_err()); + } + + #[test] + fn zcode_keeps_interactive_sessions_in_sequence() { + let roots = testutil::roots("zcode"); + let path = roots.zcode_db(); + opencode_db(&path, true); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "UPDATE session SET title = 'Named', title_source = 'generated' WHERE id = 'ses_main'; + INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, task_type) + VALUES ('sess_sub', NULL, '/proj/oc', 'sub', 0, 0, 'subagent_child'); + UPDATE message SET sequence = CAST(substr(id, 5) AS integer); + UPDATE message SET sequence = 0 WHERE id = 'msg_6';", + ) + .unwrap(); + drop(conn); + let found = scan(&roots); + assert_eq!(found.iter().map(|s| s.id.as_str()).collect::>(), ["ses_main"]); + assert_eq!(found[0].title, "Named"); + let conv = load_db(&roots, Db::Zcode, "ses_main").unwrap(); + assert_eq!(conv.turns.first().map(|(_, t)| t.clone()), Some(Turn::User("thanks".into())), "zcode's sequence wins over time"); + } + + #[test] + fn omp_follows_the_active_branch() { + let roots = testutil::roots("omp"); + let cwd = roots.home.join("work"); + fs::create_dir_all(&cwd).unwrap(); + let cwd = cwd.to_string_lossy().to_string(); + let rows = [ + json!({"type": "title", "v": 1, "title": "Port the parser", "updatedAt": "x", "pad": " "}), + json!({"type": "session", "version": 3, "id": "019c625b-b900-7000-8000-000000000001", "cwd": cwd}), + json!({"type": "model_change", "id": "m0", "parentId": null, "model": "anthropic/x"}), + json!({"type": "message", "id": "a1", "parentId": "m0", "message": {"role": "user", "content": "port it", "timestamp": 1_790_000_000_000u64}}), + json!({"type": "message", "id": "a2", "parentId": "a1", "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hm", "thinkingSignature": "sig"}, + {"type": "text", "text": "Reading."}, + {"type": "toolCall", "id": "toolu_01", "name": "read", "arguments": {"path": "a.rs"}}]}}), + json!({"type": "message", "id": "a3", "parentId": "a2", "message": {"role": "toolResult", "toolCallId": "toolu_01", "toolName": "read", "content": [{"type": "text", "text": "fn main() {}"}], "isError": false}}), + json!({"type": "message", "id": "b1", "parentId": "a3", "message": {"role": "user", "content": [{"type": "text", "text": "abandoned"}]}}), + json!({"type": "compaction", "id": "a4", "parentId": "a3", "summary": "x", "firstKeptEntryId": "a1"}), + json!({"type": "message", "id": "a5", "parentId": "a4", "message": {"role": "user", "content": [{"type": "text", "text": "now test it"}, {"type": "image", "data": "..", "mimeType": "image/png"}]}}), + ]; + let text: Vec = rows.iter().map(|r| r.to_string()).collect(); + testutil::write(&roots.omp_agent().join("sessions/-work/2026_019c.jsonl"), &(text.join("\n") + "\n")); + // A sub-agent's file is not a conversation of its own. + testutil::write( + &roots.omp_agent().join("sessions/-work/2026_sub.jsonl"), + &format!("{}\n", json!({"type": "session", "id": "sub", "cwd": cwd, "parentSession": "019c625b"})), + ); + let found = scan(&roots); + assert_eq!(found.len(), 1); + assert_eq!((found[0].title.as_str(), found[0].messages), ("Port the parser", 2)); + let conv = load_omp(&roots, "019c625b-b900-7000-8000-000000000001").unwrap(); + let turns: Vec = tidy(conv.turns).into_iter().map(|(_, t)| t).collect(); + assert_eq!( + turns, + vec![ + Turn::User("port it".into()), + Turn::Assistant("Reading.".into()), + Turn::Call { call_id: "toolu_01".into(), name: "read".into(), arguments: "{\"path\":\"a.rs\"}".into() }, + Turn::Output { call_id: "toolu_01".into(), name: "read".into(), output: "fn main() {}".into() }, + Turn::User("now test it\n[image]".into()), + ] + ); + let pick = SessionPick { source: "omp".into(), id: found[0].id.clone(), cwd: String::new() }; + let done = import(&roots, &pick).unwrap(); + assert_eq!(done.cwd, folder_key(&cwd)); + assert!(jucode_session_dir(&roots.jucode, &done.cwd).join(format!("{}.json", done.id)).exists()); + } + + #[test] + fn claude_sessions_are_found_by_the_folder_in_their_rows() { + let roots = testutil::roots("claude-scan"); + let cwd = "/proj/claude demo"; + let id = "aaaaaaaa-1111-4111-8111-111111111111"; + let path = claude_copy_path(&roots.native(), cwd, id); + let rows = [ + json!({"type": "queue-operation", "content": "x"}), + json!({"type": "user", "cwd": cwd, "uuid": "u1", "message": {"role": "user", "content": "hello there"}}), + json!({"type": "assistant", "cwd": cwd, "uuid": "a1", "message": {"content": [{"type": "tool_use", "id": "t", "name": "Bash", "input": {}}]}}), + json!({"type": "user", "cwd": cwd, "uuid": "u2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}}), + json!({"type": "user", "cwd": cwd, "isMeta": true, "message": {"role": "user", "content": ""}}), + json!({"type": "user", "cwd": cwd, "uuid": "u3", "message": {"role": "user", "content": [{"type": "text", "text": "and again"}]}}), + ]; + let text: Vec = rows.iter().map(|r| r.to_string()).collect(); + testutil::write(&path, &text.join("\n")); + let found = scan(&roots); + assert_eq!(found.len(), 1); + assert_eq!((found[0].cwd.as_str(), found[0].messages, found[0].title.as_str()), (cwd, 2, "hello there")); + let done = import(&roots, &SessionPick { source: "claude".into(), id: id.into(), cwd: cwd.into() }).unwrap(); + assert!(!done.reused && done.engine == "claude"); + let found = scan(&roots); + assert_eq!(found.len(), 1, "the copy is not offered"); + assert!(found[0].imported); + assert!(import(&roots, &SessionPick { source: "claude".into(), id: id.into(), cwd: cwd.into() }).unwrap().reused); + } + + #[test] + fn session_dir_hash_matches_the_cli() { + // A folder `jucode` saved sessions for (seen under ~/.jucode/sessions). + assert_eq!( + jucode_session_dir(Path::new("/x"), "/Users/chad/Documents/JuCode"), + Path::new("/x/sessions/16b727a85fdb3de5") + ); + assert_eq!(label_of(" a\n b "), "a b"); + assert_eq!(label_of(&"x".repeat(80)), format!("{}...", "x".repeat(72))); + } +} diff --git a/src-tauri/src/agent_import/skills.rs b/src-tauri/src/agent_import/skills.rs new file mode 100644 index 0000000..4330786 --- /dev/null +++ b/src-tauri/src/agent_import/skills.rs @@ -0,0 +1,232 @@ +//! Skills of the other agents: folders with a SKILL.md, in each tool's +//! skills directory and in the plugins it has installed. Importing copies +//! the folder into `~/.jucode/skills`, where JuCode loads skills from; a +//! skill JuCode already has by that name (there or in `~/.agents/skills`) +//! is left alone. + +use super::{plugins, Roots}; +use serde::Serialize; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Serialize, Debug, Clone)] +pub struct Found { + pub source: String, + pub name: String, + pub description: String, + /// The skill's folder. + pub path: String, + /// The plugin it comes with, if any. + pub plugin: Option, + /// JuCode already has a skill of this name. + pub present: bool, +} + +/// How deep skills may sit under a skills folder (JuCode descends into +/// folders without a SKILL.md, as a skill pack lays them out). +const MAX_DEPTH: usize = 3; +/// Folder depth a copy follows (symlinks can loop). +const MAX_COPY_DEPTH: usize = 16; + +struct Skill { + name: String, + description: String, + dir: PathBuf, +} + +/// `key: value` from a SKILL.md's front matter (a folded `>` / `|` value +/// reads as its lines joined). +fn front_matter(text: &str, key: &str) -> Option { + let body = text.strip_prefix("---")?; + let end = body.find("\n---")?; + let mut lines = body[..end].lines(); + while let Some(line) = lines.next() { + let Some(value) = line.strip_prefix(key).and_then(|rest| rest.trim_start().strip_prefix(':')) else { continue }; + let value = value.trim(); + let value = if matches!(value, "|" | ">" | "|-" | ">-" | "") { + lines + .by_ref() + .take_while(|l| l.starts_with(' ') || l.starts_with('\t')) + .map(str::trim) + .collect::>() + .join(" ") + } else { + value.trim_matches(|c| c == '"' || c == '\'').to_string() + }; + return Some(value).filter(|v| !v.is_empty()); + } + None +} + +fn find_skills(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(read) = fs::read_dir(dir) else { return }; + let mut entries: Vec = read.flatten().map(|e| e.path()).collect(); + entries.sort(); + for path in entries { + let hidden = path.file_name().and_then(|n| n.to_str()).is_none_or(|n| n.starts_with('.')); + // Hidden folders hold a tool's own (Codex's `.system`). + if hidden || !path.is_dir() { + continue; + } + let file = path.join("SKILL.md"); + match fs::read_to_string(&file) { + Ok(text) => { + let folder = path.file_name().and_then(|n| n.to_str()).unwrap_or("skill").to_string(); + let description = front_matter(&text, "description").unwrap_or_default(); + let name = front_matter(&text, "name").unwrap_or(folder); + out.push(Skill { name, description, dir: path }); + } + Err(_) if depth < MAX_DEPTH => find_skills(&path, depth + 1, out), + Err(_) => {} + } + } +} + +/// Names of the skills JuCode loads for every project. +fn installed_names(roots: &Roots) -> HashSet { + let mut skills = Vec::new(); + find_skills(&roots.jucode.join("skills"), 0, &mut skills); + find_skills(&roots.home.join(".agents").join("skills"), 0, &mut skills); + skills.into_iter().map(|s| s.name).collect() +} + +pub fn scan(roots: &Roots) -> Vec { + let mut places: Vec<(&'static str, PathBuf, Option)> = vec![ + ("claude", roots.claude.join("skills"), None), + ("codex", roots.codex.join("skills"), None), + ("opencode", roots.config_home.join("opencode").join("skills"), None), + ("opencode", roots.config_home.join("opencode").join("skill"), None), + ("zcode", roots.home.join(".zcode").join("skills"), None), + ("omp", roots.omp_agent().join("skills"), None), + ]; + places.extend(plugins(roots).into_iter().map(|p| (p.source, p.dir.join("skills"), Some(p.name)))); + let installed = installed_names(roots); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for (source, dir, plugin) in places { + let mut skills = Vec::new(); + find_skills(&dir, 0, &mut skills); + for skill in skills { + let path = skill.dir.display().to_string(); + if !seen.insert((source, path.clone())) { + continue; + } + out.push(Found { + source: source.to_string(), + present: installed.contains(&skill.name) || dest(roots, &skill.dir).is_some_and(|d| d.exists()), + name: skill.name, + description: skill.description, + path, + plugin: plugin.clone(), + }); + } + } + out +} + +/// Where a skill is copied to: its folder name under `~/.jucode/skills`. +fn dest(roots: &Roots, dir: &Path) -> Option { + let folder = dir.file_name()?.to_str()?; + (!folder.starts_with('.')).then(|| roots.jucode.join("skills").join(folder)) +} + +fn copy_dir(src: &Path, dst: &Path, depth: usize) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?; + if depth >= MAX_COPY_DEPTH { + return Ok(()); + } + let read = fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))?; + for entry in read.flatten() { + let name = entry.file_name(); + if matches!(name.to_str(), Some(".git" | "node_modules" | ".DS_Store")) { + continue; + } + let from = entry.path(); + let to = dst.join(&name); + // Follows symlinks (zcode links skills in from Codex); a broken one is skipped. + let Ok(meta) = fs::metadata(&from) else { continue }; + if meta.is_dir() { + copy_dir(&from, &to, depth + 1)?; + } else if meta.is_file() { + fs::copy(&from, &to).map_err(|e| format!("cannot copy {}: {e}", from.display()))?; + } + } + Ok(()) +} + +/// Copies one scanned skill; `Ok((false, …))` when JuCode already has it. +pub fn import(roots: &Roots, found: &[Found], source: &str, path: &str) -> Result<(bool, PathBuf), String> { + // Only what the scan offered: the path comes back from the page. + let skill = found + .iter() + .find(|s| s.source == source && s.path == path) + .ok_or_else(|| format!("skill not found: {path}"))?; + let src = Path::new(&skill.path); + let dest = dest(roots, src).ok_or_else(|| format!("not a skill folder: {path}"))?; + if dest.exists() || installed_names(roots).contains(&skill.name) { + return Ok((false, dest)); + } + let parent = dest.parent().unwrap_or(&roots.jucode); + let tmp = parent.join(format!(".import-{}.tmp", dest.file_name().and_then(|n| n.to_str()).unwrap_or("skill"))); + let _ = fs::remove_dir_all(&tmp); + copy_dir(src, &tmp, 0).inspect_err(|_| { + let _ = fs::remove_dir_all(&tmp); + })?; + fs::rename(&tmp, &dest).map_err(|e| format!("cannot write {}: {e}", dest.display()))?; + Ok((true, dest)) +} + +#[cfg(test)] +mod tests { + use super::super::testutil; + use super::*; + + fn skill(dir: &Path, name: &str, desc: &str) { + testutil::write(&dir.join("SKILL.md"), &format!("---\nname: {name}\ndescription: \"{desc}\"\n---\n\nBody\n")); + } + + #[test] + fn finds_skills_and_plugin_skills_and_copies_them() { + let roots = testutil::roots("skills"); + skill(&roots.claude.join("skills/review"), "review", "Review code"); + testutil::write(&roots.claude.join("skills/review/scripts/run.sh"), "echo hi"); + testutil::write(&roots.claude.join("skills/review/node_modules/x/index.js"), "x"); + skill(&roots.codex.join("skills/.system/imagegen"), "imagegen", "Codex's own"); + skill(&roots.codex.join("skills/pack/inner"), "inner-skill", "Nested in a pack"); + // A zcode plugin, two versions: the newer one counts. + let cache = roots.zcode_plugins().join("official/docs"); + skill(&cache.join("0.1.9/skills/docx"), "docx-old", "old"); + skill(&cache.join("0.1.10/skills/docx"), "docx", "new"); + // JuCode already has one by that name. + skill(&roots.home.join(".agents/skills/inner"), "inner-skill", "mine"); + + let found = scan(&roots); + let names: Vec<(&str, &str, Option<&str>, bool)> = + found.iter().map(|s| (s.source.as_str(), s.name.as_str(), s.plugin.as_deref(), s.present)).collect(); + assert_eq!( + names, + [("claude", "review", None, false), ("codex", "inner-skill", None, true), ("zcode", "docx", Some("docs"), false)] + ); + assert_eq!(found[0].description, "Review code"); + + let review = &found[0]; + let (copied, dest) = import(&roots, &found, "claude", &review.path).unwrap(); + assert!(copied); + assert_eq!(dest, roots.jucode.join("skills/review")); + assert_eq!(fs::read_to_string(dest.join("scripts/run.sh")).unwrap(), "echo hi"); + assert!(!dest.join("node_modules").exists()); + assert!(!import(&roots, &found, "claude", &review.path).unwrap().0, "a second import leaves it"); + assert!(!import(&roots, &found, "codex", &found[1].path).unwrap().0, "a name JuCode has is skipped"); + assert!(import(&roots, &found, "claude", "/etc").is_err(), "only scanned skills are copied"); + assert!(scan(&roots)[0].present); + } + + #[test] + fn front_matter_reads_quoted_and_folded_values() { + let text = "---\nname: 'x'\ndescription: >\n first line\n second\nother: y\n---\nbody"; + assert_eq!(front_matter(text, "name").as_deref(), Some("x")); + assert_eq!(front_matter(text, "description").as_deref(), Some("first line second")); + assert_eq!(front_matter("no front matter", "name"), None); + } +} diff --git a/src-tauri/src/claude_history.rs b/src-tauri/src/claude_history.rs index b8af149..e85f709 100644 --- a/src-tauri/src/claude_history.rs +++ b/src-tauri/src/claude_history.rs @@ -84,7 +84,7 @@ pub(crate) fn truncate_chars(s: &str, max: usize) -> String { /// Rows Claude injects that are not conversation content: subagent streams, /// meta caveats and the post-compaction continuation summary. -fn is_synthetic(v: &serde_json::Value) -> bool { +pub(crate) fn is_synthetic(v: &serde_json::Value) -> bool { v["isSidechain"].as_bool() == Some(true) || v["isMeta"].as_bool() == Some(true) || v["isCompactSummary"].as_bool() == Some(true) @@ -94,7 +94,7 @@ fn is_synthetic(v: &serde_json::Value) -> bool { /// Extracts the user/assistant text of a session-file row, or None. /// String contents starting with `<` are slash-command echoes /// (`…`, `…`) — never conversation text. -fn row_text(v: &serde_json::Value) -> Option { +pub(crate) fn row_text(v: &serde_json::Value) -> Option { let content = &v["message"]["content"]; if let Some(s) = content.as_str() { let t = s.trim(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9025ec..212c854 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, Manager}; mod acp_registry; +mod agent_import; mod app_cli; #[cfg(desktop)] mod app_update; @@ -3444,6 +3445,8 @@ pub fn run() { #[cfg(desktop)] app_update::update_policy, native_import::import_native_session, + agent_import::import_scan, + agent_import::import_apply, pty_open, pty_write, pty_resize, diff --git a/src-tauri/src/native_import.rs b/src-tauri/src/native_import.rs index 75dfc71..9c9fdd1 100644 --- a/src-tauri/src/native_import.rs +++ b/src-tauri/src/native_import.rs @@ -59,11 +59,11 @@ pub struct ImportedSession { } #[derive(Serialize, Deserialize, Clone, Debug)] -struct ImportRecord { - source: String, - from: String, - to: String, - at_ms: u64, +pub(crate) struct ImportRecord { + pub source: String, + pub from: String, + pub to: String, + pub at_ms: u64, } /// Where each engine keeps its sessions and where imports are recorded @@ -74,7 +74,7 @@ pub struct Homes { pub registry: PathBuf, } -fn homes() -> Homes { +pub(crate) fn homes() -> Homes { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) @@ -89,21 +89,21 @@ fn homes() -> Homes { } const MAX_LISTED: usize = 50; -const MAX_TITLE_CHARS: usize = 80; +pub(crate) const MAX_TITLE_CHARS: usize = 80; /// Bytes scanned for a Codex title when the thread has no name. const MAX_TITLE_SCAN: u64 = 1024 * 1024; /// Files in a `history_base` chain before giving up (a cycle guard; real /// threads reach a handful). const MAX_CHAIN: usize = 256; -fn now_ms() -> u64 { +pub(crate) fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } -fn mtime_ms(path: &Path) -> u64 { +pub(crate) fn mtime_ms(path: &Path) -> u64 { fs::metadata(path) .and_then(|m| m.modified()) .ok() @@ -161,14 +161,14 @@ fn utc_parts(ms: u64) -> (i64, u32, u32, u32, u32, u32) { // --- the import registry ----------------------------------------------------- -fn load_registry(path: &Path) -> Vec { +pub(crate) fn load_registry(path: &Path) -> Vec { fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } -fn save_registry(path: &Path, records: &[ImportRecord]) -> Result<(), String> { +pub(crate) fn save_registry(path: &Path, records: &[ImportRecord]) -> Result<(), String> { if let Some(dir) = path.parent() { fs::create_dir_all(dir).map_err(|e| e.to_string())?; } @@ -184,7 +184,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { // --- Claude Code ------------------------------------------------------------- -fn claude_copy_path(h: &Homes, cwd: &str, id: &str) -> PathBuf { +pub(crate) fn claude_copy_path(h: &Homes, cwd: &str, id: &str) -> PathBuf { project_dir(&h.claude, cwd).join(format!("{id}.jsonl")) } @@ -208,7 +208,7 @@ fn list_claude(h: &Homes, cwd: &str) -> Result, String> { .collect()) } -fn existing_copy( +pub(crate) fn existing_copy( registry: &[ImportRecord], source: &str, from: &str, @@ -288,7 +288,7 @@ fn json_lines(path: &Path) -> Result> })) } -fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result { +pub(crate) fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result { if !is_session_id(id) { return Err(format!("invalid session id: {id}")); } @@ -316,14 +316,14 @@ fn import_claude(h: &Homes, cwd: &str, id: &str) -> Result` suffix for the others. file_id: String, /// Under `archived_sessions`: read as a fork's base, never listed. - archived: bool, + pub archived: bool, } fn first_line(path: &Path) -> Option { @@ -352,7 +352,7 @@ fn rollout_files(codex: &Path) -> Vec { } /// Every thread's segments, oldest first, keyed by thread id. -fn codex_threads(codex: &Path) -> HashMap> { +pub(crate) fn codex_threads(codex: &Path) -> HashMap> { let mut threads: HashMap> = HashMap::new(); let archive = codex.join("archived_sessions"); for path in rollout_files(codex) { @@ -385,7 +385,7 @@ fn same_dir(a: &str, b: &str) -> bool { } /// `session_index.jsonl`: the names Codex gave its threads (last one wins). -fn codex_names(codex: &Path) -> HashMap { +pub(crate) fn codex_names(codex: &Path) -> HashMap { let mut names = HashMap::new(); let Ok(text) = fs::read_to_string(codex.join("session_index.jsonl")) else { return names }; for v in text.lines().filter_map(|l| serde_json::from_str::(l).ok()) { @@ -400,7 +400,7 @@ fn codex_names(codex: &Path) -> HashMap { /// The first thing the user typed (Codex injects AGENTS.md and environment /// blocks as user messages too; those start with `<` or a heading). -fn codex_first_prompt(path: &Path) -> String { +pub(crate) fn codex_first_prompt(path: &Path) -> String { let Ok(file) = fs::File::open(path) else { return String::new() }; for line in BufReader::new(file).take(MAX_TITLE_SCAN).lines().map_while(Result::ok) { let Ok(v) = serde_json::from_str::(&line) else { continue }; @@ -449,7 +449,7 @@ fn codex_default_provider(codex: &Path) -> String { /// A conversation someone had, not a sub-agent a conversation spawned (those /// resume only through their parent). -fn is_conversation(segments: &[Segment]) -> bool { +pub(crate) fn is_conversation(segments: &[Segment]) -> bool { let meta = &segments[0].meta; !segments.iter().all(|s| s.archived) && meta["thread_source"].as_str() != Some("subagent") @@ -457,7 +457,7 @@ fn is_conversation(segments: &[Segment]) -> bool { } /// Threads JuCode's own Codex sessions wrote: already JuCode sessions. -const JUCODE_ORIGINATOR: &str = "jucode-daemon"; +pub(crate) const JUCODE_ORIGINATOR: &str = "jucode-daemon"; fn codex_origin(meta: &Value) -> String { match meta["originator"].as_str().unwrap_or("") { @@ -598,7 +598,7 @@ fn clean_codex( out.flush().map_err(|e| e.to_string()) } -fn import_codex(h: &Homes, cwd: &str, id: &str) -> Result { +pub(crate) fn import_codex(h: &Homes, cwd: &str, id: &str) -> Result { if !is_session_id(id) { return Err(format!("invalid thread id: {id}")); } @@ -651,7 +651,7 @@ pub fn native_sessions(source: String, cwd: String) -> Result /// Copies one of them for JuCode (see module docs); returns the copy's id, /// which the engine resumes. An earlier copy is reused. /// Imports run one at a time: each reads, extends and rewrites the registry. -static IMPORTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub(crate) static IMPORTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[tauri::command(async)] pub fn import_native_session(source: String, cwd: String, id: String) -> Result { From 7b104802d93c0b26f75431b4d9dbc6b0dfc8fc14 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 02:48:58 +0800 Subject: [PATCH 25/31] =?UTF-8?q?Settings=20=E2=86=92=20=E5=AF=BC=E5=85=A5?= =?UTF-8?q?:=20scan=20the=20other=20agents,=20pick,=20import=20in=20one=20?= =?UTF-8?q?go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A section that scans Claude Code, Codex, opencode, zcode and omp and lists what they have: conversations by tool and folder, skills, MCP servers, each group with its count and a select-all. Picked by default: conversations not imported yet, and skills and servers JuCode has none of by that name, except the ones that come with a plugin (they often rely on their tool). 一键导入 imports conversations and skills on the host side and adds the servers through the daemon (mcp_set), as Settings → MCP does, then shows what was imported, what was already there and what failed. The folders of imported conversations can be added to the sidebar as projects, where their conversations open from 历史. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/agentImport.test.ts | 150 ++++++++ src/lib/agentImport.ts | 137 ++++++++ src/lib/i18n/messages/settings.ts | 74 +++- src/lib/protocol.ts | 67 ++++ src/lib/settings/ImportSection.svelte | 474 ++++++++++++++++++++++++++ src/lib/settings/SettingsPage.svelte | 10 +- src/lib/settings/nav.ts | 4 +- src/routes/+page.svelte | 13 + 8 files changed, 925 insertions(+), 4 deletions(-) create mode 100644 src/lib/agentImport.test.ts create mode 100644 src/lib/agentImport.ts create mode 100644 src/lib/settings/ImportSection.svelte diff --git a/src/lib/agentImport.test.ts b/src/lib/agentImport.test.ts new file mode 100644 index 0000000..ad24b5a --- /dev/null +++ b/src/lib/agentImport.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { + byProject, + bySource, + defaultPicks, + folderName, + importedFolders, + mcpKey, + planMcp, + sessionKey, + skillKey, + summarize +} from './agentImport'; +import type { ImportMcp, ImportOutcome, ImportSession, ImportSkill } from './protocol'; + +const session = (over: Partial = {}): ImportSession => ({ + source: 'zcode', + id: 's1', + title: 'Fix it', + cwd: '/p/a', + updated_at_ms: 1000, + messages: 3, + imported: false, + ...over +}); +const skill = (over: Partial = {}): ImportSkill => ({ + source: 'codex', + name: 'review', + description: '', + path: '/h/.codex/skills/review', + plugin: null, + present: false, + ...over +}); +const server = (over: Partial = {}): ImportMcp => ({ + source: 'claude', + from: '', + name: 'files', + transport: 'stdio', + command: 'npx', + args: [], + url: null, + present: false, + entry: { name: 'files', transport: 'stdio', command: 'npx', enabled: true }, + ...over +}); +const outcome = (over: Partial = {}): ImportOutcome => ({ + kind: 'session', + source: 'zcode', + key: 's1', + status: 'imported', + target: 'sabc', + title: 'Fix it', + cwd: '/p/a', + engine: 'jucode', + cwd_exists: true, + error: null, + ...over +}); + +describe('grouping', () => { + it('orders tools as listed and drops empty ones', () => { + const groups = bySource([session({ source: 'omp' }), session({ source: 'claude' }), session({ source: 'omp', id: 's2' })]); + expect(groups.map((g) => [g.source, g.items.length])).toEqual([ + ['claude', 1], + ['omp', 2] + ]); + }); + + it('puts the most recently used folder first, newest conversation first', () => { + const groups = byProject([ + session({ id: 'old', cwd: '/p/a', updated_at_ms: 1 }), + session({ id: 'b', cwd: '/p/b', updated_at_ms: 5 }), + session({ id: 'new', cwd: '/p/a', updated_at_ms: 9 }) + ]); + expect(groups.map((g) => [g.cwd, g.sessions.map((s) => s.id)])).toEqual([ + ['/p/a', ['new', 'old']], + ['/p/b', ['b']] + ]); + }); + + it('names a folder by its last component', () => { + expect(folderName('/Users/me/proj/')).toBe('proj'); + expect(folderName('C:\\work\\app')).toBe('app'); + }); +}); + +describe('defaultPicks', () => { + it('skips what was imported, what JuCode has, and what comes with a plugin', () => { + const picks = defaultPicks({ + sessions: [session(), session({ id: 'done', imported: true })], + skills: [skill(), skill({ name: 'had', path: '/x', present: true }), skill({ name: 'pdf', path: '/y', plugin: 'pdf' })], + mcp: [server(), server({ name: 'had', present: true }), server({ name: 'notion', from: 'notion' })] + }); + expect(Object.keys(picks).sort()).toEqual( + [sessionKey(session()), skillKey(skill()), mcpKey(server())].sort() + ); + }); +}); + +describe('planMcp', () => { + it('skips names already configured and the second pick of a name', () => { + const a = server(); + const b = server({ source: 'codex', name: 'files' }); + const c = server({ name: 'search' }); + const d = server({ name: 'mine' }); + const plan = planMcp([a, b, c, d], ['mine']); + expect(plan.add).toEqual([a, c]); + expect(plan.skip).toEqual([b, d]); + }); +}); + +describe('summarize', () => { + it('counts per kind and lists failures', () => { + const summary = summarize( + [ + outcome(), + outcome({ key: 's2', status: 'existing' }), + outcome({ key: 's3', status: 'failed', title: null, error: 'gone' }), + outcome({ kind: 'skill', key: '/h/skills/review', status: 'imported', cwd: null }), + outcome({ kind: 'skill', key: '/h/skills/pdf', status: 'failed', title: null, error: 'denied' }) + ], + [ + { server: server(), status: 'imported' }, + { server: server({ name: 'x' }), status: 'failed', error: 'no daemon' } + ] + ); + expect(summary.sessions).toEqual({ imported: 1, existing: 1, failed: 1 }); + expect(summary.skills).toEqual({ imported: 1, existing: 0, failed: 1 }); + expect(summary.mcp).toEqual({ imported: 1, existing: 0, failed: 1 }); + expect(summary.failures).toEqual([ + { label: 's3', error: 'gone' }, + { label: 'pdf', error: 'denied' }, + { label: 'x', error: 'no daemon' } + ]); + }); + + it('lists the folders of imported conversations that still exist, once each', () => { + expect( + importedFolders([ + outcome(), + outcome({ key: 's2', status: 'existing' }), + outcome({ key: 's3', cwd: '/p/gone', cwd_exists: false }), + outcome({ key: 's4', status: 'failed', cwd: '/p/c' }), + outcome({ key: 's5', cwd: '/p/b' }), + outcome({ kind: 'skill', cwd: null }) + ]) + ).toEqual(['/p/a', '/p/b']); + }); +}); diff --git a/src/lib/agentImport.ts b/src/lib/agentImport.ts new file mode 100644 index 0000000..a9bde4d --- /dev/null +++ b/src/lib/agentImport.ts @@ -0,0 +1,137 @@ +// Pure logic for Settings → 导入 (one-click import from the other coding +// agents): grouping what the scan found, what is picked by default, which +// MCP servers still need adding, and the summary of a run. Framework-free so +// it is unit-testable. + +import type { ImportMcp, ImportOutcome, ImportScan, ImportSession, ImportSkill, ImportSource } from './protocol'; + +export const SOURCES: ImportSource[] = ['claude', 'codex', 'opencode', 'zcode', 'omp']; + +export const SOURCE_LABELS: Record = { + claude: 'Claude Code', + codex: 'Codex', + opencode: 'opencode', + zcode: 'zcode', + omp: 'omp' +}; + +export const sessionKey = (s: ImportSession) => `session:${s.source}:${s.id}`; +export const skillKey = (s: ImportSkill) => `skill:${s.source}:${s.path}`; +export const mcpKey = (m: ImportMcp) => `mcp:${m.source}:${m.from}:${m.name}`; + +/** Last path component (either separator). */ +export function folderName(path: string): string { + return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path; +} + +export interface ProjectGroup { + cwd: string; + sessions: ImportSession[]; +} +export interface SourceGroup { + source: ImportSource; + items: T[]; +} + +/** Items per tool, in SOURCES order; tools with nothing are left out. */ +export function bySource(items: T[]): SourceGroup[] { + return SOURCES.map((source) => ({ source, items: items.filter((x) => x.source === source) })).filter( + (g) => g.items.length > 0 + ); +} + +/** A tool's conversations per folder: the most recently used folder first, + * newest conversation first within it. */ +export function byProject(sessions: ImportSession[]): ProjectGroup[] { + const groups = new Map(); + for (const s of [...sessions].sort((a, b) => b.updated_at_ms - a.updated_at_ms)) { + const list = groups.get(s.cwd); + if (list) list.push(s); + else groups.set(s.cwd, [s]); + } + return [...groups].map(([cwd, list]) => ({ cwd, sessions: list })); +} + +/** What a scan picks by default: conversations not imported yet, skills and + * servers JuCode does not have. Ones that come with a plugin are left for + * the user to choose: they often rely on the tool they came with. */ +export function defaultPicks(scan: ImportScan): Record { + const picks: Record = {}; + for (const s of scan.sessions) if (!s.imported) picks[sessionKey(s)] = true; + for (const s of scan.skills) if (!s.present && !s.plugin) picks[skillKey(s)] = true; + for (const m of scan.mcp) if (!m.present && !m.from) picks[mcpKey(m)] = true; + return picks; +} + +export interface McpPlan { + add: ImportMcp[]; + /** A server of that name is already configured (or picked twice). */ + skip: ImportMcp[]; +} + +/** Splits the picked servers into ones to add and ones whose name JuCode + * already has; the first of two picks with one name wins. */ +export function planMcp(picked: ImportMcp[], configured: Iterable): McpPlan { + const taken = new Set(configured); + const plan: McpPlan = { add: [], skip: [] }; + for (const m of picked) { + if (taken.has(m.name)) plan.skip.push(m); + else { + taken.add(m.name); + plan.add.push(m); + } + } + return plan; +} + +export interface Tally { + imported: number; + existing: number; + failed: number; +} +export interface McpResult { + server: ImportMcp; + status: 'imported' | 'existing' | 'failed'; + error?: string; +} +export interface RunSummary { + sessions: Tally; + skills: Tally; + mcp: Tally; + /** What failed, with why. */ + failures: { label: string; error: string }[]; +} + +function tally(statuses: string[]): Tally { + return { + imported: statuses.filter((s) => s === 'imported').length, + existing: statuses.filter((s) => s === 'existing').length, + failed: statuses.filter((s) => s === 'failed').length + }; +} + +export function summarize(outcomes: ImportOutcome[], mcp: McpResult[]): RunSummary { + const of = (kind: ImportOutcome['kind']) => outcomes.filter((o) => o.kind === kind).map((o) => o.status); + return { + sessions: tally(of('session')), + skills: tally(of('skill')), + mcp: tally(mcp.map((r) => r.status)), + failures: [ + ...outcomes + .filter((o) => o.status === 'failed') + .map((o) => ({ label: o.title || (o.kind === 'skill' ? folderName(o.key) : o.key), error: o.error ?? '' })), + ...mcp.filter((r) => r.status === 'failed').map((r) => ({ label: r.server.name, error: r.error ?? '' })) + ] + }; +} + +/** Folders of the conversations a run brought over that still exist: the + * projects to show them under. */ +export function importedFolders(outcomes: ImportOutcome[]): string[] { + const out: string[] = []; + for (const o of outcomes) { + if (o.kind !== 'session' || o.status === 'failed' || !o.cwd || !o.cwd_exists) continue; + if (!out.includes(o.cwd)) out.push(o.cwd); + } + return out; +} diff --git a/src/lib/i18n/messages/settings.ts b/src/lib/i18n/messages/settings.ts index 44a782d..3e94679 100644 --- a/src/lib/i18n/messages/settings.ts +++ b/src/lib/i18n/messages/settings.ts @@ -19,6 +19,40 @@ const settings = { jucode: 'JuCode CLI', about: '关于' }, + import: { + title: '从其他工具导入', + intro: '把本机 Claude Code、Codex、opencode、zcode、omp 里的会话、技能和 MCP 服务器导入 JuCode。原工具的文件不会改动。', + scan: '扫描', + rescan: '重新扫描', + scanning: '正在扫描…', + scanFailed: '扫描失败:{msg}', + nothing: '没有找到可导入的内容', + sessions: '会话', + skills: '技能', + mcp: 'MCP 服务器', + selectAll: '全选', + selected: '已选 {n} / {total}', + prompts: '{n} 轮', + untitled: '无标题', + imported: '已导入', + present: '已有', + off: '未启用', + plugin: '插件 {name}', + sessionsHint: 'Claude Code 和 Codex 的会话继续用原来的后端;其他工具的会话转成 JuCode 会话。', + skillsHint: '复制到 ~/.jucode/skills。插件带的技能可能依赖原工具,默认不选。', + mcpHint: '加到 JuCode 的 MCP 配置里。插件带的服务器默认不选。', + run: '一键导入', + running: '正在导入…', + pickSome: '先选要导入的内容', + resultTitle: '导入完成', + resultSessions: '会话:新导入 {imported},已有 {existing}', + resultSkills: '技能:新导入 {imported},已有 {existing}', + resultMcp: 'MCP 服务器:新导入 {imported},已有 {existing}', + failedCount: '{n} 项失败:', + where: '导入的会话在对应项目的「历史」里打开,Claude Code 和 Codex 的在历史的同名标签下。', + addFolders: '把 {n} 个项目文件夹加到侧栏', + foldersAdded: '已加到侧栏' + }, section: { general: '通用', account: '账户', @@ -32,7 +66,8 @@ const settings = { agents: '后端', acp: '外部智能体(ACP)', daemon: '后台服务', - updates: '更新' + updates: '更新', + import: '导入' }, page: { back: '返回', @@ -470,6 +505,40 @@ const settings = { jucode: 'JuCode CLI', about: 'About' }, + import: { + title: 'Import from other tools', + intro: 'Bring conversations, skills and MCP servers over from Claude Code, Codex, opencode, zcode and omp on this computer. Their files are left as they are.', + scan: 'Scan', + rescan: 'Scan again', + scanning: 'Scanning…', + scanFailed: 'Scan failed: {msg}', + nothing: 'Nothing to import was found', + sessions: 'Conversations', + skills: 'Skills', + mcp: 'MCP servers', + selectAll: 'Select all', + selected: '{n} of {total} selected', + prompts: '{n} prompts', + untitled: 'Untitled', + imported: 'Imported', + present: 'Already added', + off: 'Off', + plugin: 'plugin {name}', + sessionsHint: 'Claude Code and Codex conversations continue on their own backend; the others become JuCode conversations.', + skillsHint: 'Copied to ~/.jucode/skills. Skills from plugins may rely on their tool and are not selected by default.', + mcpHint: 'Added to JuCode\'s MCP servers. Servers from plugins are not selected by default.', + run: 'Import', + running: 'Importing…', + pickSome: 'Select something to import first', + resultTitle: 'Import finished', + resultSessions: 'Conversations: {imported} imported, {existing} already there', + resultSkills: 'Skills: {imported} imported, {existing} already there', + resultMcp: 'MCP servers: {imported} imported, {existing} already there', + failedCount: '{n} failed:', + where: 'Open imported conversations from the project\'s History; Claude Code and Codex ones are under their own tab there.', + addFolders: 'Add {n} project folders to the sidebar', + foldersAdded: 'Added to the sidebar' + }, section: { general: 'General', account: 'Account', @@ -483,7 +552,8 @@ const settings = { agents: 'Backends', acp: 'External agents (ACP)', daemon: 'Background service', - updates: 'Updates' + updates: 'Updates', + import: 'Import' }, page: { back: 'Back', diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index 868d021..8c33956 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -177,6 +177,73 @@ export function importNativeSession(source: NativeSource, cwd: string, id: strin return invoke('import_native_session', { source, cwd, id }); } +// 一键导入: what the other coding agents on this machine have — conversations +// in every folder, skills and MCP servers (src-tauri/src/agent_import). +export type ImportSource = 'claude' | 'codex' | 'opencode' | 'zcode' | 'omp'; +export interface ImportSession { + source: ImportSource; + id: string; + title: string; + cwd: string; + updated_at_ms: number; + /** Prompts the user sent. */ + messages: number; + /** Imported before: importing again opens that copy. */ + imported: boolean; +} +export interface ImportSkill { + source: ImportSource; + name: string; + description: string; + path: string; + /** The plugin it comes with. */ + plugin: string | null; + /** JuCode already has a skill of this name. */ + present: boolean; +} +export interface ImportMcp { + source: ImportSource; + /** The plugin or project folder it is set for; empty for the tool's own settings. */ + from: string; + name: string; + transport: 'stdio' | 'http'; + command: string | null; + args: string[] | null; + url: string | null; + present: boolean; + /** The `mcp_servers` entry to add. */ + entry: McpServerEntry; +} +export interface ImportScan { + sessions: ImportSession[]; + skills: ImportSkill[]; + mcp: ImportMcp[]; +} +export interface ImportOutcome { + kind: 'session' | 'skill'; + source: ImportSource; + /** Session id or skill folder, as the scan named it. */ + key: string; + status: 'imported' | 'existing' | 'failed'; + /** Session: the copy to open. Skill: where it was copied. */ + target: string | null; + title: string | null; + cwd: string | null; + /** The backend the copy continues on. */ + engine: 'jucode' | 'claude' | 'codex' | null; + cwd_exists: boolean; + error: string | null; +} +export function importScan(): Promise { + return invoke('import_scan'); +} +export function importApply(selection: { + sessions: { source: ImportSource; id: string; cwd: string }[]; + skills: { source: ImportSource; path: string }[]; +}): Promise { + return invoke('import_apply', { selection }); +} + /** One conversation saved in a directory, by any engine, as the daemon * lists it (`session_history`). `updated_at` is in milliseconds. */ export interface HistoryItem { diff --git a/src/lib/settings/ImportSection.svelte b/src/lib/settings/ImportSection.svelte new file mode 100644 index 0000000..eda4eb4 --- /dev/null +++ b/src/lib/settings/ImportSection.svelte @@ -0,0 +1,474 @@ + + + + + + + {#if scanError} +
{scanError}
+ {:else if empty} + + {/if} +
+ +{#snippet groupHead(kind: string, source: ImportSource, keys: string[], total: number)} + {@const id = `${kind}:${source}`} +
+ setAll(keys, on)} /> + +
+{/snippet} + +{#snippet selectAll(keys: string[])} + {#if keys.length} + setAll(keys, on)}> + {t('settings.import.selectAll')} · {t('settings.import.selected', { n: count(keys), total: keys.length })} + + {/if} +{/snippet} + +{#if found && found.sessions.length} + + {#snippet action()}{@render selectAll(sessionKeys(found!.sessions))}{/snippet} + {#each sessionGroups as g (g.source)} +
+ {@render groupHead('session', g.source, sessionKeys(g.items), g.items.length)} + + {#each byProject(g.items) as p (p.cwd)} +
+ {folderName(p.cwd)} + {p.cwd} +
+ {#each p.sessions as s (s.id)} + {@const key = sessionKey(s)} +
+ (picks[key] = on)}> + {s.title || t('settings.import.untitled')} + + + {#if s.imported}{t('settings.import.imported')}{/if} + {t('settings.import.prompts', { n: s.messages })} · {date(s.updated_at_ms)} + +
+ {/each} + {/each} +
+
+ {/each} +
+{/if} + +{#if found && found.skills.length} + + {#snippet action()}{@render selectAll(skillKeys(found!.skills))}{/snippet} + {#each skillGroups as g (g.source)} +
+ {@render groupHead('skill', g.source, skillKeys(g.items), g.items.length)} + + {#each g.items as s (s.path)} + {@const key = skillKey(s)} +
+ (picks[key] = on)}> + {s.name} + + {#if s.plugin}{t('settings.import.plugin', { name: s.plugin })}{/if} + {s.description} + {#if s.present}{t('settings.import.present')}{/if} +
+ {/each} +
+
+ {/each} +
+{/if} + +{#if found && found.mcp.length} + + {#snippet action()}{@render selectAll(mcpKeys(found!.mcp))}{/snippet} + {#each mcpGroups as g (g.source)} +
+ {@render groupHead('mcp', g.source, mcpKeys(g.items), g.items.length)} + + {#each g.items as m (mcpKey(m))} + {@const key = mcpKey(m)} + {@const what = m.transport === 'http' ? m.url : [m.command, ...(m.args ?? [])].join(' ')} +
+ (picks[key] = on)}> + {m.name} + + {m.transport} + {#if m.from}{m.from.startsWith('/') ? folderName(m.from) : t('settings.import.plugin', { name: m.from })}{/if} + {what} + {#if !m.entry.enabled}{t('settings.import.off')}{/if} + {#if m.present}{t('settings.import.present')}{/if} +
+ {/each} +
+
+ {/each} +
+{/if} + +{#if found && !empty} + + + + + {#if runError} +
{runError}
+ {/if} + {#if summary} +
+ +
{t('settings.import.resultTitle')}
+ {#if summary.sessions.imported + summary.sessions.existing + summary.sessions.failed} +
{t('settings.import.resultSessions', { ...summary.sessions })}
+ {/if} + {#if summary.skills.imported + summary.skills.existing + summary.skills.failed} +
{t('settings.import.resultSkills', { ...summary.skills })}
+ {/if} + {#if summary.mcp.imported + summary.mcp.existing + summary.mcp.failed} +
{t('settings.import.resultMcp', { ...summary.mcp })}
+ {/if} + {#if summary.failures.length} +
{t('settings.import.failedCount', { n: summary.failures.length })}
+
    + {#each summary.failures as f, i (i)} +
  • {f.label} {f.error}
  • + {/each} +
+ {/if} + {#if summary.sessions.imported + summary.sessions.existing} +
{t('settings.import.where')}
+ {/if} +
+ {#if folders.length && onAddFolders} +
+ {#if foldersAdded} + {t('settings.import.foldersAdded')} + {:else} + + {/if} +
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/src/lib/settings/SettingsPage.svelte b/src/lib/settings/SettingsPage.svelte index f6ed828..dc3f8f5 100644 --- a/src/lib/settings/SettingsPage.svelte +++ b/src/lib/settings/SettingsPage.svelte @@ -19,6 +19,7 @@ import StorefrontIcon from 'phosphor-svelte/lib/StorefrontIcon'; import RobotIcon from 'phosphor-svelte/lib/RobotIcon'; import PlugsConnectedIcon from 'phosphor-svelte/lib/PlugsConnectedIcon'; + import TrayArrowDownIcon from 'phosphor-svelte/lib/TrayArrowDownIcon'; import InfoIcon from 'phosphor-svelte/lib/InfoIcon'; import DesktopTowerIcon from 'phosphor-svelte/lib/DesktopTowerIcon'; import SignInIcon from 'phosphor-svelte/lib/SignInIcon'; @@ -69,6 +70,7 @@ import AcpSection from './AcpSection.svelte'; import DaemonSection from './DaemonSection.svelte'; import McpSection from './McpSection.svelte'; + import ImportSection from './ImportSection.svelte'; import PermissionRulesSection from './PermissionRulesSection.svelte'; import UpdateCard from './UpdateCard.svelte'; import ThirdPartyNotices from './ThirdPartyNotices.svelte'; @@ -86,7 +88,8 @@ onClose, onAuthChange, onMarket, - onFeedback + onFeedback, + onAddFolders }: { sessionId: string; /** The active session's ChatState — source of the live `mcp_servers` view. @@ -102,6 +105,8 @@ onMarket?: () => void; /** Open 反馈问题. */ onFeedback?: () => void; + /** Show these folders as projects in the sidebar (导入); how many were new. */ + onAddFolders?: (paths: string[]) => number; } = $props(); const ICONS: Record = { @@ -116,6 +121,7 @@ market: StorefrontIcon, agents: RobotIcon, acp: PlugsConnectedIcon, + import: TrayArrowDownIcon, daemon: DesktopTowerIcon, updates: InfoIcon }; @@ -906,6 +912,8 @@
{:else if current === 'acp'} + {:else if current === 'import'} + {:else if current === 'daemon'} diff --git a/src/lib/settings/nav.ts b/src/lib/settings/nav.ts index b47d3fc..1a1cb33 100644 --- a/src/lib/settings/nav.ts +++ b/src/lib/settings/nav.ts @@ -14,6 +14,7 @@ export type SectionKey = | 'market' | 'agents' | 'acp' + | 'import' | 'daemon' | 'updates'; @@ -21,7 +22,7 @@ export type SectionKey = // or only sessions run by the JuCode CLI engine (its ~/.jucode/config.json). export const GROUPS: { key: string; sections: SectionKey[] }[] = [ { key: 'app', sections: ['general', 'account', 'usage', 'voice'] }, - { key: 'agents', sections: ['agents', 'acp', 'daemon', 'market'] }, + { key: 'agents', sections: ['agents', 'acp', 'daemon', 'market', 'import'] }, { key: 'jucode', sections: ['providers', 'models', 'mcp', 'network'] }, { key: 'about', sections: ['updates'] } ]; @@ -88,6 +89,7 @@ export const ROWS: SearchRow[] = [ { section: 'agents', id: 'dependencies', titleKey: 'setup.deps.title', descKey: 'setup.deps.sub' }, { section: 'acp', id: 'acp-agents', titleKey: 'settings.acp.groupLabel', descKey: 'settings.acp.hint' }, { section: 'daemon', id: 'title-model', titleKey: 'settings.behavior.titleModel', descKey: 'settings.behavior.titleModelHint' }, + { section: 'import', id: 'import-scan', titleKey: 'settings.import.title', descKey: 'settings.import.intro' }, { section: 'daemon', id: 'relay', titleKey: 'settings.backend.relayToggle', descKey: 'settings.backend.relayHint' }, { section: 'daemon', id: 'devices', titleKey: 'settings.backend.devices', descKey: 'settings.backend.devicesHint' }, { section: 'updates', id: 'app-version', titleKey: 'settings.update.currentVersion', descKey: 'settings.update.groupLabel' } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 861b2b3..2e08e80 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -89,6 +89,7 @@ import ChatPane, { type ChatPaneApi, type ProviderOption } from '$lib/ChatPane.svelte'; import SettingsPage from '$lib/settings/SettingsPage.svelte'; import type { SectionKey } from '$lib/settings/nav'; + import { folderName } from '$lib/agentImport'; import Welcome from '$lib/welcome/Welcome.svelte'; import Marketplace from '$lib/Marketplace.svelte'; import Sidebar from '$lib/Sidebar.svelte'; @@ -904,6 +905,17 @@ projectChromeFor = { id: p.id, x: ev.clientX, y: ev.clientY }; } + /** Settings → 导入: the folders of imported conversations become projects + * (without a chat; their conversations are in the project's 历史). */ + function addImportedFolders(paths: string[]): number { + let added = 0; + for (const path of paths) { + if (store.userProjects.some((p) => normPath(p.path) === normPath(path))) continue; + store.addProjectShell({ id: store.uid(), name: folderName(path), path }); + added++; + } + return added; + } async function addProject() { const path = await open({ directory: true, title: t('shell.pickDirTitle') }); if (!path || Array.isArray(path)) return; @@ -1585,6 +1597,7 @@ showMarket = true; }} onFeedback={() => (showFeedback = true)} + onAddFolders={addImportedFolders} onClose={closeSettings} /> {/if} From e884e6dc9f91338ac4897ce3bbe74ee00d536285 Mon Sep 17 00:00:00 2001 From: Chad Newman Date: Fri, 9 Oct 2026 12:58:39 +0800 Subject: [PATCH 26/31] test: expect the zcode plugin path with native separators The scan reads the plugin folder back from the disk, so on Windows it is all backslashes; the expectation joined "official/android/0.1.0" in one go and kept the forward slashes. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/agent_import/mcp.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_import/mcp.rs b/src-tauri/src/agent_import/mcp.rs index 48f63df..b6d292c 100644 --- a/src-tauri/src/agent_import/mcp.rs +++ b/src-tauri/src/agent_import/mcp.rs @@ -389,7 +389,8 @@ mod tests { &roots.config_home.join("opencode/opencode.jsonc"), "{\n // mine\n \"mcp\": {\n \"jira\": {\"type\": \"local\", \"command\": [\"bun\", \"x\", \"jira-mcp\"], \"environment\": {\"K\": \"v\"}, \"timeout\": 5000,},\n \"web\": {\"type\": \"remote\", \"url\": \"https://w.example.com/mcp\", \"enabled\": false},\n },\n}\n", ); - let plugin = roots.zcode_plugins().join("official/android/0.1.0"); + // Joined one part at a time: the scan reads it back with native separators. + let plugin = roots.zcode_plugins().join("official").join("android").join("0.1.0"); testutil::write( &plugin.join(".mcp.json"), &json!({"mcpServers": { From 5a4067b5bc7782f858c8ef4f49736e931a7f467e Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 9 Oct 2026 05:55:32 -0700 Subject: [PATCH 27/31] Approval keys: only on a card in view, never Esc, not on repeat or right after it shows The chat pane under settings or the desk no longer counts as active, the remote page passes keys only to the session on top of the shown tab, and no key answers while a dialog or menu is open. Esc no longer denies; a held or doubled key, or one within a second of the card coming into view, does nothing. The elicitation decline button shows 3, the key that declines it. --- src/lib/ApprovalCard.svelte | 30 ++++++++++++++++++------------ src/lib/ChatPane.svelte | 6 ++++-- src/lib/RemoteSession.svelte | 7 +++++-- src/lib/remote/HostView.svelte | 1 + src/routes/+page.svelte | 2 +- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/lib/ApprovalCard.svelte b/src/lib/ApprovalCard.svelte index c656b18..4f94a5e 100644 --- a/src/lib/ApprovalCard.svelte +++ b/src/lib/ApprovalCard.svelte @@ -46,7 +46,7 @@ onRespond: (op: ApproveOp) => void; /** 始终允许 asks where to keep the rule (caps.ruleScopes). */ ruleScopes?: boolean; - /** Answers the 1/2/3 keys (only the active pane's card). */ + /** Answers the 1/2/3 keys: only while the card is in view. */ keys?: boolean; } = $props(); @@ -65,19 +65,26 @@ })) ); // Keys for the plain allow / deny card (not questions, hunks or plans): - // 1 / y allow once, 2 / a always, 3 / n / Esc deny. Never while the user - // types somewhere, nor within a second of the last keystroke (a sentence - // in the composer must not answer a card that just appeared). + // 1 / y allow once, 2 / a always, 3 / n deny. Esc never answers (it closes + // pages and dialogs). Only while the card is in view (`keys`) with no + // dialog or menu over it, never while the user types somewhere, not on key + // repeat, and not within a second of the card coming into view or of the + // last keystroke (a sentence in the composer, or a held key that answered + // the previous card, must not answer this one). let lastTyped = 0; + let shownAt = 0; + $effect(() => { + if (keys) shownAt = Date.now(); + }); function onKey(e: KeyboardEvent) { - if (!keys || e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return; + if (!keys || e.repeat || e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return; const el = document.activeElement as HTMLElement | null; - const typing = !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); - if (typing) { + if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) { lastTyped = Date.now(); - if (e.key !== 'Escape') return; + return; } - if (Date.now() - lastTyped < 1000 || questions || approval.hunks?.length || isPlan) return; + if (Date.now() - Math.max(lastTyped, shownAt) < 1000 || questions || approval.hunks?.length || isPlan) return; + if (document.querySelector('[aria-modal="true"], [role="menu"]')) return; const key = e.key.toLowerCase(); let op: ApproveOp | null = null; if (key === '1' || key === 'y') op = buildApproveOp(approval.callId, 'allow'); @@ -86,8 +93,7 @@ if (scopeKeys) scopeOpen = true; else allowAlways(); return; - } else if (key === '3' || key === 'n' || (key === 'escape' && !typing && !scopeOpen)) - op = buildApproveOp(approval.callId, 'deny'); + } else if (key === '3' || key === 'n') op = buildApproveOp(approval.callId, 'deny'); if (!op) return; e.preventDefault(); onRespond(op); @@ -340,7 +346,7 @@ variant="danger" size="sm" onclick={() => onRespond(buildApproveOp(approval.callId, 'deny'))} - >{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}{isElicitation ? '2' : '3'}{isElicitation ? t('shell.elicitDecline') : t('shell.deny')}3
{/if} diff --git a/src/lib/ChatPane.svelte b/src/lib/ChatPane.svelte index 8fe48ef..a69a0d2 100644 --- a/src/lib/ChatPane.svelte +++ b/src/lib/ChatPane.svelte @@ -117,8 +117,10 @@ /** Providers with configured auth (for "not configured" hints). */ providers?: string[]; providersList?: ProviderOption[]; - /** This pane's session is `store.activeId` — overlays and window keys - * only run on the active pane, so two panes never fight over them. */ + /** This pane's session is `store.activeId` and no page (settings, the + * desk) covers it — overlays and window keys only run on the active + * pane, so two panes never fight over them and a hidden one never + * answers. */ isActive?: boolean; onRegister?: (id: string, api: ChatPaneApi) => void; /** Passes the api back so a remount (tile drag) can't drop the fresh one. */ diff --git a/src/lib/RemoteSession.svelte b/src/lib/RemoteSession.svelte index 15fbdc6..abb0bea 100644 --- a/src/lib/RemoteSession.svelte +++ b/src/lib/RemoteSession.svelte @@ -62,7 +62,8 @@ onBack, onFiles, onFile, - onChanges + onChanges, + active = false }: { /** An existing session; omit to start a new one as `agent`, in `cwd`, * or as a chat. */ @@ -85,6 +86,8 @@ /** Routes daemon frames and exits for `id` here; returns an unregister. */ register: (id: string, onFrame: (raw: string) => void, onExit: () => void) => () => void; onBack: () => void; + /** The page on top of the shown tab: its approval card answers keys. */ + active?: boolean; } = $props(); const host = useHost(); const { daemon, agents: agentDirectory } = host; @@ -621,7 +624,7 @@ {#if chat.pendingApproval}
{#key chat.pendingApproval.callId} - + {/key}
{/if} diff --git a/src/lib/remote/HostView.svelte b/src/lib/remote/HostView.svelte index 9dc4c0d..4a15d60 100644 --- a/src/lib/remote/HostView.svelte +++ b/src/lib/remote/HostView.svelte @@ -358,6 +358,7 @@ onFiles={root ? () => push({ kind: 'files', root, title: baseName(root) }) : undefined} onFile={root ? (file: string, line?: number) => push({ kind: 'files', root, title: baseName(root), file, line }) : undefined} onChanges={root ? () => push({ kind: 'changes', root, title: baseName(root) }) : undefined} + active={!hidden && name === tab && i === stacks[name].length - 1} /> {:else} {@render loadingPage(screen.title)} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2e08e80..6728ce6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1534,7 +1534,7 @@ {store} {providers} {providersList} - isActive={sid === activeId} + isActive={sid === activeId && !showSettings && !showDesk} onRegister={registerPane} onUnregister={unregisterPane} onOpenSettings={openSettings} From ca6dc8fc00e219fd840786cbad6461ddb95e8355 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 9 Oct 2026 05:56:19 -0700 Subject: [PATCH 28/31] JuCode backend: plan mode off until the bundled CLI supports it The bundled CLI 0.4.11 rejects approval mode plan, so the picker and the + menu must not offer it yet. Turn it on with the CLI release that adds plan mode. --- src/lib/backends/jucode.test.ts | 5 +++-- src/lib/backends/jucode.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/backends/jucode.test.ts b/src/lib/backends/jucode.test.ts index c8347f8..1343dd8 100644 --- a/src/lib/backends/jucode.test.ts +++ b/src/lib/backends/jucode.test.ts @@ -39,12 +39,13 @@ describe('jucode adapter (passthrough)', () => { // permission modes), not a superset capability — the native engine uses the // shared read-only/auto-edit/full-auto trio, so it is legitimately false. // mcpEngineOwned / ruleScopes describe claude's own config, not features - // the native engine lacks. + // the native engine lacks. planMode waits for the CLI release that adds it. for (const [key, value] of Object.entries(JUCODE_CAPS)) { - if (key === 'extendedApprovalModes' || key === 'mcpEngineOwned' || key === 'ruleScopes' || key === 'sideQuestions' || key === 'agentTrace') continue; + if (key === 'extendedApprovalModes' || key === 'mcpEngineOwned' || key === 'ruleScopes' || key === 'sideQuestions' || key === 'agentTrace' || key === 'planMode') continue; expect(value, `cap ${key} must be true for the native engine`).toBe(true); } expect(JUCODE_CAPS.extendedApprovalModes).toBe(false); + expect(JUCODE_CAPS.planMode).toBe(false); expect(adapter.caps).toEqual(JUCODE_CAPS); }); diff --git a/src/lib/backends/jucode.ts b/src/lib/backends/jucode.ts index cf668fd..3795c25 100644 --- a/src/lib/backends/jucode.ts +++ b/src/lib/backends/jucode.ts @@ -10,8 +10,9 @@ import type { BackendCaps, EngineAdapter, NormalizedEvent } from './types'; export const JUCODE_CAPS: BackendCaps = { approvalModes: true, extendedApprovalModes: false, - // Plan mode: proposed_plan cards, approve_plan. - planMode: true, + // Plan mode (proposed_plan cards, approve_plan): off until the bundled CLI + // is the release that adds it; 0.4.11 rejects approval mode `plan`. + planMode: false, hunkApproval: true, steer: true, interrupt: true, From 9ca311de654a021529eca1f889f49a082687c0cc Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 9 Oct 2026 05:57:31 -0700 Subject: [PATCH 29/31] Import: MCP servers start unpicked, skill copies skip symlinks An MCP server runs a command, so the import picker leaves every one for the user to choose. A skill copy no longer follows symlinks inside the skill (one may point anywhere on the machine), which also drops the copy depth limit that only guarded against link loops. The module comment now says where the Claude Code and Codex copies are written. --- src-tauri/src/agent_import/mod.rs | 5 ++++- src-tauri/src/agent_import/skills.rs | 24 ++++++++++++------------ src/lib/agentImport.test.ts | 5 ++--- src/lib/agentImport.ts | 9 +++++---- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/agent_import/mod.rs b/src-tauri/src/agent_import/mod.rs index 01974e3..46f678e 100644 --- a/src-tauri/src/agent_import/mod.rs +++ b/src-tauri/src/agent_import/mod.rs @@ -1,6 +1,9 @@ //! 一键导入: the conversations, skills and MCP servers of the other coding //! agents on this machine (Claude Code, Codex, opencode, zcode, omp), listed -//! in one scan and imported in one go. Their own folders are only read. +//! in one scan and imported in one go. Their files are never changed; the +//! only writes outside `~/.jucode` are the Claude Code and Codex conversation +//! copies, added next to that engine's own sessions (`~/.claude/projects`, +//! `~/.codex/sessions`). //! //! - Conversations: Claude Code and Codex ones become a cleaned copy in the //! engine's own format (native_import) and continue on that engine. diff --git a/src-tauri/src/agent_import/skills.rs b/src-tauri/src/agent_import/skills.rs index 4330786..a5bd1a2 100644 --- a/src-tauri/src/agent_import/skills.rs +++ b/src-tauri/src/agent_import/skills.rs @@ -26,8 +26,6 @@ pub struct Found { /// How deep skills may sit under a skills folder (JuCode descends into /// folders without a SKILL.md, as a skill pack lays them out). const MAX_DEPTH: usize = 3; -/// Folder depth a copy follows (symlinks can loop). -const MAX_COPY_DEPTH: usize = 16; struct Skill { name: String, @@ -131,11 +129,8 @@ fn dest(roots: &Roots, dir: &Path) -> Option { (!folder.starts_with('.')).then(|| roots.jucode.join("skills").join(folder)) } -fn copy_dir(src: &Path, dst: &Path, depth: usize) -> Result<(), String> { +fn copy_dir(src: &Path, dst: &Path) -> Result<(), String> { fs::create_dir_all(dst).map_err(|e| format!("cannot create {}: {e}", dst.display()))?; - if depth >= MAX_COPY_DEPTH { - return Ok(()); - } let read = fs::read_dir(src).map_err(|e| format!("cannot read {}: {e}", src.display()))?; for entry in read.flatten() { let name = entry.file_name(); @@ -144,11 +139,13 @@ fn copy_dir(src: &Path, dst: &Path, depth: usize) -> Result<(), String> { } let from = entry.path(); let to = dst.join(&name); - // Follows symlinks (zcode links skills in from Codex); a broken one is skipped. - let Ok(meta) = fs::metadata(&from) else { continue }; - if meta.is_dir() { - copy_dir(&from, &to, depth + 1)?; - } else if meta.is_file() { + // Symlinks inside a skill are skipped: one may point anywhere on the + // machine (a skill folder that is itself a link, as zcode links + // skills in from Codex, is still read through). + let Ok(kind) = entry.file_type() else { continue }; + if kind.is_dir() { + copy_dir(&from, &to)?; + } else if kind.is_file() { fs::copy(&from, &to).map_err(|e| format!("cannot copy {}: {e}", from.display()))?; } } @@ -170,7 +167,7 @@ pub fn import(roots: &Roots, found: &[Found], source: &str, path: &str) -> Resul let parent = dest.parent().unwrap_or(&roots.jucode); let tmp = parent.join(format!(".import-{}.tmp", dest.file_name().and_then(|n| n.to_str()).unwrap_or("skill"))); let _ = fs::remove_dir_all(&tmp); - copy_dir(src, &tmp, 0).inspect_err(|_| { + copy_dir(src, &tmp).inspect_err(|_| { let _ = fs::remove_dir_all(&tmp); })?; fs::rename(&tmp, &dest).map_err(|e| format!("cannot write {}: {e}", dest.display()))?; @@ -192,6 +189,8 @@ mod tests { skill(&roots.claude.join("skills/review"), "review", "Review code"); testutil::write(&roots.claude.join("skills/review/scripts/run.sh"), "echo hi"); testutil::write(&roots.claude.join("skills/review/node_modules/x/index.js"), "x"); + #[cfg(unix)] + std::os::unix::fs::symlink(&roots.home, roots.claude.join("skills/review/home")).unwrap(); skill(&roots.codex.join("skills/.system/imagegen"), "imagegen", "Codex's own"); skill(&roots.codex.join("skills/pack/inner"), "inner-skill", "Nested in a pack"); // A zcode plugin, two versions: the newer one counts. @@ -216,6 +215,7 @@ mod tests { assert_eq!(dest, roots.jucode.join("skills/review")); assert_eq!(fs::read_to_string(dest.join("scripts/run.sh")).unwrap(), "echo hi"); assert!(!dest.join("node_modules").exists()); + assert!(fs::symlink_metadata(dest.join("home")).is_err(), "a symlink is not followed"); assert!(!import(&roots, &found, "claude", &review.path).unwrap().0, "a second import leaves it"); assert!(!import(&roots, &found, "codex", &found[1].path).unwrap().0, "a name JuCode has is skipped"); assert!(import(&roots, &found, "claude", "/etc").is_err(), "only scanned skills are copied"); diff --git a/src/lib/agentImport.test.ts b/src/lib/agentImport.test.ts index ad24b5a..921a1ce 100644 --- a/src/lib/agentImport.test.ts +++ b/src/lib/agentImport.test.ts @@ -5,7 +5,6 @@ import { defaultPicks, folderName, importedFolders, - mcpKey, planMcp, sessionKey, skillKey, @@ -86,14 +85,14 @@ describe('grouping', () => { }); describe('defaultPicks', () => { - it('skips what was imported, what JuCode has, and what comes with a plugin', () => { + it('skips what was imported, what JuCode has, what comes with a plugin, and every MCP server', () => { const picks = defaultPicks({ sessions: [session(), session({ id: 'done', imported: true })], skills: [skill(), skill({ name: 'had', path: '/x', present: true }), skill({ name: 'pdf', path: '/y', plugin: 'pdf' })], mcp: [server(), server({ name: 'had', present: true }), server({ name: 'notion', from: 'notion' })] }); expect(Object.keys(picks).sort()).toEqual( - [sessionKey(session()), skillKey(skill()), mcpKey(server())].sort() + [sessionKey(session()), skillKey(skill())].sort() ); }); }); diff --git a/src/lib/agentImport.ts b/src/lib/agentImport.ts index a9bde4d..0e15f85 100644 --- a/src/lib/agentImport.ts +++ b/src/lib/agentImport.ts @@ -52,14 +52,15 @@ export function byProject(sessions: ImportSession[]): ProjectGroup[] { return [...groups].map(([cwd, list]) => ({ cwd, sessions: list })); } -/** What a scan picks by default: conversations not imported yet, skills and - * servers JuCode does not have. Ones that come with a plugin are left for - * the user to choose: they often rely on the tool they came with. */ +/** What a scan picks by default: conversations not imported yet and skills + * JuCode does not have. Skills that come with a plugin are left for the + * user to choose: they often rely on the tool they came with. MCP servers + * are never picked by default: one runs a command, so only the user turns + * it on. */ export function defaultPicks(scan: ImportScan): Record { const picks: Record = {}; for (const s of scan.sessions) if (!s.imported) picks[sessionKey(s)] = true; for (const s of scan.skills) if (!s.present && !s.plugin) picks[skillKey(s)] = true; - for (const m of scan.mcp) if (!m.present && !m.from) picks[mcpKey(m)] = true; return picks; } From 8aecdc32ab7aa96db0dc810cb886be99b1aa3cc0 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 9 Oct 2026 05:58:28 -0700 Subject: [PATCH 30/31] Changes: revert by the agent's diffs on Windows paths, never fall back to a whole-file revert The listed path is matched to its recorded diffs with either separator. When the agent's edits no longer apply, the panel reports it and leaves the file alone instead of offering the whole-file revert that drops the user's changes. A successful revert clears the file's recorded diffs, so a later revert does not replay ones already undone. --- src/lib/ChangesPanel.svelte | 33 ++++++++++++++++++++------------- src/lib/i18n/messages/dock.ts | 4 ++-- src/routes/+page.svelte | 6 +++++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/lib/ChangesPanel.svelte b/src/lib/ChangesPanel.svelte index ff2c10e..7951b76 100644 --- a/src/lib/ChangesPanel.svelte +++ b/src/lib/ChangesPanel.svelte @@ -19,12 +19,19 @@ files = [], agentDiffs = {}, onRevert - }: { cwd?: string; files?: string[]; agentDiffs?: Record; onRevert?: (p: string) => void } = $props(); - /** The recorded diffs for a listed path (absolute or project-relative). */ - function diffsOf(path: string): string[] { - const root = cwd.replace(/\/+$/, ''); - const rel = root && path.startsWith(root + '/') ? path.slice(root.length + 1) : path; - return agentDiffs[rel] ?? []; + }: { + cwd?: string; + files?: string[]; + agentDiffs?: Record; + /** A file was reverted: `rel` is its `agentDiffs` key. */ + onRevert?: (p: string, rel: string) => void; + } = $props(); + /** A listed path (absolute or project-relative, either separator) as the + * diffs' headers name it: project-relative, `/`-separated. */ + function relOf(path: string): string { + const p = path.replace(/\\/g, '/'); + const root = cwd.replace(/\\/g, '/').replace(/\/+$/, ''); + return root && p.startsWith(root + '/') ? p.slice(root.length + 1) : p; } const dir = () => cwd || undefined; @@ -89,8 +96,11 @@ } async function revert(path: string) { - const diffs = diffsOf(path); + const rel = relOf(path); + const diffs = agentDiffs[rel] ?? []; // The agent's own edits are known: undo exactly them, keep the user's. + // When they no longer apply the file stays as it is: a whole-file + // revert would drop the user's changes. if (diffs.length && cwd) { const ok = await confirm({ title: t('dock.changes.revertTitle'), @@ -102,17 +112,14 @@ busy = true; error = ''; try { - const root = cwd.replace(/\/+$/, ''); - const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; await revertAgentEdits(cwd, rel, diffs); - onRevert?.(path); - return; + onRevert?.(path, rel); } catch (e) { - // The file changed too much since: fall through to the full revert. error = t('dock.changes.revertAgentFailed', { error: String(e) }); } finally { busy = false; } + return; } const ok = await confirm({ title: t('dock.changes.revertTitle'), @@ -128,7 +135,7 @@ const untracked = st.trimStart().startsWith('??'); if (untracked) await git(['clean', '-fd', '--', path], dir()); else await git(['restore', '--worktree', '--', path], dir()); - onRevert?.(path); + onRevert?.(path, rel); } catch (e) { error = String(e); } finally { diff --git a/src/lib/i18n/messages/dock.ts b/src/lib/i18n/messages/dock.ts index c0aed6f..528e171 100644 --- a/src/lib/i18n/messages/dock.ts +++ b/src/lib/i18n/messages/dock.ts @@ -184,7 +184,7 @@ const dock = { revertFile: '还原此文件', revertConfirm: '把「{path}」还原到上次提交的版本?你自己在这个文件里没提交的改动也会一起丢掉,此操作不可撤销。', revertAgentConfirm: '撤销智能体对「{path}」的修改?你自己的改动会保留。', - revertAgentFailed: '无法只撤销智能体的修改(文件之后又改动过):{error}', + revertAgentFailed: '无法只撤销智能体的修改(文件之后又改动过),文件未改动:{error}', revertTitle: '还原文件', newFileDiff: '(新文件或无可显示的 diff)' }, @@ -396,7 +396,7 @@ const dock = { revertFile: 'Revert this file', revertConfirm: 'Restore "{path}" to its last commit? Your own uncommitted changes to it are lost too. This cannot be undone.', revertAgentConfirm: 'Undo the agent\'s edits to "{path}"? Your own changes stay.', - revertAgentFailed: 'Could not undo only the agent\'s edits (the file changed since): {error}', + revertAgentFailed: 'Could not undo only the agent\'s edits (the file changed since); the file is unchanged: {error}', revertTitle: 'Revert file', newFileDiff: '(New file or no diff to display)' }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6728ce6..e81569f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1570,7 +1570,11 @@ /> {:else if tab.panel === 'goal'} {:else if tab.panel === 'agents'}{#if chat && activeId}{#key activeId} activeId && dispatch(activeId, op)} />{/key}{/if} - {:else if tab.panel === 'changes'} chat && (chat.changedFiles = chat.changedFiles.filter((x) => x !== p))} /> + {:else if tab.panel === 'changes'} { + if (!chat) return; + chat.changedFiles = chat.changedFiles.filter((x) => x !== p); + delete chat.agentDiffs[rel]; + }} /> {:else if tab.panel === 'turns'} {:else if tab.panel === 'files'} {:else if tab.panel === 'git'} openTaskProject(path, meta)} onTaskRemoved={closeTaskProject} /> From 5fc3172702d9e74dd15ef9c6a1ac8e2dfac77332 Mon Sep 17 00:00:00 2001 From: gaoyu06 Date: Fri, 9 Oct 2026 05:59:02 -0700 Subject: [PATCH 31/31] File links: never open a network share a reply links to A UNC path (\\server\share or //server/share) from a model-rendered link would make Windows connect to that server and send the user's credentials. parseFileHref now gives no path for one, and isAbsolutePath no longer counts it as absolute. --- src/lib/fileRefs.test.ts | 9 ++++++++- src/lib/fileRefs.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/fileRefs.test.ts b/src/lib/fileRefs.test.ts index 5b0a52f..12123ed 100644 --- a/src/lib/fileRefs.test.ts +++ b/src/lib/fileRefs.test.ts @@ -46,8 +46,15 @@ describe('file references', () => { expect(parseFileHref('docs/100%.md')).toEqual({ path: 'docs/100%.md' }); }); + it('never opens a network share a reply links to', () => { + expect(parseFileHref('%5C%5Cserver%5Cshare%5Ca.ts')).toEqual({ path: '' }); + expect(parseFileHref('file:////server/share/a.ts#L3')).toEqual({ path: '' }); + expect(parseFileHref('//server/share/a.ts')).toEqual({ path: '' }); + expect(isAbsolutePath('\\\\server\\share\\a.ts')).toBe(false); + }); + it('joins and reads paths with either separator', () => { - for (const p of ['/abs/a.ts', 'C:\\p\\a.ts', 'c:/p/a.ts', '\\\\server\\share\\a.ts']) { + for (const p of ['/abs/a.ts', 'C:\\p\\a.ts', 'c:/p/a.ts']) { expect(isAbsolutePath(p)).toBe(true); expect(joinPath('C:\\proj', p)).toBe(p); } diff --git a/src/lib/fileRefs.ts b/src/lib/fileRefs.ts index e55e62a..432988a 100644 --- a/src/lib/fileRefs.ts +++ b/src/lib/fileRefs.ts @@ -73,6 +73,10 @@ export function parseFileHref(href: string): FileRef { let path = decodePath(main.split('?')[0].trim()); // `file:///C:/x` leaves `/C:/x`; on Windows the drive starts the path. if (/^\/[A-Za-z]:[\\/]/.test(path)) path = path.slice(1); + // A network share (`\\server\share`, `//server/share`) is never opened + // from a reply: Windows would connect to the server and send the user's + // credentials to it. + if (/^[\\/]{2}/.test(path)) return { path: '' }; const m = /^L(\d+)(?::(\d+)|C(\d+))?$/.exec(hash); if (!m) return { path }; const line = Number(m[1]); @@ -88,9 +92,9 @@ function decodePath(path: string): string { } } -/** An absolute path on any OS: `/x`, `C:\x`, `C:/x` or `\\server\share`. */ +/** An absolute path on any OS: `/x`, `C:\x` or `C:/x`. */ export function isAbsolutePath(path: string): boolean { - return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path); + return /^(?:\/|[A-Za-z]:[\\/])/.test(path); } /** `path` under `root`, unless it is absolute already. */