From ab6302b39cf154c52bb98733e971a2a85801adaf Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Mon, 27 Jul 2026 20:44:45 +0800 Subject: [PATCH 1/2] feat(miniapp): add LoopX console --- .../src/miniapp/bridge_builder.rs | 3 +- .../product-domains/src/miniapp/builtin.rs | 10 + .../loopx-console/esm_dependencies.json | 1 + .../builtin/assets/loopx-console/index.html | 189 ++++ .../builtin/assets/loopx-console/meta.json | 53 ++ .../builtin/assets/loopx-console/style.css | 871 ++++++++++++++++++ .../builtin/assets/loopx-console/ui.js | 810 ++++++++++++++++ .../builtin/assets/loopx-console/worker.js | 2 + .../src/miniapp/host_routing.rs | 16 +- .../tests/miniapp_contracts.rs | 71 +- .../scenes/miniapps/hooks/useMiniAppBridge.ts | 12 +- .../utils/buildMiniAppWorkspaceInfo.test.ts | 52 ++ .../utils/buildMiniAppWorkspaceInfo.ts | 23 + 13 files changed, 2106 insertions(+), 7 deletions(-) create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js create mode 100644 src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts create mode 100644 src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs index 457f2fe594..6e0705213f 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -5,7 +5,7 @@ use serde_json; /// Build the Runtime Adapter script (JS) to inject into the iframe. /// Exposes window.app with call(), fs.*, shell.*, net.*, os.*, storage.*, dialog.*, -/// ai.*, agent.*, deck.*, chat.*, clipboard.*, lifecycle, events. +/// ai.*, agent.*, deck.*, chat.*, workspace.*, clipboard.*, lifecycle, events. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, @@ -74,6 +74,7 @@ pub fn build_bridge_script( shell: {{ exec: (cmd, opts) => _call('shell.exec', Array.isArray(cmd) ? {{ args: cmd, ...(opts||{{}}) }} : {{ command: cmd, ...(opts||{{}}) }}) }}, net: {{ fetch: (url, opts) => _call('net.fetch', {{ url: typeof url === 'string' ? url : (url && url.url), ...(opts||{{}}) }}) }}, os: {{ info: () => _call('os.info', {{}}) }}, + workspace: {{ info: () => _rpc('workspace.info', {{}}) }}, system: {{ openExternal: (url) => _rpc('system.openExternal', {{ url }}), revealInFolder: (path) => _rpc('system.revealInFolder', {{ path }}), diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index cc7d09437a..3595597221 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -149,6 +149,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/coding-selfie/worker.js"), esm_dependencies_json: "[]", }, + BuiltinMiniAppBundle { + id: "builtin-loopx-console", + version: 1, + meta_json: include_str!("builtin/assets/loopx-console/meta.json"), + html: include_str!("builtin/assets/loopx-console/index.html"), + css: include_str!("builtin/assets/loopx-console/style.css"), + ui_js: include_str!("builtin/assets/loopx-console/ui.js"), + worker_js: include_str!("builtin/assets/loopx-console/worker.js"), + esm_dependencies_json: "[]", + }, BuiltinMiniAppBundle { id: "builtin-ppt-live", version: 258, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html new file mode 100644 index 0000000000..62390ab9b7 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html @@ -0,0 +1,189 @@ + + + + + + LoopX Console + + +
+
+
+ +
+

LoopX 控制台

+

+ LoopX + + -- +

+
+
+
+ -- + +
+
+ + + +
+
+ -- + Goals +
+
+ -- + Progress +
+
+ -- + Gates +
+
+ -- + Runnable +
+
+ -- + Waiting +
+
+ +
+
+ +
+ Current workspace + -- + -- +
+
+
+ -- + +
+
+ +
+
+
+
+

Goals

