mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.
master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.
This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.
Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.
test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.
Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+59
-14
@@ -80,7 +80,7 @@ try {
|
||||
const prev = localStorage.getItem('codeman-crash-diag');
|
||||
if (prev) {
|
||||
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
}
|
||||
} catch {}
|
||||
_crashDiag.log('PAGE LOAD');
|
||||
@@ -89,7 +89,7 @@ _crashDiag.log('PAGE LOAD');
|
||||
function _crashDiagBeacon() {
|
||||
try {
|
||||
if (_crashDiag._entries.length > 0) {
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -1268,7 +1268,11 @@ class CodemanApp {
|
||||
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
|
||||
return window.__CODEMAN_SOLO__;
|
||||
}
|
||||
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
// Strip the reverse-proxy base so the match works under a sub-path mount.
|
||||
const base = window.CodemanBase?.base || '';
|
||||
let path = location.pathname;
|
||||
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
|
||||
const m = path.match(/^\/session\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1293,7 +1297,7 @@ class CodemanApp {
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
|
||||
return;
|
||||
@@ -1545,7 +1549,7 @@ class CodemanApp {
|
||||
// regardless of filter (server side).
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||
|
||||
// Store all event listeners for cleanup on reconnect.
|
||||
//
|
||||
@@ -2183,15 +2187,26 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
/** Build one response-viewer message so the brief and full views share markup and CSS. */
|
||||
_buildResponseViewerMessage(text, role, agentLabel) {
|
||||
_buildResponseViewerMessage(text, role, agentLabel, meta) {
|
||||
const div = document.createElement('div');
|
||||
const isUser = role === 'user';
|
||||
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
|
||||
// Consecutive messages from one speaker inside one turn are segments of a
|
||||
// single utterance: one badge, a hairline seam. Claude emits a median of 3
|
||||
// messages per turn (p90 11, max 51), so a badge per message would be the
|
||||
// card spam the old concatenation was introduced to avoid. `meta` is
|
||||
// optional so the brief view's 3-argument call keeps its exact shape.
|
||||
const continuation = !!(meta && meta.continuation);
|
||||
if (continuation) div.classList.add('rv-msg-cont');
|
||||
if (meta && meta.kind) div.dataset.kind = meta.kind;
|
||||
if (meta && meta.queued) div.dataset.queued = '1';
|
||||
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
if (!continuation) {
|
||||
const roleBadge = document.createElement('div');
|
||||
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
|
||||
roleBadge.textContent = isUser ? 'You' : agentLabel;
|
||||
div.appendChild(roleBadge);
|
||||
}
|
||||
|
||||
const renderedText = document.createElement('div');
|
||||
renderedText.className = 'rv-text';
|
||||
@@ -2351,19 +2366,49 @@ class CodemanApp {
|
||||
if (!body) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
body.textContent = 'No conversation history available';
|
||||
// Never destroy what the eye button already rendered: the brief view has
|
||||
// a terminal-buffer fallback (see toggleResponseViewer) that this
|
||||
// endpoint does not, so an empty full-context result must not wipe a
|
||||
// real answer the user is reading.
|
||||
// ⚠️ Idempotent, because More deliberately stays live here: the branch
|
||||
// returns before the button is hidden so a transcript that appears a
|
||||
// moment later can still be loaded, and appending would then stack a
|
||||
// second identical notice on every retry.
|
||||
// `:scope >` keeps the lookup off model-rendered markdown inside .rv-text.
|
||||
let notice = body.querySelector(':scope > .rv-notice');
|
||||
if (!notice) {
|
||||
notice = document.createElement('div');
|
||||
notice.className = 'rv-notice';
|
||||
body.appendChild(notice);
|
||||
}
|
||||
const emptyText = 'No full conversation history available for this session';
|
||||
notice.textContent = window.codemanT?.(emptyText) || emptyText;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render conversation thread
|
||||
const agentLabel = this._getResponseViewerAgentLabel();
|
||||
body.innerHTML = '';
|
||||
let previous = null;
|
||||
for (const msg of messages) {
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));
|
||||
// ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone.
|
||||
// Only the Claude reader emits turns; Codex and the external-CLI pane
|
||||
// parser emit adjacent assistant/response blocks with no turn at all, and
|
||||
// an older server emits none either — all three must keep rendering one
|
||||
// badged card per message exactly as they do today.
|
||||
const continuation =
|
||||
!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn;
|
||||
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));
|
||||
previous = msg;
|
||||
}
|
||||
this._bindResponseViewerInteractions(body);
|
||||
|
||||
if (title) title.textContent = `Conversation (${messages.length} messages)`;
|
||||
const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size;
|
||||
if (title) {
|
||||
title.textContent = turns
|
||||
? `Conversation (${messages.length} messages, ${turns} turns)`
|
||||
: `Conversation (${messages.length} messages)`;
|
||||
}
|
||||
if (moreBtn) moreBtn.style.display = 'none';
|
||||
// Scroll to bottom (latest message)
|
||||
body.scrollTop = body.scrollHeight;
|
||||
@@ -2753,7 +2798,7 @@ class CodemanApp {
|
||||
// up to the limit).
|
||||
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
|
||||
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
this._wsSessionId = sessionId;
|
||||
|
||||
@@ -22,6 +22,63 @@
|
||||
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Reverse-proxy base path
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
|
||||
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
|
||||
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
|
||||
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
|
||||
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
|
||||
// Rather than touch ~190 call sites, all runtime URL construction routes through this
|
||||
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
|
||||
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
|
||||
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
|
||||
const CodemanBase = (function () {
|
||||
// `window` is absent in some unit-test vm contexts that load this module in
|
||||
// isolation; guard so the module still evaluates (base degrades to root).
|
||||
const _win = typeof window !== 'undefined' ? window : undefined;
|
||||
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
|
||||
/**
|
||||
* Prefix a root-absolute application path with the mount base. Leaves untouched:
|
||||
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
|
||||
* (`//host`) and absolute URLs, and paths already carrying the prefix.
|
||||
*/
|
||||
function url(path) {
|
||||
if (!base) return path;
|
||||
if (typeof path !== 'string' || path.length === 0) return path;
|
||||
if (path[0] !== '/') return path; // relative / fragment / query
|
||||
if (path[1] === '/') return path; // protocol-relative
|
||||
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
|
||||
return base + path;
|
||||
}
|
||||
return { base, url };
|
||||
})();
|
||||
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
|
||||
|
||||
// Transparently prefix root-absolute app paths on every fetch, so the many
|
||||
// `/api/...` string literals across the frontend need no per-call edit.
|
||||
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
try {
|
||||
const u = new URL(input.url);
|
||||
if (u.origin === location.origin) {
|
||||
const prefixed = CodemanBase.url(u.pathname);
|
||||
if (prefixed !== u.pathname) {
|
||||
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
/* not a parseable URL — fall through */
|
||||
}
|
||||
}
|
||||
return _origFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Web Push Utilities
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -958,6 +1015,7 @@ const SSE_EVENTS = {
|
||||
HOOK_AGENT_WORKING: 'hook:agent_working',
|
||||
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
|
||||
HOOK_TASK_COMPLETED: 'hook:task_completed',
|
||||
HOOK_PROMPT_SUBMITTED: 'hook:prompt_submitted',
|
||||
|
||||
// Approvals Inbox
|
||||
APPROVAL_PENDING: 'approval:pending',
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
'Instance count': '实例数量',
|
||||
'No response yet': '暂无回复',
|
||||
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
|
||||
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
|
||||
'Last Response': '最近一次回复',
|
||||
More: '更多',
|
||||
'Codeman version': '{name}版本',
|
||||
@@ -713,6 +714,19 @@
|
||||
'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.':
|
||||
'在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。',
|
||||
'Run in an isolated Docker container': '在隔离的 Docker 容器中运行',
|
||||
'Attach to an existing container': '接入已在运行的容器',
|
||||
'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.':
|
||||
'开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。',
|
||||
'Container Name': '容器名称',
|
||||
'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。',
|
||||
'Check container': '检查容器',
|
||||
'Container Workdir': '容器内工作目录',
|
||||
'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.':
|
||||
'容器内已存在的路径。接入不挂载任何目录,因此它不必与主机工作区路径相同。',
|
||||
'Already have a container running?': '已经有正在运行的容器?',
|
||||
'Attach to it instead': '改为接入该容器',
|
||||
'Codeman only runs docker exec into it and never touches its lifecycle.':
|
||||
'{name}只会 docker exec 进入它,绝不触碰其生命周期。',
|
||||
'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.':
|
||||
'绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。',
|
||||
'A reusable docker host profile. Reuse the same ID across cases to share settings.':
|
||||
|
||||
@@ -2589,8 +2589,15 @@
|
||||
<div class="modal-content modal-lg set-shell">
|
||||
<div class="modal-header set-shell-head">
|
||||
<h3>Add Case</h3>
|
||||
<!-- mobile.css hides this modal's .set-foot, so on a phone the footer's
|
||||
Create/Link button is unreachable and the modal cannot be submitted
|
||||
at all. Mirrors the Settings modal's header Save: close first in the
|
||||
DOM so the focus trap still lands on it, row-reverse puts this to
|
||||
its left. Both buttons are driven together by switchCaseModalTab()
|
||||
and submitCaseModal(). -->
|
||||
<div class="set-head-actions">
|
||||
<button class="modal-close" onclick="app.closeCreateCaseModal()" aria-label="Close create case">×</button>
|
||||
<button class="set-head-save" id="caseModalSubmitMobile" onclick="app.submitCaseModal()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-body">
|
||||
@@ -2644,6 +2651,7 @@
|
||||
<div class="form-row docker-quick-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
|
||||
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
|
||||
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
|
||||
</div>
|
||||
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
|
||||
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Container settings (optional, sensible defaults)</span></summary>
|
||||
@@ -2847,6 +2855,24 @@
|
||||
<h2>Docker</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Run the case inside a container: one per case, shared by all its sessions.</p>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
|
||||
<span class="form-hint">On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.</span>
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Name</label>
|
||||
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<datalist id="dockerContainerList"></datalist>
|
||||
<span class="form-hint">Pick from the running containers or type a name. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
|
||||
</div>
|
||||
<div class="form-row docker-adopt-only">
|
||||
<label>Container Workdir</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerAdoptWorkdir" placeholder="/workspace" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkdirPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
@@ -2854,7 +2880,10 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Workspace Path</label>
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkspacePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@@ -2862,12 +2891,12 @@
|
||||
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Network</label>
|
||||
<select id="dockerNetwork">
|
||||
<option value="bridge">bridge (internet on, default)</option>
|
||||
@@ -2875,7 +2904,7 @@
|
||||
<option value="custom">custom bridge</option>
|
||||
</select>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<details class="advanced-options docker-create-only">
|
||||
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced container settings</span></summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
|
||||
@@ -185,9 +185,13 @@ const PathPicker = {
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
// A caller may supply its own source (the container-workdir picker browses
|
||||
// INSIDE a container, which the host filesystem endpoint cannot answer).
|
||||
// It returns the same shape, so everything below is unchanged.
|
||||
const result = this._options.fetchListing
|
||||
? await this._options.fetchListing(path)
|
||||
: await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json();
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
@@ -304,7 +308,7 @@ const PathPicker = {
|
||||
// A hidden file is only reachable while the toggle is on, and the preview
|
||||
// endpoint re-resolves the path independently, so it needs the flag too.
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
const previewUrl = (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/preview?${params.toString()}`);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
|
||||
@@ -3333,8 +3333,10 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
recessed tray and matching pill geometry instead of reading as a fat
|
||||
accent pill parked beside a stray × glyph. Tray colors come from skin
|
||||
tokens, never a hardcoded black alpha, or the light skins get a grey slab.
|
||||
`:has()` keeps the tray off the two sheets that carry a lone × (Session
|
||||
Options and Add Case save from inside their own forms). */
|
||||
`:has()` keeps the tray off Session Options, the one sheet left carrying a
|
||||
lone × because it saves from inside its own per-section forms. Add Case
|
||||
now has a header save of its own (its footer is hidden below 860px, so
|
||||
that button is the only way to submit it there), and picks up the tray. */
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions:has(.set-head-save) {
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -3353,6 +3355,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* Add Case's pending state has to show on the header button: below 860px it is
|
||||
the only submit control (the footer is hidden), and the #caseModalSubmit
|
||||
.loading rule in the phone block dims a button nobody can see. Measured at
|
||||
390px before this: header opacity 1 for the whole clone, hidden footer 0.6. */
|
||||
#createCaseModal .set-head-save.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
@@ -390,7 +390,7 @@ class NotificationManager {
|
||||
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
|
||||
body: localizedBody,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
|
||||
silent: true, // We handle audio ourselves
|
||||
});
|
||||
|
||||
|
||||
+39
-32
@@ -3430,7 +3430,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
@@ -3605,7 +3605,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
: '';
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
return `
|
||||
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||
@@ -4004,18 +4004,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
this.filePreviewDetachUrl = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4073,13 +4075,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to file-content below, which would dump the binary bytes as mojibake.
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||
const previewSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
footerEl.textContent = 'PDF';
|
||||
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const rawSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
@@ -4113,19 +4115,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const data = result.data;
|
||||
|
||||
if (data.type === 'image') {
|
||||
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'video') {
|
||||
// playsinline: iOS otherwise hijacks playback into its fullscreen
|
||||
// player, which leaves the overlay behind it and its own close button
|
||||
// as the only way back.
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
@@ -4422,9 +4424,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||
const url = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const url = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
@@ -4460,19 +4464,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stack = this.ensureAttachmentCardStack();
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
const attachmentRawUrl =
|
||||
const attachmentRawUrl = CodemanBase.url(
|
||||
rawUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
const attachmentPreviewUrl =
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const attachmentPreviewUrl = CodemanBase.url(
|
||||
previewUrl ||
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||
const attachmentThumbnailUrl =
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
|
||||
);
|
||||
const attachmentThumbnailUrl = CodemanBase.url(
|
||||
thumbnailUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||
|
||||
@@ -4736,7 +4743,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
@@ -4776,7 +4783,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
@@ -4785,7 +4792,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
window.open(CodemanBase.url(item.downloadUrl), '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
@@ -4931,7 +4938,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Connect to SSE stream
|
||||
const eventSource = new EventSource(
|
||||
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
|
||||
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
|
||||
);
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -5073,7 +5080,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Build image URL using the existing file-raw endpoint
|
||||
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
|
||||
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
|
||||
const imageUrl = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`);
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
|
||||
+337
-19
@@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeCasePicker();
|
||||
this.updateDirDisplayForCase(select.value);
|
||||
this.updateMobileCaseLabel(select.value);
|
||||
// Warm the container's CLI list HERE rather than when the run menu opens.
|
||||
// The probe is a `docker exec` round trip, so gating it on the menu meant the
|
||||
// menu painted every mode first and only narrowed a moment later — which
|
||||
// reads as "it shows all of them" and lets a mode be picked that the
|
||||
// container does not have.
|
||||
const picked = (this.cases || []).find((c) => c.name === select.value);
|
||||
if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null);
|
||||
if (save) {
|
||||
this.saveLastUsedCase(select.value);
|
||||
}
|
||||
@@ -477,10 +484,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
// A DOCKER case runs its agents INSIDE the container, so host CLI
|
||||
// availability answers the wrong question: the host may have no claude at
|
||||
// all while the container ships one, and gating on the host hides a mode
|
||||
// that would have worked. Adoption records what the container really has
|
||||
// (`availableModes`); an owned container runs our base image, which ships
|
||||
// every CLI, so an absent list means "do not gate" rather than "nothing".
|
||||
// Same source every run* path reads the selected case from.
|
||||
const caseName = document.getElementById('quickStartCase')?.value;
|
||||
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
|
||||
const isDocker = activeCase?.location === 'docker';
|
||||
// Prefer a LIVE probe over the value stored at attach time: a container's
|
||||
// CLIs can be installed or removed long after the case was linked, and a
|
||||
// case linked before that field existed has none at all.
|
||||
const containerModes = isDocker
|
||||
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
|
||||
: null;
|
||||
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
|
||||
// An unreachable container hides every agent mode and explains why, instead
|
||||
// of silently offering modes that cannot start.
|
||||
//
|
||||
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
|
||||
// state before the first session — the launch chain creates and starts it — so
|
||||
// reporting it as a fault hid every agent mode on a freshly linked Docker case
|
||||
// behind "start it yourself first", for a container Codeman was about to create.
|
||||
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
if (!btn) continue;
|
||||
let available;
|
||||
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
|
||||
else available = this.isCliAvailable(mode);
|
||||
btn.style.display = available ? 'flex' : 'none';
|
||||
}
|
||||
this._renderRunModeNotice(menu, probeError);
|
||||
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
|
||||
// perfectly installed while no pane-capable profile exists, because DeepSeek
|
||||
// ships no terminal front door. In that state the honest offer is "add one",
|
||||
@@ -622,6 +659,81 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* One-line explanation at the top of the run menu. Only a container that could
|
||||
* not be read produces one; everything else removes it, so a stale reason can
|
||||
* never outlive the condition that caused it.
|
||||
*/
|
||||
_renderRunModeNotice(menu, message) {
|
||||
if (!menu) return;
|
||||
let el = menu.querySelector('.run-mode-notice');
|
||||
if (!message) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.className = 'run-mode-notice';
|
||||
menu.prepend(el);
|
||||
}
|
||||
// Server-supplied text: set it, never parse it as markup.
|
||||
el.textContent = message;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the container which CLIs it actually has, and re-gate the menu once the
|
||||
* answer lands. Cached per case for the page's lifetime: the menu re-opens
|
||||
* often and the probe is a `docker exec` round trip.
|
||||
*
|
||||
* Best-effort by design — an unreachable daemon or a stopped container leaves
|
||||
* the cache empty, which the caller reads as "unknown" and therefore does not
|
||||
* gate. Hiding every mode because a probe failed would be worse than showing
|
||||
* one that turns out to be missing, which the launch path already refuses with
|
||||
* a specific message.
|
||||
*/
|
||||
async _probeDockerCaseModes(activeCase, menu) {
|
||||
const name = activeCase?.name;
|
||||
const container = activeCase?.docker?.container;
|
||||
const hostId = activeCase?.docker?.hostId;
|
||||
if (!name || !container || !hostId) return;
|
||||
this._dockerCaseModes = this._dockerCaseModes || {};
|
||||
if (this._dockerModeProbeInFlight?.[name]) return;
|
||||
this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {};
|
||||
this._dockerModeProbeInFlight[name] = true;
|
||||
try {
|
||||
// ⚠️ _api serializes `body` and sets Content-Type itself. Passing an
|
||||
// already-stringified body double-encodes it and the server rejects a
|
||||
// JSON string where it expects an object (400 INVALID_INPUT).
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container },
|
||||
});
|
||||
if (probe?.ok && Array.isArray(probe.availableModes)) {
|
||||
this._dockerCaseModes[name] = probe.availableModes;
|
||||
delete this._dockerCaseProbeError?.[name];
|
||||
} else {
|
||||
// An ADOPTED container that cannot be probed — recreated, stopped, engine
|
||||
// down — must NOT fall through to "show everything". Offering claude on a
|
||||
// container that is not running is a click that can only fail, with the
|
||||
// reason visible nowhere. Record the reason and say it in the menu.
|
||||
//
|
||||
// ⚠️ An OWNED container gets no error: it does not exist until the first
|
||||
// session launches it, so "not found" is the expected answer for every
|
||||
// newly linked Docker case, and gating on it made those cases unusable.
|
||||
// Leaving the cache empty reads as "unknown", which does not gate.
|
||||
if (activeCase?.docker?.owned === false) {
|
||||
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
|
||||
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
|
||||
}
|
||||
delete this._dockerCaseModes[name];
|
||||
}
|
||||
// Only repaint while the menu the user opened is still on screen.
|
||||
if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu);
|
||||
} finally {
|
||||
delete this._dockerModeProbeInFlight[name];
|
||||
}
|
||||
},
|
||||
|
||||
async _loadRunModeHistory() {
|
||||
const container = document.getElementById('runModeHistory');
|
||||
if (!container) return;
|
||||
@@ -2387,6 +2499,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
modal.querySelectorAll('.set-rail-item').forEach(btn => {
|
||||
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
|
||||
});
|
||||
// Adopt-an-existing-container toggle + its read-only preflight. Assigned (not
|
||||
// addEventListener) so reopening the modal cannot stack duplicate handlers,
|
||||
// matching the rail wiring right above.
|
||||
const adoptToggle = document.getElementById('dockerAdoptExisting');
|
||||
if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode();
|
||||
const adoptCheck = document.getElementById('dockerAdoptCheckBtn');
|
||||
if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight();
|
||||
const adoptJump = document.getElementById('dockerAdoptJumpBtn');
|
||||
if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt();
|
||||
// Containers come from the host profile, so switching Host ID invalidates the
|
||||
// suggestions. Dropping the marker (rather than refetching here) keeps the
|
||||
// fetch lazy — it happens when adopt mode is actually on.
|
||||
const hostIdInput = document.getElementById('dockerHostId');
|
||||
if (hostIdInput) {
|
||||
hostIdInput.onchange = () => {
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions();
|
||||
};
|
||||
}
|
||||
// A fresh open re-reads the engine: containers start and stop between visits.
|
||||
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
|
||||
this._syncDockerAdoptMode();
|
||||
// Scroll-into-view on focus for mobile keyboard visibility
|
||||
modal.querySelectorAll('input[type="text"]').forEach(input => {
|
||||
if (!input._mobileScrollWired) {
|
||||
@@ -2416,15 +2550,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A switched-to panel starts at its own top.
|
||||
const doc = document.getElementById('createCaseDoc');
|
||||
if (doc) doc.scrollTop = 0;
|
||||
// Update submit button (hide for manage tab)
|
||||
const submitBtn = document.getElementById('caseModalSubmit');
|
||||
// Update submit buttons (hide for manage tab). Two of them: mobile.css hides
|
||||
// this modal's .set-foot, so phones submit through the header button instead.
|
||||
const submitBtns = ['caseModalSubmit', 'caseModalSubmitMobile']
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean);
|
||||
if (tabName === 'case-manage') {
|
||||
submitBtn.style.display = 'none';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
this.renderCaseManageList();
|
||||
this.refreshDockerExports();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent =
|
||||
const label =
|
||||
tabName === 'case-create'
|
||||
? 'Create'
|
||||
: tabName === 'case-clone'
|
||||
@@ -2434,6 +2572,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
: tabName === 'case-docker'
|
||||
? 'Link Docker'
|
||||
: 'Link';
|
||||
submitBtns.forEach((btn) => {
|
||||
btn.style.display = '';
|
||||
btn.textContent = label;
|
||||
});
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
@@ -2454,14 +2596,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async submitCaseModal() {
|
||||
const btn = document.getElementById('caseModalSubmit');
|
||||
const originalText = btn.textContent;
|
||||
btn.classList.add('loading');
|
||||
btn.textContent =
|
||||
// Both submit buttons move together: whichever one the user pressed, the
|
||||
// other must show the same pending state and be equally unclickable.
|
||||
const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean);
|
||||
const originalText = btns.map((btn) => btn.textContent);
|
||||
const pendingText =
|
||||
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
|
||||
// A clone holds this request open for minutes; without disabling the button a
|
||||
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
|
||||
btn.disabled = true;
|
||||
btns.forEach((btn) => {
|
||||
btn.classList.add('loading');
|
||||
btn.textContent = pendingText;
|
||||
btn.disabled = true;
|
||||
});
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
@@ -2475,9 +2622,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
await this.linkCase();
|
||||
}
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
btns.forEach((btn, index) => {
|
||||
btn.classList.remove('loading');
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText[index];
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2862,6 +3011,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/** HOST workspace directory — the same picker Link Existing uses. */
|
||||
openDockerWorkspacePathPicker() {
|
||||
const pathInput = document.getElementById('dockerWorkspacePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Host Workspace Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('dockerCaseName');
|
||||
if (nameInput && !nameInput.value.trim()) {
|
||||
const folder = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Container workdir. Browses INSIDE the container, because for an adopted
|
||||
* container nothing is mounted at a matching host path — the host picker would
|
||||
* be listing a different filesystem, and typing this field blind is exactly
|
||||
* what makes the launch fail with an OCI chdir error.
|
||||
*/
|
||||
openDockerWorkdirPicker() {
|
||||
const pathInput = document.getElementById('dockerAdoptWorkdir');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (!container) {
|
||||
this.showToast('Enter the container name first', 'error');
|
||||
return;
|
||||
}
|
||||
PathPicker.open({
|
||||
title: `Select Folder Inside ${container}`,
|
||||
initialPath: pathInput.value.trim() || '/',
|
||||
directoriesOnly: true,
|
||||
fetchListing: async (path) => {
|
||||
const data = await this._apiJson('/api/docker-cases/browse', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, path: path || '/' },
|
||||
});
|
||||
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
|
||||
if (data.error) return { success: false, error: data.error };
|
||||
// Shape it like the host endpoint: one root, so Up/Location behave.
|
||||
return {
|
||||
success: true,
|
||||
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
|
||||
};
|
||||
},
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
@@ -2943,10 +3147,107 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reflect the "attach to an existing container" checkbox onto the modal so CSS
|
||||
* can swap which half of the Docker panel applies. An attribute rather than
|
||||
* per-row inline styles: the panel is rebuilt by nothing, but the create-time
|
||||
* rows are a SET (image, network, advanced block) and one attribute keeps them
|
||||
* in lockstep with the container-name row.
|
||||
*/
|
||||
_syncDockerAdoptMode() {
|
||||
const modal = document.getElementById('createCaseModal');
|
||||
if (!modal) return;
|
||||
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
|
||||
if (adopting) modal.setAttribute('data-docker-adopt', '1');
|
||||
else modal.removeAttribute('data-docker-adopt');
|
||||
if (adopting) void this._loadDockerContainerOptions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fill the container-name `<datalist>`. A native datalist is deliberate: the
|
||||
* field must accept a free-typed name (the engine may be remote, or the
|
||||
* container may not exist yet when the form is filled), and datalist gives
|
||||
* type-to-filter over the suggestions without a custom dropdown.
|
||||
*
|
||||
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
|
||||
* and an empty list simply leaves the field as plain text input.
|
||||
*/
|
||||
async _loadDockerContainerOptions() {
|
||||
const list = document.getElementById('dockerContainerList');
|
||||
if (!list) return;
|
||||
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
|
||||
if (list.dataset.loadedFor === hostId) return; // one fetch per host per open
|
||||
const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`);
|
||||
const containers = data?.containers || [];
|
||||
list.textContent = '';
|
||||
for (const c of containers) {
|
||||
const option = document.createElement('option');
|
||||
option.value = c.name;
|
||||
// Engine-supplied strings: set as text, never as markup.
|
||||
option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`;
|
||||
list.appendChild(option);
|
||||
}
|
||||
list.dataset.loadedFor = hostId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cross-link from the Create New tab's "Run in an isolated Docker container"
|
||||
* row. Adoption lives on the Docker tab, but the place users actually look for
|
||||
* anything container-shaped is that checkbox, so this jumps them there with the
|
||||
* toggle already on rather than leaving the feature undiscoverable.
|
||||
*/
|
||||
jumpToDockerAdopt() {
|
||||
this.switchCaseModalTab('case-docker');
|
||||
const toggle = document.getElementById('dockerAdoptExisting');
|
||||
if (toggle) toggle.checked = true;
|
||||
this._syncDockerAdoptMode();
|
||||
document.getElementById('dockerContainerName')?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read-only preflight against an existing container. It links nothing, so the
|
||||
* user can find out "not running" / "no tmux" / "codex present, claude missing"
|
||||
* before committing to a case name — the same reason the server refuses at link
|
||||
* time rather than at session launch.
|
||||
*/
|
||||
async _dockerAdoptPreflight() {
|
||||
const statusEl = document.getElementById('dockerLinkStatus');
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim();
|
||||
const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
if (!container) {
|
||||
if (statusEl) statusEl.textContent = 'Enter a container name first.';
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Inspecting container...';
|
||||
// _apiJson folds every failure to null, and a preflight's whole value is the
|
||||
// reason it failed, so the envelope is unwrapped by hand here.
|
||||
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
|
||||
method: 'POST',
|
||||
body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) },
|
||||
});
|
||||
if (!statusEl) return;
|
||||
if (!probe) {
|
||||
statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.';
|
||||
return;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
statusEl.textContent = probe.error || 'Container is not adoptable.';
|
||||
return;
|
||||
}
|
||||
const modes = (probe.availableModes || []).filter((m) => m !== 'shell');
|
||||
statusEl.textContent = modes.length
|
||||
? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.`
|
||||
: `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`;
|
||||
},
|
||||
|
||||
async linkDockerCase() {
|
||||
const name = document.getElementById('dockerCaseName').value.trim();
|
||||
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
|
||||
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
|
||||
const adopting = !!document.getElementById('dockerAdoptExisting')?.checked;
|
||||
const container = document.getElementById('dockerContainerName')?.value.trim() || '';
|
||||
const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || '';
|
||||
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
|
||||
const network = document.getElementById('dockerNetwork').value;
|
||||
const memory = document.getElementById('dockerMemory').value.trim();
|
||||
@@ -2967,9 +3268,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showToast('Workspace path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
if (adopting && !container) {
|
||||
this.showToast('Enter the name of the running container to attach to', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...';
|
||||
}
|
||||
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
|
||||
const resources = {};
|
||||
if (memory) resources.memory = memory;
|
||||
@@ -3000,16 +3307,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
|
||||
|
||||
const caseRes = await fetch('/api/cases/docker-link', {
|
||||
// Adoption reuses this whole flow and differs only in the final call: a
|
||||
// different endpoint (which never creates a container) plus the container
|
||||
// name. The host upsert above still applies — it is what resolves the
|
||||
// engine/context/daemon for the `docker exec`; its create-time fields are
|
||||
// simply never read for an adopted case.
|
||||
const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
|
||||
body: JSON.stringify(
|
||||
adopting
|
||||
? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) }
|
||||
: { name, hostId, hostWorkspacePath }
|
||||
),
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
|
||||
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
|
||||
const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell');
|
||||
const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : '';
|
||||
this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
@@ -3046,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
|
||||
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
|
||||
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
|
||||
const _swBase = window.CodemanBase?.base || '';
|
||||
navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => {
|
||||
this._swRegistration = reg;
|
||||
// Listen for messages from service worker (notification clicks)
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
|
||||
@@ -12607,6 +12607,43 @@ kbd {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Consecutive messages from one speaker inside one turn are segments of a
|
||||
single utterance, not separate cards: no repeated badge, a hairline seam.
|
||||
The role accent survives because the colour rules above match on BOTH
|
||||
:has(.rv-role-*) and .rv-msg-* — a badge-less continuation still hits the
|
||||
class arm. Do not drop either arm. */
|
||||
.rv-message.rv-msg-cont {
|
||||
margin-top: -18px;
|
||||
border-top: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.rv-message.rv-msg-cont > .rv-text {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.rv-message:has(+ .rv-msg-cont) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* A prompt the user typed while the agent was working (absorbed mid-turn).
|
||||
A pseudo-element, not a text node, so the i18n MutationObserver cannot
|
||||
rewrite it. */
|
||||
.rv-message[data-queued='1'] .rv-role::after {
|
||||
content: ' ⏱';
|
||||
}
|
||||
|
||||
.rv-notice {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Markdown rendered content inside response viewer.
|
||||
Prose uses a proportional font for readability; code keeps monospace. */
|
||||
.rv-text,
|
||||
@@ -16645,6 +16682,44 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
label as a row label, its `.form-hint` as a row description. Scoped to the
|
||||
document, so `.form-row` everywhere else is untouched.
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
/* Adopt-an-existing-container mode swaps which half of the Docker panel applies:
|
||||
the create-time fields (image, network, resources, credential mounts) describe
|
||||
a `docker create` that adoption never runs, and the container name is the one
|
||||
field only adoption needs. `.docker-adopt-only` is hidden by default so the
|
||||
panel stays exactly as it was until the checkbox is ticked. Rules carry
|
||||
`!important` because the adapter block above paints `.form-row` as a row card
|
||||
and `details.advanced-options` has its own display. */
|
||||
/* Run-menu notice: why a container case is offering no agent modes. Lives at the
|
||||
top of the menu so the reason is where the missing entries would have been. */
|
||||
.run-mode-notice {
|
||||
padding: 8px 12px;
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted, #9aa0a6);
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#createCaseModal .docker-adopt-only {
|
||||
display: none !important;
|
||||
}
|
||||
#createCaseModal[data-docker-adopt='1'] .docker-adopt-only {
|
||||
display: block !important;
|
||||
}
|
||||
#createCaseModal[data-docker-adopt='1'] .docker-create-only {
|
||||
display: none !important;
|
||||
}
|
||||
#createCaseModal .btn-inline-check {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent, #4a9eff);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#createCaseModal .set-doc .form-row {
|
||||
margin: 0 0 3px;
|
||||
padding: 7px 10px;
|
||||
|
||||
+13
-6
@@ -20,6 +20,13 @@
|
||||
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
||||
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
||||
// is prefixed through B() so the cached shell, icons and API calls resolve under
|
||||
// the mount instead of escaping to the origin root.
|
||||
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
||||
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
||||
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
@@ -45,7 +52,7 @@ const APP_SHELL = [
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
].map(B);
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
icon: B('/icon-192.png'),
|
||||
badge: B('/icon-192.png'),
|
||||
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
};
|
||||
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const { sessionId, approvalId, url } = event.notification.data || {};
|
||||
const targetUrl = url || '/';
|
||||
const targetUrl = url || B('/');
|
||||
const action = event.action || null;
|
||||
|
||||
// Approve/Deny action buttons answer the Approvals Inbox item directly from
|
||||
@@ -152,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
// because a service worker fetch carries the worker's own (same) origin.
|
||||
if ((action === 'approve' || action === 'deny') && approvalId) {
|
||||
event.waitUntil(
|
||||
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
|
||||
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
|
||||
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try {
|
||||
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
|
||||
this._ws = new WebSocket(`${proto}//${location.host}${window.CodemanBase?.base || ''}/ws/voice/stream?${params}`);
|
||||
} catch (err) {
|
||||
this._onError?.('Failed to open voice stream: ' + err.message);
|
||||
this._cleanup();
|
||||
|
||||
@@ -182,7 +182,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
|
||||
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
|
||||
frame.src = CodemanBase.url(src);
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
|
||||
Reference in New Issue
Block a user