+ 0 +
+
+ + + +
+
+
+
+ +
+
+ + Select a goal + Inspect its current route and next safe action. +
+ +
+
+
+ + +
+ + + +
+
+ + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json new file mode 100644 index 0000000000..82ea4bf423 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json @@ -0,0 +1,53 @@ +{ + "id": "builtin-loopx-console", + "name": "LoopX 控制台", + "description": "查看 LoopX 全局目标、待办与 gate,并在当前工作区发起或继续有边界的 Agent 执行。", + "icon": "Route", + "category": "developer", + "tags": [ + "LoopX", + "目标", + "Agent", + "内置" + ], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "shell": { + "allow": [ + "loopx" + ] + }, + "net": { + "allow": [] + }, + "node": { + "enabled": false + }, + "agent": { + "enabled": true, + "rate_limit_per_minute": 6 + } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "LoopX 控制台", + "description": "查看 LoopX 全局目标、待办与 gate,并在当前工作区发起或继续有边界的 Agent 执行。", + "tags": ["LoopX", "目标", "Agent", "内置"] + }, + "en-US": { + "name": "LoopX Console", + "description": "Inspect global LoopX goals, work and gates, then start or continue a bounded Agent run in the current workspace.", + "tags": ["LoopX", "goals", "Agent", "built-in"] + }, + "zh-TW": { + "name": "LoopX 控制台", + "description": "查看 LoopX 全局目標、待辦與 gate,並在目前工作區發起或繼續有邊界的 Agent 執行。", + "tags": ["LoopX", "目標", "Agent", "內置"] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css new file mode 100644 index 0000000000..8afb750122 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css @@ -0,0 +1,871 @@ +:root { + color-scheme: light dark; + font-family: var(--bitfun-font-sans, Inter, "Segoe UI", system-ui, sans-serif); + font-synthesis: none; + --lx-bg: var(--bitfun-bg, #f5f7f8); + --lx-surface: var(--bitfun-bg-secondary, #ffffff); + --lx-surface-subtle: var(--bitfun-bg-tertiary, #eef1f3); + --lx-border: var(--bitfun-border, #d8dee3); + --lx-text: var(--bitfun-text, #1c252b); + --lx-muted: var(--bitfun-text-muted, #64727c); + --lx-accent: var(--bitfun-accent, #087f71); + --lx-accent-contrast: var(--bitfun-accent-text, var(--bitfun-bg, #ffffff)); + --lx-success: var(--bitfun-success, #16805c); + --lx-warning: var(--bitfun-warning, #a56300); + --lx-danger: var(--bitfun-error, #bd3a45); + --lx-info: var(--bitfun-info, #236f9f); + background: var(--lx-bg); + color: var(--lx-text); +} + +html[data-theme-type="dark"] { + color-scheme: dark; +} + +html[data-theme-type="light"] { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + width: 100%; + min-width: 300px; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--lx-bg); +} + +button, +textarea { + color: inherit; + font: inherit; +} + +button { + border: 0; +} + +button:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--lx-accent); + outline-offset: 2px; +} + +.app-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--lx-bg); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 54px; + padding: 8px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.brand, +.toolbar, +.toolbar-status, +.section-heading, +.workspace-main, +.goal-primary, +.goal-meta, +.detail-meta, +.modal-actions { + display: flex; + align-items: center; +} + +.brand { + min-width: 0; + gap: 10px; +} + +.brand-mark { + display: grid; + flex: 0 0 32px; + width: 32px; + height: 32px; + place-items: center; + border-radius: 6px; + background: var(--lx-accent); + color: var(--lx-accent-contrast); +} + +.brand-mark svg { + width: 19px; + height: 19px; +} + +.brand-copy { + min-width: 0; +} + +.brand-copy h1 { + margin: 0; + overflow: hidden; + font-size: 15px; + font-weight: 650; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-copy p { + margin: 2px 0 0; + color: var(--lx-muted); + font-size: 11px; + letter-spacing: 0; +} + +.brand-meta { + display: flex; + align-items: center; + gap: 5px; +} + +.brand-meta .dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--lx-muted); +} + +.toolbar { + flex: 0 0 auto; + gap: 8px; +} + +.toolbar-status { + gap: 6px; + color: var(--lx-muted); + font-size: 12px; + white-space: nowrap; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--lx-muted); +} + +.status-dot.online { + background: var(--lx-success); +} + +.status-dot.error { + background: var(--lx-danger); +} + +.icon-button, +.primary-button, +.secondary-button, +.segment-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + border-radius: 5px; + cursor: pointer; +} + +.icon-button { + width: 32px; + padding: 0; + border: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.icon-button svg { + width: 16px; + height: 16px; +} + +.icon-button:hover:not(:disabled), +.secondary-button:hover:not(:disabled), +.segment-button:hover:not(:disabled) { + background: var(--lx-surface-subtle); +} + +.icon-button.loading svg { + animation: spin 800ms linear infinite; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.notice { + display: flex; + align-items: center; + gap: 10px; + min-height: 34px; + padding: 6px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--lx-warning) 35%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface)); + color: var(--lx-warning); + font-size: 12px; +} + +.notice[hidden] { + display: none; +} + +.notice-message { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-button { + flex: 0 0 auto; + padding: 2px 4px; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.summary-strip { + display: grid; + grid-template-columns: repeat(5, minmax(84px, 1fr)); + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.metric { + min-width: 0; + padding: 9px 14px; + border-right: 1px solid var(--lx-border); +} + +.metric:last-child { + border-right: 0; +} + +.metric-value { + display: block; + overflow: hidden; + font-size: 18px; + font-weight: 650; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric-label { + display: block; + margin-top: 2px; + overflow: hidden; + color: var(--lx-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-band { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 48px; + padding: 7px 14px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-bg); +} + +.workspace-main { + min-width: 0; + gap: 8px; +} + +.workspace-main svg { + flex: 0 0 auto; + width: 16px; + height: 16px; + color: var(--lx-muted); +} + +.workspace-copy { + min-width: 0; +} + +.workspace-name, +.workspace-path { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-name { + font-size: 12px; + font-weight: 600; +} + +.workspace-kicker { + display: none; +} + +.workspace-state { + max-width: 260px; + overflow: hidden; + color: var(--lx-muted); + font-size: 11px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-path { + margin-top: 1px; + color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 10px; +} + +.workspace-action { + flex: 0 0 auto; +} + +.primary-button, +.secondary-button { + gap: 6px; + min-width: 92px; + padding: 6px 11px; + font-size: 12px; + font-weight: 600; +} + +.primary-button { + background: var(--lx-accent); + color: var(--lx-accent-contrast); +} + +.primary-button:hover:not(:disabled) { + filter: brightness(0.94); +} + +.secondary-button { + border: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.primary-button svg, +.secondary-button svg { + width: 15px; + height: 15px; +} + +.console-grid { + display: grid; + flex: 1 1 auto; + grid-template-columns: minmax(270px, 34%) minmax(0, 1fr); + min-height: 0; + background: var(--lx-surface); +} + +.goals-pane, +.detail-pane { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.goals-pane { + border-right: 1px solid var(--lx-border); +} + +.pane-header { + position: sticky; + z-index: 2; + top: 0; + padding: 10px 12px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.section-heading { + justify-content: space-between; + gap: 8px; +} + +.section-heading h2 { + margin: 0; + font-size: 13px; + font-weight: 650; + letter-spacing: 0; +} + +.section-count { + color: var(--lx-muted); + font-size: 11px; +} + +.segments { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2px; + margin-top: 8px; + padding: 2px; + border: 1px solid var(--lx-border); + border-radius: 6px; + background: var(--lx-surface-subtle); +} + +.segment-button { + min-width: 0; + min-height: 26px; + padding: 4px 7px; + background: transparent; + color: var(--lx-muted); + font-size: 11px; +} + +.segment-button.active { + background: var(--lx-surface); + color: var(--lx-text); + font-weight: 600; + box-shadow: 0 1px 2px color-mix(in srgb, var(--lx-text) 12%, transparent); +} + +.goal-list { + margin: 0; + padding: 5px; + list-style: none; +} + +.goal-item { + position: relative; + display: grid; + width: 100%; + min-height: 66px; + gap: 6px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.goal-item:hover { + background: var(--lx-surface-subtle); +} + +.goal-item.selected { + border-color: color-mix(in srgb, var(--lx-accent) 50%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-accent) 8%, var(--lx-surface)); +} + +.goal-item.current::before { + position: absolute; + top: 9px; + bottom: 9px; + left: 2px; + width: 3px; + border-radius: 2px; + background: var(--lx-accent); + content: ""; +} + +.goal-primary { + min-width: 0; + justify-content: space-between; + gap: 8px; +} + +.goal-title { + min-width: 0; + overflow: hidden; + font-size: 12px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-status, +.detail-status { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: var(--lx-surface-subtle); + color: var(--lx-muted); + font-size: 10px; + font-weight: 600; +} + +.goal-status.action, +.detail-status.action { + background: color-mix(in srgb, var(--lx-success) 14%, var(--lx-surface)); + color: var(--lx-success); +} + +.goal-status.waiting, +.detail-status.waiting { + background: color-mix(in srgb, var(--lx-warning) 14%, var(--lx-surface)); + color: var(--lx-warning); +} + +.goal-path { + overflow: hidden; + color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-meta { + gap: 10px; + color: var(--lx-muted); + font-size: 10px; +} + +.goal-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + display: grid; + min-height: 130px; + padding: 24px; + place-items: center; + color: var(--lx-muted); + font-size: 12px; + text-align: center; +} + +.detail-pane { + padding: 0 18px 24px; +} + +.detail-header { + position: sticky; + z-index: 2; + top: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 14px 0 12px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.detail-heading { + min-width: 0; +} + +.detail-heading h2 { + margin: 0; + overflow-wrap: anywhere; + font-size: 16px; + font-weight: 650; + letter-spacing: 0; +} + +.detail-meta { + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + color: var(--lx-muted); + font-size: 10px; +} + +.detail-meta .separator::before { + content: "\00b7"; +} + +.detail-action { + flex: 0 0 auto; +} + +.detail-section { + padding: 13px 0; + border-bottom: 1px solid var(--lx-border); +} + +.detail-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-bottom: 1px solid var(--lx-border); +} + +.detail-facts > div { + min-width: 0; + padding: 10px 9px 10px 0; +} + +.detail-facts span, +.detail-facts strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-facts span { + color: var(--lx-muted); + font-size: 10px; +} + +.detail-facts strong { + margin-top: 3px; + font-size: 11px; + font-weight: 600; +} + +.detail-list, +.timeline { + display: grid; + gap: 6px; +} + +.detail-list-item, +.timeline-item { + padding-left: 9px; + border-left: 2px solid var(--lx-border); + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.45; +} + +.detail-list-item.gate { + border-left-color: var(--lx-warning); +} + +.detail-list-item.todo { + border-left-color: var(--lx-success); +} + +.timeline-item time { + display: block; + margin-bottom: 2px; + color: var(--lx-muted); + font-size: 10px; +} + +.handoff-content { + max-height: 230px; + overflow: auto; + padding: 9px 10px; + border: 1px solid var(--lx-border); + border-radius: 5px; + background: var(--lx-bg); + color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.detail-section:last-child { + border-bottom: 0; +} + +.detail-section h3 { + margin: 0 0 7px; + color: var(--lx-muted); + font-size: 10px; + font-weight: 650; + letter-spacing: 0; + text-transform: uppercase; +} + +.detail-section p { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; +} + +.detail-section .muted { + color: var(--lx-muted); +} + +.detail-section .gate { + color: var(--lx-warning); +} + +.detail-section .todo { + color: var(--lx-text); +} + +.detail-section code { + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 11px; +} + +.modal { + width: min(520px, calc(100% - 28px)); + padding: 0; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: var(--lx-surface); + color: var(--lx-text); + box-shadow: 0 18px 45px color-mix(in srgb, #000000 28%, transparent); +} + +.modal::backdrop { + background: color-mix(in srgb, #000000 48%, transparent); +} + +.modal form { + margin: 0; +} + +.modal-header, +.modal-body, +.modal-actions { + padding: 13px 15px; +} + +.modal-header { + border-bottom: 1px solid var(--lx-border); +} + +.modal-header h2 { + margin: 0; + font-size: 14px; + letter-spacing: 0; +} + +.modal-body label { + display: block; + margin-bottom: 7px; + color: var(--lx-muted); + font-size: 11px; + font-weight: 600; +} + +.modal-body textarea { + display: block; + width: 100%; + min-height: 112px; + resize: vertical; + padding: 9px 10px; + border: 1px solid var(--lx-border); + border-radius: 5px; + background: var(--lx-bg); + font-size: 12px; + line-height: 1.5; +} + +.modal-actions { + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--lx-border); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 780px) { + .console-grid { + display: block; + overflow: auto; + } + + .goals-pane, + .detail-pane { + overflow: visible; + } + + .goals-pane { + border-right: 0; + border-bottom: 1px solid var(--lx-border); + } + + .pane-header, + .detail-header { + position: static; + } + + .goal-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .topbar, + .workspace-band, + .detail-header { + align-items: stretch; + } + + .topbar { + min-height: 50px; + } + + .toolbar-status { + display: none; + } + + .summary-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .metric:nth-child(3) { + border-right: 0; + } + + .metric:nth-child(n + 4) { + border-top: 1px solid var(--lx-border); + } + + .workspace-band, + .detail-header { + flex-direction: column; + } + + .workspace-action, + .detail-action, + .workspace-action button, + .detail-action button { + width: 100%; + } + + .goal-list { + grid-template-columns: minmax(0, 1fr); + } + + .detail-pane { + padding-right: 12px; + padding-left: 12px; + } +} + +@media (prefers-reduced-motion: reduce) { + .icon-button.loading svg { + animation: none; + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js new file mode 100644 index 0000000000..1843bb2611 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js @@ -0,0 +1,810 @@ +const CACHE_KEY = 'loopx-console-cache-v1'; +const CACHE_VERSION = 1; +const COMMAND_TIMEOUT_MS = 60_000; + +const copy = { + 'zh-CN': { + title: 'LoopX 控制台', + updated: '更新于 {time}', + neverUpdated: '尚未更新', + ready: '可用', + refreshing: '正在刷新', + cached: '正在显示缓存', + unavailable: '不可用', + retry: '重试', + refresh: '刷新', + goals: '目标', + progress: '最近进展', + gates: '用户 Gate', + runnable: '可执行', + waiting: '等待通道', + currentWorkspace: '当前工作区', + noWorkspace: '未打开工作区', + localWorkspace: '本地工作区', + remoteWorkspace: '远程工作区', + matchedGoal: '已匹配 LoopX 目标', + noMatchedGoal: '当前项目尚未接入 LoopX', + remoteReadOnly: '远程工作区首版仅支持查看', + globalReadOnly: '打开本地工作区后可执行', + newGoal: '新建目标', + all: '全部', + action: '行动项', + waitingFilter: '等待中', + noGoals: '没有可显示的目标', + selectGoal: '选择一个目标', + selectGoalCopy: '查看它的当前通道和下一步安全动作。', + current: '当前', + continue: '继续执行', + running: 'Agent 执行中', + status: '状态', + waitingOn: '等待对象', + adapter: '适配器', + nextAction: '下一步安全动作', + noNextAction: 'LoopX 暂未提供下一步动作。', + userGate: '用户 Gate', + visibleWork: '可见待办', + handoff: 'Agent 交接包', + reload: '重新加载', + detailLoading: '正在读取只读交接包...', + detailUnavailable: '交接包暂不可用,可重试加载。', + recentProgress: '最近进展', + startGoal: '新建 LoopX 目标', + goal: '目标描述', + goalPlaceholder: '描述你希望在当前项目持续推进的目标', + cancel: '取消', + startInAgent: '交给 Agent', + close: '关闭', + goalRequired: '请输入目标描述。', + loopxMissing: '未找到 LoopX。请先安装或修复 PATH,然后重试。', + incompatible: 'LoopX JSON 契约版本不兼容。已保留上一次成功数据。', + invalidData: 'LoopX 返回了无效数据。已保留上一次成功数据。', + refreshFailed: '刷新失败。已保留上一次成功数据。', + noCacheSuffix: '当前没有可用缓存。', + agentStarted: '已切换到 Agent 会话', + agentFailed: 'Agent 启动失败,请重试。', + currentOnly: '请先切换到该目标对应的本地工作区。', + cacheRestored: '已恢复上次成功数据,正在后台刷新。', + composerPlaceholder: '让 Agent 按 LoopX 控制面推进当前目标', + }, + 'en-US': { + title: 'LoopX Console', + updated: 'Updated {time}', + neverUpdated: 'Not updated yet', + ready: 'Ready', + refreshing: 'Refreshing', + cached: 'Showing cache', + unavailable: 'Unavailable', + retry: 'Retry', + refresh: 'Refresh', + goals: 'Goals', + progress: 'Recent progress', + gates: 'User gates', + runnable: 'Runnable', + waiting: 'Waiting lanes', + currentWorkspace: 'Current workspace', + noWorkspace: 'No workspace open', + localWorkspace: 'Local workspace', + remoteWorkspace: 'Remote workspace', + matchedGoal: 'Matched LoopX goal', + noMatchedGoal: 'This project is not connected to LoopX', + remoteReadOnly: 'Remote workspaces are read-only in this release', + globalReadOnly: 'Open a local workspace to run actions', + newGoal: 'New goal', + all: 'All', + action: 'Action', + waitingFilter: 'Waiting', + noGoals: 'No goals to show', + selectGoal: 'Select a goal', + selectGoalCopy: 'Inspect its current lane and next safe action.', + current: 'Current', + continue: 'Continue', + running: 'Agent is running', + status: 'Status', + waitingOn: 'Waiting on', + adapter: 'Adapter', + nextAction: 'Next safe action', + noNextAction: 'LoopX has not provided a next action.', + userGate: 'User gate', + visibleWork: 'Visible work', + handoff: 'Agent handoff', + reload: 'Reload', + detailLoading: 'Loading the read-only handoff packet...', + detailUnavailable: 'The handoff packet is unavailable. Retry when ready.', + recentProgress: 'Recent progress', + startGoal: 'Start a LoopX goal', + goal: 'Goal', + goalPlaceholder: 'Describe the durable goal for this project', + cancel: 'Cancel', + startInAgent: 'Start in Agent', + close: 'Close', + goalRequired: 'Enter a goal.', + loopxMissing: 'LoopX was not found. Install it or repair PATH, then retry.', + incompatible: 'The LoopX JSON contract is incompatible. The last successful data is preserved.', + invalidData: 'LoopX returned invalid data. The last successful data is preserved.', + refreshFailed: 'Refresh failed. The last successful data is preserved.', + noCacheSuffix: 'No cached data is available.', + agentStarted: 'Focused the Agent session', + agentFailed: 'Could not start the Agent. Retry when ready.', + currentOnly: 'Switch to this goal\'s local workspace before continuing.', + cacheRestored: 'Restored the last successful data and refreshing in the background.', + composerPlaceholder: 'Ask the Agent to advance the current goal through LoopX', + }, +}; + +const state = { + locale: 'zh-CN', + version: '', + registry: null, + summary: null, + fetchedAt: '', + workspace: { available: false, name: '', path: '', kind: null, isRemote: false }, + selectedGoalId: null, + filter: 'all', + packets: new Map(), + refreshing: false, + running: null, + notice: null, +}; + +const dom = {}; + +function $(id) { + return document.getElementById(id); +} + +function t(key, values = {}) { + const dictionary = copy[state.locale] || copy['en-US']; + let value = dictionary[key] || copy['en-US'][key] || key; + for (const [name, replacement] of Object.entries(values)) { + value = value.replace(`{${name}}`, String(replacement)); + } + return value; +} + +function resolveLocale(locale) { + const normalized = String(locale || '').toLowerCase(); + if (normalized.startsWith('zh')) return 'zh-CN'; + return 'en-US'; +} + +function createElement(tagName, className, text) { + const element = document.createElement(tagName); + if (className) element.className = className; + if (text !== undefined && text !== null) element.textContent = String(text); + return element; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function numberOrZero(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function formatTimestamp(value) { + if (!value) return '--'; + return String(value) + .replace('T', ' ') + .replace(/:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, ''); +} + +function normalizePath(value) { + let path = String(value || '').trim().replace(/\//g, '\\'); + while (path.length > 3 && path.endsWith('\\')) path = path.slice(0, -1); + if (/^[A-Za-z]:\\/.test(path)) path = path.toLowerCase(); + return path; +} + +function selectedGoal() { + return asArray(state.registry?.goals).find((goal) => goal.id === state.selectedGoalId) || null; +} + +function currentGoal() { + if (!state.workspace.available || state.workspace.isRemote) return null; + const workspacePath = normalizePath(state.workspace.path); + if (!workspacePath) return null; + return asArray(state.registry?.goals).find((goal) => normalizePath(goal.repo) === workspacePath) || null; +} + +function goalFacts(goalId) { + const lanes = asArray(state.summary?.lanes); + const groups = state.summary?.groups || {}; + return { + lane: lanes.find((item) => item.goal_id === goalId) || null, + gates: asArray(state.summary?.gates || groups.user_gates).filter((item) => item.goal_id === goalId), + todos: asArray(state.summary?.todos || groups.runnable_agent_work).filter((item) => item.goal_id === goalId), + progress: asArray(state.summary?.recent_progress || groups.recent_progress).filter( + (item) => item.goal_id === goalId, + ), + }; +} + +function goalCategory(goal) { + const facts = goalFacts(goal.id); + const status = String(facts.lane?.status || '').toLowerCase(); + const waitingOn = String(facts.lane?.waiting_on || goal.waiting_on || '').toLowerCase(); + if (facts.gates.length || status === 'operator_gate' || (waitingOn && waitingOn !== 'codex')) { + return 'waiting'; + } + if (facts.todos.length || status === 'eligible' || waitingOn === 'codex') return 'action'; + return 'neutral'; +} + +function isLoopxMissing(error) { + const message = String(error?.message || error || '').toLowerCase(); + return ( + message.includes('not found') || + message.includes('not recognized') || + message.includes('cannot find') || + message.includes('enoent') + ); +} + +function schemaError() { + const error = new Error('LOOPX_SCHEMA_INCOMPATIBLE'); + error.code = 'schema'; + return error; +} + +function invalidDataError() { + const error = new Error('LOOPX_INVALID_DATA'); + error.code = 'invalid'; + return error; +} + +async function runLoopx(args, timeout = COMMAND_TIMEOUT_MS) { + const result = await window.app.shell.exec(['loopx', ...args], { + cwd: window.app.appDataDir, + timeout, + }); + return String(result?.stdout || '').trim(); +} + +async function runLoopxJson(args) { + const stdout = await runLoopx(args); + try { + return JSON.parse(stdout.replace(/^\uFEFF/, '')); + } catch { + throw invalidDataError(); + } +} + +function validateRegistry(value) { + if (value?.schema_version !== '0.1') throw schemaError(); + if (value?.ok !== true || !Array.isArray(value.goals)) throw invalidDataError(); + return value; +} + +function validateSummary(value) { + if (value?.schema_version !== 'global_manager_command_response_v0') throw schemaError(); + if (value?.ok !== true || !value.summary || !Array.isArray(value.lanes)) throw invalidDataError(); + return value; +} + +function validatePacket(value, goalId) { + if ( + value?.ok !== true || + value?.goal_id !== goalId || + value?.handoff_only !== true || + (typeof value.handoff_text !== 'string' && typeof value.project_agent_handoff !== 'string') + ) { + throw schemaError(); + } + return value; +} + +async function readCache() { + try { + let cached = await window.app.storage.get(CACHE_KEY); + if (typeof cached === 'string') cached = JSON.parse(cached); + if ( + cached?.cacheVersion === CACHE_VERSION && + cached.registry?.schema_version === '0.1' && + cached.summary?.schema_version === 'global_manager_command_response_v0' + ) { + state.version = String(cached.version || ''); + state.registry = cached.registry; + state.summary = cached.summary; + state.fetchedAt = String(cached.fetchedAt || ''); + state.selectedGoalId = cached.selectedGoalId || null; + state.notice = { kind: 'cache', message: t('cacheRestored') }; + return true; + } + } catch { + // A broken cache is ignored; the live read below remains authoritative. + } + return false; +} + +async function writeCache() { + await window.app.storage.set(CACHE_KEY, { + cacheVersion: CACHE_VERSION, + version: state.version, + registry: state.registry, + summary: state.summary, + fetchedAt: state.fetchedAt, + selectedGoalId: state.selectedGoalId, + }); +} + +function setNotice(kind, message) { + state.notice = message ? { kind, message } : null; + renderNotice(); +} + +function renderNotice() { + if (!state.notice) { + dom.notice.hidden = true; + return; + } + dom.notice.hidden = false; + dom.notice.dataset.kind = state.notice.kind; + dom.noticeText.textContent = state.notice.message; + dom.noticeRetry.textContent = t('retry'); + dom.noticeRetry.hidden = state.notice.kind === 'cache'; +} + +function renderRuntime() { + dom.runtimeVersion.textContent = state.version || 'LoopX'; + dom.updatedAt.textContent = state.fetchedAt + ? t('updated', { time: formatTimestamp(state.fetchedAt) }) + : t('neverUpdated'); + dom.refreshButton.disabled = state.refreshing; + dom.refreshButton.classList.toggle('loading', state.refreshing); + dom.refreshButton.title = t('refresh'); + dom.refreshButton.setAttribute('aria-label', t('refresh')); + dom.runtimeDot.className = 'status-dot'; + if (state.refreshing) { + dom.runtimeStatus.textContent = t('refreshing'); + } else if (state.registry && state.summary) { + dom.runtimeStatus.textContent = state.notice?.kind === 'error' ? t('cached') : t('ready'); + dom.runtimeDot.classList.add('online'); + } else { + dom.runtimeStatus.textContent = t('unavailable'); + dom.runtimeDot.classList.add('error'); + } +} + +function renderMetrics() { + const metrics = state.summary?.summary || {}; + dom.metricGoals.textContent = state.registry ? numberOrZero(state.registry.goal_count) : '--'; + dom.metricProgress.textContent = state.summary ? numberOrZero(metrics.progress_count) : '--'; + dom.metricGates.textContent = state.summary ? numberOrZero(metrics.open_gate_count) : '--'; + dom.metricRunnable.textContent = state.summary ? numberOrZero(metrics.runnable_todo_count) : '--'; + dom.metricWaiting.textContent = state.summary ? numberOrZero(metrics.waiting_lane_count) : '--'; +} + +function renderWorkspace() { + const workspace = state.workspace; + const matched = currentGoal(); + dom.workspaceKicker.textContent = t('currentWorkspace'); + dom.workspaceName.textContent = workspace.available ? workspace.name || t('localWorkspace') : t('noWorkspace'); + dom.workspacePath.textContent = workspace.available ? workspace.path || '--' : '--'; + dom.newGoalButton.hidden = true; + if (!workspace.available) { + dom.workspaceState.textContent = t('globalReadOnly'); + } else if (workspace.isRemote) { + dom.workspaceState.textContent = t('remoteReadOnly'); + } else if (matched) { + dom.workspaceState.textContent = `${t('matchedGoal')}: ${matched.id}`; + } else { + dom.workspaceState.textContent = t('noMatchedGoal'); + dom.newGoalButton.hidden = false; + dom.newGoalButton.disabled = Boolean(state.running); + } +} + +function renderGoals() { + const goals = asArray(state.registry?.goals); + const filtered = goals.filter((goal) => state.filter === 'all' || goalCategory(goal) === state.filter); + const matched = currentGoal(); + dom.goalCount.textContent = String(filtered.length); + dom.goalsList.replaceChildren(); + + for (const button of dom.goalFilters.querySelectorAll('button')) { + button.classList.toggle('active', button.dataset.filter === state.filter); + } + + if (!filtered.length) { + dom.goalsList.append(createElement('div', 'empty-state', t('noGoals'))); + return; + } + + for (const goal of filtered) { + const facts = goalFacts(goal.id); + const category = goalCategory(goal); + const item = createElement('button', 'goal-item'); + item.type = 'button'; + item.classList.toggle('selected', goal.id === state.selectedGoalId); + item.classList.toggle('current', goal.id === matched?.id); + item.dataset.goalId = goal.id; + + const primary = createElement('span', 'goal-primary'); + primary.append(createElement('span', 'goal-title', goal.id)); + const status = createElement( + 'span', + `goal-status ${category}`, + facts.lane?.status || goal.status || '--', + ); + primary.append(status); + item.append(primary); + item.append(createElement('span', 'goal-path', goal.repo || '--')); + const meta = createElement('span', 'goal-meta'); + meta.append(createElement('span', '', facts.lane?.waiting_on || goal.waiting_on || goal.domain || '--')); + if (facts.gates.length) meta.append(createElement('span', '', `${facts.gates.length} gate`)); + if (facts.todos.length) meta.append(createElement('span', '', `${facts.todos.length} todo`)); + item.append(meta); + dom.goalsList.append(item); + } +} + +function renderList(container, items, className, formatter) { + container.replaceChildren(); + for (const item of items) { + container.append(createElement('div', `detail-list-item ${className}`, formatter(item))); + } +} + +function renderDetail() { + const goal = selectedGoal(); + dom.detailEmpty.hidden = Boolean(goal); + dom.detailContent.hidden = !goal; + if (!goal) return; + + const facts = goalFacts(goal.id); + const matched = currentGoal(); + const isCurrent = matched?.id === goal.id; + const packetState = state.packets.get(goal.id); + dom.detailHeading.textContent = goal.id; + dom.detailRepo.textContent = goal.repo || '--'; + dom.detailCurrent.hidden = !isCurrent; + dom.detailCurrent.textContent = t('current'); + dom.detailStatus.textContent = facts.lane?.status || goal.status || '--'; + dom.detailWaiting.textContent = facts.lane?.waiting_on || goal.waiting_on || '--'; + dom.detailAdapter.textContent = goal.adapter_kind || '--'; + dom.nextActionText.textContent = + facts.lane?.next_safe_action || goal.recommended_action || goal.next_probe || t('noNextAction'); + + dom.gateSection.hidden = facts.gates.length === 0; + dom.gateCount.textContent = String(facts.gates.length); + renderList(dom.gateList, facts.gates, 'gate', (item) => item.question || item.next_safe_action || item.gate_id); + + dom.todoSection.hidden = facts.todos.length === 0; + dom.todoCount.textContent = String(facts.todos.length); + renderList( + dom.todoList, + facts.todos, + 'todo', + (item) => [item.priority, item.title || item.next_safe_action || item.top_todo_id || item.todo_id] + .filter(Boolean) + .join(' '), + ); + + if (packetState?.status === 'ready') { + dom.handoffContent.textContent = + packetState.packet.handoff_text || packetState.packet.project_agent_handoff || '--'; + } else if (packetState?.status === 'error') { + dom.handoffContent.textContent = t('detailUnavailable'); + } else { + dom.handoffContent.textContent = t('detailLoading'); + } + + dom.progressSection.hidden = facts.progress.length === 0; + dom.progressList.replaceChildren(); + for (const progress of facts.progress) { + const item = createElement('div', 'timeline-item'); + item.append(createElement('time', '', formatTimestamp(progress.generated_at))); + item.append( + createElement('div', '', progress.recommended_action || progress.classification || '--'), + ); + dom.progressList.append(item); + } + + dom.continueButton.hidden = false; + dom.continueButton.disabled = !isCurrent || state.workspace.isRemote || Boolean(state.running); + dom.continueButton.title = isCurrent ? '' : t('currentOnly'); + dom.continueLabel.textContent = state.running?.goalId === goal.id ? t('running') : t('continue'); +} + +function applyCopy() { + document.documentElement.lang = state.locale; + dom.appTitle.textContent = t('title'); + dom.metricGoalsLabel.textContent = t('goals'); + dom.metricProgressLabel.textContent = t('progress'); + dom.metricGatesLabel.textContent = t('gates'); + dom.metricRunnableLabel.textContent = t('runnable'); + dom.metricWaitingLabel.textContent = t('waiting'); + dom.newGoalLabel.textContent = t('newGoal'); + dom.goalsHeading.textContent = t('goals'); + const filterButtons = dom.goalFilters.querySelectorAll('button'); + filterButtons[0].textContent = t('all'); + filterButtons[1].textContent = t('action'); + filterButtons[2].textContent = t('waitingFilter'); + dom.emptyTitle.textContent = t('selectGoal'); + dom.emptyCopy.textContent = t('selectGoalCopy'); + dom.statusLabel.textContent = t('status'); + dom.waitingLabel.textContent = t('waitingOn'); + dom.adapterLabel.textContent = t('adapter'); + dom.nextActionHeading.textContent = t('nextAction'); + dom.gateHeading.textContent = t('userGate'); + dom.todoHeading.textContent = t('visibleWork'); + dom.handoffHeading.textContent = t('handoff'); + dom.reloadDetailButton.textContent = t('reload'); + dom.progressHeading.textContent = t('recentProgress'); + dom.dialogTitle.textContent = t('startGoal'); + dom.goalTextLabel.textContent = t('goal'); + dom.goalText.placeholder = t('goalPlaceholder'); + dom.dialogCancel.textContent = t('cancel'); + dom.dialogSubmitLabel.textContent = t('startInAgent'); + dom.dialogClose.title = t('close'); + dom.dialogClose.setAttribute('aria-label', t('close')); +} + +function render() { + applyCopy(); + renderNotice(); + renderRuntime(); + renderMetrics(); + renderWorkspace(); + renderGoals(); + renderDetail(); +} + +async function loadPacket(goalId, force = false) { + if (!goalId || (!force && state.packets.has(goalId))) return; + state.packets.set(goalId, { status: 'loading' }); + renderDetail(); + try { + const packet = validatePacket( + await runLoopxJson(['--format', 'json', 'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5']), + goalId, + ); + state.packets.set(goalId, { status: 'ready', packet }); + } catch (error) { + state.packets.set(goalId, { status: 'error', error }); + } + renderDetail(); +} + +function refreshFailureMessage(error) { + const suffix = state.registry && state.summary ? '' : ` ${t('noCacheSuffix')}`; + if (error?.code === 'schema') return `${t('incompatible')}${suffix}`; + if (error?.code === 'invalid') return `${t('invalidData')}${suffix}`; + if (isLoopxMissing(error)) return `${t('loopxMissing')}${suffix}`; + return `${t('refreshFailed')}${suffix}`; +} + +async function refresh() { + if (state.refreshing) return; + state.refreshing = true; + renderRuntime(); + try { + const [version, registryValue] = await Promise.all([ + runLoopx(['--version'], 20_000), + runLoopxJson(['--format', 'json', 'registry']), + ]); + const registry = validateRegistry(registryValue); + const summary = validateSummary( + await runLoopxJson(['--format', 'json', 'global-summary', '--time-range', '24h', '--limit', '8']), + ); + + state.version = version; + state.registry = registry; + state.summary = summary; + state.fetchedAt = summary.generated_at || new Date().toISOString(); + state.notice = null; + const goals = asArray(registry.goals); + const matched = currentGoal(); + if (!goals.some((goal) => goal.id === state.selectedGoalId)) { + state.selectedGoalId = matched?.id || goals[0]?.id || null; + } + await writeCache().catch(() => { + // Live data remains authoritative even when the optional cache cannot be persisted. + }); + if (state.selectedGoalId) void loadPacket(state.selectedGoalId, true); + } catch (error) { + state.notice = { kind: 'error', message: refreshFailureMessage(error) }; + } finally { + state.refreshing = false; + render(); + } +} + +async function claimComposer() { + try { + await window.app.chat.claimComposer({ + title: t('title'), + composer: { placeholder: t('composerPlaceholder') }, + }); + } catch { + // The console remains useful as a read-only dashboard without composer ownership. + } +} + +async function startAgent(prompt, displayText, sessionName, goalId) { + const result = await window.app.agent.run(prompt, { + sessionName, + displayText, + enableTools: true, + }); + if (!result?.sessionId || !result?.turnId) throw new Error('AGENT_SESSION_UNAVAILABLE'); + state.running = { sessionId: result.sessionId, turnId: result.turnId, goalId }; + await window.app.chat.focusSession(result.sessionId); + setNotice('agent', t('agentStarted')); + render(); +} + +function newGoalPrompt(goalText) { + return `You are operating inside the current BitFun local workspace. +The host surface is BitFun's interactive chat-box, not Codex App. +LoopX is the control-plane source of truth. Do not directly edit .loopx files, goal state files, todo files, or the global registry. + +Start the following durable goal by running LoopX's guided flow in the current project: +loopx start-goal --guided --project . --goal-text --host-surface chat-box + + +${goalText} + + +Pass the goal text exactly as provided. Preserve every LoopX goal-selection, approval, and write gate. Do not confirm or approve any gate on the user's behalf. If LoopX asks for a decision, stop and surface that decision in this interactive chat. Only after the guided flow permits work, complete one bounded progress segment with an implementation artifact, targeted validation, and LoopX state writeback.`; +} + +function continueGoalPrompt(goalId) { + return `You are operating inside the current BitFun local workspace. +The host surface is BitFun's interactive chat-box, not Codex App. +LoopX is the control-plane source of truth for goal_id=${goalId}. Do not directly edit .loopx files, goal state files, todo files, or the global registry. + +First read the current handoff with this exact read-only command: +loopx --format json review-packet --goal-id ${goalId} --handoff-only --limit 5 + +Confirm the packet matches goal_id=${goalId}, then follow its current gate, owner, stop condition, quota, and next-action guidance. Never infer or auto-approve a user/controller gate. Complete exactly one bounded progress segment in this workspace, including a coherent implementation artifact, targeted validation, and LoopX writeback through the LoopX CLI. If the packet blocks execution or requires user authority, stop and ask in this interactive chat.`; +} + +async function submitNewGoal() { + const goalText = dom.goalText.value.trim(); + if (!goalText) { + dom.goalText.setCustomValidity(t('goalRequired')); + dom.goalText.reportValidity(); + return; + } + dom.goalText.setCustomValidity(''); + if (!state.workspace.available || state.workspace.isRemote || state.running) return; + dom.dialogSubmit.disabled = true; + try { + await startAgent(newGoalPrompt(goalText), goalText, `${t('title')}: ${state.workspace.name}`, null); + dom.newGoalDialog.close(); + dom.goalText.value = ''; + } catch { + setNotice('error', t('agentFailed')); + } finally { + dom.dialogSubmit.disabled = false; + } +} + +async function continueGoal() { + const goal = selectedGoal(); + if (!goal || currentGoal()?.id !== goal.id || state.workspace.isRemote || state.running) return; + try { + await loadPacket(goal.id, true); + const packetState = state.packets.get(goal.id); + if (packetState?.status !== 'ready') throw new Error('HANDOFF_UNAVAILABLE'); + await startAgent( + continueGoalPrompt(goal.id), + goalFacts(goal.id).lane?.next_safe_action || `${t('continue')}: ${goal.id}`, + `${t('title')}: ${goal.id}`, + goal.id, + ); + } catch { + setNotice('error', t('agentFailed')); + } +} + +function onAgentEvent(event) { + if (!state.running || !event || typeof event !== 'object') return; + const sourceEvent = String(event.sourceEvent || event.source_event || event.type || ''); + const sessionId = event.sessionId || event.session_id; + const turnId = event.turnId || event.turn_id; + if (sessionId && sessionId !== state.running.sessionId) return; + if (turnId && turnId !== state.running.turnId) return; + + if (sourceEvent.endsWith('dialog-turn-completed')) { + state.running = null; + setNotice(null, null); + render(); + void refresh(); + } else if ( + sourceEvent.endsWith('dialog-turn-failed') || + sourceEvent.endsWith('dialog-turn-cancelled') + ) { + state.running = null; + setNotice('error', t('agentFailed')); + render(); + } +} + +function bindEvents() { + dom.refreshButton.addEventListener('click', () => void refresh()); + dom.noticeRetry.addEventListener('click', () => void refresh()); + dom.goalFilters.addEventListener('click', (event) => { + const button = event.target.closest('button[data-filter]'); + if (!button) return; + state.filter = button.dataset.filter; + renderGoals(); + }); + dom.goalsList.addEventListener('click', (event) => { + const button = event.target.closest('[data-goal-id]'); + if (!button) return; + state.selectedGoalId = button.dataset.goalId; + renderGoals(); + renderDetail(); + void loadPacket(state.selectedGoalId); + }); + dom.reloadDetailButton.addEventListener('click', () => void loadPacket(state.selectedGoalId, true)); + dom.newGoalButton.addEventListener('click', () => { + dom.dialogWorkspace.textContent = state.workspace.path; + dom.newGoalDialog.showModal(); + dom.goalText.focus(); + }); + dom.dialogClose.addEventListener('click', () => dom.newGoalDialog.close()); + dom.dialogCancel.addEventListener('click', () => dom.newGoalDialog.close()); + dom.newGoalForm.addEventListener('submit', (event) => { + event.preventDefault(); + void submitNewGoal(); + }); + dom.goalText.addEventListener('input', () => dom.goalText.setCustomValidity('')); + dom.continueButton.addEventListener('click', () => void continueGoal()); + window.app.agent.onEvent(onAgentEvent); + window.app.onLocaleChange((locale) => { + state.locale = resolveLocale(locale); + render(); + void claimComposer(); + }); +} + +function collectDom() { + const ids = [ + 'app-title', 'runtime-version', 'updated-at', 'runtime-status', 'runtime-dot', 'refresh-button', + 'notice', 'notice-text', 'notice-retry', 'metric-goals', 'metric-goals-label', 'metric-progress', + 'metric-progress-label', 'metric-gates', 'metric-gates-label', 'metric-runnable', + 'metric-runnable-label', 'metric-waiting', 'metric-waiting-label', 'workspace-kicker', + 'workspace-name', 'workspace-path', 'workspace-state', 'new-goal-button', 'new-goal-label', + 'goals-heading', 'goal-count', 'goal-filters', 'goals-list', 'detail-empty', 'detail-content', + 'empty-title', 'empty-copy', 'detail-heading', 'detail-current', 'detail-repo', 'continue-button', + 'continue-label', 'status-label', 'detail-status', 'waiting-label', 'detail-waiting', 'adapter-label', + 'detail-adapter', 'next-action-heading', 'next-action-text', 'gate-section', 'gate-heading', + 'gate-count', 'gate-list', 'todo-section', 'todo-heading', 'todo-count', 'todo-list', + 'handoff-heading', 'reload-detail-button', 'handoff-content', 'progress-section', + 'progress-heading', 'progress-list', 'new-goal-dialog', 'new-goal-form', 'dialog-title', + 'dialog-close', 'goal-text-label', 'goal-text', 'dialog-workspace', 'dialog-cancel', + 'dialog-submit', 'dialog-submit-label', + ]; + for (const id of ids) { + const key = id.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + dom[key] = $(id); + } +} + +async function initialize() { + collectDom(); + state.locale = resolveLocale(window.app.locale); + bindEvents(); + const [hadCache, workspace] = await Promise.all([ + readCache(), + window.app.workspace.info().catch(() => state.workspace), + ]); + state.workspace = workspace || state.workspace; + const goals = asArray(state.registry?.goals); + const matched = currentGoal(); + if (!goals.some((goal) => goal.id === state.selectedGoalId)) { + state.selectedGoalId = matched?.id || goals[0]?.id || null; + } + render(); + await claimComposer(); + if (!hadCache) setNotice(null, null); + void refresh(); + if (state.selectedGoalId) void loadPacket(state.selectedGoalId); +} + +void initialize(); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js new file mode 100644 index 0000000000..e209ac8053 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/worker.js @@ -0,0 +1,2 @@ +// LoopX Console uses host primitives and the Agent bridge; no Node worker is needed. +module.exports = {}; diff --git a/src/crates/contracts/product-domains/src/miniapp/host_routing.rs b/src/crates/contracts/product-domains/src/miniapp/host_routing.rs index 4019367cca..04cff176ae 100644 --- a/src/crates/contracts/product-domains/src/miniapp/host_routing.rs +++ b/src/crates/contracts/product-domains/src/miniapp/host_routing.rs @@ -4,7 +4,12 @@ use std::path::{Path, PathBuf}; const HOST_NAMESPACES: &[&str] = &["fs", "shell", "os", "net"]; const DEFAULT_SHELL_EXEC_TIMEOUT_MS: u64 = 30_000; -const SHELL_EXEC_DEFAULT_ENV: [(&str, &str); 2] = [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")]; +const SHELL_EXEC_DEFAULT_ENV: [(&str, &str); 4] = [ + ("GIT_TERMINAL_PROMPT", "0"), + ("LC_ALL", "C"), + ("PYTHONUTF8", "1"), + ("PYTHONIOENCODING", "utf-8"), +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FsAccessMode { @@ -432,7 +437,7 @@ pub fn shell_exec_timeout_ms(explicit_timeout_ms: Option) -> u64 { explicit_timeout_ms.unwrap_or(DEFAULT_SHELL_EXEC_TIMEOUT_MS) } -pub fn shell_exec_default_env() -> [(&'static str, &'static str); 2] { +pub fn shell_exec_default_env() -> [(&'static str, &'static str); 4] { SHELL_EXEC_DEFAULT_ENV } @@ -524,7 +529,12 @@ mod tests { assert_eq!(shell_exec_timeout_ms(Some(8_000)), 8_000); assert_eq!( shell_exec_default_env(), - [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")] + [ + ("GIT_TERMINAL_PROMPT", "0"), + ("LC_ALL", "C"), + ("PYTHONUTF8", "1"), + ("PYTHONIOENCODING", "utf-8") + ] ); } diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index 8f3b10f8fc..8807880d7a 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -6,7 +6,7 @@ use bitfun_product_domains::miniapp::builtin::{ builtin_content_hash, builtin_source_files, legacy_builtin_version_marker_content, parse_builtin_install_marker, preserved_builtin_created_at, resolve_builtin_seed_action, resolve_builtin_seed_check, serialize_builtin_install_marker, should_seed_builtin_app, - BuiltinInstallMarker, BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck, + BuiltinInstallMarker, BuiltinMiniAppBundle, BuiltinSeedAction, BuiltinSeedCheck, BUILTIN_APPS, BUILTIN_INSTALL_MARKER, BUILTIN_PLACEHOLDER_COMPILED_HTML, LEGACY_BUILTIN_VERSION_MARKER, }; use bitfun_product_domains::miniapp::compiler::compile; @@ -557,6 +557,68 @@ fn miniapp_bridge_exposes_topic_session_lifecycle() { assert!(bridge.contains("chat.clearSession")); } +#[test] +fn miniapp_bridge_exposes_read_only_workspace_info() { + let bridge = build_bridge_script("app-1", "/tmp/app", "/tmp/workspace", "dark", "win32"); + + assert!(bridge.contains("workspace:")); + assert!(bridge.contains("workspace.info")); +} + +#[test] +fn builtin_loopx_console_has_complete_resources_and_minimal_permissions() { + let app = BUILTIN_APPS + .iter() + .find(|app| app.id == "builtin-loopx-console") + .expect("LoopX console should be registered as a built-in MiniApp"); + let meta: serde_json::Value = + serde_json::from_str(app.meta_json).expect("LoopX console metadata should be valid JSON"); + + assert!(!app.html.trim().is_empty()); + assert!(!app.css.trim().is_empty()); + assert!(!app.ui_js.trim().is_empty()); + assert!(!app.worker_js.trim().is_empty()); + assert_eq!(app.esm_dependencies_json, "[]"); + assert_eq!(meta["id"], "builtin-loopx-console"); + assert_eq!( + meta["permissions"]["shell"]["allow"], + serde_json::json!(["loopx"]) + ); + assert_eq!(meta["permissions"]["net"]["allow"], serde_json::json!([])); + assert_eq!(meta["permissions"]["node"]["enabled"], false); + assert_eq!(meta["permissions"]["agent"]["enabled"], true); + assert!(meta["permissions"].get("fs").is_none()); +} + +#[test] +fn builtin_loopx_console_keeps_loopx_on_read_only_control_plane_paths() { + let app = BUILTIN_APPS + .iter() + .find(|app| app.id == "builtin-loopx-console") + .expect("LoopX console should be registered as a built-in MiniApp"); + let source = format!( + "{}\n{}\n{}\n{}", + app.html, app.css, app.ui_js, app.worker_js + ); + let source_lower = source.to_lowercase(); + + assert!(source.contains("['--version']")); + assert!(source.contains("['--format', 'json', 'registry']")); + assert!(source.contains("'global-summary', '--time-range', '24h', '--limit', '8'")); + assert!( + source.contains("'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5'") + ); + assert!(source.contains("schema_version !== '0.1'")); + assert!(source.contains("schema_version !== 'global_manager_command_response_v0'")); + assert!(source.contains("window.app.appDataDir")); + assert!(source.contains("window.app.agent.run")); + assert!(source.contains("window.app.chat.claimComposer")); + assert!(source.contains("window.app.chat.focusSession")); + assert!(!source_lower.contains("acp")); + assert!(!source_lower.contains("serve-status")); + assert!(!source_lower.contains("--execute")); +} + #[test] fn miniapp_permission_policy_preserves_scope_resolution() { let permissions = MiniAppPermissions { @@ -841,7 +903,12 @@ fn miniapp_host_routing_preserves_existing_primitive_and_allowlist_contract() { assert_eq!(shell_exec_timeout_ms(Some(8_000)), 8_000); assert_eq!( shell_exec_default_env(), - [("GIT_TERMINAL_PROMPT", "0"), ("LC_ALL", "C")] + [ + ("GIT_TERMINAL_PROMPT", "0"), + ("LC_ALL", "C"), + ("PYTHONUTF8", "1"), + ("PYTHONIOENCODING", "utf-8") + ] ); assert!(command_basename_allowed(&[], "git")); diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index cfb647d33c..d88e508106 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -4,6 +4,7 @@ * ai.* → Host AI client, agent.* → Host agent bridge (hidden subagent runs), * deck.renderPage → hidden host WebView slide rasterization (export), * chat.* → floating session bubble composer claims and session focus, + * workspace.info → the host's existing read-only workspace context, * clipboard.* → Host navigator.clipboard. * Also handles bitfun/request-theme and pushes theme changes to the iframe. */ @@ -14,6 +15,7 @@ import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; import { buildMiniAppThemeVars } from '../utils/buildMiniAppThemeVars'; +import { buildMiniAppWorkspaceInfo } from '../utils/buildMiniAppWorkspaceInfo'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useI18n } from '@/infrastructure/i18n'; import type { MiniAppRunScope } from '../customization/miniAppCustomizationTypes'; @@ -52,13 +54,17 @@ export function useMiniAppBridge( app: MiniApp, runScope: MiniAppRunScope, ) { - const { workspacePath } = useCurrentWorkspace(); + const { workspace, workspaceName, workspacePath } = useCurrentWorkspace(); const { theme: currentTheme } = useTheme(); const { currentLanguage } = useI18n('scenes/miniapp'); const themeRef = useRef(currentTheme); themeRef.current = currentTheme; const workspacePathRef = useRef(workspacePath); workspacePathRef.current = workspacePath; + const workspaceInfoRef = useRef( + buildMiniAppWorkspaceInfo(workspace, workspaceName, workspacePath), + ); + workspaceInfoRef.current = buildMiniAppWorkspaceInfo(workspace, workspaceName, workspacePath); const localeRef = useRef(currentLanguage); localeRef.current = currentLanguage; @@ -131,6 +137,10 @@ export function useMiniAppBridge( } try { + if (method === 'workspace.info') { + reply(workspaceInfoRef.current); + return; + } if (method === 'worker.call') { const innerMethod = (params.method as string) ?? ''; const innerParams = (params.params as Record) ?? {}; diff --git a/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts new file mode 100644 index 0000000000..df56077ebc --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { WorkspaceKind, WorkspaceType, type WorkspaceInfo } from '@/shared/types'; +import { buildMiniAppWorkspaceInfo } from './buildMiniAppWorkspaceInfo'; + +function workspace(kind: WorkspaceKind): WorkspaceInfo { + return { + id: `workspace-${kind}`, + name: `${kind} workspace`, + rootPath: `C:\\projects\\${kind}`, + workspaceType: WorkspaceType.SingleProject, + workspaceKind: kind, + languages: [], + openedAt: '2026-07-27T00:00:00Z', + lastAccessed: '2026-07-27T00:00:00Z', + tags: [], + }; +} + +describe('buildMiniAppWorkspaceInfo', () => { + it('returns an unavailable contract when no workspace is open', () => { + expect(buildMiniAppWorkspaceInfo(null, 'stale name', 'C:\\stale')).toEqual({ + available: false, + name: '', + path: '', + kind: null, + isRemote: false, + }); + }); + + it('returns the current local workspace context', () => { + expect(buildMiniAppWorkspaceInfo(workspace(WorkspaceKind.Normal), 'BitFun', 'C:\\codeagent\\BitFun')) + .toEqual({ + available: true, + name: 'BitFun', + path: 'C:\\codeagent\\BitFun', + kind: WorkspaceKind.Normal, + isRemote: false, + }); + }); + + it('marks a remote workspace without changing its displayed path', () => { + expect(buildMiniAppWorkspaceInfo(workspace(WorkspaceKind.Remote), 'Remote app', '/srv/app')) + .toEqual({ + available: true, + name: 'Remote app', + path: '/srv/app', + kind: WorkspaceKind.Remote, + isRemote: true, + }); + }); +}); diff --git a/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts new file mode 100644 index 0000000000..6780fd34e1 --- /dev/null +++ b/src/web-ui/src/app/scenes/miniapps/utils/buildMiniAppWorkspaceInfo.ts @@ -0,0 +1,23 @@ +import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; + +export interface MiniAppWorkspaceInfo { + available: boolean; + name: string; + path: string; + kind: string | null; + isRemote: boolean; +} + +export function buildMiniAppWorkspaceInfo( + workspace: WorkspaceInfo | null | undefined, + workspaceName: string, + workspacePath: string, +): MiniAppWorkspaceInfo { + return { + available: Boolean(workspace), + name: workspace ? workspaceName : '', + path: workspace ? workspacePath : '', + kind: workspace?.workspaceKind ?? null, + isRemote: isRemoteWorkspace(workspace), + }; +} From b837d27e6f99332a9f1d2d2e6d000395f3dd38c0 Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Thu, 30 Jul 2026 19:36:08 +0800 Subject: [PATCH 2/2] feat(miniapp): rework LoopX console with autofix flow and main-scene agent routing - Overhaul LoopX console UI: Issue autofix pipeline, queue, gates, heartbeat - Fix agent.run missing workspacePath that caused agent startup failure - Add error logging to app.storage (loopx-console-error-log) for diagnostics - Remove floating bubble claim; route focusSession to main session scene - Add cron bridge support in useMiniAppBridge - Update miniapp contract tests - Ignore .loopx/.codex/.opencode/.local in .gitignore --- .gitignore | 6 + .../src/miniapp/bridge_builder.rs | 13 +- .../product-domains/src/miniapp/builtin.rs | 3 +- .../builtin/assets/loopx-console/index.html | 249 ++-- .../builtin/assets/loopx-console/meta.json | 30 +- .../builtin/assets/loopx-console/style.css | 1193 +++++++++++----- .../builtin/assets/loopx-console/ui.js | 1212 ++++++++++------- .../product-domains/src/miniapp/types.rs | 16 + .../tests/miniapp_contracts.rs | 40 +- .../scenes/miniapps/hooks/useMiniAppBridge.ts | 67 +- .../api/service-api/MiniAppAPI.ts | 1 + 11 files changed, 1920 insertions(+), 910 deletions(-) diff --git a/.gitignore b/.gitignore index 39499a6d2d..5a26d77395 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,9 @@ external/ /.bitfun/search/flashgrep-index/ .agents/ /.flashgrep-index-engine/ + +# LoopX / agent control-plane state (project-local; never commit) +.loopx/ +.codex/ +.opencode/ +.local/ diff --git a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs index 6e0705213f..b1472a147b 100644 --- a/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs +++ b/src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs @@ -5,7 +5,7 @@ use serde_json; /// Build the Runtime Adapter script (JS) to inject into the iframe. /// Exposes window.app with call(), fs.*, shell.*, net.*, os.*, storage.*, dialog.*, -/// ai.*, agent.*, deck.*, chat.*, workspace.*, clipboard.*, lifecycle, events. +/// ai.*, agent.*, cron.*, deck.*, chat.*, workspace.*, clipboard.*, lifecycle, events. pub fn build_bridge_script( app_id: &str, app_data_dir: &str, @@ -132,6 +132,17 @@ pub fn build_bridge_script( offEvent: (fn) => app.off('agent:event', fn), }}, + // Cron namespace — create/list/delete host scheduled jobs so an agentic + // MiniApp can wire its own recurring heartbeat (e.g. a LoopX goal driven + // by the host CronService) without leaving the iframe. Requires manifest + // permissions.cron.enabled = true; enforced host-side. The host reuses the + // existing cron Tauri commands; this bridge only routes the call. + cron: {{ + createJob: (request) => _rpc('cron.createJob', request || {{}}), + listJobs: (opts) => _rpc('cron.listJobs', opts || {{}}), + deleteJob: (jobId) => _rpc('cron.deleteJob', {{ jobId }}), + }}, + // Deck namespace — renders one slide HTML page in a hidden host WebView // and returns base64 PNG/PDF. Used by presentation MiniApps for // page-by-page export rasterization. diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 3595597221..e0d369850a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -151,7 +151,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ }, BuiltinMiniAppBundle { id: "builtin-loopx-console", - version: 1, + version: 6, meta_json: include_str!("builtin/assets/loopx-console/meta.json"), html: include_str!("builtin/assets/loopx-console/index.html"), css: include_str!("builtin/assets/loopx-console/style.css"), @@ -377,6 +377,7 @@ mod tests { "builtin-daily-divination", "builtin-regex-playground", "builtin-coding-selfie", + "builtin-loopx-console", "builtin-ppt-live", ] ); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html index 62390ab9b7..2edfd22b8a 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/index.html @@ -3,7 +3,7 @@ - LoopX Console + Issue Autofix
@@ -18,15 +18,30 @@
-

LoopX 控制台

+

Issue 自动修复

- LoopX + GitHub Issues - -- + LoopX

+
+
+ + + 其他目标 + 0 + +
+
+ LoopX 目标 + -- +
+
+
+
--
-
-
- -- - Goals -
-
- -- - Progress -
-
- -- - Gates -
-
- -- - Runnable -
-
- -- - Waiting -
-
-
- Current workspace + 执行工作区 -- --
-- -
+
+
+ -- + 自动推进 +
+
+ -- + Issue 队列 +
+
+ -- + 最近交付 +
+
+ -- + 需要确认 +
+
+ +
+
+

Issue 交付流水线

+ -- +
+
+
1监控
+
2筛选
+
3修复
+
4验证
+
5PR
+
+
+
-
+
-

Goals

- 0 -
-
- - - +
+

Issue 队列

+

--

+
+ 0
-
+
+
+
+
- Select a goal - Inspect its current route and next safe action. + 导入当前 Open Issues + -- +
+
-
Status--
-
Waiting on--
-
Adapter--
+
模式--
+
状态--
+
等待对象--
+
下次推进--
-

Next safe action

+

当前执行

+ --

--

- - -
@@ -167,20 +202,66 @@

Recent progress

-
+ + +
+ + +
diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json index 82ea4bf423..89ce4fbaae 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/meta.json @@ -1,16 +1,17 @@ { "id": "builtin-loopx-console", - "name": "LoopX 控制台", - "description": "查看 LoopX 全局目标、待办与 gate,并在当前工作区发起或继续有边界的 Agent 执行。", + "name": "Issue 自动修复", + "description": "首次导入当前仓库已有的 Open Issues,并通过 LoopX 原生流程完成筛选、修复、验证和 PR 交付。", "icon": "Route", "category": "developer", "tags": [ "LoopX", - "目标", + "GitHub Issues", + "自动修复", "Agent", "内置" ], - "version": 1, + "version": 6, "created_at": 0, "updated_at": 0, "permissions": { @@ -28,25 +29,28 @@ "agent": { "enabled": true, "rate_limit_per_minute": 6 + }, + "cron": { + "enabled": true } }, "ai_context": null, "i18n": { "locales": { "zh-CN": { - "name": "LoopX 控制台", - "description": "查看 LoopX 全局目标、待办与 gate,并在当前工作区发起或继续有边界的 Agent 执行。", - "tags": ["LoopX", "目标", "Agent", "内置"] + "name": "Issue 自动修复", + "description": "首次导入当前仓库已有的 Open Issues,并通过 LoopX 原生流程完成筛选、修复、验证和 PR 交付。", + "tags": ["LoopX", "GitHub Issues", "自动修复", "Agent", "内置"] }, "en-US": { - "name": "LoopX Console", - "description": "Inspect global LoopX goals, work and gates, then start or continue a bounded Agent run in the current workspace.", - "tags": ["LoopX", "goals", "Agent", "built-in"] + "name": "Issue Autofix", + "description": "Import the repository's currently open GitHub Issues once and use LoopX's native flow to triage, fix, validate, and deliver pull requests.", + "tags": ["LoopX", "GitHub Issues", "autofix", "Agent", "built-in"] }, "zh-TW": { - "name": "LoopX 控制台", - "description": "查看 LoopX 全局目標、待辦與 gate,並在目前工作區發起或繼續有邊界的 Agent 執行。", - "tags": ["LoopX", "目標", "Agent", "內置"] + "name": "Issue 自動修復", + "description": "首次匯入目前倉庫已有的 Open Issues,並透過 LoopX 原生流程完成篩選、修復、驗證和 PR 交付。", + "tags": ["LoopX", "GitHub Issues", "自動修復", "Agent", "內置"] } } } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css index 8afb750122..d1a1ad9c28 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/style.css @@ -37,7 +37,7 @@ html[data-theme-type="light"] { html, body { width: 100%; - min-width: 300px; + min-width: 320px; height: 100%; margin: 0; overflow: hidden; @@ -45,6 +45,8 @@ body { } button, +input, +select, textarea { color: inherit; font: inherit; @@ -55,42 +57,52 @@ button { } button:focus-visible, -textarea:focus-visible { +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible { outline: 2px solid var(--lx-accent); outline-offset: 2px; } .app-shell { display: flex; - flex-direction: column; width: 100%; height: 100%; + min-height: 0; + flex-direction: column; background: var(--lx-bg); } -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - min-height: 54px; - padding: 8px 14px; - border-bottom: 1px solid var(--lx-border); - background: var(--lx-surface); -} - +.topbar, .brand, .toolbar, .toolbar-status, -.section-heading, +.brand-meta, +.workspace-band, .workspace-main, -.goal-primary, -.goal-meta, +.workspace-action, +.section-heading, +.detail-title-row, +.detail-action, .detail-meta, -.modal-actions { +.modal-actions, +.check-control, +.target-workspace { display: flex; align-items: center; } +.topbar { + position: relative; + z-index: 20; + justify-content: space-between; + min-height: 58px; + padding: 9px 16px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-surface); +} + .brand { min-width: 0; gap: 10px; @@ -98,18 +110,18 @@ textarea:focus-visible { .brand-mark { display: grid; - flex: 0 0 32px; - width: 32px; - height: 32px; + flex: 0 0 34px; + width: 34px; + height: 34px; place-items: center; - border-radius: 6px; + border-radius: 7px; background: var(--lx-accent); color: var(--lx-accent-contrast); } .brand-mark svg { - width: 19px; - height: 19px; + width: 20px; + height: 20px; } .brand-copy { @@ -120,7 +132,7 @@ textarea:focus-visible { margin: 0; overflow: hidden; font-size: 15px; - font-weight: 650; + font-weight: 680; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; @@ -130,16 +142,21 @@ textarea:focus-visible { margin: 2px 0 0; color: var(--lx-muted); font-size: 11px; - letter-spacing: 0; } .brand-meta { - display: flex; - align-items: center; - gap: 5px; + min-width: 0; + gap: 6px; +} + +.brand-meta span:not(.dot) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .brand-meta .dot { + flex: 0 0 auto; width: 3px; height: 3px; border-radius: 50%; @@ -173,14 +190,186 @@ textarea:focus-visible { background: var(--lx-danger); } +.goal-switcher { + position: relative; +} + +.goal-switcher summary { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 6px; + padding: 5px 9px; + border: 1px solid var(--lx-border); + border-radius: 5px; + background: var(--lx-surface); + color: var(--lx-muted); + cursor: pointer; + font-size: 12px; + list-style: none; +} + +.goal-switcher summary::-webkit-details-marker { + display: none; +} + +.goal-switcher summary:hover { + background: var(--lx-surface-subtle); + color: var(--lx-text); +} + +.goal-switcher summary svg { + width: 15px; + height: 15px; +} + +.goal-menu { + position: absolute; + z-index: 30; + top: calc(100% + 7px); + right: 0; + width: min(380px, calc(100vw - 28px)); + max-height: min(540px, calc(100vh - 92px)); + overflow: hidden; + border: 1px solid var(--lx-border); + border-radius: 7px; + background: var(--lx-surface); + box-shadow: 0 14px 34px color-mix(in srgb, #000000 24%, transparent); +} + +.goal-menu-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--lx-border); +} + +.goal-menu-header strong { + font-size: 12px; +} + +.goal-menu-header span { + overflow: hidden; + color: var(--lx-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-list { + max-height: min(470px, calc(100vh - 142px)); + overflow: auto; + padding: 5px; +} + +.goal-item { + position: relative; + display: grid; + width: 100%; + min-height: 60px; + gap: 5px; + padding: 8px 9px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.goal-item:hover { + background: var(--lx-surface-subtle); +} + +.goal-item.selected { + border-color: color-mix(in srgb, var(--lx-accent) 44%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-accent) 7%, var(--lx-surface)); +} + +.goal-item.current::before { + position: absolute; + top: 8px; + bottom: 8px; + left: 2px; + width: 3px; + border-radius: 2px; + background: var(--lx-accent); + content: ""; +} + +.goal-primary { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.goal-title { + min-width: 0; + overflow: hidden; + font-size: 12px; + font-weight: 620; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-path, +.goal-meta { + overflow: hidden; + color: var(--lx-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-path { + font-family: Consolas, "SFMono-Regular", monospace; +} + +.goal-meta { + display: flex; + gap: 9px; +} + +.goal-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.goal-status, +.detail-status { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + background: var(--lx-surface-subtle); + color: var(--lx-muted); + font-size: 10px; + font-weight: 650; +} + +.goal-status.action, +.detail-status.action { + background: color-mix(in srgb, var(--lx-success) 14%, var(--lx-surface)); + color: var(--lx-success); +} + +.goal-status.waiting, +.detail-status.waiting { + background: color-mix(in srgb, var(--lx-warning) 14%, var(--lx-surface)); + color: var(--lx-warning); +} + .icon-button, .primary-button, .secondary-button, -.segment-button { +.danger-action-button { display: inline-flex; + min-height: 32px; align-items: center; justify-content: center; - min-height: 32px; border-radius: 5px; cursor: pointer; } @@ -192,14 +381,15 @@ textarea:focus-visible { background: var(--lx-surface); } -.icon-button svg { - width: 16px; - height: 16px; +.icon-button svg, +.primary-button svg, +.secondary-button svg { + width: 15px; + height: 15px; } .icon-button:hover:not(:disabled), -.secondary-button:hover:not(:disabled), -.segment-button:hover:not(:disabled) { +.secondary-button:hover:not(:disabled) { background: var(--lx-surface-subtle); } @@ -212,20 +402,84 @@ button:disabled { opacity: 0.48; } +.primary-button, +.secondary-button, +.danger-action-button { + gap: 6px; + min-width: 92px; + padding: 6px 11px; + font-size: 12px; + font-weight: 620; +} + +.primary-button { + background: var(--lx-accent); + color: var(--lx-accent-contrast); +} + +.primary-button:hover:not(:disabled), +.danger-action-button:hover:not(:disabled) { + filter: brightness(0.94); +} + +.secondary-button { + border: 1px solid var(--lx-border); + background: var(--lx-surface); +} + +.compact-button { + min-width: 0; + min-height: 28px; + padding: 4px 9px; +} + +.danger-button { + color: var(--lx-danger); +} + +.danger-button:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--lx-danger) 45%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-danger) 8%, var(--lx-surface)); +} + +.danger-action-button { + border: 1px solid var(--lx-danger); + background: var(--lx-danger); + color: var(--lx-accent-contrast); +} + +.text-button { + flex: 0 0 auto; + padding: 2px 4px; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 11px; + font-weight: 620; +} + .notice { display: flex; align-items: center; gap: 10px; min-height: 34px; - padding: 6px 14px; + padding: 6px 16px; border-bottom: 1px solid color-mix(in srgb, var(--lx-warning) 35%, var(--lx-border)); background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface)); color: var(--lx-warning); font-size: 12px; } -.notice[hidden] { - display: none; +.notice[data-kind="error"] { + border-bottom-color: color-mix(in srgb, var(--lx-danger) 35%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-danger) 8%, var(--lx-surface)); + color: var(--lx-danger); +} + +.notice[data-kind="success"] { + border-bottom-color: color-mix(in srgb, var(--lx-success) 35%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-success) 8%, var(--lx-surface)); + color: var(--lx-success); } .notice-message { @@ -236,26 +490,95 @@ button:disabled { white-space: nowrap; } -.text-button { +.workspace-band { + justify-content: space-between; + gap: 16px; + min-height: 52px; + padding: 7px 16px; + border-bottom: 1px solid var(--lx-border); + background: var(--lx-bg); +} + +.workspace-main { + min-width: 0; + gap: 9px; +} + +.workspace-main > svg { flex: 0 0 auto; - padding: 2px 4px; - background: transparent; - color: inherit; - cursor: pointer; + width: 17px; + height: 17px; + color: var(--lx-muted); +} + +.workspace-copy { + min-width: 0; +} + +.workspace-kicker, +.workspace-name, +.workspace-path { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-kicker { + margin-bottom: 1px; + color: var(--lx-muted); + font-size: 9px; + font-weight: 650; + text-transform: uppercase; +} + +.workspace-name { font-size: 12px; - font-weight: 600; + font-weight: 620; +} + +.workspace-path { + margin-top: 1px; + color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 10px; +} + +.workspace-action { + min-width: 0; + flex: 0 1 auto; + justify-content: flex-end; + gap: 10px; +} + +.workspace-state { + max-width: 420px; + overflow: hidden; + color: var(--lx-muted); + font-size: 11px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-state.warning { + color: var(--lx-warning); +} + +.workspace-state.ready { + color: var(--lx-success); } .summary-strip { display: grid; - grid-template-columns: repeat(5, minmax(84px, 1fr)); + grid-template-columns: repeat(4, minmax(110px, 1fr)); border-bottom: 1px solid var(--lx-border); background: var(--lx-surface); } .metric { min-width: 0; - padding: 9px 14px; + padding: 9px 16px; border-right: 1px solid var(--lx-border); } @@ -267,304 +590,336 @@ button:disabled { display: block; overflow: hidden; font-size: 18px; - font-weight: 650; + font-weight: 680; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } +.metric-value--text { + font-size: 13px; + line-height: 21px; +} + +.metric-value.ready { + color: var(--lx-success); +} + +.metric-value.warning { + color: var(--lx-warning); +} + +.metric-value.error { + color: var(--lx-danger); +} + .metric-label { display: block; margin-top: 2px; overflow: hidden; color: var(--lx-muted); - font-size: 11px; + font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } -.workspace-band { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 48px; - padding: 7px 14px; +.pipeline { + padding: 10px 16px 11px; border-bottom: 1px solid var(--lx-border); - background: var(--lx-bg); + background: var(--lx-surface); } -.workspace-main { - min-width: 0; - gap: 8px; +.pipeline-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; } -.workspace-main svg { - flex: 0 0 auto; - width: 16px; - height: 16px; - color: var(--lx-muted); +.pipeline-heading h2 { + margin: 0; + font-size: 11px; + font-weight: 680; } -.workspace-copy { +.pipeline-heading span { min-width: 0; -} - -.workspace-name, -.workspace-path { - display: block; overflow: hidden; + color: var(--lx-muted); + font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } -.workspace-name { - font-size: 12px; - font-weight: 600; -} - -.workspace-kicker { - display: none; +.pipeline-stages { + display: grid; + grid-template-columns: repeat(5, minmax(84px, 1fr)); + gap: 0; } -.workspace-state { - max-width: 260px; - overflow: hidden; +.pipeline-stage { + position: relative; + display: flex; + min-width: 0; + min-height: 30px; + align-items: center; + gap: 7px; + padding: 4px 12px 4px 8px; + border-top: 1px solid var(--lx-border); + border-bottom: 1px solid var(--lx-border); + background: var(--lx-bg); color: var(--lx-muted); font-size: 11px; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; } -.workspace-path { - margin-top: 1px; - color: var(--lx-muted); - font-family: Consolas, "SFMono-Regular", monospace; - font-size: 10px; +.pipeline-stage:first-child { + border-left: 1px solid var(--lx-border); + border-radius: 5px 0 0 5px; } -.workspace-action { - flex: 0 0 auto; +.pipeline-stage:last-child { + border-right: 1px solid var(--lx-border); + border-radius: 0 5px 5px 0; } -.primary-button, -.secondary-button { - gap: 6px; - min-width: 92px; - padding: 6px 11px; - font-size: 12px; - font-weight: 600; +.pipeline-stage:not(:last-child)::after { + position: absolute; + z-index: 1; + top: 9px; + right: -5px; + width: 9px; + height: 9px; + border-top: 1px solid var(--lx-border); + border-right: 1px solid var(--lx-border); + background: inherit; + content: ""; + transform: rotate(45deg); } -.primary-button { - background: var(--lx-accent); - color: var(--lx-accent-contrast); +.stage-index { + display: grid; + flex: 0 0 18px; + width: 18px; + height: 18px; + place-items: center; + border: 1px solid var(--lx-border); + border-radius: 50%; + background: var(--lx-surface); + font-size: 9px; + font-weight: 700; } -.primary-button:hover:not(:disabled) { - filter: brightness(0.94); +.pipeline-stage.complete { + background: color-mix(in srgb, var(--lx-success) 9%, var(--lx-surface)); + color: var(--lx-success); } -.secondary-button { - border: 1px solid var(--lx-border); - background: var(--lx-surface); +.pipeline-stage.active { + background: color-mix(in srgb, var(--lx-info) 10%, var(--lx-surface)); + color: var(--lx-info); + font-weight: 650; } -.primary-button svg, -.secondary-button svg { - width: 15px; - height: 15px; +.pipeline-stage.blocked { + background: color-mix(in srgb, var(--lx-warning) 10%, var(--lx-surface)); + color: var(--lx-warning); +} + +.pipeline-stage.complete .stage-index, +.pipeline-stage.active .stage-index, +.pipeline-stage.blocked .stage-index { + border-color: currentColor; } .console-grid { display: grid; flex: 1 1 auto; - grid-template-columns: minmax(270px, 34%) minmax(0, 1fr); + grid-template-columns: minmax(320px, 31%) minmax(0, 1fr); min-height: 0; background: var(--lx-surface); } -.goals-pane, +.queue-pane, .detail-pane { min-width: 0; min-height: 0; overflow: auto; } -.goals-pane { +.queue-pane { border-right: 1px solid var(--lx-border); + background: var(--lx-bg); } .pane-header { position: sticky; - z-index: 2; + z-index: 5; top: 0; - padding: 10px 12px; + padding: 12px 13px 10px; border-bottom: 1px solid var(--lx-border); background: var(--lx-surface); } .section-heading { + min-width: 0; justify-content: space-between; - gap: 8px; + gap: 10px; } -.section-heading h2 { - margin: 0; - font-size: 13px; - font-weight: 650; - letter-spacing: 0; +.section-heading > div { + min-width: 0; } -.section-count { - color: var(--lx-muted); - font-size: 11px; +.section-heading h2, +.section-heading h3 { + margin: 0; + letter-spacing: 0; } -.segments { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 2px; - margin-top: 8px; - padding: 2px; - border: 1px solid var(--lx-border); - border-radius: 6px; - background: var(--lx-surface-subtle); +.section-heading h2 { + font-size: 13px; + font-weight: 680; } -.segment-button { - min-width: 0; - min-height: 26px; - padding: 4px 7px; - background: transparent; +.section-heading h3 { color: var(--lx-muted); - font-size: 11px; -} - -.segment-button.active { - background: var(--lx-surface); - color: var(--lx-text); - font-weight: 600; - box-shadow: 0 1px 2px color-mix(in srgb, var(--lx-text) 12%, transparent); + font-size: 10px; + font-weight: 680; + text-transform: uppercase; } -.goal-list { - margin: 0; - padding: 5px; - list-style: none; +.section-subtitle { + margin: 3px 0 0; + overflow: hidden; + color: var(--lx-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; } -.goal-item { - position: relative; - display: grid; - width: 100%; - min-height: 66px; - gap: 6px; - padding: 9px 10px; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - text-align: left; - cursor: pointer; +.section-count, +.count-badge { + color: var(--lx-muted); + font-size: 10px; } -.goal-item:hover { +.count-badge { + display: inline-grid; + min-width: 22px; + height: 20px; + padding: 0 5px; + place-items: center; + border: 1px solid var(--lx-border); + border-radius: 999px; background: var(--lx-surface-subtle); + font-weight: 650; } -.goal-item.selected { - border-color: color-mix(in srgb, var(--lx-accent) 50%, var(--lx-border)); - background: color-mix(in srgb, var(--lx-accent) 8%, var(--lx-surface)); +.queue-section { + min-height: 120px; + padding: 6px; } -.goal-item.current::before { - position: absolute; - top: 9px; - bottom: 9px; - left: 2px; - width: 3px; - border-radius: 2px; - background: var(--lx-accent); - content: ""; +.issue-list { + display: grid; + gap: 4px; } -.goal-primary { - min-width: 0; - justify-content: space-between; - gap: 8px; +.issue-item { + position: relative; + display: grid; + min-height: 76px; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 9px; + padding: 10px 10px 9px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--lx-surface); } -.goal-title { - min-width: 0; - overflow: hidden; - font-size: 12px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; +.issue-item:first-child { + border-color: color-mix(in srgb, var(--lx-info) 40%, var(--lx-border)); + background: color-mix(in srgb, var(--lx-info) 5%, var(--lx-surface)); } -.goal-status, -.detail-status { - flex: 0 0 auto; - padding: 2px 6px; - border-radius: 999px; +.issue-priority { + grid-row: 1 / span 2; + align-self: start; + min-width: 28px; + padding: 2px 5px; + border-radius: 4px; background: var(--lx-surface-subtle); color: var(--lx-muted); - font-size: 10px; - font-weight: 600; + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 9px; + font-weight: 700; + text-align: center; } -.goal-status.action, -.detail-status.action { - background: color-mix(in srgb, var(--lx-success) 14%, var(--lx-surface)); - color: var(--lx-success); +.issue-priority.p0 { + background: color-mix(in srgb, var(--lx-danger) 12%, var(--lx-surface)); + color: var(--lx-danger); } -.goal-status.waiting, -.detail-status.waiting { - background: color-mix(in srgb, var(--lx-warning) 14%, var(--lx-surface)); - color: var(--lx-warning); +.issue-priority.p1 { + background: color-mix(in srgb, var(--lx-info) 12%, var(--lx-surface)); + color: var(--lx-info); } -.goal-path { - overflow: hidden; - color: var(--lx-muted); - font-family: Consolas, "SFMono-Regular", monospace; - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; +.issue-title { + min-width: 0; + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 620; + line-height: 1.45; } -.goal-meta { - gap: 10px; +.issue-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; color: var(--lx-muted); - font-size: 10px; + font-size: 9px; } -.goal-meta span { +.issue-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.empty-state { +.queue-empty { display: grid; - min-height: 130px; - padding: 24px; + min-height: 160px; + padding: 22px; place-items: center; color: var(--lx-muted); - font-size: 12px; + font-size: 11px; text-align: center; } +.gate-section { + margin: 0 12px; + padding: 12px 0 16px; + border-top: 1px solid var(--lx-border); +} + .detail-pane { - padding: 0 18px 24px; + padding: 0 20px 20px; + background: var(--lx-surface); +} + +#detail-content { + display: flex; + min-height: 100%; + flex-direction: column; } .detail-header { position: sticky; - z-index: 2; + z-index: 4; top: 0; display: flex; align-items: flex-start; @@ -579,44 +934,47 @@ button:disabled { min-width: 0; } +.detail-title-row { + min-width: 0; + gap: 8px; +} + .detail-heading h2 { margin: 0; overflow-wrap: anywhere; font-size: 16px; - font-weight: 650; + font-weight: 680; letter-spacing: 0; } .detail-meta { - flex-wrap: wrap; - gap: 6px; - margin-top: 6px; + min-width: 0; + margin-top: 5px; color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; font-size: 10px; } -.detail-meta .separator::before { - content: "\00b7"; +.detail-meta span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .detail-action { flex: 0 0 auto; -} - -.detail-section { - padding: 13px 0; - border-bottom: 1px solid var(--lx-border); + gap: 7px; } .detail-facts { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); border-bottom: 1px solid var(--lx-border); } .detail-facts > div { min-width: 0; - padding: 10px 9px 10px 0; + padding: 10px 10px 10px 0; } .detail-facts span, @@ -629,100 +987,147 @@ button:disabled { .detail-facts span { color: var(--lx-muted); - font-size: 10px; + font-size: 9px; } .detail-facts strong { margin-top: 3px; font-size: 11px; - font-weight: 600; + font-weight: 630; +} + +.detail-section { + padding: 14px 0; + border-bottom: 1px solid var(--lx-border); +} + +.next-action-section { + background: color-mix(in srgb, var(--lx-info) 4%, var(--lx-surface)); +} + +.detail-section p { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; +} + +.adapter-label { + overflow: hidden; + color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.progress-section { + flex: 0 0 auto; + min-height: 0; } .detail-list, .timeline { display: grid; - gap: 6px; + gap: 7px; } .detail-list-item, .timeline-item { - padding-left: 9px; + padding-left: 10px; border-left: 2px solid var(--lx-border); overflow-wrap: anywhere; font-size: 11px; - line-height: 1.45; + line-height: 1.5; } .detail-list-item.gate { border-left-color: var(--lx-warning); } -.detail-list-item.todo { - border-left-color: var(--lx-success); +.timeline-item { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 10px; + border-left-color: var(--lx-info); } .timeline-item time { - display: block; - margin-bottom: 2px; color: var(--lx-muted); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 9px; +} + +.diagnostics { + margin-top: 0; + border-bottom: 1px solid var(--lx-border); +} + +.diagnostics > summary { + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--lx-muted); + cursor: pointer; font-size: 10px; + font-weight: 680; + list-style-position: inside; + text-transform: uppercase; +} + +.diagnostics > summary .text-button { + margin-left: auto; + text-transform: none; } .handoff-content { - max-height: 230px; + min-height: min(240px, 28vh); + max-height: 300px; overflow: auto; - padding: 9px 10px; + padding: 10px 11px; border: 1px solid var(--lx-border); - border-radius: 5px; + border-bottom: 0; + border-radius: 5px 5px 0 0; background: var(--lx-bg); color: var(--lx-muted); font-family: Consolas, "SFMono-Regular", monospace; font-size: 10px; - line-height: 1.5; + line-height: 1.55; overflow-wrap: anywhere; white-space: pre-wrap; } -.detail-section:last-child { - border-bottom: 0; -} - -.detail-section h3 { - margin: 0 0 7px; +.empty-state { + display: grid; + min-height: 220px; + padding: 30px; + place-content: center; + justify-items: center; + gap: 8px; color: var(--lx-muted); - font-size: 10px; - font-weight: 650; - letter-spacing: 0; - text-transform: uppercase; -} - -.detail-section p { - margin: 0; - overflow-wrap: anywhere; font-size: 12px; - line-height: 1.55; - white-space: pre-wrap; -} - -.detail-section .muted { - color: var(--lx-muted); + text-align: center; } -.detail-section .gate { - color: var(--lx-warning); +.empty-state svg { + width: 24px; + height: 24px; } -.detail-section .todo { - color: var(--lx-text); +.empty-state .primary-button { + margin-top: 6px; } -.detail-section code { - font-family: Consolas, "SFMono-Regular", monospace; - font-size: 11px; +.empty-state .primary-button svg { + width: 14px; + height: 14px; } .modal { - width: min(520px, calc(100% - 28px)); + width: min(560px, calc(100% - 28px)); padding: 0; border: 1px solid var(--lx-border); border-radius: 7px; @@ -742,7 +1147,7 @@ button:disabled { .modal-header, .modal-body, .modal-actions { - padding: 13px 15px; + padding: 14px 16px; } .modal-header { @@ -755,27 +1160,106 @@ button:disabled { letter-spacing: 0; } -.modal-body label { +.setup-form > label:not(.check-control), +.form-row label { display: block; - margin-bottom: 7px; + margin-bottom: 6px; color: var(--lx-muted); - font-size: 11px; - font-weight: 600; + font-size: 10px; + font-weight: 650; } -.modal-body textarea { - display: block; +.setup-form input[type="url"], +.setup-form select, +.setup-form textarea { width: 100%; - min-height: 112px; - resize: vertical; - padding: 9px 10px; + padding: 8px 9px; border: 1px solid var(--lx-border); border-radius: 5px; background: var(--lx-bg); font-size: 12px; +} + +.setup-form input[type="url"] { + margin-bottom: 13px; +} + +.setup-form textarea { + min-height: 88px; + resize: vertical; + line-height: 1.5; +} + +.form-row { + margin-bottom: 13px; +} + +.check-control { + gap: 8px; + min-height: 34px; + margin-bottom: 13px; + font-size: 11px; + cursor: pointer; +} + +.check-control input { + width: 15px; + height: 15px; + accent-color: var(--lx-accent); +} + +.target-workspace { + min-width: 0; + justify-content: space-between; + gap: 12px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--lx-border); + color: var(--lx-muted); + font-size: 10px; +} + +.target-workspace strong { + min-width: 0; + overflow: hidden; + color: var(--lx-text); + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.compact-modal { + width: min(440px, calc(100% - 28px)); +} + +.delete-dialog-body p { + margin: 0; + font-size: 12px; line-height: 1.5; } +.delete-dialog-body code { + display: block; + margin: 12px 0 0; + padding: 9px 10px; + border: 1px solid var(--lx-border); + border-radius: 5px; + background: var(--lx-bg); + overflow-wrap: anywhere; + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 11px; +} + +.delete-dialog-body .workspace-path { + margin: 6px 0 12px; +} + +.delete-dialog-body .modal-note { + color: var(--lx-muted); + font-size: 11px; +} + .modal-actions { justify-content: flex-end; gap: 8px; @@ -788,80 +1272,161 @@ button:disabled { } } -@media (max-width: 780px) { +@media (max-width: 900px) { .console-grid { - display: block; - overflow: auto; + grid-template-columns: minmax(280px, 38%) minmax(0, 1fr); } - .goals-pane, - .detail-pane { - overflow: visible; + .detail-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .goals-pane { - border-right: 0; + .detail-facts > div:nth-child(-n + 2) { border-bottom: 1px solid var(--lx-border); } +} - .pane-header, - .detail-header { - position: static; +@media (max-width: 720px) { + body { + overflow: auto; } - .goal-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + .app-shell { + min-height: 100%; + height: auto; + } + + .toolbar-status { + display: none; } -} -@media (max-width: 520px) { - .topbar, .workspace-band, .detail-header { align-items: stretch; + flex-direction: column; } - .topbar { - min-height: 50px; + .workspace-action { + justify-content: space-between; } - .toolbar-status { - display: none; + .workspace-state { + max-width: none; + text-align: left; } .summary-strip { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .metric:nth-child(3) { + .metric:nth-child(2) { border-right: 0; } - .metric:nth-child(n + 4) { + .metric:nth-child(n + 3) { border-top: 1px solid var(--lx-border); } - .workspace-band, + .pipeline-stages { + overflow-x: auto; + grid-template-columns: repeat(5, minmax(104px, 1fr)); + } + + .console-grid { + display: block; + } + + .queue-pane, + .detail-pane { + overflow: visible; + } + + .queue-pane { + border-right: 0; + border-bottom: 1px solid var(--lx-border); + } + + .pane-header, .detail-header { - flex-direction: column; + position: static; } - .workspace-action, - .detail-action, - .workspace-action button, - .detail-action button { + .queue-section { + max-height: 330px; + overflow: auto; + } + + .detail-action { width: 100%; } - .goal-list { - grid-template-columns: minmax(0, 1fr); + .detail-action .primary-button, + .detail-action .secondary-button { + flex: 1 1 auto; + } +} + +@media (max-width: 480px) { + .topbar { + min-height: 54px; + padding-right: 12px; + padding-left: 12px; + } + + .goal-switcher summary span:not(.section-count) { + display: none; } + .workspace-band, + .pipeline, .detail-pane { padding-right: 12px; padding-left: 12px; } + + .workspace-action { + align-items: stretch; + flex-direction: column; + } + + .workspace-action .secondary-button { + width: 100%; + } + + .pipeline-heading { + align-items: flex-start; + flex-direction: column; + gap: 3px; + } + + .detail-action { + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + } + + .detail-action #continue-button { + grid-column: 1 / -1; + } + + .detail-facts { + grid-template-columns: minmax(0, 1fr); + } + + .detail-facts > div { + border-bottom: 1px solid var(--lx-border); + } + + .timeline-item { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + + .modal-header, + .modal-body, + .modal-actions { + padding-right: 13px; + padding-left: 13px; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js index 1843bb2611..72c1dbce23 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/loopx-console/ui.js @@ -1,10 +1,14 @@ -const CACHE_KEY = 'loopx-console-cache-v1'; -const CACHE_VERSION = 1; +const CACHE_KEY = 'loopx-console-cache-v2'; +const CACHE_VERSION = 2; const COMMAND_TIMEOUT_MS = 60_000; +const DEFAULT_CADENCE_MS = 60 * 60 * 1000; +const LOG_KEY = 'loopx-console-error-log'; const copy = { 'zh-CN': { - title: 'LoopX 控制台', + title: 'Issue 自动修复', + otherGoals: '其他目标', + goals: 'LoopX 目标', updated: '更新于 {time}', neverUpdated: '尚未更新', ready: '可用', @@ -13,61 +17,101 @@ const copy = { unavailable: '不可用', retry: '重试', refresh: '刷新', - goals: '目标', - progress: '最近进展', - gates: '用户 Gate', - runnable: '可执行', - waiting: '等待通道', - currentWorkspace: '当前工作区', + executionWorkspace: '执行工作区', noWorkspace: '未打开工作区', localWorkspace: '本地工作区', - remoteWorkspace: '远程工作区', - matchedGoal: '已匹配 LoopX 目标', - noMatchedGoal: '当前项目尚未接入 LoopX', - remoteReadOnly: '远程工作区首版仅支持查看', - globalReadOnly: '打开本地工作区后可执行', - newGoal: '新建目标', - all: '全部', - action: '行动项', - waitingFilter: '等待中', - noGoals: '没有可显示的目标', - selectGoal: '选择一个目标', - selectGoalCopy: '查看它的当前通道和下一步安全动作。', + remoteReadOnly: '远程工作区暂不支持自动修复', + globalReadOnly: '打开本地工作区后可配置自动修复', + matchedGoal: '已绑定当前工作区', + noMatchedGoal: '尚未配置 GitHub Issue 自动修复', + workspaceMismatch: '监控源与当前工作区不一致,已暂停自动推进', + configureAutofix: '导入当前 Issues', + startTracking: '导入当前 Issues', + selectWorkspaceFirst: '请先在左侧选择一个本地工作区,再开始跟踪 Issue。', + monitor: '自动推进', + queue: '初始 Issue 队列', + recentDelivery: '最近交付', + confirmations: '需要确认', + pipeline: 'Issue 交付流水线', + pipelineIdle: '等待导入当前 Issues', + pipelineReady: '按初始队列逐个修复并交付 PR', + pipelineBlocked: '自动推进已暂停,请先处理异常', + pipelineMonitor: '监控', + pipelineTriage: '筛选', + pipelineFix: '修复', + pipelineValidate: '验证', + pipelinePr: 'PR', + noGoals: '没有其他 LoopX 目标', current: '当前', - continue: '继续执行', + continue: '立即推进', running: 'Agent 执行中', + mode: '模式', + modeAutofix: '自动修复 + PR', + modeTracking: '仅跟踪', + modeUnconfigured: '未配置', status: '状态', waitingOn: '等待对象', - adapter: '适配器', - nextAction: '下一步安全动作', + heartbeatStatus: '下次推进', + monitorReady: '运行中', + monitorMissing: '未配置', + monitorBroken: '需修复', + monitorUnavailable: '不可读取', + monitorRepair: '修复调度', + monitorRepairing: '正在修复', + monitorRepairSuccess: '自动推进已绑定到正确的 Goal 和工作区。', + monitorRepairFailed: '自动推进修复失败,请展开执行详情检查 LoopX。', + currentExecution: '当前执行', noNextAction: 'LoopX 暂未提供下一步动作。', - userGate: '用户 Gate', - visibleWork: '可见待办', - handoff: 'Agent 交接包', + queueEmpty: '初始导入范围内没有可执行 Issue。', + userGate: '需要确认', + executionDetails: '执行详情', reload: '重新加载', - detailLoading: '正在读取只读交接包...', - detailUnavailable: '交接包暂不可用,可重试加载。', - recentProgress: '最近进展', - startGoal: '新建 LoopX 目标', - goal: '目标描述', - goalPlaceholder: '描述你希望在当前项目持续推进的目标', + detailLoading: '正在读取 Goal 状态和交接包...', + detailUnavailable: '执行详情暂不可用,可重试加载。', + selectGoal: '导入当前 Open Issues', + selectGoalCopy: '指定 GitHub Issues 地址和推进频率。LoopX 只导入配置时已有的 Open Issues。', + setupTitle: '导入当前 Issues', + issueSource: 'GitHub Issues(首次导入)', + issueScopeNote: '之后新增的 Issue 不会加入;修复和临时文件都不得改动当前目录,代码只在独立 worktree 中执行。', + issueSourceRequired: '请输入有效的 GitHub Issues 地址。', + cadence: 'Agent 自动推进间隔', + cadence30m: '每 30 分钟(高频)', + cadence1h: '每小时(推荐)', + cadence6h: '每 6 小时(低频)', + cadence1d: '每天', + autoPr: '修复后推送 fork 并创建 PR', + constraints: '处理规则(可选)', + constraintsPlaceholder: '例如:只处理 bug 标签;每轮最多修复一个 Issue', + targetWorkspace: '执行工作区', cancel: '取消', - startInAgent: '交给 Agent', + startInAgent: '交给 Agent 导入', close: '关闭', - goalRequired: '请输入目标描述。', loopxMissing: '未找到 LoopX。请先安装或修复 PATH,然后重试。', incompatible: 'LoopX JSON 契约版本不兼容。已保留上一次成功数据。', invalidData: 'LoopX 返回了无效数据。已保留上一次成功数据。', refreshFailed: '刷新失败。已保留上一次成功数据。', noCacheSuffix: '当前没有可用缓存。', - agentStarted: '已切换到 Agent 会话', + agentStarted: 'Agent 已启动,正在导入初始 Issue;完成后会创建自动推进。', agentFailed: 'Agent 启动失败,请重试。', - currentOnly: '请先切换到该目标对应的本地工作区。', + currentOnly: '只能在该 Goal 对应的本地工作区推进。', + sourceMismatch: 'GitHub 仓库与当前目录不匹配,请检查地址。', cacheRestored: '已恢复上次成功数据,正在后台刷新。', - composerPlaceholder: '让 Agent 按 LoopX 控制面推进当前目标', + composerPlaceholder: '让 Agent 按 LoopX 推进当前 Issue', + heartbeatAutoCreated: '自动推进已创建,BitFun 将按设定频率处理初始 Issue 队列。', + deleteGoal: '删除目标', + deleteGoalTitle: '删除 LoopX 目标', + deleteGoalCopy: '这会断开该目标与对应项目的连接。', + deleteGoalArchive: 'LoopX 会先备份注册表,并归档该项目中的目标状态。', + deleteGoalConfirm: '确认删除', + deleteGoalDeleting: '正在删除', + deleteGoalSuccess: '目标已删除,原状态已归档。', + deleteGoalFailed: '目标删除失败,请检查 LoopX 状态后重试。', + deleteUnavailable: '该目标缺少可用的本地项目路径,暂时无法删除。', }, 'en-US': { - title: 'LoopX Console', + title: 'Issue Autofix', + otherGoals: 'Other goals', + goals: 'LoopX goals', updated: 'Updated {time}', neverUpdated: 'Not updated yet', ready: 'Ready', @@ -76,58 +120,96 @@ const copy = { unavailable: 'Unavailable', retry: 'Retry', refresh: 'Refresh', - goals: 'Goals', - progress: 'Recent progress', - gates: 'User gates', - runnable: 'Runnable', - waiting: 'Waiting lanes', - currentWorkspace: 'Current workspace', + executionWorkspace: 'Execution workspace', noWorkspace: 'No workspace open', localWorkspace: 'Local workspace', - remoteWorkspace: 'Remote workspace', - matchedGoal: 'Matched LoopX goal', - noMatchedGoal: 'This project is not connected to LoopX', - remoteReadOnly: 'Remote workspaces are read-only in this release', - globalReadOnly: 'Open a local workspace to run actions', - newGoal: 'New goal', - all: 'All', - action: 'Action', - waitingFilter: 'Waiting', - noGoals: 'No goals to show', - selectGoal: 'Select a goal', - selectGoalCopy: 'Inspect its current lane and next safe action.', + remoteReadOnly: 'Remote workspaces do not support autofix yet', + globalReadOnly: 'Open a local workspace to configure autofix', + matchedGoal: 'Bound to the current workspace', + noMatchedGoal: 'GitHub Issue autofix is not configured', + workspaceMismatch: 'The issue source does not match this workspace; automation is paused', + configureAutofix: 'Import current issues', + startTracking: 'Import current issues', + selectWorkspaceFirst: 'Select a local workspace in the sidebar before tracking issues.', + monitor: 'Auto advance', + queue: 'Initial issue queue', + recentDelivery: 'Recent delivery', + confirmations: 'Confirmations', + pipeline: 'Issue delivery pipeline', + pipelineIdle: 'Waiting to import current issues', + pipelineReady: 'Fix the initial queue and deliver pull requests', + pipelineBlocked: 'Automation is paused until the issue is fixed', + pipelineMonitor: 'Monitor', + pipelineTriage: 'Triage', + pipelineFix: 'Fix', + pipelineValidate: 'Validate', + pipelinePr: 'PR', + noGoals: 'No other LoopX goals', current: 'Current', - continue: 'Continue', - running: 'Agent is running', + continue: 'Advance now', + running: 'Agent running', + mode: 'Mode', + modeAutofix: 'Autofix + PR', + modeTracking: 'Tracking only', + modeUnconfigured: 'Not configured', status: 'Status', waitingOn: 'Waiting on', - adapter: 'Adapter', - nextAction: 'Next safe action', + heartbeatStatus: 'Next run', + monitorReady: 'Running', + monitorMissing: 'Not configured', + monitorBroken: 'Needs repair', + monitorUnavailable: 'Unavailable', + monitorRepair: 'Repair monitor', + monitorRepairing: 'Repairing', + monitorRepairSuccess: 'The monitor is now bound to the correct goal and workspace.', + monitorRepairFailed: 'Could not repair the monitor. Inspect execution details for LoopX state.', + currentExecution: 'Current execution', noNextAction: 'LoopX has not provided a next action.', - userGate: 'User gate', - visibleWork: 'Visible work', - handoff: 'Agent handoff', + queueEmpty: 'No actionable issues remain in the initial import.', + userGate: 'Confirmations', + executionDetails: 'Execution details', reload: 'Reload', - detailLoading: 'Loading the read-only handoff packet...', - detailUnavailable: 'The handoff packet is unavailable. Retry when ready.', - recentProgress: 'Recent progress', - startGoal: 'Start a LoopX goal', - goal: 'Goal', - goalPlaceholder: 'Describe the durable goal for this project', + detailLoading: 'Loading goal status and handoff...', + detailUnavailable: 'Execution details are unavailable. Retry when ready.', + selectGoal: 'Import current open issues', + selectGoalCopy: 'Choose a GitHub Issues URL and cadence. LoopX imports only the issues that are open during setup.', + setupTitle: 'Import current issues', + issueSource: 'GitHub Issues (initial import)', + issueScopeNote: 'Issues opened later are ignored. Fixes and temporary files must not change this checkout; code work runs in isolated worktrees.', + issueSourceRequired: 'Enter a valid GitHub Issues URL.', + cadence: 'Agent advance interval', + cadence30m: 'Every 30 minutes (high frequency)', + cadence1h: 'Hourly (recommended)', + cadence6h: 'Every 6 hours (low frequency)', + cadence1d: 'Daily', + autoPr: 'Push to a fork and open a PR after each fix', + constraints: 'Processing rules (optional)', + constraintsPlaceholder: 'For example: bug label only; at most one issue per run', + targetWorkspace: 'Execution workspace', cancel: 'Cancel', - startInAgent: 'Start in Agent', + startInAgent: 'Import with Agent', close: 'Close', - goalRequired: 'Enter a goal.', loopxMissing: 'LoopX was not found. Install it or repair PATH, then retry.', - incompatible: 'The LoopX JSON contract is incompatible. The last successful data is preserved.', - invalidData: 'LoopX returned invalid data. The last successful data is preserved.', - refreshFailed: 'Refresh failed. The last successful data is preserved.', + incompatible: 'The LoopX JSON contract is incompatible. Cached data is preserved.', + invalidData: 'LoopX returned invalid data. Cached data is preserved.', + refreshFailed: 'Refresh failed. Cached data is preserved.', noCacheSuffix: 'No cached data is available.', - agentStarted: 'Focused the Agent session', + agentStarted: 'The Agent started and is importing the initial issues; automatic advancement will be scheduled after it completes.', agentFailed: 'Could not start the Agent. Retry when ready.', - currentOnly: 'Switch to this goal\'s local workspace before continuing.', - cacheRestored: 'Restored the last successful data and refreshing in the background.', - composerPlaceholder: 'Ask the Agent to advance the current goal through LoopX', + currentOnly: 'This goal can only advance in its local workspace.', + sourceMismatch: 'The GitHub repository does not match the current folder.', + cacheRestored: 'Restored cached data and refreshing in the background.', + composerPlaceholder: 'Ask the Agent to advance the current issue through LoopX', + heartbeatAutoCreated: 'Automatic advancement is scheduled for the initial issue queue.', + deleteGoal: 'Delete goal', + deleteGoalTitle: 'Delete LoopX goal', + deleteGoalCopy: 'This disconnects the goal from its project.', + deleteGoalArchive: 'LoopX backs up the registry and archives the project state first.', + deleteGoalConfirm: 'Delete goal', + deleteGoalDeleting: 'Deleting', + deleteGoalSuccess: 'The goal was deleted and its previous state was archived.', + deleteGoalFailed: 'Could not delete the goal. Check LoopX state and retry.', + deleteUnavailable: 'This goal does not have an available local project path.', }, }; @@ -139,19 +221,61 @@ const state = { fetchedAt: '', workspace: { available: false, name: '', path: '', kind: null, isRemote: false }, selectedGoalId: null, - filter: 'all', packets: new Map(), + cronJobs: [], + cronAvailable: false, refreshing: false, + repairingHeartbeat: false, running: null, notice: null, + pendingNewGoal: null, + deletingGoalId: null, }; const dom = {}; -function $(id) { - return document.getElementById(id); +const errorLog = []; +const MAX_LOG_ENTRIES = 50; + +function logError(context, error) { + const entry = { + time: new Date().toISOString(), + context: String(context), + message: error?.message || String(error), + stack: error?.stack || '', + state: { + running: state.running ? { sessionId: state.running.sessionId, turnId: state.running.turnId, goalId: state.running.goalId } : null, + pendingNewGoal: state.pendingNewGoal ? { workspacePath: state.pendingNewGoal.workspacePath, issueUrl: state.pendingNewGoal.issueUrl } : null, + selectedGoalId: state.selectedGoalId, + workspaceAvailable: state.workspace.available, + workspacePath: state.workspace.path, + workspaceIsRemote: state.workspace.isRemote, + refreshing: state.refreshing, + repairingHeartbeat: state.repairingHeartbeat, + deletingGoalId: state.deletingGoalId, + registryGoals: asArray(state.registry?.goals).map((g) => g.id), + }, + }; + errorLog.push(entry); + if (errorLog.length > MAX_LOG_ENTRIES) errorLog.shift(); + console.error('[loopx-console]', context, error); + void flushErrorLog().catch(() => {}); +} + +async function flushErrorLog() { + try { await window.app.storage.set(LOG_KEY, errorLog); } catch { /* storage may be unavailable during init */ } } +async function readErrorLog() { + try { + let cached = await window.app.storage.get(LOG_KEY); + if (typeof cached === 'string') cached = JSON.parse(cached); + return Array.isArray(cached) ? cached : []; + } catch { return []; } +} + +function $(id) { return document.getElementById(id); } + function t(key, values = {}) { const dictionary = copy[state.locale] || copy['en-US']; let value = dictionary[key] || copy['en-US'][key] || key; @@ -162,9 +286,7 @@ function t(key, values = {}) { } function resolveLocale(locale) { - const normalized = String(locale || '').toLowerCase(); - if (normalized.startsWith('zh')) return 'zh-CN'; - return 'en-US'; + return String(locale || '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US'; } function createElement(tagName, className, text) { @@ -174,27 +296,25 @@ function createElement(tagName, className, text) { return element; } -function asArray(value) { - return Array.isArray(value) ? value : []; -} - -function numberOrZero(value) { - const number = Number(value); - return Number.isFinite(number) ? number : 0; -} +function asArray(value) { return Array.isArray(value) ? value : []; } +function numberOrZero(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } function formatTimestamp(value) { if (!value) return '--'; - return String(value) - .replace('T', ' ') - .replace(/:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, ''); + return String(value).replace('T', ' ').replace(/:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/, ''); +} + +function formatEpoch(value) { + const milliseconds = Number(value); + if (!Number.isFinite(milliseconds) || milliseconds <= 0) return '--'; + return new Intl.DateTimeFormat(state.locale, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + .format(new Date(milliseconds)); } function normalizePath(value) { let path = String(value || '').trim().replace(/\//g, '\\'); while (path.length > 3 && path.endsWith('\\')) path = path.slice(0, -1); - if (/^[A-Za-z]:\\/.test(path)) path = path.toLowerCase(); - return path; + return /^[A-Za-z]:\\/.test(path) ? path.toLowerCase() : path; } function selectedGoal() { @@ -204,71 +324,119 @@ function selectedGoal() { function currentGoal() { if (!state.workspace.available || state.workspace.isRemote) return null; const workspacePath = normalizePath(state.workspace.path); - if (!workspacePath) return null; return asArray(state.registry?.goals).find((goal) => normalizePath(goal.repo) === workspacePath) || null; } +function canDeleteGoal(goal) { + return Boolean(goal && state.workspace.available && !state.workspace.isRemote && String(goal.repo || '').trim()); +} + +function packetState(goalId) { return state.packets.get(goalId) || null; } + +function statusAttentionItem(goalId) { + return asArray(packetState(goalId)?.statusData?.attention_queue?.items) + .find((item) => item.goal_id === goalId) || null; +} + function goalFacts(goalId) { - const lanes = asArray(state.summary?.lanes); const groups = state.summary?.groups || {}; + const lanes = asArray(state.summary?.lanes); + const attention = statusAttentionItem(goalId); + const asset = attention?.project_asset || {}; + const detailedTodos = asArray(asset.agent_todos?.items || attention?.agent_todos?.first_open_items); + const statusProgress = asArray(packetState(goalId)?.statusData?.run_history?.recent_runs); return { - lane: lanes.find((item) => item.goal_id === goalId) || null, + lane: attention || lanes.find((item) => item.goal_id === goalId) || null, gates: asArray(state.summary?.gates || groups.user_gates).filter((item) => item.goal_id === goalId), - todos: asArray(state.summary?.todos || groups.runnable_agent_work).filter((item) => item.goal_id === goalId), - progress: asArray(state.summary?.recent_progress || groups.recent_progress).filter( - (item) => item.goal_id === goalId, - ), + todos: detailedTodos.length + ? detailedTodos.filter((item) => item.done !== true && item.status !== 'done') + : asArray(state.summary?.todos || groups.runnable_agent_work).filter((item) => item.goal_id === goalId), + progress: statusProgress.length + ? statusProgress + : asArray(state.summary?.recent_progress || groups.recent_progress).filter((item) => item.goal_id === goalId), + asset, }; } +function searchableGoalText(goalId) { + const goal = asArray(state.registry?.goals).find((item) => item.id === goalId); + const details = packetState(goalId); + return JSON.stringify({ goal, summary: goalFacts(goalId), status: details?.statusData, packet: details?.packet }); +} + +function detectIssueSource(goalId) { + const match = searchableGoalText(goalId).match(/https:\/\/github\.com\/([^/\s\"'<>]+)\/([^/\s\"'<>]+?)(?:\.git)?(?:\/issues(?:[/?#][^\s\"'<>]*)?)?(?=[\s\"'<>]|$)/i); + if (!match) return ''; + return `https://github.com/${match[1]}/${match[2]}/issues`; +} + +function issueRepository(source) { + const match = String(source || '').match(/^https:\/\/github\.com\/([^/]+)\/([^/?#]+)\/issues(?:[/?#].*)?$/i); + return match ? { owner: match[1], repo: match[2].replace(/\.git$/i, '') } : null; +} + +function repositoryMismatch(goal) { + const repository = issueRepository(detectIssueSource(goal?.id)); + if (!repository || !goal?.repo) return false; + const folder = normalizePath(goal.repo).split('\\').filter(Boolean).at(-1) || ''; + return folder.toLowerCase() !== repository.repo.toLowerCase(); +} + +function isIssueGoal(goal) { + if (!goal) return false; + const text = searchableGoalText(goal.id).toLowerCase(); + return Boolean(detectIssueSource(goal.id)) || text.includes('github_issue') || text.includes('issue monitor'); +} + +function isAutofixGoal(goalId) { + const text = searchableGoalText(goalId).toLowerCase(); + const fixes = /auto.?fix|修复|implement|pull request|\bpr\b/.test(text); + return isIssueGoal(asArray(state.registry?.goals).find((goal) => goal.id === goalId)) && fixes; +} + +function ownedHeartbeatJobs(goalId) { + return state.cronJobs.filter((job) => { + const name = String(job?.name || ''); + const text = String(job?.payload?.text || ''); + return name.endsWith(`: ${goalId}`) || text.includes(`goal_id=${goalId}`) || text.includes(`\`${goalId}\``); + }); +} + +function heartbeatFacts(goal) { + if (!state.cronAvailable) return { state: 'unavailable', job: null, jobs: [] }; + const jobs = ownedHeartbeatJobs(goal?.id); + const job = jobs.find((item) => normalizePath(item?.target?.workspace?.workspacePath) === normalizePath(goal?.repo)) || jobs[0] || null; + if (!job) return { state: 'missing', job: null, jobs }; + const healthy = job.enabled !== false && + normalizePath(job?.target?.workspace?.workspacePath) === normalizePath(goal?.repo) && + numberOrZero(job?.state?.consecutiveFailures) === 0; + return { state: healthy ? 'ready' : 'broken', job, jobs }; +} + function goalCategory(goal) { const facts = goalFacts(goal.id); - const status = String(facts.lane?.status || '').toLowerCase(); - const waitingOn = String(facts.lane?.waiting_on || goal.waiting_on || '').toLowerCase(); - if (facts.gates.length || status === 'operator_gate' || (waitingOn && waitingOn !== 'codex')) { - return 'waiting'; - } - if (facts.todos.length || status === 'eligible' || waitingOn === 'codex') return 'action'; + const status = String(facts.lane?.status || goal.status || '').toLowerCase(); + if (facts.gates.length || status.includes('gate')) return 'waiting'; + if (facts.todos.length || ['eligible', 'active'].includes(status)) return 'action'; return 'neutral'; } function isLoopxMissing(error) { const message = String(error?.message || error || '').toLowerCase(); - return ( - message.includes('not found') || - message.includes('not recognized') || - message.includes('cannot find') || - message.includes('enoent') - ); + return ['not found', 'not recognized', 'cannot find', 'enoent'].some((part) => message.includes(part)); } -function schemaError() { - const error = new Error('LOOPX_SCHEMA_INCOMPATIBLE'); - error.code = 'schema'; - return error; -} - -function invalidDataError() { - const error = new Error('LOOPX_INVALID_DATA'); - error.code = 'invalid'; - return error; -} +function schemaError() { const error = new Error('LOOPX_SCHEMA_INCOMPATIBLE'); error.code = 'schema'; return error; } +function invalidDataError() { const error = new Error('LOOPX_INVALID_DATA'); error.code = 'invalid'; return error; } -async function runLoopx(args, timeout = COMMAND_TIMEOUT_MS) { - const result = await window.app.shell.exec(['loopx', ...args], { - cwd: window.app.appDataDir, - timeout, - }); +async function runLoopx(args, timeout = COMMAND_TIMEOUT_MS, cwd = window.app.appDataDir) { + const result = await window.app.shell.exec(['loopx', ...args], { cwd, timeout }); return String(result?.stdout || '').trim(); } -async function runLoopxJson(args) { - const stdout = await runLoopx(args); - try { - return JSON.parse(stdout.replace(/^\uFEFF/, '')); - } catch { - throw invalidDataError(); - } +async function runLoopxJson(args, cwd) { + const stdout = await runLoopx(args, COMMAND_TIMEOUT_MS, cwd); + try { return JSON.parse(stdout.replace(/^\uFEFF/, '')); } catch { throw invalidDataError(); } } function validateRegistry(value) { @@ -284,14 +452,24 @@ function validateSummary(value) { } function validatePacket(value, goalId) { - if ( - value?.ok !== true || - value?.goal_id !== goalId || - value?.handoff_only !== true || - (typeof value.handoff_text !== 'string' && typeof value.project_agent_handoff !== 'string') - ) { - throw schemaError(); - } + if (value?.ok !== true || value?.goal_id !== goalId || value?.handoff_only !== true || + (typeof value.handoff_text !== 'string' && typeof value.project_agent_handoff !== 'string')) throw schemaError(); + return value; +} + +function validateGoalStatus(value, goalId) { + if (value?.ok !== true || value?.goal_filter !== goalId || numberOrZero(value?.status_contract?.schema_version) < 2) throw schemaError(); + return value; +} + +function validateGoalDeletion(value, goalId) { + if (value?.ok !== true || value?.schema_version !== 'loopx_project_uninstall_v0' || value?.execute !== true || + !asArray(value.goal_ids).includes(goalId)) throw schemaError(); + return value; +} + +function validateHeartbeatPrompt(value, goalId) { + if (value?.ok !== true || value?.goal_id !== goalId || typeof value?.task_body !== 'string' || !value.task_body.trim()) throw schemaError(); return value; } @@ -299,11 +477,8 @@ async function readCache() { try { let cached = await window.app.storage.get(CACHE_KEY); if (typeof cached === 'string') cached = JSON.parse(cached); - if ( - cached?.cacheVersion === CACHE_VERSION && - cached.registry?.schema_version === '0.1' && - cached.summary?.schema_version === 'global_manager_command_response_v0' - ) { + if (cached?.cacheVersion === CACHE_VERSION && cached.registry?.schema_version === '0.1' && + cached.summary?.schema_version === 'global_manager_command_response_v0') { state.version = String(cached.version || ''); state.registry = cached.registry; state.summary = cached.summary; @@ -312,53 +487,38 @@ async function readCache() { state.notice = { kind: 'cache', message: t('cacheRestored') }; return true; } - } catch { - // A broken cache is ignored; the live read below remains authoritative. - } + } catch { /* Live data remains authoritative. */ } return false; } async function writeCache() { await window.app.storage.set(CACHE_KEY, { - cacheVersion: CACHE_VERSION, - version: state.version, - registry: state.registry, - summary: state.summary, - fetchedAt: state.fetchedAt, - selectedGoalId: state.selectedGoalId, + cacheVersion: CACHE_VERSION, version: state.version, registry: state.registry, + summary: state.summary, fetchedAt: state.fetchedAt, selectedGoalId: state.selectedGoalId, }); } -function setNotice(kind, message) { - state.notice = message ? { kind, message } : null; - renderNotice(); -} +function setNotice(kind, message) { state.notice = message ? { kind, message } : null; renderNotice(); } function renderNotice() { - if (!state.notice) { - dom.notice.hidden = true; - return; - } + if (!state.notice) { dom.notice.hidden = true; return; } dom.notice.hidden = false; dom.notice.dataset.kind = state.notice.kind; dom.noticeText.textContent = state.notice.message; dom.noticeRetry.textContent = t('retry'); - dom.noticeRetry.hidden = state.notice.kind === 'cache'; + dom.noticeRetry.hidden = !['error'].includes(state.notice.kind); } function renderRuntime() { dom.runtimeVersion.textContent = state.version || 'LoopX'; - dom.updatedAt.textContent = state.fetchedAt - ? t('updated', { time: formatTimestamp(state.fetchedAt) }) - : t('neverUpdated'); + dom.updatedAt.textContent = state.fetchedAt ? t('updated', { time: formatTimestamp(state.fetchedAt) }) : t('neverUpdated'); dom.refreshButton.disabled = state.refreshing; dom.refreshButton.classList.toggle('loading', state.refreshing); dom.refreshButton.title = t('refresh'); dom.refreshButton.setAttribute('aria-label', t('refresh')); dom.runtimeDot.className = 'status-dot'; - if (state.refreshing) { - dom.runtimeStatus.textContent = t('refreshing'); - } else if (state.registry && state.summary) { + if (state.refreshing) dom.runtimeStatus.textContent = t('refreshing'); + else if (state.registry && state.summary) { dom.runtimeStatus.textContent = state.notice?.kind === 'error' ? t('cached') : t('ready'); dom.runtimeDot.classList.add('online'); } else { @@ -367,83 +527,121 @@ function renderRuntime() { } } -function renderMetrics() { - const metrics = state.summary?.summary || {}; - dom.metricGoals.textContent = state.registry ? numberOrZero(state.registry.goal_count) : '--'; - dom.metricProgress.textContent = state.summary ? numberOrZero(metrics.progress_count) : '--'; - dom.metricGates.textContent = state.summary ? numberOrZero(metrics.open_gate_count) : '--'; - dom.metricRunnable.textContent = state.summary ? numberOrZero(metrics.runnable_todo_count) : '--'; - dom.metricWaiting.textContent = state.summary ? numberOrZero(metrics.waiting_lane_count) : '--'; -} - function renderWorkspace() { + const goal = selectedGoal(); const workspace = state.workspace; - const matched = currentGoal(); - dom.workspaceKicker.textContent = t('currentWorkspace'); + const mismatch = repositoryMismatch(goal) || (goal && normalizePath(goal.repo) !== normalizePath(workspace.path)); + dom.workspaceKicker.textContent = t('executionWorkspace'); dom.workspaceName.textContent = workspace.available ? workspace.name || t('localWorkspace') : t('noWorkspace'); dom.workspacePath.textContent = workspace.available ? workspace.path || '--' : '--'; - dom.newGoalButton.hidden = true; - if (!workspace.available) { - dom.workspaceState.textContent = t('globalReadOnly'); - } else if (workspace.isRemote) { - dom.workspaceState.textContent = t('remoteReadOnly'); - } else if (matched) { - dom.workspaceState.textContent = `${t('matchedGoal')}: ${matched.id}`; - } else { - dom.workspaceState.textContent = t('noMatchedGoal'); - dom.newGoalButton.hidden = false; - dom.newGoalButton.disabled = Boolean(state.running); - } + dom.newGoalButton.hidden = false; + dom.newGoalButton.disabled = !workspace.available || workspace.isRemote || Boolean(state.running) || Boolean(state.deletingGoalId); + dom.newGoalButton.title = !workspace.available ? t('selectWorkspaceFirst') : ''; + dom.workspaceState.className = 'workspace-state'; + if (!workspace.available) dom.workspaceState.textContent = t('globalReadOnly'); + else if (workspace.isRemote) dom.workspaceState.textContent = t('remoteReadOnly'); + else if (mismatch) { dom.workspaceState.textContent = t('workspaceMismatch'); dom.workspaceState.classList.add('warning'); } + else if (goal && isIssueGoal(goal)) { dom.workspaceState.textContent = t('matchedGoal'); dom.workspaceState.classList.add('ready'); } + else dom.workspaceState.textContent = t('noMatchedGoal'); } function renderGoals() { const goals = asArray(state.registry?.goals); - const filtered = goals.filter((goal) => state.filter === 'all' || goalCategory(goal) === state.filter); const matched = currentGoal(); - dom.goalCount.textContent = String(filtered.length); + dom.goalCount.textContent = String(goals.length); dom.goalsList.replaceChildren(); - - for (const button of dom.goalFilters.querySelectorAll('button')) { - button.classList.toggle('active', button.dataset.filter === state.filter); - } - - if (!filtered.length) { - dom.goalsList.append(createElement('div', 'empty-state', t('noGoals'))); - return; - } - - for (const goal of filtered) { + if (!goals.length) { dom.goalsList.append(createElement('div', 'empty-state', t('noGoals'))); return; } + for (const goal of goals) { const facts = goalFacts(goal.id); const category = goalCategory(goal); const item = createElement('button', 'goal-item'); item.type = 'button'; + item.dataset.goalId = goal.id; item.classList.toggle('selected', goal.id === state.selectedGoalId); item.classList.toggle('current', goal.id === matched?.id); - item.dataset.goalId = goal.id; - const primary = createElement('span', 'goal-primary'); primary.append(createElement('span', 'goal-title', goal.id)); - const status = createElement( - 'span', - `goal-status ${category}`, - facts.lane?.status || goal.status || '--', - ); - primary.append(status); - item.append(primary); - item.append(createElement('span', 'goal-path', goal.repo || '--')); + primary.append(createElement('span', `goal-status ${category}`, facts.lane?.status || goal.status || '--')); + item.append(primary, createElement('span', 'goal-path', goal.repo || '--')); const meta = createElement('span', 'goal-meta'); - meta.append(createElement('span', '', facts.lane?.waiting_on || goal.waiting_on || goal.domain || '--')); - if (facts.gates.length) meta.append(createElement('span', '', `${facts.gates.length} gate`)); + if (isIssueGoal(goal)) meta.append(createElement('span', '', isAutofixGoal(goal.id) ? t('modeAutofix') : t('modeTracking'))); if (facts.todos.length) meta.append(createElement('span', '', `${facts.todos.length} todo`)); + if (facts.gates.length) meta.append(createElement('span', '', `${facts.gates.length} gate`)); item.append(meta); dom.goalsList.append(item); } } -function renderList(container, items, className, formatter) { - container.replaceChildren(); - for (const item of items) { - container.append(createElement('div', `detail-list-item ${className}`, formatter(item))); +function renderMetrics() { + const goal = selectedGoal(); + if (!goal) { + dom.metricMonitor.textContent = '--'; dom.metricQueue.textContent = '--'; + dom.metricProgress.textContent = '--'; dom.metricGates.textContent = '--'; return; + } + const facts = goalFacts(goal.id); + const heartbeat = heartbeatFacts(goal); + const monitorKey = heartbeat.state === 'ready' ? 'monitorReady' : heartbeat.state === 'missing' ? 'monitorMissing' : + heartbeat.state === 'unavailable' ? 'monitorUnavailable' : 'monitorBroken'; + dom.metricMonitor.textContent = t(monitorKey); + dom.metricMonitor.className = `metric-value metric-value--text ${heartbeat.state === 'ready' ? 'ready' : heartbeat.state === 'missing' ? 'warning' : 'error'}`; + dom.metricQueue.textContent = String(facts.todos.length); + dom.metricProgress.textContent = String(facts.progress.length); + dom.metricGates.textContent = String(facts.gates.length); +} + +function pipelineState(goal, facts, heartbeat) { + const text = searchableGoalText(goal.id).toLowerCase(); + const progress = JSON.stringify(facts.progress).toLowerCase(); + const mismatch = repositoryMismatch(goal) || normalizePath(goal.repo) !== normalizePath(state.workspace.path); + const monitor = mismatch || heartbeat.state === 'broken' ? 'blocked' : heartbeat.state === 'ready' ? 'complete' : detectIssueSource(goal.id) ? 'active' : ''; + const triage = /triage|classif|筛选|分类/.test(progress) ? 'complete' : facts.todos.length ? 'active' : ''; + const fix = /fix|implement|修复/.test(progress) ? 'complete' : /fix|implement|修复/.test(text) && facts.todos.length ? 'active' : ''; + const validate = /validat|\btest|验证|测试/.test(progress) ? 'complete' : /validat|\btest|验证|测试/.test(text) ? 'active' : ''; + const pr = /github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+|pull request.*(?:created|opened)|pr.*(?:创建|提交)/.test(progress) ? 'complete' : /pull request|\bpr\b/.test(text) ? 'active' : ''; + return { monitor, triage, fix, validate, pr, blocked: mismatch || heartbeat.state === 'broken' }; +} + +function renderPipeline() { + const goal = selectedGoal(); + const stages = ['monitor', 'triage', 'fix', 'validate', 'pr']; + if (!goal) { + dom.pipelineSummary.textContent = t('pipelineIdle'); + stages.forEach((name) => { dom[`pipeline${name[0].toUpperCase()}${name.slice(1)}`].className = 'pipeline-stage'; }); + return; + } + const status = pipelineState(goal, goalFacts(goal.id), heartbeatFacts(goal)); + dom.pipelineSummary.textContent = status.blocked ? t('pipelineBlocked') : t('pipelineReady'); + for (const name of stages) { + const element = dom[`pipeline${name[0].toUpperCase()}${name.slice(1)}`]; + element.className = `pipeline-stage${status[name] ? ` ${status[name]}` : ''}`; + } +} + +function renderIssueQueue(facts) { + dom.todoList.replaceChildren(); + dom.todoCount.textContent = String(facts.todos.length); + if (!facts.todos.length) { + dom.todoList.append(createElement('div', 'queue-empty', t('queueEmpty'))); + } else { + for (const todo of facts.todos) { + const item = createElement('article', 'issue-item'); + const priority = String(todo.priority || 'P2').toUpperCase(); + item.append(createElement('span', `issue-priority ${priority.toLowerCase()}`, priority)); + const body = createElement('div', 'issue-body'); + body.append(createElement('div', 'issue-title', todo.title || todo.text || todo.next_safe_action || todo.todo_id || '--')); + const meta = createElement('div', 'issue-meta'); + const issueNumber = String(todo.title || todo.text || '').match(/#\d+/)?.[0]; + [issueNumber, todo.action_kind, todo.task_class].filter(Boolean).forEach((value) => meta.append(createElement('span', '', value))); + body.append(meta); + item.append(body); + dom.todoList.append(item); + } + } + dom.gateSection.hidden = !facts.gates.length; + dom.gateCount.textContent = String(facts.gates.length); + dom.gateList.replaceChildren(); + for (const gate of facts.gates) { + dom.gateList.append(createElement('div', 'detail-list-item gate', gate.question || gate.next_safe_action || gate.gate_id || '--')); } } @@ -451,121 +649,120 @@ function renderDetail() { const goal = selectedGoal(); dom.detailEmpty.hidden = Boolean(goal); dom.detailContent.hidden = !goal; - if (!goal) return; + if (!goal) { + dom.emptyConfigureButton.disabled = !state.workspace.available || state.workspace.isRemote || Boolean(state.running); + dom.emptyConfigureButton.title = !state.workspace.available ? t('selectWorkspaceFirst') : ''; + dom.emptyCopy.textContent = state.workspace.available ? t('selectGoalCopy') : t('selectWorkspaceFirst'); + dom.queueSource.textContent = 'GitHub Issues'; + renderIssueQueue({ todos: [], gates: [] }); + return; + } const facts = goalFacts(goal.id); - const matched = currentGoal(); - const isCurrent = matched?.id === goal.id; - const packetState = state.packets.get(goal.id); + const details = packetState(goal.id); + const heartbeat = heartbeatFacts(goal); + const source = detectIssueSource(goal.id); + const isCurrent = normalizePath(goal.repo) === normalizePath(state.workspace.path); + const mismatch = !isCurrent || repositoryMismatch(goal); + dom.trackedSource.textContent = source ? source.replace('https://github.com/', '') : 'GitHub Issues'; + dom.queueSource.textContent = source || goal.repo || '--'; dom.detailHeading.textContent = goal.id; dom.detailRepo.textContent = goal.repo || '--'; dom.detailCurrent.hidden = !isCurrent; dom.detailCurrent.textContent = t('current'); + dom.detailCurrent.className = `detail-status ${goalCategory(goal)}`; + dom.detailMode.textContent = isIssueGoal(goal) ? (isAutofixGoal(goal.id) ? t('modeAutofix') : t('modeTracking')) : t('modeUnconfigured'); dom.detailStatus.textContent = facts.lane?.status || goal.status || '--'; dom.detailWaiting.textContent = facts.lane?.waiting_on || goal.waiting_on || '--'; - dom.detailAdapter.textContent = goal.adapter_kind || '--'; - dom.nextActionText.textContent = - facts.lane?.next_safe_action || goal.recommended_action || goal.next_probe || t('noNextAction'); - - dom.gateSection.hidden = facts.gates.length === 0; - dom.gateCount.textContent = String(facts.gates.length); - renderList(dom.gateList, facts.gates, 'gate', (item) => item.question || item.next_safe_action || item.gate_id); - - dom.todoSection.hidden = facts.todos.length === 0; - dom.todoCount.textContent = String(facts.todos.length); - renderList( - dom.todoList, - facts.todos, - 'todo', - (item) => [item.priority, item.title || item.next_safe_action || item.top_todo_id || item.todo_id] - .filter(Boolean) - .join(' '), - ); - - if (packetState?.status === 'ready') { - dom.handoffContent.textContent = - packetState.packet.handoff_text || packetState.packet.project_agent_handoff || '--'; - } else if (packetState?.status === 'error') { - dom.handoffContent.textContent = t('detailUnavailable'); - } else { - dom.handoffContent.textContent = t('detailLoading'); - } - - dom.progressSection.hidden = facts.progress.length === 0; + dom.detailHeartbeat.textContent = heartbeat.state === 'ready' + ? formatEpoch(heartbeat.job?.state?.nextRunAtMs) + : t(heartbeat.state === 'missing' ? 'monitorMissing' : heartbeat.state === 'unavailable' ? 'monitorUnavailable' : 'monitorBroken'); + dom.adapterLabel.textContent = facts.asset?.support_mode || goal.adapter_kind || '--'; + dom.nextActionText.textContent = facts.lane?.recommended_action || facts.lane?.next_safe_action || + facts.asset?.next_action || goal.recommended_action || goal.next_probe || t('noNextAction'); + renderIssueQueue(facts); + + if (details?.status === 'ready') dom.handoffContent.textContent = details.packet.handoff_text || details.packet.project_agent_handoff || '--'; + else if (details?.status === 'error') dom.handoffContent.textContent = t('detailUnavailable'); + else dom.handoffContent.textContent = t('detailLoading'); + + dom.progressSection.hidden = !facts.progress.length; dom.progressList.replaceChildren(); for (const progress of facts.progress) { const item = createElement('div', 'timeline-item'); item.append(createElement('time', '', formatTimestamp(progress.generated_at))); - item.append( - createElement('div', '', progress.recommended_action || progress.classification || '--'), - ); + item.append(createElement('div', '', progress.recommended_action || progress.classification || '--')); dom.progressList.append(item); } dom.continueButton.hidden = false; - dom.continueButton.disabled = !isCurrent || state.workspace.isRemote || Boolean(state.running); - dom.continueButton.title = isCurrent ? '' : t('currentOnly'); + dom.continueButton.disabled = mismatch || state.workspace.isRemote || Boolean(state.running); + dom.continueButton.title = mismatch ? t(repositoryMismatch(goal) ? 'sourceMismatch' : 'currentOnly') : ''; dom.continueLabel.textContent = state.running?.goalId === goal.id ? t('running') : t('continue'); + dom.heartbeatButton.hidden = heartbeat.state === 'ready' || heartbeat.state === 'unavailable'; + dom.heartbeatButton.disabled = mismatch || state.repairingHeartbeat || Boolean(state.running); + dom.heartbeatLabel.textContent = state.repairingHeartbeat ? t('monitorRepairing') : t('monitorRepair'); + dom.deleteGoalButton.hidden = false; + dom.deleteGoalButton.disabled = !canDeleteGoal(goal) || Boolean(state.running) || Boolean(state.deletingGoalId); + dom.deleteGoalButton.title = canDeleteGoal(goal) ? t('deleteGoal') : t('deleteUnavailable'); + dom.deleteGoalButton.setAttribute('aria-label', dom.deleteGoalButton.title); } function applyCopy() { document.documentElement.lang = state.locale; dom.appTitle.textContent = t('title'); - dom.metricGoalsLabel.textContent = t('goals'); - dom.metricProgressLabel.textContent = t('progress'); - dom.metricGatesLabel.textContent = t('gates'); - dom.metricRunnableLabel.textContent = t('runnable'); - dom.metricWaitingLabel.textContent = t('waiting'); - dom.newGoalLabel.textContent = t('newGoal'); - dom.goalsHeading.textContent = t('goals'); - const filterButtons = dom.goalFilters.querySelectorAll('button'); - filterButtons[0].textContent = t('all'); - filterButtons[1].textContent = t('action'); - filterButtons[2].textContent = t('waitingFilter'); - dom.emptyTitle.textContent = t('selectGoal'); - dom.emptyCopy.textContent = t('selectGoalCopy'); - dom.statusLabel.textContent = t('status'); - dom.waitingLabel.textContent = t('waitingOn'); - dom.adapterLabel.textContent = t('adapter'); - dom.nextActionHeading.textContent = t('nextAction'); - dom.gateHeading.textContent = t('userGate'); - dom.todoHeading.textContent = t('visibleWork'); - dom.handoffHeading.textContent = t('handoff'); - dom.reloadDetailButton.textContent = t('reload'); - dom.progressHeading.textContent = t('recentProgress'); - dom.dialogTitle.textContent = t('startGoal'); - dom.goalTextLabel.textContent = t('goal'); - dom.goalText.placeholder = t('goalPlaceholder'); - dom.dialogCancel.textContent = t('cancel'); - dom.dialogSubmitLabel.textContent = t('startInAgent'); - dom.dialogClose.title = t('close'); - dom.dialogClose.setAttribute('aria-label', t('close')); + dom.otherGoalsLabel.textContent = t('otherGoals'); dom.goalsHeading.textContent = t('goals'); + dom.metricMonitorLabel.textContent = t('monitor'); dom.metricQueueLabel.textContent = t('queue'); + dom.metricProgressLabel.textContent = t('recentDelivery'); dom.metricGatesLabel.textContent = t('confirmations'); + dom.newGoalLabel.textContent = t('configureAutofix'); dom.pipelineHeading.textContent = t('pipeline'); + dom.emptyConfigureLabel.textContent = t('startTracking'); + dom.pipelineMonitorLabel.textContent = t('pipelineMonitor'); dom.pipelineTriageLabel.textContent = t('pipelineTriage'); + dom.pipelineFixLabel.textContent = t('pipelineFix'); dom.pipelineValidateLabel.textContent = t('pipelineValidate'); + dom.pipelinePrLabel.textContent = t('pipelinePr'); dom.todoHeading.textContent = t('queue'); + dom.gateHeading.textContent = t('userGate'); dom.emptyTitle.textContent = t('selectGoal'); dom.emptyCopy.textContent = t('selectGoalCopy'); + dom.modeLabel.textContent = t('mode'); dom.statusLabel.textContent = t('status'); dom.waitingLabel.textContent = t('waitingOn'); + dom.heartbeatStatusLabel.textContent = t('heartbeatStatus'); dom.nextActionHeading.textContent = t('currentExecution'); + dom.progressHeading.textContent = t('recentDelivery'); dom.handoffHeading.textContent = t('executionDetails'); + dom.reloadDetailButton.textContent = t('reload'); dom.dialogTitle.textContent = t('setupTitle'); + dom.issueSourceLabel.textContent = t('issueSource'); dom.issueScopeNote.textContent = t('issueScopeNote'); + dom.issueCadenceLabel.textContent = t('cadence'); + dom.cadence30m.textContent = t('cadence30m'); dom.cadence1h.textContent = t('cadence1h'); + dom.cadence6h.textContent = t('cadence6h'); dom.cadence1d.textContent = t('cadence1d'); + dom.issuePrDeliveryLabel.textContent = t('autoPr'); dom.goalTextLabel.textContent = t('constraints'); + dom.goalText.placeholder = t('constraintsPlaceholder'); dom.dialogWorkspaceLabel.textContent = t('targetWorkspace'); + dom.dialogCancel.textContent = t('cancel'); dom.dialogSubmitLabel.textContent = t('startInAgent'); + dom.dialogClose.title = t('close'); dom.dialogClose.setAttribute('aria-label', t('close')); + dom.deleteDialogTitle.textContent = t('deleteGoalTitle'); dom.deleteDialogCopy.textContent = t('deleteGoalCopy'); + dom.deleteDialogArchive.textContent = t('deleteGoalArchive'); dom.deleteDialogCancel.textContent = t('cancel'); + dom.deleteDialogSubmitLabel.textContent = state.deletingGoalId ? t('deleteGoalDeleting') : t('deleteGoalConfirm'); + dom.deleteDialogClose.title = t('close'); dom.deleteDialogClose.setAttribute('aria-label', t('close')); } function render() { - applyCopy(); - renderNotice(); - renderRuntime(); - renderMetrics(); - renderWorkspace(); - renderGoals(); - renderDetail(); + try { + applyCopy(); renderNotice(); renderRuntime(); renderWorkspace(); renderGoals(); + renderMetrics(); renderPipeline(); renderDetail(); + } catch (error) { logError('render()', error); } } async function loadPacket(goalId, force = false) { if (!goalId || (!force && state.packets.has(goalId))) return; - state.packets.set(goalId, { status: 'loading' }); - renderDetail(); + const goal = asArray(state.registry?.goals).find((item) => item.id === goalId); + if (!goal?.repo) return; + state.packets.set(goalId, { status: 'loading' }); render(); try { - const packet = validatePacket( - await runLoopxJson(['--format', 'json', 'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5']), - goalId, - ); - state.packets.set(goalId, { status: 'ready', packet }); - } catch (error) { - state.packets.set(goalId, { status: 'error', error }); - } - renderDetail(); + const [packetValue, statusValue] = await Promise.all([ + runLoopxJson(['--format', 'json', 'review-packet', '--goal-id', goalId, '--handoff-only', '--limit', '5'], goal.repo), + runLoopxJson(['--format', 'json', 'status', '--goal-id', goalId, '--limit', '8'], goal.repo), + ]); + state.packets.set(goalId, { status: 'ready', packet: validatePacket(packetValue, goalId), statusData: validateGoalStatus(statusValue, goalId) }); + } catch (error) { state.packets.set(goalId, { status: 'error', error }); } + render(); +} + +async function loadCronJobs() { + try { state.cronJobs = asArray(await window.app.cron.listJobs({})); state.cronAvailable = true; } + catch { state.cronJobs = []; state.cronAvailable = false; } } function refreshFailureMessage(error) { @@ -576,234 +773,283 @@ function refreshFailureMessage(error) { return `${t('refreshFailed')}${suffix}`; } +function chooseSelectedGoal(goals) { + if (goals.some((goal) => goal.id === state.selectedGoalId)) return; + const workspacePath = normalizePath(state.workspace.path); + const localIssueGoal = goals.find((goal) => normalizePath(goal.repo) === workspacePath && isIssueGoal(goal)); + const localGoal = goals.find((goal) => normalizePath(goal.repo) === workspacePath); + state.selectedGoalId = localIssueGoal?.id || localGoal?.id || goals.find(isIssueGoal)?.id || goals[0]?.id || null; +} + async function refresh() { if (state.refreshing) return; - state.refreshing = true; - renderRuntime(); + state.refreshing = true; renderRuntime(); try { - const [version, registryValue] = await Promise.all([ - runLoopx(['--version'], 20_000), - runLoopxJson(['--format', 'json', 'registry']), + const [version, registryValue, workspace] = await Promise.all([ + runLoopx(['--version'], 20_000), runLoopxJson(['--format', 'json', 'registry']), + window.app.workspace.info().catch(() => state.workspace), ]); + state.workspace = workspace || state.workspace; const registry = validateRegistry(registryValue); - const summary = validateSummary( - await runLoopxJson(['--format', 'json', 'global-summary', '--time-range', '24h', '--limit', '8']), - ); - - state.version = version; - state.registry = registry; - state.summary = summary; - state.fetchedAt = summary.generated_at || new Date().toISOString(); - state.notice = null; - const goals = asArray(registry.goals); - const matched = currentGoal(); - if (!goals.some((goal) => goal.id === state.selectedGoalId)) { - state.selectedGoalId = matched?.id || goals[0]?.id || null; - } - await writeCache().catch(() => { - // Live data remains authoritative even when the optional cache cannot be persisted. - }); + const [summaryValue] = await Promise.all([ + runLoopxJson(['--format', 'json', 'global-summary', '--time-range', '24h', '--limit', '8']), + loadCronJobs(), + ]); + state.version = version; state.registry = registry; state.summary = validateSummary(summaryValue); + state.fetchedAt = state.summary.generated_at || new Date().toISOString(); state.notice = null; + chooseSelectedGoal(asArray(registry.goals)); + await writeCache().catch(() => {}); if (state.selectedGoalId) void loadPacket(state.selectedGoalId, true); - } catch (error) { - state.notice = { kind: 'error', message: refreshFailureMessage(error) }; - } finally { - state.refreshing = false; - render(); - } + } catch (error) { state.notice = { kind: 'error', message: refreshFailureMessage(error) }; } + finally { state.refreshing = false; render(); } } -async function claimComposer() { +async function startAgent(prompt, displayText, sessionName, goalId) { + const workspacePath = state.workspace.available && !state.workspace.isRemote ? state.workspace.path : ''; + if (!workspacePath) { logError('startAgent: no workspace', new Error('WORKSPACE_UNAVAILABLE')); throw new Error('WORKSPACE_UNAVAILABLE'); } + logError('startAgent: calling agent.run', new Error('STEP')); + let result; try { - await window.app.chat.claimComposer({ - title: t('title'), - composer: { placeholder: t('composerPlaceholder') }, - }); - } catch { - // The console remains useful as a read-only dashboard without composer ownership. - } + result = await window.app.agent.run(prompt, { sessionName, displayText, enableTools: true, workspacePath }); + } catch (error) { logError('startAgent: agent.run rejected', error); throw error; } + if (!result?.sessionId || !result?.turnId) { logError('startAgent: missing sessionId/turnId', new Error('AGENT_SESSION_UNAVAILABLE: ' + JSON.stringify(result))); throw new Error('AGENT_SESSION_UNAVAILABLE'); } + state.running = { sessionId: result.sessionId, turnId: result.turnId, goalId }; + try { + setNotice('success', t('agentStarted')); render(); + } catch (error) { logError('startAgent: render after success', error); } + void window.app.chat.focusSession(result.sessionId).catch(() => {}); } -async function startAgent(prompt, displayText, sessionName, goalId) { - const result = await window.app.agent.run(prompt, { - sessionName, - displayText, - enableTools: true, - }); - if (!result?.sessionId || !result?.turnId) throw new Error('AGENT_SESSION_UNAVAILABLE'); - state.running = { sessionId: result.sessionId, turnId: result.turnId, goalId }; - await window.app.chat.focusSession(result.sessionId); - setNotice('agent', t('agentStarted')); - render(); +function buildIssueGoalText(issueUrl, allowPr, constraints) { + return `Process the complete set of open GitHub Issues present at ${issueUrl} when this goal is initialized. Freeze that exact set as the goal's immutable intake scope and ignore issues opened later. Use LoopX's native Issue-Fix domain state for feasibility, selected successors, delivery evidence, outcome projection, and grouped PR lifecycle monitoring; do not maintain a parallel queue. Advance at most one bounded executable issue per run. Leave the registered checkout's tracked and untracked contents unchanged, and make code changes only in a clean isolated git worktree. ${allowPr ? 'After validation, push to the authenticated fork and open a pull request against the upstream repository only when LoopX authority permits it.' : 'Do not push or open a pull request; stop after a validated local fix and request approval.'} Never merge, release, close issues, or post GitHub comments without explicit user approval. Heartbeats may advance or monitor only the recorded initial scope. ${constraints ? `Additional constraints: ${constraints}` : ''}`; } -function newGoalPrompt(goalText) { - return `You are operating inside the current BitFun local workspace. +function newGoalPrompt(goalText, workspacePath, issueUrl) { + return `You are operating inside the exact local workspace: ${workspacePath} The host surface is BitFun's interactive chat-box, not Codex App. -LoopX is the control-plane source of truth. Do not directly edit .loopx files, goal state files, todo files, or the global registry. +LoopX is the control-plane source of truth. Do not edit .loopx files, goal state files, todo files, or the global registry directly. -Start the following durable goal by running LoopX's guided flow in the current project: +Start the durable goal below with LoopX guided flow in this exact project: loopx start-goal --guided --project . --goal-text --host-surface chat-box ${goalText} -Pass the goal text exactly as provided. Preserve every LoopX goal-selection, approval, and write gate. Do not confirm or approve any gate on the user's behalf. If LoopX asks for a decision, stop and surface that decision in this interactive chat. Only after the guided flow permits work, complete one bounded progress segment with an implementation artifact, targeted validation, and LoopX state writeback.`; +Pass the goal text exactly and execute the transaction returned by the guided flow. + +Perform one-time intake before any code change: +1. Fetch the complete paginated set of issue URLs that are OPEN at ${issueUrl} now, using public metadata only. +2. Record the snapshot time and materialize every concrete URL as LoopX-native intake work in this goal. This exact set is immutable: never refresh the repository issue list and never add issues opened later. +3. Create or update the LoopX-native successor todo/recommended action that tells future heartbeat turns to select at most one recorded URL per run, use "loopx issue-fix workflow-plan --url --fetch-metadata --repo-path . --format json", then use "loopx issue-fix feasibility ... --goal-id --project ." with real reproduction and scope observations. Follow the returned route, todo, gate, and writeback contracts. Do not pass the repository Issues page to workflow-plan and do not create an app-local or parallel workflow ledger. + +Do not run workflow-plan or feasibility for every URL in this setup turn. Persist the entire initial URL set in LoopX, record the bounded successor todo, then stop so BitFun can create the heartbeat. Keep triage, the selected successor, validation, delivery evidence, and grouped PR lifecycle monitoring in LoopX. Preserve every approval and write gate; never approve on the user's behalf. Do not write snapshots, command packs, status output, or other temporary files into the registered checkout; use stdout or an OS temporary directory and remove temporary artifacts when finished. Before modifying code, create or reuse a clean issue-specific git worktree and leave both tracked and untracked contents of the registered checkout unchanged. Do not report that a heartbeat exists because BitFun will create it only after this guided turn succeeds.`; } function continueGoalPrompt(goalId) { - return `You are operating inside the current BitFun local workspace. -The host surface is BitFun's interactive chat-box, not Codex App. -LoopX is the control-plane source of truth for goal_id=${goalId}. Do not directly edit .loopx files, goal state files, todo files, or the global registry. + return `You are operating inside the local repository registered for LoopX goal_id=${goalId}. +LoopX is the control-plane source of truth. Do not edit its state files directly. -First read the current handoff with this exact read-only command: +Read the current packet with: loopx --format json review-packet --goal-id ${goalId} --handoff-only --limit 5 -Confirm the packet matches goal_id=${goalId}, then follow its current gate, owner, stop condition, quota, and next-action guidance. Never infer or auto-approve a user/controller gate. Complete exactly one bounded progress segment in this workspace, including a coherent implementation artifact, targeted validation, and LoopX writeback through the LoopX CLI. If the packet blocks execution or requires user authority, stop and ask in this interactive chat.`; +Confirm the packet matches goal_id=${goalId}. Follow its gate, owner, stop condition, quota, and next action. Select at most one executable GitHub Issue already recorded in this goal's immutable initial scope. Do not query the repository issue list to discover or add issues opened after the initial snapshot. Use LoopX's native Issue-Fix workflow and feasibility state for that concrete issue, and keep PR monitoring grouped by LoopX's repository lifecycle bucket instead of creating one monitor per PR. Do not write snapshots, command output, or other temporary files into the registered checkout. Before modifying code, create or reuse a clean issue-specific git worktree and leave both tracked and untracked contents of the registered checkout unchanged. Implement a coherent fix, run targeted validation, and write evidence/state back through LoopX. Push a fork branch and create a PR only when current authority permits it. Never infer a user gate or merge/release/close/comment without explicit authority.`; } async function submitNewGoal() { - const goalText = dom.goalText.value.trim(); - if (!goalText) { - dom.goalText.setCustomValidity(t('goalRequired')); - dom.goalText.reportValidity(); - return; + const issueUrl = dom.issueSource.value.trim().replace(/\/$/, ''); + if (!issueRepository(issueUrl)) { + dom.issueSource.setCustomValidity(t('issueSourceRequired')); dom.issueSource.reportValidity(); return; } - dom.goalText.setCustomValidity(''); + dom.issueSource.setCustomValidity(''); if (!state.workspace.available || state.workspace.isRemote || state.running) return; + const workspaceRepo = normalizePath(state.workspace.path).split('\\').filter(Boolean).at(-1) || ''; + if (workspaceRepo.toLowerCase() !== issueRepository(issueUrl).repo.toLowerCase()) { + dom.issueSource.setCustomValidity(t('sourceMismatch')); dom.issueSource.reportValidity(); return; + } + const everyMs = numberOrZero(dom.issueCadence.value) || DEFAULT_CADENCE_MS; + const goalText = buildIssueGoalText(issueUrl, dom.issuePrDelivery.checked, dom.goalText.value.trim()); + const beforeGoalIds = asArray(state.registry?.goals).map((goal) => goal.id); dom.dialogSubmit.disabled = true; + state.pendingNewGoal = { workspacePath: normalizePath(state.workspace.path), everyMs, issueUrl, beforeGoalIds }; try { - await startAgent(newGoalPrompt(goalText), goalText, `${t('title')}: ${state.workspace.name}`, null); - dom.newGoalDialog.close(); - dom.goalText.value = ''; - } catch { - setNotice('error', t('agentFailed')); - } finally { - dom.dialogSubmit.disabled = false; + await startAgent(newGoalPrompt(goalText, state.workspace.path, issueUrl), goalText, `${t('title')}: ${state.workspace.name}`, null); + dom.newGoalDialog.close(); dom.goalText.value = ''; + } catch (error) { + state.pendingNewGoal = null; + logError('submitNewGoal: startAgent failed', error); + setNotice('error', `${t('agentFailed')} ${error?.message ? '(' + error.message + ')' : ''}`); } + finally { dom.dialogSubmit.disabled = false; } } async function continueGoal() { const goal = selectedGoal(); - if (!goal || currentGoal()?.id !== goal.id || state.workspace.isRemote || state.running) return; + if (!goal || normalizePath(goal.repo) !== normalizePath(state.workspace.path) || repositoryMismatch(goal) || state.workspace.isRemote || state.running) return; try { await loadPacket(goal.id, true); - const packetState = state.packets.get(goal.id); - if (packetState?.status !== 'ready') throw new Error('HANDOFF_UNAVAILABLE'); - await startAgent( - continueGoalPrompt(goal.id), - goalFacts(goal.id).lane?.next_safe_action || `${t('continue')}: ${goal.id}`, - `${t('title')}: ${goal.id}`, - goal.id, - ); - } catch { - setNotice('error', t('agentFailed')); + if (packetState(goal.id)?.status !== 'ready') throw new Error('HANDOFF_UNAVAILABLE'); + await startAgent(continueGoalPrompt(goal.id), goalFacts(goal.id).lane?.recommended_action || `${t('continue')}: ${goal.id}`, `${t('title')}: ${goal.id}`, goal.id); + } catch (error) { + logError('continueGoal: failed', error); + setNotice('error', `${t('agentFailed')} ${error?.message ? '(' + error.message + ')' : ''}`); } } -function onAgentEvent(event) { - if (!state.running || !event || typeof event !== 'object') return; - const sourceEvent = String(event.sourceEvent || event.source_event || event.type || ''); - const sessionId = event.sessionId || event.session_id; - const turnId = event.turnId || event.turn_id; - if (sessionId && sessionId !== state.running.sessionId) return; - if (turnId && turnId !== state.running.turnId) return; - - if (sourceEvent.endsWith('dialog-turn-completed')) { - state.running = null; - setNotice(null, null); - render(); - void refresh(); - } else if ( - sourceEvent.endsWith('dialog-turn-failed') || - sourceEvent.endsWith('dialog-turn-cancelled') - ) { - state.running = null; - setNotice('error', t('agentFailed')); - render(); +function openDeleteGoalDialog() { + const goal = selectedGoal(); + if (!canDeleteGoal(goal) || state.running || state.deletingGoalId) return; + dom.deleteDialogGoal.textContent = goal.id; dom.deleteDialogRepo.textContent = goal.repo; + dom.deleteGoalDialog.showModal(); dom.deleteDialogSubmit.focus(); +} + +async function deleteGoal() { + const goal = selectedGoal(); + if (!goal || !canDeleteGoal(goal) || state.running || state.deletingGoalId) return; + state.deletingGoalId = goal.id; dom.deleteDialogSubmit.disabled = true; render(); + try { + validateGoalDeletion(await runLoopxJson(['--format', 'json', 'uninstall-project', '--goal-id', goal.id, '--archive-state', '--remove-empty-registry', '--execute'], goal.repo), goal.id); + state.packets.delete(goal.id); state.selectedGoalId = null; dom.deleteGoalDialog.close(); + await refresh(); setNotice('success', t('deleteGoalSuccess')); + } catch { setNotice('error', t('deleteGoalFailed')); } + finally { state.deletingGoalId = null; dom.deleteDialogSubmit.disabled = false; render(); } +} + +async function registeredAgentId(goal) { + try { + const history = await runLoopxJson(['--format', 'json', 'history', '--goal-id', goal.id, '--limit', '1'], goal.repo); + const agents = asArray(history?.goals?.[0]?.coordination?.registered_agents); + return agents[0]?.id || agents[0]?.agent_id || null; + } catch { return null; } +} + +async function heartbeatPrompt(goal) { + const agentId = await registeredAgentId(goal); + const args = ['--format', 'json', 'heartbeat-prompt', '--goal-id', goal.id]; + if (agentId) args.push('--agent-id', agentId); + args.push('-H', 'local_scheduler', '-O', 'host_automation', '-M', 'hosted_automation', '--thin'); + return validateHeartbeatPrompt(await runLoopxJson(args, goal.repo), goal.id).task_body; +} + +async function ensureHeartbeat(goal, everyMs = DEFAULT_CADENCE_MS, replaceOwned = false) { + if (!goal || normalizePath(goal.repo) !== normalizePath(state.workspace.path) || repositoryMismatch(goal)) throw new Error('GOAL_WORKSPACE_MISMATCH'); + const taskBody = await heartbeatPrompt(goal); + if (replaceOwned) { + for (const job of ownedHeartbeatJobs(goal.id)) await window.app.cron.deleteJob(job.id); } + await window.app.cron.createJob({ + name: `${t('title')}: ${goal.id}`, + schedule: { kind: 'every', everyMs }, payload: { text: taskBody }, enabled: true, + target: { kind: 'workspace', workspace: { workspacePath: goal.repo }, launch: { agentType: 'agentic' } }, + }); + await loadCronJobs(); +} + +async function repairHeartbeat() { + const goal = selectedGoal(); + if (!goal || state.repairingHeartbeat) return; + state.repairingHeartbeat = true; render(); + try { + const everyMs = numberOrZero(heartbeatFacts(goal).job?.schedule?.everyMs) || DEFAULT_CADENCE_MS; + await ensureHeartbeat(goal, everyMs, true); setNotice('success', t('monitorRepairSuccess')); + } catch { setNotice('error', t('monitorRepairFailed')); } + finally { state.repairingHeartbeat = false; render(); } +} + +function resolveNewGoal(pending) { + const goals = asArray(state.registry?.goals).filter((goal) => normalizePath(goal.repo) === pending.workspacePath); + return goals.find((goal) => !pending.beforeGoalIds.includes(goal.id)) || + goals.find((goal) => isIssueGoal(goal)) || + (goals.length === 1 ? goals[0] : null); +} + +function onAgentEvent(event) { + try { + if (!state.running || !event || typeof event !== 'object') return; + const sourceEvent = String(event.sourceEvent || event.source_event || event.type || ''); + const sessionId = event.sessionId || event.session_id; const turnId = event.turnId || event.turn_id; + if (sessionId && sessionId !== state.running.sessionId) return; + if (turnId && turnId !== state.running.turnId) return; + if (sourceEvent.endsWith('dialog-turn-completed')) { + logError('onAgentEvent: dialog-turn-completed', new Error('STEP')); + const pending = state.pendingNewGoal; state.pendingNewGoal = null; state.running = null; setNotice(null, null); render(); + void refresh().then(async () => { + try { + if (!pending || state.workspace.isRemote || normalizePath(state.workspace.path) !== pending.workspacePath) return; + const goal = resolveNewGoal(pending); + if (!goal) { logError('onAgentEvent: resolveNewGoal returned null', new Error('NO_NEW_GOAL')); setNotice('error', t('monitorRepairFailed')); return; } + state.selectedGoalId = goal.id; + try { await ensureHeartbeat(goal, pending.everyMs, false); setNotice('success', t('heartbeatAutoCreated')); } + catch (error) { logError('onAgentEvent: ensureHeartbeat failed', error); setNotice('error', t('monitorRepairFailed')); } + render(); void loadPacket(goal.id, true); + } catch (error) { logError('onAgentEvent: post-refresh callback', error); } + }).catch((error) => logError('onAgentEvent: refresh() rejected', error)); + } else if (sourceEvent.endsWith('dialog-turn-failed') || sourceEvent.endsWith('dialog-turn-cancelled')) { + logError('onAgentEvent: ' + sourceEvent, new Error('TURN_FAILED_OR_CANCELLED')); + state.pendingNewGoal = null; state.running = null; setNotice('error', t('agentFailed')); render(); + } + } catch (error) { logError('onAgentEvent: top-level catch', error); } } function bindEvents() { - dom.refreshButton.addEventListener('click', () => void refresh()); - dom.noticeRetry.addEventListener('click', () => void refresh()); - dom.goalFilters.addEventListener('click', (event) => { - const button = event.target.closest('button[data-filter]'); - if (!button) return; - state.filter = button.dataset.filter; - renderGoals(); - }); + dom.refreshButton.addEventListener('click', () => void refresh()); dom.noticeRetry.addEventListener('click', () => void refresh()); dom.goalsList.addEventListener('click', (event) => { - const button = event.target.closest('[data-goal-id]'); - if (!button) return; - state.selectedGoalId = button.dataset.goalId; - renderGoals(); - renderDetail(); - void loadPacket(state.selectedGoalId); + const button = event.target.closest('[data-goal-id]'); if (!button) return; + state.selectedGoalId = button.dataset.goalId; dom.goalSwitcher.open = false; render(); void loadPacket(state.selectedGoalId); }); - dom.reloadDetailButton.addEventListener('click', () => void loadPacket(state.selectedGoalId, true)); - dom.newGoalButton.addEventListener('click', () => { - dom.dialogWorkspace.textContent = state.workspace.path; - dom.newGoalDialog.showModal(); - dom.goalText.focus(); - }); - dom.dialogClose.addEventListener('click', () => dom.newGoalDialog.close()); - dom.dialogCancel.addEventListener('click', () => dom.newGoalDialog.close()); - dom.newGoalForm.addEventListener('submit', (event) => { - event.preventDefault(); - void submitNewGoal(); - }); - dom.goalText.addEventListener('input', () => dom.goalText.setCustomValidity('')); - dom.continueButton.addEventListener('click', () => void continueGoal()); + dom.reloadDetailButton.addEventListener('click', (event) => { event.preventDefault(); void loadPacket(state.selectedGoalId, true); }); + const openSetupDialog = () => { + if (!state.workspace.available || state.workspace.isRemote || state.running) { + setNotice('error', t('selectWorkspaceFirst')); + return; + } + dom.dialogWorkspace.textContent = state.workspace.path; dom.issueSource.value = ''; + dom.newGoalDialog.showModal(); dom.issueSource.focus(); + }; + dom.newGoalButton.addEventListener('click', openSetupDialog); + dom.emptyConfigureButton.addEventListener('click', openSetupDialog); + dom.dialogClose.addEventListener('click', () => dom.newGoalDialog.close()); dom.dialogCancel.addEventListener('click', () => dom.newGoalDialog.close()); + dom.newGoalForm.addEventListener('submit', (event) => { event.preventDefault(); void submitNewGoal(); }); + dom.issueSource.addEventListener('input', () => dom.issueSource.setCustomValidity('')); + dom.continueButton.addEventListener('click', () => void continueGoal()); dom.heartbeatButton.addEventListener('click', () => void repairHeartbeat()); + dom.deleteGoalButton.addEventListener('click', openDeleteGoalDialog); + dom.deleteDialogClose.addEventListener('click', () => dom.deleteGoalDialog.close()); dom.deleteDialogCancel.addEventListener('click', () => dom.deleteGoalDialog.close()); + dom.deleteGoalForm.addEventListener('submit', (event) => { event.preventDefault(); void deleteGoal(); }); window.app.agent.onEvent(onAgentEvent); - window.app.onLocaleChange((locale) => { - state.locale = resolveLocale(locale); - render(); - void claimComposer(); - }); + window.app.onLocaleChange((locale) => { state.locale = resolveLocale(locale); render(); }); } function collectDom() { const ids = [ - 'app-title', 'runtime-version', 'updated-at', 'runtime-status', 'runtime-dot', 'refresh-button', - 'notice', 'notice-text', 'notice-retry', 'metric-goals', 'metric-goals-label', 'metric-progress', - 'metric-progress-label', 'metric-gates', 'metric-gates-label', 'metric-runnable', - 'metric-runnable-label', 'metric-waiting', 'metric-waiting-label', 'workspace-kicker', - 'workspace-name', 'workspace-path', 'workspace-state', 'new-goal-button', 'new-goal-label', - 'goals-heading', 'goal-count', 'goal-filters', 'goals-list', 'detail-empty', 'detail-content', - 'empty-title', 'empty-copy', 'detail-heading', 'detail-current', 'detail-repo', 'continue-button', - 'continue-label', 'status-label', 'detail-status', 'waiting-label', 'detail-waiting', 'adapter-label', - 'detail-adapter', 'next-action-heading', 'next-action-text', 'gate-section', 'gate-heading', - 'gate-count', 'gate-list', 'todo-section', 'todo-heading', 'todo-count', 'todo-list', - 'handoff-heading', 'reload-detail-button', 'handoff-content', 'progress-section', - 'progress-heading', 'progress-list', 'new-goal-dialog', 'new-goal-form', 'dialog-title', - 'dialog-close', 'goal-text-label', 'goal-text', 'dialog-workspace', 'dialog-cancel', - 'dialog-submit', 'dialog-submit-label', + 'app-title', 'tracked-source', 'runtime-version', 'goal-switcher', 'other-goals-label', 'goal-count', 'goals-heading', + 'updated-at', 'goals-list', 'runtime-dot', 'runtime-status', 'refresh-button', 'notice', 'notice-text', 'notice-retry', + 'workspace-kicker', 'workspace-name', 'workspace-path', 'workspace-state', 'new-goal-button', 'new-goal-label', + 'metric-monitor', 'metric-monitor-label', 'metric-queue', 'metric-queue-label', 'metric-progress', 'metric-progress-label', + 'metric-gates', 'metric-gates-label', 'pipeline-heading', 'pipeline-summary', 'pipeline-monitor', 'pipeline-monitor-label', + 'pipeline-triage', 'pipeline-triage-label', 'pipeline-fix', 'pipeline-fix-label', 'pipeline-validate', 'pipeline-validate-label', + 'pipeline-pr', 'pipeline-pr-label', 'todo-heading', 'queue-source', 'todo-count', 'todo-list', 'gate-section', 'gate-heading', + 'gate-count', 'gate-list', 'detail-empty', 'empty-title', 'empty-copy', 'empty-configure-button', 'empty-configure-label', + 'detail-content', 'detail-heading', 'detail-current', + 'detail-repo', 'delete-goal-button', 'heartbeat-button', 'heartbeat-label', 'continue-button', 'continue-label', 'mode-label', + 'detail-mode', 'status-label', 'detail-status', 'waiting-label', 'detail-waiting', 'heartbeat-status-label', 'detail-heartbeat', + 'next-action-heading', 'adapter-label', 'next-action-text', 'progress-section', 'progress-heading', 'progress-list', + 'handoff-heading', 'reload-detail-button', 'handoff-content', 'new-goal-dialog', 'new-goal-form', 'dialog-title', 'dialog-close', + 'issue-source-label', 'issue-source', 'issue-scope-note', 'issue-cadence-label', 'issue-cadence', 'cadence-30m', 'cadence-1h', 'cadence-6h', + 'cadence-1d', 'issue-pr-delivery', 'issue-pr-delivery-label', + 'goal-text-label', 'goal-text', 'dialog-workspace-label', 'dialog-workspace', 'dialog-cancel', 'dialog-submit', 'dialog-submit-label', + 'delete-goal-dialog', 'delete-goal-form', 'delete-dialog-title', 'delete-dialog-close', 'delete-dialog-copy', 'delete-dialog-goal', + 'delete-dialog-repo', 'delete-dialog-archive', 'delete-dialog-cancel', 'delete-dialog-submit', 'delete-dialog-submit-label', ]; - for (const id of ids) { - const key = id.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); - dom[key] = $(id); - } + for (const id of ids) dom[id.replace(/-([a-z])/g, (_, char) => char.toUpperCase())] = $(id); } async function initialize() { - collectDom(); - state.locale = resolveLocale(window.app.locale); - bindEvents(); - const [hadCache, workspace] = await Promise.all([ - readCache(), - window.app.workspace.info().catch(() => state.workspace), - ]); - state.workspace = workspace || state.workspace; - const goals = asArray(state.registry?.goals); - const matched = currentGoal(); - if (!goals.some((goal) => goal.id === state.selectedGoalId)) { - state.selectedGoalId = matched?.id || goals[0]?.id || null; - } - render(); - await claimComposer(); - if (!hadCache) setNotice(null, null); - void refresh(); + collectDom(); state.locale = resolveLocale(window.app.locale); bindEvents(); + window.addEventListener('error', (event) => { logError('global error handler', event.error || event.message); }); + window.addEventListener('unhandledrejection', (event) => { logError('unhandledrejection', event.reason); }); + const [hadCache, workspace] = await Promise.all([readCache(), window.app.workspace.info().catch((error) => { logError('initialize: workspace.info', error); return state.workspace; })]); + state.workspace = workspace || state.workspace; chooseSelectedGoal(asArray(state.registry?.goals)); render(); + if (!hadCache) setNotice(null, null); void refresh(); if (state.selectedGoalId) void loadPacket(state.selectedGoalId); } diff --git a/src/crates/contracts/product-domains/src/miniapp/types.rs b/src/crates/contracts/product-domains/src/miniapp/types.rs index f00eb1af46..5412b22004 100644 --- a/src/crates/contracts/product-domains/src/miniapp/types.rs +++ b/src/crates/contracts/product-domains/src/miniapp/types.rs @@ -53,6 +53,8 @@ pub struct MiniAppPermissions { #[serde(skip_serializing_if = "Option::is_none")] pub agent: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub cron: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub notifications: Option, } @@ -127,6 +129,20 @@ pub struct AgentPermissions { pub rate_limit_per_minute: Option, } +/// Scheduled-job (cron) permissions for MiniApps. +/// +/// Grants the MiniApp the ability to create, list, and delete host scheduled +/// jobs through the host cron bridge, so an agentic MiniApp can wire its own +/// recurring heartbeat (e.g. a LoopX goal driven by the host CronService) +/// without leaving the iframe. Gated by manifest `permissions.cron.enabled`; +/// enforced host-side by reusing the existing cron Tauri commands. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CronPermissions { + /// Whether scheduled-job access is enabled for this MiniApp. + #[serde(default)] + pub enabled: bool, +} + /// Host notification permissions for MiniApps. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct NotificationPermissions { diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index 8807880d7a..82ba65729b 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -587,6 +587,7 @@ fn builtin_loopx_console_has_complete_resources_and_minimal_permissions() { assert_eq!(meta["permissions"]["net"]["allow"], serde_json::json!([])); assert_eq!(meta["permissions"]["node"]["enabled"], false); assert_eq!(meta["permissions"]["agent"]["enabled"], true); + assert_eq!(meta["permissions"]["cron"]["enabled"], true); assert!(meta["permissions"].get("fs").is_none()); } @@ -612,11 +613,46 @@ fn builtin_loopx_console_keeps_loopx_on_read_only_control_plane_paths() { assert!(source.contains("schema_version !== 'global_manager_command_response_v0'")); assert!(source.contains("window.app.appDataDir")); assert!(source.contains("window.app.agent.run")); - assert!(source.contains("window.app.chat.claimComposer")); assert!(source.contains("window.app.chat.focusSession")); + assert!(source.contains("void window.app.chat.focusSession(result.sessionId).catch(() => {});")); + assert!(!source.contains("await window.app.chat.focusSession(result.sessionId);")); + assert!(!source.contains("window.app.chat.claimComposer")); + assert!(source.contains("window.app.cron.listJobs")); + assert!(source.contains("window.app.cron.createJob")); + assert!(source.contains("window.app.cron.deleteJob")); + assert!(source.contains("'heartbeat-prompt', '--goal-id', goal.id")); + assert!(source.contains("typeof value?.task_body !== 'string'")); + assert!(source.contains("payload: { text: taskBody }")); + assert!(source.contains("const DEFAULT_CADENCE_MS = 60 * 60 * 1000;")); + assert!(source.contains("value=\"3600000\" selected")); + assert!(source.contains("id=\"empty-configure-button\"")); + assert!(source.contains("id=\"issue-scope-note\"")); + assert!(source.contains("window.app.workspace.info().catch(() => state.workspace)")); + assert!(source.contains("normalizePath(goal.repo) !== normalizePath(state.workspace.path)")); + assert!(source.contains("Freeze that exact set as the goal's immutable intake scope")); + assert!(source.contains("LoopX-native successor todo/recommended action")); + assert!(source.contains("loopx issue-fix workflow-plan --url ")); + assert!(source.contains("loopx issue-fix feasibility ... --goal-id ")); + assert!(source.contains("Do not run workflow-plan or feasibility for every URL in this setup turn")); + assert!(source.contains("already recorded in this goal's immutable initial scope")); + assert!(source.contains( + "leave both tracked and untracked contents of the registered checkout unchanged" + )); + assert!(source.contains("Do not write snapshots, command packs, status output")); + assert!(!source.contains("For each recorded URL")); + assert!(!source.contains("materialize every concrete URL as LoopX-native triage/feasibility work")); + assert!( + !source.contains("Maintain a durable queue for newly opened, closed, and updated issues") + ); + assert!(!source.contains("Continuously monitor ${issueUrl}")); assert!(!source_lower.contains("acp")); assert!(!source_lower.contains("serve-status")); - assert!(!source_lower.contains("--execute")); + // The only direct LoopX write is user-confirmed goal deletion. Goal work + // runs through Agent and recurring execution through the host Cron bridge. + assert_eq!(source_lower.matches("--execute").count(), 1); + assert!(source.contains("'uninstall-project', '--goal-id', goal.id,")); + assert!(source.contains("'--archive-state', '--remove-empty-registry', '--execute']")); + assert!(source.contains("schema_version !== 'loopx_project_uninstall_v0'")); } #[test] diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index d88e508106..ffc1d243c1 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -21,6 +21,11 @@ import { useI18n } from '@/infrastructure/i18n'; import type { MiniAppRunScope } from '../customization/miniAppCustomizationTypes'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { workspaceAPI } from '@/infrastructure/api'; +import { + cronAPI, + type CreateCronJobRequest, + type ListCronJobsRequest, +} from '@/infrastructure/api'; import { useMiniAppStore, MINIAPP_COMPOSER_MESSAGE_EVENT, @@ -29,6 +34,7 @@ import { type MiniAppComposerMessageDetail, } from '../miniAppStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { useSceneStore } from '@/app/stores/sceneStore'; import { createLogger } from '@/shared/utils/logger'; interface JSONRPC { @@ -79,11 +85,13 @@ export function useMiniAppBridge( const nodeDisabledRef = useRef(app.permissions?.node?.enabled === false); const systemNotificationsAllowedRef = useRef(app.permissions?.notifications?.system === true); const agentEnabledRef = useRef(app.permissions?.agent?.enabled === true); + const cronEnabledRef = useRef(app.permissions?.cron?.enabled === true); useLayoutEffect(() => { nodeDisabledRef.current = app.permissions?.node?.enabled === false; systemNotificationsAllowedRef.current = app.permissions?.notifications?.system === true; agentEnabledRef.current = app.permissions?.agent?.enabled === true; - }, [app.id, app.permissions?.node?.enabled, app.permissions?.notifications?.system, app.permissions?.agent?.enabled]); + cronEnabledRef.current = app.permissions?.cron?.enabled === true; + }, [app.id, app.permissions?.node?.enabled, app.permissions?.notifications?.system, app.permissions?.agent?.enabled, app.permissions?.cron?.enabled]); // Hidden agent sessions started by this iframe; used to filter the global // agentic:// event stream before forwarding events into the iframe. @@ -377,6 +385,36 @@ export function useMiniAppBridge( return; } + // ── Scheduled-jobs (cron) commands ───────────────────────────────── + // Gated on cron permission: an agentic MiniApp may wire its own + // recurring heartbeat (e.g. a LoopX goal driven by the host CronService) + // by reusing the existing cron Tauri commands. This bridge only routes + // the call; the host owns scheduling, state, and authority. + if (method.startsWith('cron.')) { + if (!cronEnabledRef.current) { + replyError(`MiniApp '${appId}' does not have cron permission (permissions.cron.enabled).`); + return; + } + if (method === 'cron.createJob') { + const request = params as unknown as CreateCronJobRequest; + const result = await cronAPI.createJob(request); + reply(result); + return; + } + if (method === 'cron.listJobs') { + const result = await cronAPI.listJobs((params as ListCronJobsRequest) ?? {}); + reply(result); + return; + } + if (method === 'cron.deleteJob') { + const result = await cronAPI.deleteJob(String(params.jobId ?? '')); + reply(result); + return; + } + replyError(`Unknown cron method: ${method}`); + return; + } + // ── Floating bubble chat commands ──────────────────────────────────── // Gated on agent permission: the composer claim exists so agentic // MiniApps can reuse the bubble as their input surface, and @@ -433,18 +471,23 @@ export function useMiniAppBridge( return; } const claim = useMiniAppStore.getState().composerClaims[appId]; - if (claim?.token !== composerTokenRef.current) { - replyError('chat.focusSession: this MiniApp does not hold the bubble composer.'); - return; + if (claim?.token === composerTokenRef.current) { + // Has a valid composer claim — bind the validated session to this + // runner's bubble. FloatingMiniChat owns the temporary global-store + // switch while its panel is open, then restores the user's normal + // session on close. + useMiniAppStore.getState().setComposerSession( + appId, + composerTokenRef.current, + sessionId, + ); + } else { + // No composer claim — route the agent session to the main session + // scene instead of the floating bubble. This covers MiniApps that + // drive agent.run from their own UI without claiming the composer. + flowChatStore.switchSession(sessionId); + useSceneStore.getState().openScene('session'); } - // Bind the validated session to this runner's composer claim. - // FloatingMiniChat owns the temporary global-store switch while its - // panel is open, then restores the user's normal session on close. - useMiniAppStore.getState().setComposerSession( - appId, - composerTokenRef.current, - sessionId, - ); reply(null); return; } diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts index dd0f562aa2..51bab72900 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts @@ -38,6 +38,7 @@ export interface MiniAppPermissions { enabled?: boolean; rate_limit_per_minute?: number; }; + cron?: { enabled?: boolean }; notifications?: { system?: boolean }; }