Merge remote-tracking branch 'origin/master' into relprep/pr-510

Brings the 1.33.2 based adoption feature up to master. Conflicts were
resolved by keeping master's intent and re-applying the feature on top:
both sides kept for imports, the script tag and the stylesheet blocks; the
trust-dialog guard keeps master's stopped check plus the adopted refusal;
the Ralph and image watchers keep master's remote branch plus the adopted
skip; the close confirmation keeps master's worktree option plus the
adopted wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:51:00 +01:00
commit 57b2dcc466
858 files changed
+173626 -7078

No files matched your search

+3885 -352
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -261,6 +261,10 @@ Object.assign(CodemanApp.prototype, {
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`;
} else if (item.answerInTerminal) {
// Supervised-hook CLIs (DeepSeek Harness): keystrokes are unverifiable, so
// Approve/Deny would hit the answer route and fail. Offer only Open session.
actions = `<span class="approval-terminal-hint" data-i18n-skip>Answer in the terminal</span>`;
} else if (item.options && item.options.length) {
actions = item.options
.map(
+2589 -134
View File
File diff suppressed because it is too large. Load diff
+630
View File
@@ -0,0 +1,630 @@
/**
* @fileoverview Edit history: a header button (next to Attachments) and the floating panel it opens
* right below, listing every file edit the ACTIVE session's agent (and its subagents) made, newest
* first.
*
* OPTIONAL and per-device: `showEditHistory` (App Settings → Header & Panels → Header), default
* OFF. While it is off the button never shows and nothing is requested. The panel DRIVES the
* server's reading, which never runs in the background: it reads
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
* (the end of a turn). No poll, and nothing at all while the panel is closed.
*
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
* enforces the attachment guard and its extension allowlist). A path from the transcript is
* never served by a route of its own.
* - `backfillTruncated` offers "Load older" (`POST /api/sessions/:id/edits/older`).
*
* Paths, agent descriptions and diffs are untrusted text: they are only ever written with
* `textContent`, never `innerHTML`.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency api-client.js (this._api, this._apiJson; resolved at call time)
* @dependency panels-ui.js (openFilePreview, _isExternalPreviewPath)
* @dependency i18n.js (window.codemanT)
* @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js
*/
/** SSE bursts (one event per tool call) collapse into one read. */
const EDIT_HISTORY_SSE_DEBOUNCE_MS = 600;
/** Tools whose end can mean changed files (Agent/Task: a subagent's edits; SendUserFile: an image). */
const EDIT_HISTORY_FOLLOW_TOOLS = new Set([
'Edit',
'Write',
'MultiEdit',
'NotebookEdit',
'Bash',
'Agent',
'Task',
'SendUserFile',
]);
/** Per-device view toggles (a viewer convenience, so plain localStorage). */
const EDIT_HISTORY_GROUP_KEY = 'codeman-edit-history-group';
const EDIT_HISTORY_MAIN_ONLY_KEY = 'codeman-edit-history-main-only';
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isEditHistoryEnabled() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.showEditHistory ?? defaults.showEditHistory ?? false) === true;
},
/** Translate an English source string (i18n.js), with `{var}` interpolation. */
_eht(source, vars) {
if (typeof window.codemanT === 'function') return window.codemanT(source, vars);
return vars ? source.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : source;
},
/** A row badge ("new"/"edit") through its scoped i18n key; English shows the bare word. */
_ehBadge(word) {
const key = `Edit history badge: ${word}`;
const out = this._eht(key);
return out === key ? word : out;
},
/** Called from applyHeaderVisibilitySettings() (boot and every settings save). */
applyEditHistoryVisibility() {
const on = this.isEditHistoryEnabled();
// Marker class, not [hidden]: the base rule is display:inline-flex !important (src/web/CLAUDE.md).
this.$('editHistoryBtn')?.classList.toggle('btn-edit-history--hidden', !on);
if (!on) {
this.closeEditHistoryPanel();
this._editHistory = null;
this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1; // an in-flight read must not repaint
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = null;
}
},
// ── Per-device view toggles ─────────────────────────────────────────────
_editHistoryPref(key) {
try {
return localStorage.getItem(key) === '1';
} catch {
return false;
}
},
_setEditHistoryPref(key, on) {
try {
localStorage.setItem(key, on ? '1' : '0');
} catch {
/* private window / blocked storage: the toggle still works for this page */
}
},
isEditHistoryGrouped() {
return this._editHistoryGroupOn ?? (this._editHistoryGroupOn = this._editHistoryPref(EDIT_HISTORY_GROUP_KEY));
},
isEditHistoryMainOnly() {
return this._editHistoryMainOnly ?? (this._editHistoryMainOnly = this._editHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY));
},
setEditHistoryGrouped(on) {
this._editHistoryGroupOn = !!on;
this._setEditHistoryPref(EDIT_HISTORY_GROUP_KEY, !!on);
this._renderEditHistoryPanel();
},
setEditHistoryMainOnly(on) {
this._editHistoryMainOnly = !!on;
this._setEditHistoryPref(EDIT_HISTORY_MAIN_ONLY_KEY, !!on);
this._renderEditHistoryPanel();
},
// ── Data ────────────────────────────────────────────────────────────────
/** The data for the session on screen, or null. */
_currentEditHistory() {
const h = this._editHistory;
return h && h.sessionId === this.activeSessionId ? h : null;
},
_editHistoryBase(sid) {
return `/api/sessions/${encodeURIComponent(sid)}/edits`;
},
/**
* Read the active session's history and repaint. `older` asks the server for entries before the
* backfill window (POST), otherwise the normal read. Answers for another session are dropped.
*/
async refreshEditHistory({ older = false } = {}) {
if (!this.isEditHistoryEnabled()) return;
const sid = this.activeSessionId || null;
if (!sid) {
this._editHistory = null;
this._renderEditHistoryPanel();
return;
}
const epoch = (this._editHistoryEpoch = (this._editHistoryEpoch || 0) + 1);
const prev = this._currentEditHistory();
if (!prev) {
// Another session's list must not stay on screen while this one loads.
this._editHistory = { sessionId: sid, state: 'loading', data: null, diffs: new Map(), open: new Set() };
this._renderEditHistoryPanel();
} else if (older) {
prev.loadingOlder = true;
this._renderEditHistoryPanel();
}
const base = this._editHistoryBase(sid);
const data = older
? await this._apiJson(`${base}/older`, { method: 'POST', body: {} })
: await this._apiJson(`${base}?sync=1`);
if (epoch !== this._editHistoryEpoch || sid !== this.activeSessionId || !this.isEditHistoryEnabled()) return;
const cur = this._currentEditHistory() || {
sessionId: sid,
diffs: new Map(),
open: new Set(),
};
cur.loadingOlder = false;
if (data && typeof data === 'object') {
cur.state = 'ok';
cur.data = data;
} else if (!cur.data) {
cur.state = 'error';
}
this._editHistory = cur;
this._renderEditHistoryPanel();
},
loadOlderEditHistory() {
return this.refreshEditHistory({ older: true });
},
/**
* SSE `transcript:tool_end` ({ sessionId, toolName }), and the end of a turn through
* `onEditHistorySessionIdle`: while the panel is open on that session, re-read after a tool that
* can change files, and when the turn ends (a parallel tool call reports only one tool end; a
* background subagent writes after the tool end that started it). Closed: nothing.
*/
_onEditHistoryTranscriptEvent(data) {
if (!data || data.sessionId !== this.activeSessionId || !this._isEditHistoryPanelOpen()) return;
if (data.toolName !== undefined && !EDIT_HISTORY_FOLLOW_TOOLS.has(data.toolName)) return;
if (!this.isEditHistoryEnabled()) return;
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = setTimeout(() => {
this._editHistoryDebounce = null;
if (this._isEditHistoryPanelOpen()) this.refreshEditHistory();
}, EDIT_HISTORY_SSE_DEBOUNCE_MS);
},
/**
* Called by app.js's `session:idle` handler ({ id }): the end of a turn. Claude's interactive
* transcript has no `result` line, so the transcript watcher's `transcript:complete` never fires
* for these sessions.
*/
onEditHistorySessionIdle(sessionId) {
this._onEditHistoryTranscriptEvent({ sessionId });
},
/** Called by selectSession(): the open panel follows the active session. */
onEditHistoryActiveSession(sessionId) {
if (!this.isEditHistoryEnabled() || !this._isEditHistoryPanelOpen()) return;
if (this._editHistory?.sessionId === sessionId) return;
this.refreshEditHistory();
},
// ── Panel ───────────────────────────────────────────────────────────────
_isEditHistoryPanelOpen() {
return !!this.$('editHistoryPanel')?.classList.contains('visible');
},
toggleEditHistoryPanel() {
if (this._isEditHistoryPanelOpen()) {
this.closeEditHistoryPanel();
return;
}
const panel = this.$('editHistoryPanel');
if (!panel) return;
panel.classList.add('visible');
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'true');
const group = this.$('editHistoryGroupToggle');
if (group) group.checked = this.isEditHistoryGrouped();
const main = this.$('editHistoryMainOnlyToggle');
if (main) main.checked = this.isEditHistoryMainOnly();
this._ensureEditHistoryPanelDrag();
this._renderEditHistoryPanel();
this.refreshEditHistory();
},
closeEditHistoryPanel() {
const panel = this.$('editHistoryPanel');
if (panel) {
panel.classList.remove('visible');
// Reset a dragged position so it reopens at the default spot (a resized size is kept).
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
}
this.$('editHistoryBtn')?.setAttribute('aria-expanded', 'false');
clearTimeout(this._editHistoryDebounce);
this._editHistoryDebounce = null;
},
/** Drag by the header (pointer events: mouse, pen and touch). One set of listeners per page. */
_ensureEditHistoryPanelDrag() {
const panel = this.$('editHistoryPanel');
const handle = panel?.querySelector('.edit-history-header');
if (!panel || !handle || handle._dragReady) return;
handle._dragReady = true;
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
// Pin the size too: the phone layout stretches between left and right, which a drag gives up.
panel.style.width = `${rect.width}px`;
panel.style.height = `${rect.height}px`;
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
const maxX = window.innerWidth - panel.offsetWidth - 4;
const maxY = window.innerHeight - panel.offsetHeight - 4;
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
});
const end = (e) => {
drag = null;
handle.releasePointerCapture?.(e.pointerId);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
},
_ehEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
},
/** "14:03:22", with the date in front when it is not today. */
_editHistoryTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString()} ${time}`;
},
/** Open `path` in the shared file preview overlay (panels-ui.js). */
_openEditHistoryPreview(path) {
const sid = this.activeSessionId;
if (sid && path) this.openFilePreview?.(path, sid);
},
_editHistoryUrl(url) {
return typeof CodemanBase !== 'undefined' && CodemanBase.url ? CodemanBase.url(url) : url;
},
/**
* Thumbnail URL for an image entry inside the workspace: the confined `file-raw` route the file
* preview uses. An image outside the workspace gets none: showing it needs an attachment id
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
* session's real attachments out of its capped registry. Clicking it opens the file preview,
* which registers it like any other external file. SVGs get none either (`file-raw` serves
* them as a download).
*/
_editHistoryThumbSrc(path, sid) {
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
},
_renderEditHistoryPanel() {
const body = this.$('editHistoryBody');
const count = this.$('editHistoryCount');
if (!body || !this._isEditHistoryPanelOpen()) return;
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-edit-key]')?.dataset.editKey
: null;
const scroll = body.scrollTop;
body.replaceChildren();
if (count) count.textContent = '';
if (!this.activeSessionId) {
body.append(el('div', 'edit-history-empty', t('Open a session to see its edits.')));
return;
}
const h = this._currentEditHistory();
if (!h || h.state === 'loading') {
body.append(el('div', 'edit-history-empty', t('Reading the edit history…')));
return;
}
if (h.state === 'error' || !h.data) {
body.append(el('div', 'edit-history-empty', t('Could not read the edit history.')));
return;
}
const data = h.data;
if (data.state === 'unsupported') {
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
return;
}
if (data.state === 'unavailable') {
body.append(
el(
'div',
'edit-history-note edit-history-note--warn',
data.reason === 'no-transcript'
? t(
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
)
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
)
);
}
const all = Array.isArray(data.entries) ? data.entries : [];
const entries = this.isEditHistoryMainOnly() ? all.filter((e) => !e.agentId) : all;
if (count) count.textContent = all.length ? String(entries.length) : '';
if (!all.length) {
body.append(el('div', 'edit-history-empty', t('No edits yet.')));
} else if (!entries.length) {
body.append(el('div', 'edit-history-empty', t('No edits by the main agent.')));
} else if (this.isEditHistoryGrouped()) {
const groups = new Map();
for (const e of entries) {
const key = e.path || e.relPath;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
for (const items of groups.values()) body.append(this._editHistoryGroup(items, data, h));
} else {
const list = el('div', 'edit-history-list');
for (const e of entries) list.append(...this._editHistoryEntry(e, data, h, true));
body.append(list);
}
if (data.backfillTruncated) {
const more = el(
'button',
'btn-toolbar btn-sm edit-history-older',
h.loadingOlder ? t('Loading older edits…') : t('Load older')
);
more.type = 'button';
more.disabled = !!h.loadingOlder;
more.addEventListener('click', () => this.loadOlderEditHistory());
body.append(more);
}
body.scrollTop = scroll;
if (focusKey) {
const again = [...body.querySelectorAll('[data-edit-key]')].find((n) => n.dataset.editKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/** One file with all its edits (newest first), for the "group by file" view. */
_editHistoryGroup(items, data, h) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const first = items[0];
const group = el('section', 'edit-history-group');
const head = el('div', 'edit-history-group-head');
head.append(this._editHistoryPathButton(first));
const code = items.filter((e) => e.kind !== 'image');
if (code.length) {
const added = code.reduce((s, e) => s + (Number(e.added) || 0), 0);
const removed = code.reduce((s, e) => s + (Number(e.removed) || 0), 0);
head.append(this._editHistoryStats(added, removed));
}
head.append(el('span', 'edit-history-group-count', String(items.length)));
group.append(head);
for (const e of items) group.append(...this._editHistoryEntry(e, data, h, false));
return group;
},
/**
* The path as folder + file name: the folder is cut from its start when the row is narrow, so a
* long absolute path (an image in claude's scratchpad) still shows which file it is.
*/
_editHistoryPathButton(e) {
const shown = e.relPath || e.path || '';
const btn = this._ehEl('button', 'edit-history-path');
const cut = shown.lastIndexOf('/') + 1;
if (cut > 0) {
const dir = this._ehEl('span', 'edit-history-path-dir');
const bdi = this._ehEl('bdi', null, shown.slice(0, cut));
bdi.dir = 'ltr';
dir.append(bdi);
btn.append(dir);
}
btn.append(this._ehEl('span', 'edit-history-path-name', shown.slice(cut)));
btn.type = 'button';
btn.title = e.path || e.relPath || '';
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
this._openEditHistoryPreview(e.path);
});
return btn;
},
_editHistoryStats(added, removed) {
const stats = this._ehEl('span', 'edit-history-stats');
stats.append(this._ehEl('span', 'edit-history-add', `+${added}`));
stats.append(this._ehEl('span', 'edit-history-del', `−${removed}`));
return stats;
},
/** A row, followed by its diff when the row is open. `withPath` false inside a file group. */
_editHistoryEntry(e, data, h, withPath) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const isImage = e.kind === 'image';
const row = el('div', `edit-history-entry edit-history-entry--${isImage ? 'image' : 'code'}`);
row.dataset.editKey = e.id;
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.append(el('span', 'edit-history-time', this._editHistoryTime(e.ts)));
let badge;
if (isImage) {
badge = el('span', 'edit-history-badge edit-history-badge--image');
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('aria-hidden', 'true');
for (const [tag, attrs] of [
['rect', { x: '3', y: '4', width: '18', height: '16', rx: '2' }],
['circle', { cx: '9', cy: '10', r: '2' }],
['path', { d: 'M21 17l-5-5-9 8' }],
]) {
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
svg.append(n);
}
badge.append(svg);
badge.title = t('Image');
} else if (e.created) {
badge = el('span', 'edit-history-badge edit-history-badge--new', this._ehBadge('new'));
badge.title = t('File created');
} else {
badge = el('span', 'edit-history-badge edit-history-badge--edit', this._ehBadge('edit'));
badge.title = t('File edited ({tool})', { tool: e.tool || '' });
}
row.append(badge);
if (withPath) row.append(this._editHistoryPathButton(e));
row.append(el('span', 'edit-history-spacer'));
if (!isImage) row.append(this._editHistoryStats(Number(e.added) || 0, Number(e.removed) || 0));
if (e.agentId) {
const agent = data.agents?.[e.agentId];
const chip = el('span', 'edit-history-agent', agent?.description || e.agentId);
const bits = [agent?.agentType, agent?.worktreeBranch].filter(Boolean);
chip.title = bits.join(' · ') || t('Subagent');
row.append(chip);
}
const out = [row];
if (isImage) {
row.title = t('Open in the file preview');
const open = () => this._openEditHistoryPreview(e.path);
row.addEventListener('click', open);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
});
const src = this._editHistoryThumbSrc(e.path, h.sessionId);
if (src) {
const img = el('img', 'edit-history-thumb');
img.loading = 'lazy';
img.alt = e.relPath || '';
img.addEventListener('click', (ev) => {
ev.stopPropagation();
open();
});
img.addEventListener('error', () => img.remove());
img.src = src;
out.push(img);
}
return out;
}
const isOpen = h.open.has(e.id);
row.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
row.title = isOpen ? t('Hide the diff') : t('Show what changed');
const toggle = () => this.toggleEditHistoryDiff(e.id);
row.addEventListener('click', toggle);
row.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
toggle();
}
});
if (isOpen) {
row.classList.add('edit-history-entry--open');
out.push(this._editHistoryDiffView(h.diffs.get(e.id)));
}
return out;
},
/** Open or close one entry's diff; the first open loads it, later ones reuse it. */
async toggleEditHistoryDiff(entryId) {
const h = this._currentEditHistory();
if (!h) return;
if (h.open.has(entryId)) {
h.open.delete(entryId);
this._renderEditHistoryPanel();
return;
}
h.open.add(entryId);
const cached = h.diffs.get(entryId);
if (cached && cached.state !== 'error') {
this._renderEditHistoryPanel();
return;
}
const view = { state: 'loading' };
h.diffs.set(entryId, view);
this._renderEditHistoryPanel();
const res = await this._api(`${this._editHistoryBase(h.sessionId)}/${encodeURIComponent(entryId)}`);
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (h.diffs.get(entryId) !== view) return;
if (res && res.ok && body?.success && typeof body.data?.diff === 'string') {
view.state = 'ok';
view.diff = body.data.diff;
view.truncated = !!body.data.truncated;
} else {
view.state = 'error';
view.error = body?.error || this._eht('Could not read the diff.');
}
if (this._editHistory === h) this._renderEditHistoryPanel();
},
/** The diff block under an open row, drawn with the Git panel's classes. */
_editHistoryDiffView(view) {
const el = (tag, cls, text) => this._ehEl(tag, cls, text);
const t = (s, v) => this._eht(s, v);
const wrap = el('div', 'edit-history-diff');
if (!view || view.state === 'loading') {
wrap.append(el('div', 'edit-history-empty', t('Reading the diff…')));
return wrap;
}
if (view.state === 'error') {
wrap.append(el('div', 'edit-history-empty', view.error));
return wrap;
}
if (!view.diff.trim()) {
wrap.append(el('div', 'edit-history-empty', t('No textual changes.')));
} else {
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of view.diff.replace(/\n$/, '').split('\n')) {
let cls = 'git-diff-line';
// The API sends hunks only (no `---`/`+++` headers), so a `---` line is a removed `--` line.
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
wrap.append(pre);
}
if (view.truncated)
wrap.append(el('div', 'edit-history-more', t('Diff cut short: it is larger than the viewer shows.')));
return wrap;
},
});
+491 -43
View File
@@ -1,12 +1,13 @@
/**
* @fileoverview Entrance animations for the four things that appear when work
* @fileoverview Entrance animations for the things that appear when work
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
* One picker per surface, plus themes that set all four to a matching look.
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
* that set all five to a matching look.
*
* Everything is OFF by default (the `legacy` theme), so an untouched install
* behaves exactly as it did before this module existed. Opt in via App Settings
* → Appearance → Entrance Animations.
* → Animations.
*
* Four constraints shape the design:
*
@@ -34,13 +35,28 @@
* once-per-id even though the POST response and the SSE event both call
* `_onSessionCreated`.
*
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
* before) and switched on in App Settings → Animations → Tile Animations, or
* preset by a theme. A styled tile plays in two beats that combine two
* surfaces. The FRAME enters as it mounts, in its own style
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
* pane). The load queue serves one capture at a time, so
* the screens light up one after another, the focused tile first. The frame
* styles move transform and opacity only (six tiles animate at once; a blur
* belongs to the serialized screen beat), and each has its own way out on the
* closing grid's still copy. A reload restores with `settle` whatever the
* setting.
*
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
* so styles can be compared without spawning real sessions or agents.
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
* replay, a window and a line, and replays the tile grid in place, so styles
* can be compared without spawning real sessions or agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
* tile-grid.js (tile mount, reveal and still-copy hooks)
* @dependency constants.js (escapeHtml)
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
*/
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
*/
const TERM_ANIM_STYLES = [
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
/**
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
* still fits once at its final size (#464). `stagger` is the default gap
* between tiles and `order` the default cascade: `reading` (row by row),
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
* tile). `from`: the tile is measured against a source after layout (its tab,
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
*/
// prettier-ignore
const TILE_ANIM_STYLES = [
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
];
/** Cascade orders for the tile grid; `auto` is each style's own. */
const TILE_ANIM_ORDERS = [
{ key: 'auto', label: 'Style default' },
{ key: 'reading', label: 'Reading order' },
{ key: 'wave', label: 'Diagonal wave' },
{ key: 'ripple', label: 'Ripple from focus' },
];
/** How long a `beam` window waits before materializing. Just under the line draw. */
const BEAM_HOLD_MS = 360;
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
const TILE_EXIT_STAGGER_MS = 35;
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
/** One-click combinations that read as a single look. */
const ANIM_THEMES = [
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
];
/**
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
* when it is picked, but the tile style is its own setting (App Settings →
* Animations → Tile Animations, off by default), so changing it afterwards
* does not turn the theme into "Custom".
*/
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
/**
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
* `fly` behaviour Codeman already had before this module existed. So a user who
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
* Entrance Animations, which persists to the localStorage keys below.
* hook short-circuits on its first line. Opt in via App Settings → Animations,
* which persists to the localStorage keys below.
*/
const TAB_ANIM_DEFAULT = 'off';
const WIN_ANIM_DEFAULT = 'fly';
const LINE_ANIM_DEFAULT = 'off';
const TERM_ANIM_DEFAULT = 'off';
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
const TILE_ANIM_DEFAULT = 'settle';
const TILE_ANIM_ORDER_DEFAULT = 'auto';
const TAB_ANIM_STAGGER_DEFAULT = 90;
/** A new id joins the current cascade if it arrives within this of the last one. */
const TAB_ANIM_BATCH_WINDOW_MS = 600;
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
win: 'codeman:winAnim',
line: 'codeman:lineAnim',
term: 'codeman:termAnim',
tile: 'codeman:tileAnim',
tileOrder: 'codeman:tileAnimOrder',
termSwitch: 'codeman:termAnimOnSwitch',
stagger: 'codeman:tabAnimStagger',
speed: 'codeman:tabAnimSpeed',
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
// were a surface gives them nothing new.
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
},
setTileAnimStyle(key, { persist = true } = {}) {
// prettier-ignore
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
},
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
setTileAnimOrder(key, { persist = true } = {}) {
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
this._syncAnimLab?.();
},
/** Replay the terminal entrance on every tab switch, not just on a new session. */
setTermAnimOnSwitch(on, { persist = true } = {}) {
this._termAnimOnSwitch = !!on;
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
this.setWinAnimStyle(theme.win);
this.setLineAnimStyle(theme.line);
this.setTermAnimStyle(theme.term);
this.setTileAnimStyle(theme.tile);
this._syncEntranceAnimSetting?.();
},
/** The theme matching the four current styles, or 'custom' for a lab mix. */
/** The current style of each surface, keyed as ANIM_SURFACES. */
_currentAnimStyles() {
return {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
tile: this._tileAnimStyle,
};
},
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
currentAnimTheme() {
const match = ANIM_THEMES.find(
(t) =>
t.tab === this._tabAnimStyle &&
t.win === this._winAnimStyle &&
t.line === this._lineAnimStyle &&
t.term === this._termAnimStyle
);
const current = this._currentAnimStyles();
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
return match ? match.key : 'custom';
},
// ── App Settings picker ───────────────────────────────────────────────────
// ── App Settings → Animations ─────────────────────────────────────────────
//
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
// styles live in their own localStorage keys, so they stay per-device and never
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
_syncEntranceAnimSetting() {
const sel = document.getElementById('appSettingsEntranceAnim');
if (!sel) return;
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
if (sel) {
sel.value = this.currentAnimTheme();
if (!sel.dataset.bound) {
sel.dataset.bound = '1';
sel.addEventListener('change', () => {
// 'custom' is a readout of a lab mix, not something you can select into.
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
else this.setAnimTheme(sel.value);
});
}
}
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
// tracking, so this closes it as Cancel does (the row says so).
const labBtn = document.getElementById('appSettingsOpenAnimLab');
if (labBtn && !labBtn.dataset.bound) {
labBtn.dataset.bound = '1';
labBtn.addEventListener('click', () => {
this.closeAppSettings?.();
this.openAnimLab();
});
}
// Tile Animations: its own row, off (`settle`) by default. A theme picked
// above presets it; picked here, it applies to tiles alone.
const tileSel = document.getElementById('appSettingsTileAnim');
if (tileSel) {
tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT;
if (!tileSel.dataset.bound) {
tileSel.dataset.bound = '1';
tileSel.addEventListener('change', () => {
this.setTileAnimStyle(tileSel.value);
this._syncEntranceAnimSetting();
});
}
}
},
setTabAnimStagger(ms, { persist = true } = {}) {
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
},
_tileAnimDuration() {
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
},
// ── Tabs ──────────────────────────────────────────────────────────────────
/** Queue a session id to animate on its next render. Idempotent per id. */
@@ -501,6 +618,317 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Tile grid ─────────────────────────────────────────────────────────────
/** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */
tileEntranceStyle() {
return this._tileAnimStyle || TILE_ANIM_DEFAULT;
},
/**
* Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating)
* until the next frame, when its cell is final and _runTileEntrances can
* order it and measure it against its source. `setBackstop(ms)` arms the
* mount's own timer that ends the entrance should animationend never come
* (a hidden browser tab, a zoomed grid hiding the tile); armed long at once,
* so a frame that never comes cannot strand a tile invisible.
*/
_stageTileEntrance(el, sessionId, setBackstop) {
el.classList.add('tile--enter-themed', 'tile--enter-hold');
(this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop });
setBackstop(4000);
if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances());
},
/**
* One frame after the tiles mounted, every cell is final (openTileGrid packs,
* then a stored grid moves its tiles back). Each held tile gets its delay
* from the cascade order and, for `fly`/`deal`, the offset that starts it on
* its tab or the Tiles button (FLIP: transform only, so the fit it already
* did at its real size stands). `beam` draws its lines instead.
*/
_runTileEntrances() {
this._tileEnterRaf = 0;
const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected);
this._tileEnterQueue = [];
if (queue.length === 0) return;
const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0];
const speed = this._animSpeed || 1;
const stagger = def.stagger / speed;
const duration = this._tileAnimDuration();
const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0;
const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order;
const ranks = this._tileEnterRanks(
queue.map((q) => q.sessionId),
order
);
const beams = [];
queue.forEach((item, k) => {
const { el, sessionId } = item;
const delay = ranks[k] * stagger;
el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`);
if (def.from) {
const to = el.getBoundingClientRect();
const from = this._tileSourceRect(def.from, sessionId);
if (from && to.width > 0 && to.height > 0) {
if (def.key === 'beam') beams.push({ from, to, delay });
else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from');
if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay);
}
}
el.classList.remove('tile--enter-hold');
// When the frame lands, for a screen whose content arrives earlier.
el._tileEnterEndsAt = performance.now() + delay + hold + duration;
item.setBackstop(delay + hold + duration + 900);
});
if (beams.length > 0) this._drawTileBeams(beams);
},
/**
* Cascade steps for `ids` (tiles in this batch) by their cells: `reading`
* row by row, `wave` by diagonal (row + column), `ripple` by distance from
* the focused tile. Equal keys share a step, so a diagonal lands together.
*/
_tileEnterRanks(ids, order) {
const grid = this._tileGrid;
const cols = Math.max(1, grid?.cols || 1);
const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1);
const pos = ids.map((id, k) => {
const c = cellOf(id);
return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols };
});
let keys;
if (order === 'wave') {
keys = pos.map((p) => p.row + p.col);
} else if (order === 'ripple') {
const f = cellOf(grid?.focusedId);
const fr = f < 0 ? 0 : Math.floor(f / cols);
const fc = f < 0 ? 0 : f % cols;
keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc));
} else {
keys = pos.map((p) => p.cell);
}
const steps = [...new Set(keys)].sort((a, b) => a - b);
return keys.map((v) => steps.indexOf(v));
},
/** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */
_tileSourceRect(kind, sessionId) {
const visible = (node) => {
const r = node?.getBoundingClientRect?.();
if (!r || !(r.width > 0 && r.height > 0)) return null;
return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null;
};
if (kind === 'tab') {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
const r = visible(tab);
if (r) return r;
}
return visible(document.querySelector('.btn-tile-grid'));
},
/**
* The transform that puts a tile laid out at `to` onto `from`, as custom
* properties `<prefix>-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own
* size for `fly` (it grows out of it), a small card turned a little for
* `deal`.
*/
_setTileFlight(el, from, to, kind, rank, prefix) {
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
let sx;
let sy;
let rot = 0;
if (kind === 'deal') {
sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3);
rot = [-14, 10, -7, 13, -11, 8][rank % 6];
} else {
sx = clamp(from.width / to.width, 0.02, 1);
sy = clamp(from.height / to.height, 0.02, 1);
}
el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`);
el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`);
el.style.setProperty(`${prefix}-sx`, sx.toFixed(4));
el.style.setProperty(`${prefix}-sy`, sy.toFixed(4));
el.style.setProperty(`${prefix}-rot`, `${rot}deg`);
},
/** The tab a tile leaves from glows as it goes (`fly`, `beam`). */
_flashTileSourceTab(sessionId, delay) {
const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`);
if (!tab) return;
tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`);
tab.classList.remove('tab-launch');
void tab.offsetWidth;
tab.classList.add('tab-launch');
const onEnd = (e) => {
if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done();
};
const done = () => {
clearTimeout(timer);
tab.removeEventListener('animationend', onEnd);
tab.classList.remove('tab-launch');
tab.style.removeProperty('--tab-launch-delay');
};
const timer = setTimeout(done, delay + 900);
tab.addEventListener('animationend', onEnd);
},
/**
* `beam`: a line draws from each tile's tab (or the Tiles button) down into
* the middle of its tile, in the connection-line look, with a packet riding it
* when the line style is `packet`; the tile materializes as it lands. Its
* own overlay: the agent lines' one is rebuilt from scratch on every redraw.
* The overlay goes once every line has faded.
*/
_drawTileBeams(beams) {
const ns = 'http://www.w3.org/2000/svg';
let svg = document.getElementById('tileBeamLines');
if (!svg) {
svg = document.createElementNS(ns, 'svg');
svg.id = 'tileBeamLines';
svg.setAttribute('class', 'connection-lines-svg tile-beam-lines');
svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(svg);
}
const packet = this._lineAnimStyle === 'packet';
let last = 0;
for (const { from, to, delay } of beams) {
const x1 = from.left + from.width / 2;
const y1 = from.bottom;
// Into the tile's middle: its top edge sits right under the tab strip,
// so a beam aimed there ran sideways along the strip instead of down.
const x2 = to.left + to.width / 2;
const y2 = to.top + to.height / 2;
const midY = (y1 + y2) / 2;
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
path.setAttribute('class', 'connection-line tile-beam-line');
svg.appendChild(path);
const len = Math.max(1, Math.round(path.getTotalLength()));
path.style.setProperty('--line-len', `${len}px`);
path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`);
if (packet) {
const dot = path.cloneNode(false);
dot.setAttribute('class', 'connection-line-packet');
svg.appendChild(dot);
}
last = Math.max(last, delay);
}
clearTimeout(this._tileBeamTimer);
this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1));
},
/**
* A tile's screen lights up when its first capture lands (tile-grid.js load
* queue), in the terminal pane's style: the same keyframes as the main pane,
* on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one
* tile at a time, since the queue serves one capture at a time), so the
* xterm inside keeps its size and its fit.
*/
playTileScreenEntrance(body) {
if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;
body._codemanScreenDone?.();
// Content that lands while the frame is still flying in waits (held at
// its first keyframe, so hidden) until the frame has nearly landed: two
// beats, frame then screen, rather than both at once.
const frame = body.closest?.('.tile');
const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0;
const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1)));
body.style.setProperty('--tile-screen-delay', `${wait}ms`);
body.classList.remove('term-enter');
void body.offsetWidth;
body.classList.add('term-enter');
let timer = null;
const done = (e) => {
if (e && (e.target !== body || e.pseudoElement)) return;
clearTimeout(timer);
body.removeEventListener('animationend', done);
body.removeEventListener('animationcancel', done);
body.classList.remove('term-enter');
body.style.removeProperty('--tile-screen-delay');
if (body._codemanScreenDone === done) body._codemanScreenDone = null;
};
body._codemanScreenDone = done;
body.addEventListener('animationend', done);
body.addEventListener('animationcancel', done);
// Backstop, as the main pane's: a backgrounded tab never fires animationend.
timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900);
},
/**
* The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the
* frame style's own way: `fly` back into each tab, `deal` gathered into the
* Tiles button, `crt` switched off to a dot, and so on. Copies leave in
* reading order, so the last one ends last (the layer goes on its
* animationend). Re-forming the grid (`now`) keeps the plain fade: that copy
* covers tiles that stay. Returns how long the slowest copy takes, for the
* layer's fallback timer, or 0 for the default fade.
*/
_stageTileExit(copies, { now = false } = {}) {
const style = this._tileAnimStyle || TILE_ANIM_DEFAULT;
const ms = TILE_EXIT_MS[style];
if (now || !ms || copies.length === 0) return 0;
const speed = this._animSpeed || 1;
const def = TILE_ANIM_STYLES.find((s) => s.key === style);
copies.forEach(({ ghost, el, sessionId }, k) => {
ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`);
if (style !== 'fly' && style !== 'deal') return;
const at = el.getBoundingClientRect();
const target = this._tileSourceRect(def.from, sessionId);
if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to');
});
return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250;
},
/**
* Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens
* light up again in queue order): no remount, reconnect or resize. With the
* grid closed it opens it, the real path.
*/
_demoTiles() {
const grid = this._tileGrid;
if (!grid?.open) {
if (this.canOpenTileGrid?.()) this.toggleTileGrid?.();
else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info');
return;
}
if (!this._tileMotionAllowed?.()) return;
const ids = grid.ids.slice();
ids.forEach((id, k) => {
const entry = grid.tiles.get(id);
if (entry) this._replayTileEntrance?.(entry.el, id, k);
});
// The screens, as the load queue would land them: focused first, then reading order.
const speed = this._animSpeed || 1;
const lead = Math.min(this._tileAnimDuration() * 0.55, 420);
const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean);
clearTimeout(this._tileDemoTimer);
const timers = order.map((id, k) =>
setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed)
);
this._tileDemoTimers?.forEach(clearTimeout);
this._tileDemoTimers = timers;
},
/** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */
_demoTilesRoundTrip() {
if (!this._tileGrid?.open) {
this._demoTiles();
return;
}
this.toggleTileGrid?.();
clearTimeout(this._tileDemoTimer);
this._tileDemoTimer = setTimeout(
() => {
if (!this._tileGrid?.open) this.toggleTileGrid?.();
},
1400 / (this._animSpeed || 1)
);
},
// ── Lab (compare styles without spawning sessions or agents) ───────────────
/** Floating picker: switch styles per surface and replay fake entrances. */
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
</label>
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
<label class="anim-lab-select">Tile order
<select data-select="tileOrder">
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
</select>
</label>
</div>
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
<input type="range" data-range="stagger" min="0" max="260" step="10">
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
<button type="button" data-demo="window">Window</button>
<button type="button" data-demo="all">All</button>
</div>
<div class="anim-lab-demo">
<span>Tiles</span>
<button type="button" data-demo="tiles">Replay</button>
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
</div>
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
`;
document.body.appendChild(panel);
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
if (attr === 'tab') this.setTabAnimStyle(style);
else if (attr === 'win') this.setWinAnimStyle(style);
else if (attr === 'term') this.setTermAnimStyle(style);
else if (attr === 'tile') this.setTileAnimStyle(style);
else this.setLineAnimStyle(style);
this._syncAnimLab();
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
this._syncEntranceAnimSetting?.();
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
});
});
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
this.setTileAnimOrder(e.target.value);
this.demoEntrance('tiles');
});
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
this.setTermAnimOnSwitch(e.target.checked);
});
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
_syncAnimLab() {
const panel = document.getElementById('animLab');
if (!panel) return;
const current = {
tab: this._tabAnimStyle,
win: this._winAnimStyle,
line: this._lineAnimStyle,
term: this._termAnimStyle,
};
const current = this._currentAnimStyles();
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
});
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
});
const order = panel.querySelector('select[data-select="tileOrder"]');
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
const check = panel.querySelector('input[data-check="termSwitch"]');
if (check) check.checked = !!this._termAnimOnSwitch;
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
return svg;
},
/** @param {'tabs'|'term'|'window'|'all'} what */
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
demoEntrance(what = 'all') {
this._clearEntranceDemo();
if (what === 'tiles') return this._demoTiles();
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
// With the grid open, the "pane" is every tile's screen.
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
// The pane is a real, shared element rather than a throwaway, so replay it
// through the same entry point a real launch uses (bypassing the owed-id
// check, which only exists to keep background sessions from hijacking it).
@@ -692,6 +1139,7 @@ Object.assign(CodemanApp.prototype, {
<span class="tab-number">${base + i + 1}</span>
<span class="tab-status idle" aria-hidden="true"></span>
<span class="tab-info"><span class="tab-name-row">
<span class="tab-harness run-mode-dot claude" aria-hidden="true"></span>
<span class="tab-name">w${base + i + 1}-demo</span>
</span></span>`;
container.appendChild(tab);
+112 -6
View File
@@ -4448,6 +4448,7 @@ var GestureController = class {
// packages/gesture-control/src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var TILE_SELECTOR = "#tileGrid .tile";
var PANEL_SELECTOR = ".cg-float";
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
var DOCK_SELECTOR = ".session-tabs";
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
var FLOAT_H = 420;
var DETACH_PULL_PX = 70;
var TAP_CANCEL_PX = 45;
var TILE_MOVE_PX = 40;
var TILE_GHOST_W = 280;
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
var GestureBridge = class {
constructor() {
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
this.status.textContent = "moving window";
return;
}
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
const rect = tileEl.getBoundingClientRect();
const ghost = this.tileGhost(tileEl, rect);
document.body.append(ghost);
tileEl.classList.add("tile--dragging");
this.grabs.set(hand, {
kind: "tile",
id: tileId,
el: tileEl,
ghost,
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
ox: x2,
oy: y2,
armed: false,
target: null,
out: false
});
this.positionGhost(ghost, x2, y2);
this.status.textContent = "moving tile";
return;
}
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (tab && id) {
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
ghost.style.height = `${rect.height}px`;
document.body.append(ghost);
tab.classList.add("cg-grabbed");
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
this.positionGhost(ghost, x2, y2);
return;
}
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
}
onDrag(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.positionGhost(grab.ghost, x2, y2);
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
if (!grab.armed) return;
const app = window.app;
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
this.setTileTarget(grab, target);
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
grab.out = out;
grab.ghost.classList.toggle("cg-armed", !!target);
grab.ghost.classList.toggle("cg-out", out);
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
return;
}
if (grab?.kind === "tab") {
this.positionGhost(grab.ghost, x2, y2);
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
this.setTileTarget(grab, target);
if (pulled !== grab.armed || targetChanged) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
}
return;
}
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on \u2014 pinch a tab, window, or button";
this.status.textContent = "on: pinch a tab, tile, window, or button";
}
}
onDrop(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab?.kind === "tile") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
const app = window.app;
if (!grab.armed) {
this.flash("cancelled");
return;
}
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
if (target) {
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
} else {
this.flash("cancelled");
}
return;
}
if (grab?.kind === "tab") {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
if (grab.armed) this.floatPanel(grab.id, x2, y2);
this.setTileTarget(grab, null);
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
else this.flash("cancelled");
return;
}
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
ghost.style.left = `${x2}px`;
ghost.style.top = `${y2}px`;
}
/** A small copy of a tile to follow the hand: its header (a copy: no
* listeners come along) over an empty body, in the tile's proportions. */
tileGhost(tileEl, rect) {
const ghost = el("div", "cg-ghost cg-tile-ghost");
const width = Math.min(TILE_GHOST_W, rect.width);
ghost.style.width = `${width}px`;
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
const header = tileEl.querySelector(".tile-header");
if (header) {
const copy = header.cloneNode(true);
copy.removeAttribute("draggable");
ghost.append(copy);
}
ghost.append(el("div", "cg-tile-ghost-body"));
return ghost;
}
/** Highlights the tile or empty cell a carried session would drop onto
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
setTileTarget(grab, target) {
if (grab.target?.el !== target?.el) {
grab.target?.el.classList.remove("tile--drop-target");
target?.el.classList.add("tile--drop-target");
}
grab.target = target;
}
cancelAllGrabs() {
for (const grab of this.grabs.values()) {
if (grab.kind === "tab") {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
this.setTileTarget(grab, null);
} else if (grab.kind === "tile") {
grab.ghost.remove();
grab.el.classList.remove("tile--dragging");
this.setTileTarget(grab, null);
} else if (grab.kind === "panel") {
grab.panel.el.style.pointerEvents = "";
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4863,6 +4962,13 @@ function injectStyles() {
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
.cg-tile-ghost {
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
}
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
.cg-dock {
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
+802
View File
@@ -0,0 +1,802 @@
/**
* @fileoverview Git status indicator in the bottom bar, and the panel it opens.
*
* Agents leave work uncommitted and unpushed. This puts a small indicator at the right of the bottom
* toolbar for the ACTIVE session's repository, or repositories when the session's folder holds several (`●3` uncommitted files, `↑2` commits not pushed,
* `✓` when everything is committed and pushed) and, on click, a draggable panel in the style of the
* Files window listing exactly which files are uncommitted and which commits are not pushed.
*
* OPTIONAL and per-device: `showGitStatus` (App Settings → Header & Panels → Bottom bar), default
* OFF. While it is off nothing polls and the button never shows. While it is on, the page asks
* `GET /api/sessions/:id/git-status` for the active session on a slow poll (and at once when the
* session changes or the window regains focus). The status route is read-only: it never changes the
* repository, so the "behind" number reflects the last `git fetch`, which the panel footer says. Fetching
* happens when the user clicks Fetch in that window's header (`POST /api/sessions/:id/git-fetch`,
* src/git-fetch.ts), or on the interval when the session is on: this is the only git command the Git panel
* runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too,
* but only when the user asked for them at that moment). Remote (SSH)
* and Docker sessions answer `unsupported`, show no indicator, and cannot be fetched.
*
* Everything that comes from git (file names, commit subjects, author names) is untrusted text: it is
* only ever written with `textContent`, never `innerHTML`.
*
* The bottom toolbar's right group is hidden on phones (mobile.css), so this surface is desktop and
* tablet only by construction.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.activeSessionId, this.loadAppSettingsFromStorage, this.getDefaultSettings, this.$)
* @dependency panels-ui.js (openFilePreview)
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
*/
/** How often the active session's repository is re-read while the indicator is on. */
const GIT_STATUS_POLL_MS = 15000;
/** The timer only decides whether a poll is due; it is cheap and runs while the indicator is on. */
const GIT_STATUS_TICK_MS = 2000;
/** A focus or visibility change refreshes at once unless the last read is younger than this. */
const GIT_STATUS_MIN_REFRESH_MS = 3000;
const GIT_STATUS_BADGE_TITLE = {
M: 'Modified',
A: 'Added',
D: 'Deleted',
R: 'Renamed',
C: 'Copied',
T: 'Type changed',
U: 'Unmerged',
'?': 'Untracked',
};
Object.assign(CodemanApp.prototype, {
/** Per-device setting, default OFF. */
isGitStatusEnabled() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.showGitStatus ?? defaults.showGitStatus ?? false) === true;
},
/**
* Starts or stops the poll to match the setting. Called from applyHeaderVisibilitySettings(), which
* runs on boot and after every settings save, so a live toggle needs no reload.
*/
applyGitStatusVisibility() {
const on = this.isGitStatusEnabled();
if (on && !this._gitStatusTimer) {
this._gitStatusTimer = setInterval(() => this._gitStatusTick(), GIT_STATUS_TICK_MS);
this._gitStatusOnVisible = () => {
if (!document.hidden) this.refreshGitStatus({ minAgeMs: GIT_STATUS_MIN_REFRESH_MS });
};
document.addEventListener('visibilitychange', this._gitStatusOnVisible);
window.addEventListener('focus', this._gitStatusOnVisible);
this.refreshGitStatus();
} else if (!on && this._gitStatusTimer) {
clearInterval(this._gitStatusTimer);
this._gitStatusTimer = null;
document.removeEventListener('visibilitychange', this._gitStatusOnVisible);
window.removeEventListener('focus', this._gitStatusOnVisible);
this._gitStatusOnVisible = null;
}
if (!on) {
this._gitStatus = null;
this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1; // an in-flight read must not repaint
// That read's `finally` no longer owns the flag (its epoch is stale), so release it here: left set,
// turning the setting back on would skip every refresh for this session until a reload.
this._gitStatusInFlight = false;
this.closeGitStatusPanel();
}
this._renderGitStatusButton();
},
_gitStatusTick() {
if (document.hidden) return;
const sid = this.activeSessionId || null;
if (sid !== this._gitStatusSessionId) {
// The active session changed (or the first one opened): show nothing stale, read now.
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel(); // not the previous repo's files
this.refreshGitStatus();
return;
}
if (sid && Date.now() - (this._gitStatusFetchedAt || 0) >= GIT_STATUS_POLL_MS) this.refreshGitStatus();
},
/** Reads the active session's git status and repaints. Stale answers (another session, setting off) are dropped. */
async refreshGitStatus({ minAgeMs = 0, fresh = false } = {}) {
if (!this.isGitStatusEnabled()) return;
const sid = this.activeSessionId || null;
this._gitStatusSessionId = sid;
if (!sid) {
this._gitStatus = null;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
return;
}
if (minAgeMs && Date.now() - (this._gitStatusFetchedAt || 0) < minAgeMs) return;
// A read for THIS session is already running: let it finish. One for another session is not worth
// waiting for (its answer is dropped below), so a session switch is never left blank.
if (this._gitStatusInFlight && this._gitStatusInFlightSid === sid) return;
this._gitStatusInFlight = true;
this._gitStatusInFlightSid = sid;
const epoch = (this._gitStatusEpoch = (this._gitStatusEpoch || 0) + 1);
this._gitStatusFetchedAt = Date.now();
try {
const query = new URLSearchParams(this.gitStatusLimits());
if (fresh) query.set('fresh', '1');
const data = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-status?${query}`);
if (epoch !== this._gitStatusEpoch || sid !== this.activeSessionId || !this.isGitStatusEnabled()) return;
this._gitStatus = data ? { sessionId: sid, data } : null;
} catch {
if (epoch === this._gitStatusEpoch) this._gitStatus = null;
} finally {
// Only the newest request owns the flag: an older one finishing late must not clear it.
if (epoch === this._gitStatusEpoch) this._gitStatusInFlight = false;
}
if (epoch !== this._gitStatusEpoch) return;
this._renderGitStatusButton();
if (this._isGitStatusPanelOpen()) this._renderGitStatusPanel();
},
/**
* How many repositories to list below a folder that is not itself a repository, and how long one
* git command may run, in seconds (Settings → Bottom bar, per device). The server clamps both again.
*/
gitStatusLimits() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const num = (v, min, max, fallback) => {
const n = Math.trunc(Number(v));
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
};
return {
maxRepos: num(settings.gitStatusMaxRepos ?? defaults.gitStatusMaxRepos, 1, 50, 12),
timeout: num(settings.gitStatusTimeoutSeconds ?? defaults.gitStatusTimeoutSeconds, 5, 120, 30),
};
},
/** Whether the Git window groups changed files under collapsible folders (default on). */
isGitStatusTree() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
return (settings.gitStatusTree ?? defaults.gitStatusTree ?? true) === true;
},
/** The data for the session on screen, or null (not enabled, no session, not a repo, remote/docker, error). */
_currentGitStatus() {
const s = this._gitStatus;
return s && s.sessionId === this.activeSessionId && s.data ? s.data : null;
},
/** `{ uncommitted, unpushed, conflicted, repos, tone }` summed over every repository, or null when there is nothing to show. */
_gitStatusSummary(overview) {
if (!overview || overview.state !== 'ok' || !overview.repos?.length) return null;
let uncommitted = 0;
let unpushed = 0;
let conflicted = 0;
let unreadable = 0;
for (const r of overview.repos) {
if (r.status.state === 'error') unreadable += 1;
uncommitted += r.status.counts.uncommitted;
unpushed += r.status.unpushedCount;
conflicted += r.status.counts.conflicted;
}
// A repository that could not be read is not "clean": it must not let the indicator say ✓.
const tone = conflicted > 0 ? 'conflict' : uncommitted > 0 || unpushed > 0 || unreadable > 0 ? 'dirty' : 'clean';
return { uncommitted, unpushed, conflicted, unreadable, repos: overview.repos.length, tone };
},
/** One sentence for the tooltip and the screen-reader label. */
_gitStatusSentence(overview) {
const sum = this._gitStatusSummary(overview);
if (!sum) return '';
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const bits = [];
if (sum.conflicted) bits.push(plural(sum.conflicted, 'file with a merge conflict', 'files with merge conflicts'));
if (sum.uncommitted) bits.push(plural(sum.uncommitted, 'uncommitted file', 'uncommitted files'));
if (sum.unpushed) bits.push(plural(sum.unpushed, 'commit not pushed', 'commits not pushed'));
if (sum.unreadable)
bits.push(plural(sum.unreadable, 'repository could not be read', 'repositories could not be read'));
if (!bits.length) bits.push('everything is committed and pushed');
let where;
if (sum.repos > 1) where = `${sum.repos} repositories`;
else {
const d = overview.repos[0].status;
where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch';
}
return `Git (${where}): ${bits.join(', ')}. Click for details.`;
},
_renderGitStatusButton() {
const btn = this.$('gitStatusBtn');
if (!btn) return;
const data = this.isGitStatusEnabled() ? this._currentGitStatus() : null;
const sum = this._gitStatusSummary(data);
btn.hidden = !sum;
btn.classList.toggle('git-status--clean', sum?.tone === 'clean');
btn.classList.toggle('git-status--dirty', sum?.tone === 'dirty');
btn.classList.toggle('git-status--conflict', sum?.tone === 'conflict');
const label = btn.querySelector('.git-status-label');
if (!sum) {
if (label) label.textContent = '';
return;
}
const parts = [];
if (sum.conflicted) parts.push(`⚠ ${sum.conflicted}`);
if (sum.uncommitted) parts.push(`● ${sum.uncommitted}`);
if (sum.unpushed) parts.push(`↑ ${sum.unpushed}`);
if (sum.unreadable) parts.push(`? ${sum.unreadable}`);
if (!parts.length) parts.push('✓');
if (label) label.textContent = parts.join(' ');
const sentence = this._gitStatusSentence(data);
btn.title = sentence;
btn.setAttribute('aria-label', sentence);
},
// ── Panel ───────────────────────────────────────────────────────────────
_isGitStatusPanelOpen() {
return !!this.$('gitStatusPanel')?.classList.contains('visible');
},
toggleGitStatusPanel() {
if (this._isGitStatusPanelOpen()) {
this.closeGitStatusPanel();
return;
}
const panel = this.$('gitStatusPanel');
if (!panel) return;
panel.classList.add('visible');
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'true');
this._ensureGitStatusPanelDrag();
this._renderGitStatusPanel();
this.refreshGitStatus({ fresh: true }); // the click should show what is true now, not what was true 14s ago
},
closeGitStatusPanel() {
this._gitDiffView = null;
const panel = this.$('gitStatusPanel');
if (panel) {
panel.classList.remove('visible');
// Reset a dragged position so it reopens at the default spot.
panel.style.left = panel.style.top = panel.style.right = panel.style.bottom = '';
}
this.$('gitStatusBtn')?.setAttribute('aria-expanded', 'false');
},
refreshGitStatusNow() {
this._gitStatusFetchedAt = 0;
this._gitStatusInFlight = false;
return this.refreshGitStatus({ fresh: true });
},
/**
* Fetch, on this click only: one `git fetch --quiet` per repository the panel shows
* (`POST /api/sessions/:id/git-fetch`). It is the only git command the Git panel runs that writes, and the
* only one Codeman runs on a timer, and it updates remote-tracking refs only: nothing here commits or
* touches the working tree, so an agent's own work is left alone, though a fetch does hold git's ref lock,
* so an agent's `git pull` in the same repository can hit the same `cannot lock ref` while it runs. One
* click means one fetch per repository: the button is busy and a second click is ignored while it runs, and
* a click that joins a fetch already running reports that run, not itself. The panel then re-reads, so the
* "behind" number reflects what this fetch brought.
*/
async fetchGitStatusNow() {
const sid = this.activeSessionId || null;
const btn = this.$('gitStatusFetchBtn');
if (!sid || !btn || this._gitFetchInFlight) return;
this._gitFetchInFlight = true;
this._renderGitFetchButton();
let report = null;
try {
const query = new URLSearchParams(this.gitStatusLimits());
report = await this._apiJson(`/api/sessions/${encodeURIComponent(sid)}/git-fetch?${query}`, { method: 'POST' });
} catch {
report = null;
}
// A fetch that lands after a session switch is not shown for the session on screen now; the refs it
// moved are still on disk, so that session's own read reflects them.
if (sid === this.activeSessionId) this._gitStatusFetch = report ? { sessionId: sid, data: report } : null;
this._gitFetchInFlight = false;
await this.refreshGitStatus({ fresh: true });
},
/** The last Fetch this window ran for the session on screen, or null. */
_currentGitFetch() {
const f = this._gitStatusFetch;
return f && f.sessionId === this.activeSessionId && f.data ? f.data : null;
},
/** Busy while a fetch runs, and never enabled for a session there is nothing to fetch for. */
_renderGitFetchButton() {
const btn = this.$('gitStatusFetchBtn');
if (!btn) return;
const overview = this._currentGitStatus();
const fetchable = !!this.activeSessionId && !!overview && overview.state === 'ok';
btn.disabled = this._gitFetchInFlight || !fetchable;
if (this._gitFetchInFlight) btn.setAttribute('aria-busy', 'true');
else btn.removeAttribute('aria-busy');
},
/** What the last Fetch brought: a summary line, then one row per repository it could not fetch. */
_gitFetchNote() {
const report = this._currentGitFetch();
if (!report || report.state !== 'ok' || !report.repos?.length) return null;
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const failed = report.repos.filter((r) => !r.ok);
const count = report.repos.length;
const note = el('div', 'git-status-fetch-note');
note.append(
el(
'div',
'git-status-fetch-summary',
`Fetched ${new Date(report.fetchedAt).toLocaleTimeString()}: ${count - failed.length} of ${count} ${count === 1 ? 'repository' : 'repositories'}.`
)
);
for (const r of failed) {
const row = el('div', 'git-status-fetch-row');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
row.append(el('span', 'git-status-repo-unreadable', `⚠ ${r.failure?.message || 'git fetch failed'}`));
note.append(row);
}
return note;
},
/** Drag by the header. Pointer events cover mouse, pen and touch; one set of listeners lives as long as the page. */
_ensureGitStatusPanelDrag() {
const panel = this.$('gitStatusPanel');
const handle = panel?.querySelector('.git-status-header');
if (!panel || !handle || handle._dragReady) return;
handle._dragReady = true;
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
const rect = panel.getBoundingClientRect();
drag = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
// Switch from right/bottom anchoring to explicit left/top so the drag has one coordinate system.
panel.style.left = `${rect.left}px`;
panel.style.top = `${rect.top}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
handle.setPointerCapture?.(e.pointerId);
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
const maxX = window.innerWidth - panel.offsetWidth - 4;
const maxY = window.innerHeight - panel.offsetHeight - 4;
panel.style.left = `${Math.max(4, Math.min(e.clientX - drag.dx, maxX))}px`;
panel.style.top = `${Math.max(4, Math.min(e.clientY - drag.dy, maxY))}px`;
});
const end = (e) => {
drag = null;
handle.releasePointerCapture?.(e.pointerId);
};
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
},
_gitEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
},
_renderGitStatusPanel() {
const body = this.$('gitStatusBody');
const head = this.$('gitStatusBranch');
const foot = this.$('gitStatusFooter');
if (!body) return;
const overview = this._currentGitStatus();
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
this._renderGitFetchButton();
// The 15 s poll replaces every row: put keyboard focus back on the same file afterwards.
const focusKey = body.contains(document.activeElement)
? document.activeElement.closest?.('[data-git-key]')?.dataset.gitKey
: null;
const view = this._gitDiffView;
if (view && view.sessionId === this.activeSessionId) {
// A file's diff is on screen: the 15 s poll re-renders the panel, and must not throw it away.
this._renderGitDiffView(body, view);
if (head) head.textContent = '';
if (foot) foot.textContent = '';
return;
}
this._gitDiffView = null;
body.replaceChildren();
const clearChrome = () => {
if (head) head.textContent = '';
if (foot) foot.textContent = '';
};
if (!this.activeSessionId) {
body.append(el('div', 'git-status-empty', 'Open a session to see its repository.'));
clearChrome();
return;
}
if (!overview) {
body.append(
el('div', 'git-status-empty', this._gitStatus === null ? 'Reading the repository…' : 'No status available.')
);
return;
}
if (overview.state !== 'ok') {
const why =
overview.state === 'not-a-repo'
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
: overview.state === 'unsupported'
? overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
: `Could not read the repository: ${overview.error || 'git failed'}`;
body.append(el('div', 'git-status-empty', why));
clearChrome();
return;
}
const repos = overview.repos;
if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) {
// One repository: the panel is that repository, as it always was. One that git could not read,
// or the only one shown of several (the limit is 1), takes the list view below instead, so its
// error row or the "Showing the first" notice is not lost.
const d = repos[0].status;
if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || '';
this._renderGitRepoInto(body, d);
} else {
if (head) head.textContent = repos.length === 1 ? '1 repository' : `${repos.length} repositories`;
for (const r of repos) body.append(this._gitRepoSection(r));
if (overview.reposTruncated) {
body.append(
el(
'div',
'git-status-more',
`Showing the first ${overview.repoLimit || repos.length} of more than ${overview.repoLimit || repos.length} repositories under this folder. Raise “Git status: max repositories” in Settings → Bottom bar to see more.`
)
);
}
}
const note = this._gitFetchNote();
if (note) body.append(note);
if (foot) {
// Runtime text (the panel footer carries data-i18n-skip), so naming the interval here does not fight the
// translator. It says what actually runs: this click, or the interval the user turned on.
foot.textContent = `Checked ${new Date(overview.checkedAt).toLocaleTimeString()}. Read-only apart from Fetch: Codeman fetches on your click, or on the interval you turned on in Settings, and never commits or changes the repository, so “behind” is as of the last fetch.`;
}
if (focusKey) {
const again = [...body.querySelectorAll('[data-git-key]')].find((n) => n.dataset.gitKey === focusKey);
again?.focus({ preventScroll: true });
}
},
/**
* One repository of several: a collapsible section, collapsed by default (the summary line already
* shows what is outstanding). Which ones the user opened stay open across the 15 s re-render.
*/
_gitRepoSection(r) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const d = r.status;
if (d.state === 'error') {
// Kept in the list with the reason, rather than silently left out.
const row = el('div', 'git-status-repo git-status-repo--error');
row.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) row.append(el('span', 'git-status-repo-path', r.path));
const why = el('span', 'git-status-repo-unreadable', `⚠ could not read: ${d.error || 'git failed'}`);
why.title = 'If this is a timeout, raise “Git status: git timeout” in Settings → Bottom bar.';
row.append(why);
return row;
}
const section = el('details', 'git-status-repo');
const outstanding = d.counts.uncommitted > 0 || d.unpushedCount > 0;
const openRepos = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const repoKey = `repo|${d.repoRoot || r.path}`;
section.open = openRepos.has(repoKey);
section.addEventListener('toggle', () => (section.open ? openRepos.add(repoKey) : openRepos.delete(repoKey)));
const summary = el('summary', 'git-status-repo-summary');
summary.append(el('span', 'git-status-repo-name', r.name));
if (r.path !== r.name) summary.append(el('span', 'git-status-repo-path', r.path));
summary.append(el('span', 'git-status-repo-branch', d.detached ? 'detached HEAD' : d.branch || ''));
const bits = [];
if (d.counts.conflicted) bits.push(`⚠ ${d.counts.conflicted}`);
if (d.counts.uncommitted) bits.push(`● ${d.counts.uncommitted}`);
if (d.unpushedCount) bits.push(`↑ ${d.unpushedCount}`);
const state = el(
'span',
`git-status-repo-state${outstanding ? ' git-status-repo-state--dirty' : ''}`,
bits.join(' ') || '✓'
);
summary.append(state);
section.append(summary);
const inner = el('div', 'git-status-repo-body');
this._renderGitRepoInto(inner, d);
section.append(inner);
return section;
},
/** The branch line, uncommitted files and unpushed commits of ONE repository into `body`. */
_renderGitRepoInto(body, data) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
// Branch / upstream line.
const line = el('div', 'git-status-branchline');
if (data.upstream && data.upstreamGone) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
const gone = el('span', 'git-status-chip git-status-chip--warn', 'Upstream not on remote');
gone.title =
'The upstream branch does not exist on the remote (never pushed, or deleted and pruned), so the commits below are on no remote.';
line.append(gone);
} else if (data.upstream) {
line.append(el('span', 'git-status-chip', `${data.branch || 'HEAD'} → ${data.upstream}`));
if (data.ahead) line.append(el('span', 'git-status-chip git-status-chip--warn', `↑ ${data.ahead} ahead`));
if (data.behind) {
const behind = el('span', 'git-status-chip', `↓ ${data.behind} behind`);
behind.title =
'As of the last git fetch. Click Fetch in this window, or turn on the fetch interval in Settings, to bring it current.';
line.append(behind);
}
} else if (data.hasRemote) {
line.append(el('span', 'git-status-chip git-status-chip--warn', 'No upstream branch'));
} else {
line.append(el('span', 'git-status-chip', 'No remote configured'));
}
if (data.counts.stashes) {
line.append(
el('span', 'git-status-chip', `${data.counts.stashes} stash${data.counts.stashes === 1 ? '' : 'es'}`)
);
}
body.append(line);
// Uncommitted changes.
const filesSection = el('section', 'git-status-section');
filesSection.append(el('h4', 'git-status-section-title', `Uncommitted changes (${data.counts.uncommitted})`));
if (!data.files.length) {
filesSection.append(el('div', 'git-status-ok', 'Nothing uncommitted.'));
} else {
const groups = [
['conflicted', 'Merge conflicts'],
['staged', 'Staged'],
['unstaged', 'Not staged'],
['untracked', 'Untracked'],
];
for (const [kind, label] of groups) {
const rows = data.files.filter((f) => f.kind === kind);
if (!rows.length) continue;
const group = el('div', `git-status-group git-status-group--${kind}`);
group.append(el('div', 'git-status-group-title', `${label} (${data.counts[kind]})`));
if (this.isGitStatusTree()) group.append(...this._gitFileTree(rows, data, kind));
else for (const f of rows) group.append(this._gitFileRow(f, data));
filesSection.append(group);
}
if (data.filesTruncated) {
filesSection.append(
el(
'div',
'git-status-more',
`Showing the first ${data.files.length} entries; the counts above include every file.`
)
);
}
}
body.append(filesSection);
// Commits not pushed.
const pushSection = el('section', 'git-status-section');
pushSection.append(el('h4', 'git-status-section-title', `Not pushed (${data.unpushedCount})`));
if (!data.unpushedCount) {
pushSection.append(
el(
'div',
'git-status-ok',
data.hasRemote ? 'Every commit on this branch is on a remote.' : 'There is no remote to push to.'
)
);
} else {
if (!data.upstream || data.upstreamGone) {
pushSection.append(
el(
'div',
'git-status-note',
data.upstreamGone
? 'The upstream branch does not exist on the remote (never pushed, or deleted), so these commits are on no remote.'
: 'This branch has no upstream, so these commits are on no remote yet.'
)
);
}
for (const c of data.unpushed) pushSection.append(this._gitCommitRow(c));
if (data.unpushedCount > data.unpushed.length) {
pushSection.append(
el('div', 'git-status-more', `…and ${data.unpushedCount - data.unpushed.length} older commits.`)
);
}
}
body.append(pushSection);
},
/**
* `rows` as folders (collapsed until clicked) holding their files. A folder with one child folder and
* nothing else is merged into it (`src/web/public` as one row) so a deep path is one click, not five.
* Which folders are open survives the 15 s re-render (`_gitTreeOpen`, keyed by repo, group and folder).
*/
_gitFileTree(rows, data, kind) {
const root = { dirs: new Map(), files: [] };
for (const f of rows) {
const trailing = f.path.endsWith('/');
const parts = f.path.replace(/\/$/, '').split('/');
const leaf = parts.pop() + (trailing ? '/' : '');
let node = root;
for (const part of parts) {
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [] });
node = node.dirs.get(part);
}
node.files.push({ f, leaf });
}
const open = (this._gitTreeOpen = this._gitTreeOpen || new Set());
const count = (n) => n.files.length + [...n.dirs.values()].reduce((sum, d) => sum + count(d), 0);
const build = (node, prefix) => {
const out = [];
for (const [name0, child0] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
let name = name0;
let child = child0;
while (child.files.length === 0 && child.dirs.size === 1) {
const [n, c] = [...child.dirs][0];
name += `/${n}`;
child = c;
}
const key = `${data.repoRoot}|${kind}|${prefix}${name}`;
const dir = this._gitEl('details', 'git-tree-dir');
dir.open = open.has(key);
dir.addEventListener('toggle', () => (dir.open ? open.add(key) : open.delete(key)));
const summary = this._gitEl('summary', 'git-tree-summary');
summary.append(this._gitEl('span', 'git-tree-name', `${name}/`));
summary.append(this._gitEl('span', 'git-tree-count', String(count(child))));
dir.append(summary);
const inner = this._gitEl('div', 'git-tree-children');
inner.append(...build(child, `${prefix}${name}/`));
dir.append(inner);
out.push(dir);
}
for (const { f, leaf } of node.files.sort((a, b) => a.leaf.localeCompare(b.leaf))) {
out.push(this._gitFileRow(f, data, leaf));
}
return out;
};
return build(root, '');
},
_gitFileRow(f, data, displayName) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-file');
// Untracked entries have `?`; staged ones show the index letter, the rest the working-tree letter.
const letter =
f.kind === 'untracked' ? '?' : f.kind === 'conflicted' ? 'U' : f.kind === 'staged' ? f.index : f.worktree;
const badge = el('span', `git-status-badge git-status-badge--${letter === '?' ? 'new' : letter}`, letter);
badge.title = GIT_STATUS_BADGE_TITLE[letter] || letter;
row.dataset.gitKey = `${f.kind}|${f.path}`;
row.append(badge);
const name = el('span', 'git-status-path', displayName ?? f.path);
if (displayName) name.title = f.path;
row.append(name);
if (f.origPath) row.append(el('span', 'git-status-orig', `← ${f.origPath}`));
// An untracked folder has no single diff; every other row opens its changes.
if (!f.path.endsWith('/') && data.repoRoot) {
row.classList.add('git-status-file--clickable');
row.tabIndex = 0;
row.setAttribute('role', 'button');
row.title = 'Show what changed';
const open = () => this.openGitDiff(data.repoRoot, f, letter);
row.addEventListener('click', open);
row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
});
}
return row;
},
// ── Diff view ───────────────────────────────────────────────────────────
/** Show `file`'s changes in the panel (a Back button returns to the list). */
async openGitDiff(repoRoot, file, letter) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
const view = { sessionId, repoRoot, file, letter, state: 'loading' };
this._gitDiffView = view;
this._renderGitStatusPanel();
const qs = new URLSearchParams({
repo: repoRoot,
path: file.path,
kind: file.kind,
...this.gitStatusLimits(),
});
const res = await this._api(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff?${qs}`);
// Back, another file or another session while this was in flight: drop the answer.
if (this._gitDiffView !== view) return;
let body = null;
try {
body = res ? await res.json() : null;
} catch {
/* fall through */
}
if (this._gitDiffView !== view) return;
if (res && res.ok && body?.success) {
view.state = 'ok';
view.result = body.data;
} else {
view.state = 'error';
view.error = body?.error || 'Could not read the diff.';
}
this._renderGitStatusPanel();
},
closeGitDiff() {
this._gitDiffView = null;
this._renderGitStatusPanel();
},
_renderGitDiffView(body, view) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
body.replaceChildren();
const bar = el('div', 'git-diff-bar');
const back = el('button', 'btn-toolbar btn-sm', '← Back');
back.type = 'button';
back.addEventListener('click', () => this.closeGitDiff());
bar.append(back);
bar.append(el('span', 'git-diff-path', view.file.path));
const kindLabel = { staged: 'staged', unstaged: 'not staged', untracked: 'new file', conflicted: 'conflict' };
bar.append(el('span', 'git-diff-kind', kindLabel[view.file.kind] || ''));
if (view.letter !== 'D') {
const open = el('button', 'btn-toolbar btn-sm', 'Open file');
open.type = 'button';
open.addEventListener('click', () =>
this.openFilePreview?.(`${view.repoRoot}/${view.file.path}`, this.activeSessionId)
);
bar.append(open);
}
body.append(bar);
if (view.state === 'loading') {
body.append(el('div', 'git-status-empty', 'Reading the diff…'));
return;
}
if (view.state === 'error') {
body.append(el('div', 'git-status-empty', view.error));
return;
}
const { diff, truncated, binary } = view.result;
if (binary) body.append(el('div', 'git-status-note', 'This is a binary file; there is no text diff to show.'));
if (!diff.trim()) {
if (!binary) body.append(el('div', 'git-status-empty', 'No textual changes (the file may differ only in mode).'));
return;
}
const pre = el('pre', 'git-diff');
const frag = document.createDocumentFragment();
for (const line of diff.split('\n')) {
let cls = 'git-diff-line';
if (line.startsWith('@@')) cls += ' git-diff-line--hunk';
else if (
/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename |old mode|new mode)/.test(line)
)
cls += ' git-diff-line--meta';
else if (line.startsWith('+')) cls += ' git-diff-line--add';
else if (line.startsWith('-')) cls += ' git-diff-line--del';
frag.append(el('span', cls, line + '\n'));
}
pre.append(frag);
body.append(pre);
if (truncated) body.append(el('div', 'git-status-more', 'Diff cut short: it is larger than the viewer shows.'));
},
_gitCommitRow(c) {
const el = (tag, cls, text) => this._gitEl(tag, cls, text);
const row = el('div', 'git-status-commit');
row.append(el('span', 'git-status-hash', c.hash));
row.append(el('span', 'git-status-subject', c.subject));
const meta = c.time ? `${c.author} · ${this.formatRelativeTime?.(c.time * 1000) ?? ''}` : c.author;
row.append(el('span', 'git-status-commit-meta', meta));
return row;
},
});
+209
View File
@@ -0,0 +1,209 @@
/**
* @fileoverview A draggable divider on the left edge of the header's right-hand toolbar.
*
* Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the
* toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a
* device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely
* positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in
* the header (sidebar, vertical rail, phones).
*
* @loadorder after app.js; self-contained, no CodemanApp dependency.
*/
(function () {
const STORAGE_KEY = 'codeman-header-right-width';
const MIN_WIDTH = 160;
const MIN_TABS_ROOM = 240;
const KEY_STEP = 16;
/** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */
function clampWidth(width, headerWidth, brandWidth) {
const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM);
return Math.round(Math.min(max, Math.max(MIN_WIDTH, width)));
}
/**
* How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of
* the labels go: window labels first, then provider names (shortened), then, in the Compact style, the
* percentages (the rings stay, and the chip's tooltip keeps the numbers).
*/
function densityFor(width) {
if (width >= 560) return '';
if (width >= 400) return 'tight';
if (width >= 280) return 'dense';
return 'tiny';
}
function readStored() {
try {
const value = Number(localStorage.getItem(STORAGE_KEY));
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
}
function writeStored(width) {
try {
if (width === null) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, String(width));
} catch {
/* storage blocked: the width lasts until the page reloads */
}
}
function init() {
const header = document.querySelector('.header');
const right = document.getElementById('headerRight');
if (!header || !right || right.querySelector('.header-divider')) return;
const brand = header.querySelector('.header-brand');
const root = document.documentElement;
const handle = document.createElement('div');
handle.className = 'header-divider';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)');
handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
handle.tabIndex = 0;
right.insertBefore(handle, right.firstChild);
// The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading
// that needs more room), and the request is what comes back when the window grows again.
let wanted = readStored();
const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 });
const current = () => right.getBoundingClientRect().width;
/** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */
function contentMinimum() {
const style = getComputedStyle(right);
const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth);
let widest = 0;
for (const child of right.children) {
if (child === handle || child.offsetParent === null) continue;
widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth);
}
return Math.ceil(widest + frame);
}
function setDensity(width) {
const density = densityFor(width);
if (density) root.setAttribute('data-header-density', density);
else root.removeAttribute('data-header-density');
}
/** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */
function settle() {
if (wanted === null) {
root.style.removeProperty('--header-right-width');
root.removeAttribute('data-header-right-width');
root.removeAttribute('data-header-density');
handle.removeAttribute('aria-valuenow');
handle.removeAttribute('aria-valuemax');
return;
}
const { header: h, brand: b } = bounds();
let clamped = clampWidth(wanted, h, b);
root.setAttribute('data-header-right-width', 'set');
// A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum.
for (let i = 0; i < 3; i++) {
root.style.setProperty('--header-right-width', clamped + 'px');
setDensity(clamped);
const floor = contentMinimum();
if (clamped >= floor) break;
clamped = floor;
}
handle.setAttribute('aria-valuenow', String(clamped));
handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b)));
}
/** Several changes in one frame (a chip refill, a resize) settle once. */
let settleFrame = 0;
function scheduleSettle() {
if (wanted === null || settleFrame) return;
settleFrame = requestAnimationFrame(() => {
settleFrame = 0;
settle();
});
}
/** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */
function relayout() {
window.app?.updateTabOverflowMode?.();
window.app?.updateConnectionLines?.();
}
function apply(width) {
wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width));
writeStored(wanted);
settle();
relayout();
}
// Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row
// can start reporting later, a header button can be switched on): re-settle when any child changes size.
const resizeObserver = new ResizeObserver(scheduleSettle);
const watch = () => {
for (const child of right.children) if (child !== handle) resizeObserver.observe(child);
};
watch();
new MutationObserver(() => {
watch();
scheduleSettle();
}).observe(right, { childList: true });
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
handle.setPointerCapture?.(event.pointerId);
document.body.classList.add('header-divider-dragging');
const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0;
let moveFrame = 0;
let lastX = event.clientX;
const onMove = (e) => {
lastX = e.clientX;
if (moveFrame) return;
moveFrame = requestAnimationFrame(() => {
moveFrame = 0;
wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight);
settle();
});
};
const finish = () => {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
handle.removeEventListener('lostpointercapture', finish);
if (moveFrame) cancelAnimationFrame(moveFrame);
moveFrame = 0;
document.body.classList.remove('header-divider-dragging');
if (wanted !== null) apply(wanted);
};
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
handle.addEventListener('lostpointercapture', finish);
});
handle.addEventListener('dblclick', () => apply(null));
handle.addEventListener('keydown', (event) => {
if (event.key === 'Home') {
apply(null);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
// Step from the width on screen, not the request, so a key held past the floor never leaves a dead zone.
apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP));
} else {
return;
}
event.preventDefault();
});
window.addEventListener('resize', scheduleSettle);
settle();
}
window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+13 -9
View File
@@ -70,17 +70,13 @@ const HOME_SESSIONS_PILL_LABEL = {
done: 'done',
};
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
/**
* Text badge per backend, mirroring the tab strip: only the shell has one. Every
* agent CLI (claude included) shows its logo instead, through the same
* `run-mode-dot <id>` slot the strip uses (see _buildHomeSessionRow).
*/
const HOME_SESSIONS_MODE_BADGE = {
shell: 'sh',
opencode: 'oc',
codex: 'cx',
gemini: 'gm',
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
omp: 'om',
};
Object.assign(CodemanApp.prototype, {
@@ -430,6 +426,14 @@ Object.assign(CodemanApp.prototype, {
badge.setAttribute('data-i18n-skip', '');
badge.textContent = row.modeBadge;
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
line1.appendChild(logo);
}
const name = document.createElement('span');
// .session-name is in the i18n skip list: a session name is user content.
+642 -2
View File
@@ -25,6 +25,7 @@
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
'.tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
@@ -45,6 +46,15 @@
// Exact English-source translations. Technical names, command examples, model
// names, keyboard chords, and user-authored content intentionally stay unchanged.
const ZH_CN = Object.freeze({
'Default Codex model': 'Codex 默认模型',
'Default Codex reasoning effort': 'Codex 默认思考强度',
'Use Codex configuration': '使用 Codex 配置',
'Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.':
'新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。',
'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.':
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
@@ -52,6 +62,8 @@
'Collapse session sidebar': '收起会话侧边栏',
'Expand session sidebar': '展开会话侧边栏',
'Filter sessions': '筛选会话',
'Search sessions': '搜索会话',
'No sessions match': '没有匹配的会话',
'Admin Panel': '管理面板',
'Open admin panel': '打开管理面板',
'Re-dock to dashboard (close window)': '重新停靠到主界面(关闭窗口)',
@@ -67,6 +79,53 @@
'Open away digest': '打开离开期间摘要',
'Session Manager': '会话管理器',
'Session actions': '会话操作',
Ungrouped: '未分组',
'Group actions': '分组操作',
'Group name': '分组名称',
'Web tab actions': '网页标签操作',
'Web tab settings': '网页标签设置',
'New group': '新建分组',
'Rename group': '重命名分组',
'Rename case': '重命名案例',
Rename: '重命名',
'New name': '新名称',
'letters, numbers, - and _': '字母、数字、- 和 _',
'Also rename the folder on disk': '同时重命名磁盘上的文件夹',
'A case under the cases folder is renamed together with its folder.':
'案例文件夹下的案例会连同其文件夹一起重命名。',
'Stop sessions and rename': '停止会话并重命名',
'Enter a different name.': '请输入不同的名称。',
'Use only letters, numbers, hyphens and underscores.': '只能使用字母、数字、连字符和下划线。',
'Failed to rename the case.': '重命名案例失败。',
'Move group up': '上移分组',
'Move group down': '下移分组',
'Delete group': '删除分组',
'Move up': '上移',
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'New session': '新建会话',
// The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip).
'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)',
'New session here': '在此新建会话',
'New session here…': '在此新建会话…',
"New session in this tab's case": '在此标签页的案例中新建会话',
"This tab's folder is not a case, so there is nothing to start beside it.":
'此标签页的文件夹不是案例,无法在其旁边新建会话。',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
'Parent loop; placed on its own': '父会话形成循环;单独放置',
// The Focus section pinned at the top of the vertical rail (shortcuts to tabs).
Focus: '焦点',
'Add to Focus': '添加到焦点',
'Remove from Focus': '从焦点移除',
'Focus actions': '焦点操作',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
'Tab groups kept changing elsewhere; your edit was not saved.': '标签分组在别处持续更改;你的编辑未保存。',
'Your tab group edit was not saved.': '你的标签分组编辑未保存。',
'Open session manager': '打开会话管理器',
Attachments: '附件',
'Open attachment history': '打开附件历史',
@@ -77,6 +136,96 @@
'Split: close the second session': '分屏:关闭第二个会话',
'Close split': '关闭分屏',
'No other sessions to split with': '没有其他可用于分屏的会话',
// Tile grid (tile-grid.js, docs/tile-grid-plan.md). 平铺 is the feature (the
// button, the setting, the grid), 窗格 one tile in it. Key names stay as
// they are; Click / Right-click are mouse actions, Arrows the arrow keys.
// Counts, exit codes and durations are patterns in translateDynamic.
Tiles: '平铺',
Split: '分屏',
'Tiled sessions': '平铺的会话',
'Tiles: show several sessions side by side (right-click for how many)':
'平铺:并排显示多个会话(右键单击可选择窗格数量)',
'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
'How many tiles': '窗格数量',
// The Tiles button's hover card (the count and the fits note are patterns).
'Click: open the grid': '单击:打开平铺网格',
'Click: close the grid': '单击:关闭平铺网格',
'Right-click: choose 2, 4 or 6 tiles': '右键单击:选择 2、4 或 6 个窗格',
'Shift+F10: the same menu from the keyboard': 'Shift+F10:用键盘打开同一菜单',
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
'Toggle Tile Grid': '切换平铺网格',
'Focus Tile Left': '聚焦左侧窗格',
'Focus Tile Right': '聚焦右侧窗格',
'Focus Tile Up': '聚焦上方窗格',
'Focus Tile Down': '聚焦下方窗格',
'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格',
'Move Tile Left': '向左移动窗格',
'Move Tile Right': '向右移动窗格',
'Move Tile Up': '向上移动窗格',
'Move Tile Down': '向下移动窗格',
'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格',
Drag: '拖动',
"a tile's header": '窗格的标题栏',
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
"a tile's header out of the window": '窗格的标题栏到窗口之外',
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
'a tab': '标签页',
'the Tiles button': '平铺按钮',
Click: '单击',
'Right-click': '右键单击',
Arrows: '方向键',
'not bound': '未绑定',
'Open group as tiles': '以平铺方式打开分组',
'No sessions to show as tiles': '没有可平铺显示的会话',
'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
'Zoom this tile': '放大此窗格',
'Restore the grid': '恢复平铺网格',
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
// view (image-input.js).
'Unsupported file type': '不支持的文件类型',
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
'No active session': '没有活动会话',
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
'Terminal not connected: its size is sent when it reconnects': '终端未连接:重新连接后会发送其尺寸',
'Could not determine terminal size': '无法确定终端尺寸',
'Failed to restore terminal size': '恢复终端尺寸失败',
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
'Drag to move the tile': '拖动可移动窗格',
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
// pop-out window's title as a handle back onto the tiles.
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
'Resize tile columns': '调整窗格列宽',
'Resize tile rows': '调整窗格行高',
Attach: '附加',
'Attaching…': '正在附加…',
'Not attached': '未附加',
'The session ended': '会话已结束',
'The agent exited': '智能体已退出',
'It cannot be restarted in place: close it from ⋯ (Close session).': '无法原地重启:请通过 ⋯(关闭会话)关闭它。',
'Could not attach the session': '无法附加会话',
// The tab's exited-agent badge (app.js applyPaneExitBadge, Ark0N/Codeman#446);
// its exit-code forms and the tab's accessible name are patterns.
exited: '已退出',
// The Run button family (session-ui.js _applyRunMode; "Run CC", "Run SH" ...
// are a pattern; mode codes and product names stay), and the toolbar beside it.
'Terminal / Shell': '终端 / Shell',
'Send Enter': '发送回车',
// The Help modal and the shortcut overlay. Key names stay; Wheel is a mouse
// input like Click (单击).
Tabs: '标签页',
'Toggle Session Sidebar': '切换会话侧边栏',
'Copy Selection': '复制选中内容',
'Copy Selection (interrupts when nothing is selected)': '复制选中内容(无选中内容时中断)',
'Focus Tabs': '聚焦标签页',
Wheel: '滚轮',
'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
Notifications: '通知',
@@ -101,6 +250,9 @@
'Close window': '关闭窗口',
'Session unavailable': '会话不可用',
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
'Session moved': '会话已移走',
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
// Welcome / quick start / common actions
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
@@ -120,11 +272,62 @@
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run GitHub Copilot': '运行 GitHub Copilot',
'Run Shell': '运行 Shell',
'More tools': '更多工具',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
'Create new case': '新建案例',
'Link Existing': '关联现有目录',
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
// which replaced the "+" and gear buttons, and its empty state.
'New or link a case…': '新建或关联案例…',
'Case settings…': '案例设置…',
'No cases match': '没有匹配的案例',
// Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the
// phone case sheet and Close Session's worktree option). Worktree and branch names,
// paths and git's own messages are user/git text and pass through untranslated.
'Git worktrees…': 'Git 工作树…',
'Git worktrees': 'Git 工作树',
'Close git worktrees': '关闭 Git 工作树',
Worktrees: '工作树',
'Worktrees…': '工作树…',
"Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.":
'每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。',
'Start sessions with': '启动会话所用',
'New worktree': '新建工作树',
Branch: '分支',
'Start from': '起点',
"New branch worktree-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'Branch and start point': '分支和起点',
"A branch, tag or commit. Default: the case's current commit (HEAD).":
'分支、标签或提交。默认:案例当前提交(HEAD)。',
'Create & start session': '创建并启动会话',
'Loading worktrees…': '正在加载工作树…',
'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。',
'detached HEAD': '游离 HEAD',
'main checkout': '主检出',
'not created by Codeman': '非 {name} 创建',
locked: '已锁定',
missing: '已丢失',
Open: '打开',
'Start a session in this worktree': '在此工作树中启动会话',
'Remove…': '移除…',
'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.':
'工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。',
'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.':
'它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。',
'Remove worktree': '移除工作树',
'Kill & remove worktree': '终止并移除工作树',
'git refuses while the worktree has uncommitted or untracked files; nothing is forced':
'工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行',
'Also delete its branch (git branch -d keeps a branch that is not merged)':
'同时删除其分支(git branch -d 会保留未合并的分支)',
'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.':
'名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。',
'A launch is already in progress': '已有启动正在进行',
'Codeman did not answer.': '{name} 没有响应。',
'Add Case': '添加案例',
'Open sessions': '打开会话',
'Recent Sessions': '最近会话',
@@ -153,6 +356,7 @@
Delete: '删除',
Remove: '移除',
Edit: '编辑',
Fetch: '拉取',
Refresh: '刷新',
Back: '返回',
Next: '下一步',
@@ -202,6 +406,7 @@
Running: '运行中',
Idle: '空闲',
Working: '工作中',
Waiting: '等待中',
Today: '今天',
Home: '主页',
Local: '本地',
@@ -223,6 +428,36 @@
'此设备使用的界面语言。动态状态消息与对话框也会使用同一语言。',
English: 'English',
Appearance: '外观',
// App Settings > Animations (#571). 平铺 is the grid, 窗格 one tile in it.
Animations: '动画',
'How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.':
'标签页、终端面板、智能体窗口和平铺窗格如何出现。默认全部关闭,选择后立即生效。',
Entrances: '入场',
'Entrance Theme': '入场主题',
'One look for how new tabs, terminal panes, agent windows and their lines arrive.':
'为新标签页、终端面板、智能体窗口及其连线的出现方式选择统一的风格。',
'Off (default)': '关闭(默认)',
'Terminal (CRT)': '终端(CRT)',
'Beam down': '光束降临',
'Launch (tiles fly from tabs)': '发射(窗格从标签页飞出)',
'Soft focus (blur)': '柔焦(模糊)',
Quiet: '安静',
Playful: '活泼',
'Custom (set in the lab)': '自定义(在实验室中设置)',
'Tile Animations': '平铺动画',
'How tiles arrive when the grid opens and leave when it closes. A theme above presets it.':
'平铺打开时窗格如何出现、关闭时如何离开。上方的主题会预设此项。',
'Fly from tab': '从标签页飞出',
Deal: '发牌',
Cascade: '级联',
Pop: '弹出',
Soft: '柔和',
'None (tiles just appear)': '无(窗格直接出现)',
Lab: '实验室',
'Animation Lab': '动画实验室',
'Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.':
'关闭设置,并按界面并排打开所有样式,可重放和调速。等同于在网址后添加 ?animlab=1。',
'Open lab': '打开实验室',
Skin: '皮肤',
'Visual theme for this device (not synced)': '此设备的视觉主题(不同步)',
'Daylight Blue': '日光蓝',
@@ -233,6 +468,8 @@
'Header Displays': '顶部栏显示',
'Font Controls': '字体控制',
'System Stats': '系统状态',
'GPU Stats': 'GPU 状态',
'CPU MHz': 'CPU 频率',
'Lifecycle Log': '生命周期日志',
'Response Viewer': '回复查看器',
'Attachments Button': '附件按钮',
@@ -252,6 +489,103 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'New Session Button on Tabs': '标签页新建会话按钮',
'A + on the tab that starts a session in the same case and group, in any installed CLI.':
'标签页上的 + 按钮,可在同一案例和分组中,用任一已安装的 CLI 启动新会话。',
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
'Detach Tiles': '分离窗格',
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
'Tab Layout': '标签页布局',
'By state: a row each for needs you, waiting, working and idle. By case: one box per case. Ledger: an aligned column grid. Classic: the single list, as before. By state and By case group the side rail and sidebar too. Alt+1..9 keeps the tab order.':
'按状态:需要你、等待中、工作中和空闲各占一行。按案例:每个案例一个框。台账:对齐的列网格。经典:与以前相同的单一列表。按状态和按案例也会为侧边标签栏和侧边栏分组。Alt+1..9 仍按标签页顺序切换。',
'By state (rows per state)': '按状态(每种状态一行)',
'By case (clusters)': '按案例(分组框)',
'Ledger (aligned columns)': '台账(对齐的列)',
'Classic (default)': '经典(默认)',
'State Order': '状态顺序',
'For Tab Layout by state. At the bottom flips the rows, so needs you sits right above the terminal.':
'用于按状态的标签页布局。选择在底部会倒转各行,让“需要你”紧挨在终端上方。',
'Needs you on top (default)': '“需要你”在顶部(默认)',
'Needs you at the bottom': '“需要你”在底部',
'Header Stats Style': '顶部栏状态样式',
'How WS, CPU, MEM and the plan-usage windows are drawn. Compact puts a ring beside each value in two pills; Tiles put each label over its value with a bar underneath.':
'WS、CPU、MEM 和套餐用量窗口的显示方式。紧凑:在两个胶囊中每个数值旁显示一个圆环;磁贴:每个标签位于数值上方,下方带一条进度条。',
'As before (bars)': '与以前相同(进度条)',
'Compact (default)': '紧凑(默认)',
'Tiles (label over value)': '磁贴(标签在数值上方)',
// The connection tile's value word in that style (app.js
// _connectionTileValueText). Scoped keys on purpose: the bare words also
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
// badge in the resume list), and a bare key would translate those too.
'Connection tile: live': '已连接',
'Connection tile: fallback': '回退',
'Connection tile: offline': '离线',
'Connection tile: queued': '已排队',
'Connection tile: retry': '重连中',
// App Settings → Bottom bar, translated as one group (the Git status rows, #543's two included, and the
// two Git fetch rows, in the order the page lists them). Keys are the trimmed label text, without the
// scope tag. The Session Options → Git strings are in that group's own block further down.
'Bottom bar': '底部栏',
'Git status': 'Git 状态',
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only apart from Fetch: Codeman never commits or changes the repository, and `git fetch` runs only when you click Fetch in that window, or on the interval below if you turned it on. It is the only git command the Git window runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked). Not shown for Docker or remote sessions. Off by default.":
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。除拉取外只读:{name} 从不提交或更改仓库,而拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),它只在您点击该窗口中的拉取按钮时运行,或在您开启了下方的定时后按间隔运行。Docker 和远程会话不显示。默认关闭。',
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
'Git status: max repositories': 'Git 状态:最多仓库数',
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
'Git status: git timeout': 'Git 状态:git 超时',
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on (here, or in Session Options → Git) is fetched, and sessions sharing one folder are fetched by one run.':
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话(这里,或在“会话选项 → Git”)才会被拉取,共用一个文件夹的会话由一次运行拉取。',
'Fetch git status': '拉取 Git 状态',
'Refresh git status': '刷新 Git 状态',
'Close git status': '关闭 Git 状态',
// Edit history (edit-history-ui.js): settings chip, header button and panel.
// The two row badges use scoped keys: a bare "new"/"edit" key would also
// translate unrelated words elsewhere (the lookup is case-insensitive).
'Edit history': '编辑历史',
'Open edit history': '打开编辑历史',
'Refresh edit history': '刷新编辑历史',
'Close edit history': '关闭编辑历史',
'Group by file': '按文件分组',
'Main agent only': '仅主智能体',
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
'Reading the edit history…': '正在读取编辑历史…',
'Could not read the edit history.': '无法读取编辑历史。',
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
'No edits yet.': '暂无编辑。',
'No edits by the main agent.': '主智能体没有编辑。',
'Load older': '加载更早的编辑',
'Loading older edits…': '正在加载更早的编辑…',
'Edit history badge: new': '新建',
'Edit history badge: edit': '编辑',
'File created': '已创建文件',
'File edited ({tool})': '已编辑文件({tool})',
Image: '图片',
Subagent: '子智能体',
'Open in the file preview': '在文件预览中打开',
'Show what changed': '显示更改内容',
'Hide the diff': '隐藏差异',
'Reading the diff…': '正在读取差异…',
'Could not read the diff.': '无法读取差异。',
'No textual changes.': '没有文本更改。',
'Diff cut short: it is larger than the viewer shows.': '差异已截断:内容超出查看器可显示的范围。',
Panels: '面板',
Monitor: '监视器',
'Project Insights': '项目洞察',
@@ -261,6 +595,9 @@
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
'Auto-name Sessions': '自动命名会话',
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
@@ -275,7 +612,6 @@
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Read My Mind': '读心术',
'Read My Mind: predict your next prompt': '读心术:预测您的下一条提示',
'Predict my next prompt': '预测我的下一条提示',
@@ -312,6 +648,9 @@
'API key': 'API 密钥',
'Optional. Left blank on edit keeps the existing key.': '可选。编辑时留空将保留现有密钥。',
'Auth header': '认证请求头',
'Default context length (tokens)': '默认上下文长度(token 数)',
"Used for models whose size cannot be discovered. The model's whole window; the Claude floor check divides it by any stored slot count. Leave empty to let each CLI assume its own default.":
'用于无法自动发现上下文大小的模型。这是模型的完整窗口;Claude 下限检查会将其除以已存储的槽位数。留空则让各个 CLI 使用其自身的默认值。',
'Never send both — some servers hang indefinitely.': '切勿同时发送两者 — 部分服务器会因此无限期挂起。',
'Authorization: Bearer (default)': 'Authorization: Bearer(默认)',
'api-key header (Azure)': 'api-key 请求头(Azure)',
@@ -321,9 +660,15 @@
'Custom Endpoints': '自定义端点',
'Choose a model': '选择模型',
'Currently loaded': '当前已加载',
'In use by a session': '会话正在使用',
'Last used': '上次使用',
'Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.':
'未标记任何模型:此端点没有报告它已加载了哪个模型,你运行中的会话也没有在使用它。请选择要启动的模型。',
'That endpoint no longer exists': '该端点已不存在',
'No models discovered for this endpoint yet': '此端点尚未发现任何模型',
// Model picker search box (index.html placeholder/aria-label and the no-matches line)
'Search models': '搜索模型',
'No models match': '没有匹配的模型',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
@@ -332,7 +677,6 @@
'Remote Access': '远程访问',
'Cloudflare Tunnel': 'Cloudflare 隧道',
'Tunnel URL': '隧道地址',
'Upload URL': '上传地址',
Updates: '更新',
'Current Version': '当前版本',
'Check for Updates': '检查更新',
@@ -381,6 +725,9 @@
'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.':
'终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。',
'Local Echo': '本地回显',
'Keep Size Under Keyboard': '键盘弹出时保持尺寸',
"For local Claude sessions, opening the on-screen keyboard keeps the session's size and scrolls the view instead, so Claude does not reprint its screen and lose transcript lines. Full-screen interfaces (Codex, Claude's fullscreen mode) are resized as before.":
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
@@ -407,7 +754,16 @@
'Agent Teams': '智能体团队',
'Claude Model': 'Claude 模型',
'1M Opus Context': 'Opus 100 万上下文',
'Default session group': '默认会话分组',
'New sessions for this case land in this group': '此案例的新会话将放入此分组',
'Could not save the default session group.': '无法保存默认会话分组。',
'Remote auto-reconnect': '远程自动重连',
'Experimental agent coordination': '实验性智能体协作',
"Lets agents read another session's output in bounded pages and type a prompt into a local Codex composer, then press Enter once after checking it is unchanged (the inspect, stage and submit commands). Experimental: the commands and their HTTP routes may change in any release. Off by default; while off those routes refuse every request.":
'允许智能体分页读取另一个会话的输出(每页有上限),并把提示词输入本地 Codex 输入框,确认未被改动后只按一次 Enter(即 inspect、stage 和 submit 命令)。实验性功能:这些命令及其 HTTP 路由可能在任何版本中变化。默认关闭;关闭时这些路由拒绝所有请求。',
'Pin tab names on the CLI': '在 CLI 上固定标签名',
"Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.":
'以标签名启动每个 Claude 会话,让 CLI 的提示框标签和 /resume 列表与你的标签一致。Claude 随后不再为这些会话生成自己的标题,在 Codeman 中的重命名也只保留到下一轮。从下一次会话启动或重新生成时生效,无需重启服务器。',
'Thinking Effort': '思考强度',
Low: '低',
Medium: '中',
@@ -429,6 +785,11 @@
'Audio Alerts': '声音提醒',
'Push Notifications': '推送通知',
'Notification Levels': '通知级别',
'Toast display time': '弹出提示显示时长',
'How long the corner pop-ups stay on screen.': '角落弹出提示在屏幕上停留的时长。',
'Browser notification display time': '浏览器通知显示时长',
'How long a desktop notification stays up before Codeman closes it. Your OS may close it sooner.':
'桌面通知在 Codeman 关闭它之前保持显示的时长。系统可能会更早关闭它。',
Critical: '严重',
'Per-Event Settings': '按事件设置',
'Permission prompts': '权限提示',
@@ -459,6 +820,8 @@
// placeholder and label are looked up by the module at build time, since
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Open a session to attach a file': '请先打开一个会话再添加附件',
'📎 Attach': '📎 添加附件',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
@@ -495,6 +858,16 @@
'Session Color': '会话颜色',
'Working Directory': '工作目录',
'Set working directory': '设置工作目录',
// Session Options > Session > Working folder (session-ui.js openSessionOptions).
'Working folder': '工作目录',
"Where this session's CLI runs, on the machine hosting Codeman.":
'此会话的 CLI 运行所在的目录,位于运行{name}的机器上。',
"Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine.":
'此会话的 CLI 运行所在的目录,位于通过 SSH 连接的远程主机上,而不是本机。',
"On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in.":
'位于运行{name}的机器上,并挂载到此会话 CLI 运行所在的 Docker 容器中。',
'Path copied': '路径已复制',
'Copy failed': '复制失败',
'Resume Conversation': '继续对话',
'Close Session': '关闭会话',
'Choose how to close': '选择关闭方式',
@@ -523,6 +896,24 @@
Memory: '内存',
GPUs: 'GPU',
// Session Options → Git (the tab's blurb, its group head, its hint and its three rows in index.html, plus
// the save toast session-ui.js raises). 'Status' is not a key here: that row's text is built at runtime
// by updateGitFetchStatus, and 'Git' stays a technical name.
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer, so nothing runs until you turn it on (here, or in App Settings → Bottom bar). Cloning and worktree management write too, but only when you asked for them at the moment you asked. The panel's Fetch button is always just a click.":
'拉取是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何运行。克隆和工作树管理也会写入,但只在您当时要求时才运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this session': '拉取此会话',
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
'Git fetch settings saved': 'Git 定时拉取设置已保存',
// Cron / lifecycle / panels
'Cron Jobs': '定时任务',
'+ New Job': '+ 新建任务',
@@ -555,7 +946,21 @@
// Dynamic common status / toasts
'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容',
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
// each notice's title, message and link label carry their own zh-CN text and are skipped.
'Security notice': '安全提示',
'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机',
// Multi-user mode, a non-admin (settings-ui.js / session-ui.js): server-wide settings
// writes are admin-only there.
'Saved on this device. Server-wide settings can only be changed by an admin.':
'已保存到本设备。服务器全局设置只能由管理员更改。',
'Server-wide settings can only be changed by an admin': '服务器全局设置只能由管理员更改',
// The reason a settings write gives when no answer came back (settings-ui.js _settingsWriteError).
'the server could not be reached': '无法连接到服务器',
'Tunnel active': '隧道已启用',
'Tunnel starting — QR code will appear when ready...': '隧道正在启动,准备好后将显示二维码…',
'Push notifications enabled': '推送通知已启用',
@@ -571,6 +976,15 @@
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
'Nothing to copy': '没有可复制的内容',
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
'Session not found': '未找到会话',
// A native host that would not open a window (app.js openInHostWindow); the
// "dashboard" is a web tab.
'Could not open a new window for this session': '无法在新窗口中打开此会话',
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
// Dictation whose session closed before the text was sent (voice-input.js).
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
Copy: '复制',
@@ -650,6 +1064,19 @@
'Enable Respawn': '启用重生',
'Stop Respawn': '停止重生',
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
'Keep the cache warm while idle': '空闲时保持缓存预热',
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
'Keep prompt caches warm': '保持提示缓存预热',
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
'Keep warm for at most': '最长保持预热',
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
hours: '小时',
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
'Auto-Compact': '自动压缩',
'Auto-Clear': '自动清空',
@@ -727,8 +1154,51 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Back (Alt+←)': '后退 (Alt+←)',
'Forward (Alt+→)': '前进 (Alt+→)',
'Back to the previous file': '返回上一个文件',
'Forward to the next file': '前进到下一个文件',
Expand: '展开',
'Drag to resize': '拖动以调整大小',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
'Unsaved changes': '未保存的更改',
Saved: '已保存',
'Loading spreadsheet…': '正在加载电子表格…',
'This workbook is too large to preview (10 MB limit).': '此工作簿太大,无法预览(上限 10 MB)。',
'This workbook has no visible worksheets.': '此工作簿没有可见的工作表。',
'This worksheet is empty.': '此工作表为空。',
'Some workbook features are not shown': '部分工作簿功能未显示',
'Spreadsheet preview timed out.': '电子表格预览超时。',
'Spreadsheet preview failed': '电子表格预览失败',
'Spreadsheet parser failed.': '电子表格解析器出错。',
'Spreadsheet parser failed to start': '电子表格解析器启动失败',
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
'Spreadsheet parser message failed': '电子表格解析器消息出错',
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
// Worker refusals, one sentence per error code (spreadsheet-preview.js
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
// counted ones are patterns in translateDynamic below.
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
// The features the preview leaves out (spreadsheet-preview.js warningText).
// Scoped keys on purpose: a bare 'charts' or 'macros' key would also
// translate a folder or case of that name (a Helm chart's charts/, a dbt
// project's macros/) in the Files panel and the case picker.
'Spreadsheet feature: charts': '图表',
'Spreadsheet feature: drawings': '绘图',
'Spreadsheet feature: pivot tables': '数据透视表',
'Spreadsheet feature: external links': '外部链接',
'Spreadsheet feature: macros': '宏',
'Formula has no cached result': '公式没有缓存的计算结果',
'Unsupported cell value': '不支持的单元格值',
'Unsupported number format': '不支持的数字格式',
'Export as JSON': '导出为 JSON',
'Export as Markdown': '导出为 Markdown',
'Mark all read': '全部标为已读',
@@ -764,6 +1234,22 @@
'现有项目文件夹的绝对路径,例如 /home/you/my-project',
'Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/':
'仅允许字母、数字、连字符和下划线;将在 ~/codeman-cases/ 中创建。',
'Letters, numbers, hyphens, underscores only. Created inside the parent folder below.':
'仅允许字母、数字、连字符和下划线;将在下方的父文件夹中创建。',
'A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.':
'在 ~/codeman-cases 下新建工作区,并生成独立的 CLAUDE.md。',
'A fresh workspace in a folder you choose, scaffolded with its own CLAUDE.md.':
'在你选择的文件夹中新建工作区,并生成独立的 CLAUDE.md。',
'Create in a custom folder': '在自定义文件夹中创建',
'📁 Create in a custom folder': '📁 在自定义文件夹中创建',
'By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.':
'新案例默认创建在 ~/codeman-cases 下。选择其他文件夹后,案例会改为创建在那里,并像其他案例一样列出。',
'Parent Folder': '父文件夹',
'Pick the folder the new case folder should be created inside.': '选择要在其中创建新案例文件夹的文件夹。',
'Choose the folder to create the case in': '选择要在其中创建案例的文件夹',
'Not available for a Docker case': 'Docker 案例不可用',
'Not available with a custom folder': '使用自定义文件夹时不可用',
'Browse…': '浏览…',
'Docker exports': 'Docker 导出',
'No exports yet. Export a docker case from its tab.': '暂无导出;请从 Docker 案例标签页导出。',
'Runs inside an isolated container. Multiple sessions can share the same container.':
@@ -870,8 +1356,11 @@
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'File Viewer Back / Forward': '文件查看器后退 / 前进',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
'Next Session (alternate)': '下一个会话(备用)',
'Switch to Tab N': '切换到第 N 个标签页',
'Move Active Tab Left': '向左移动当前标签页',
'Move Active Tab Right': '向右移动当前标签页',
@@ -911,6 +1400,17 @@
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
}
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
const TILE_STATE_ZH = {
'needs you': '需要你',
error: '错误',
waiting: '等待中',
working: '工作中',
idle: '空闲',
done: '已完成',
exited: '已退出',
};
function translateDynamic(source) {
const patterns = [
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
@@ -923,11 +1423,127 @@
[/^Lifetime: (\d+) sessions created$/, (_m, count) => `累计已创建 ${count} 个会话`],
[/^Tunnel active: (.+)$/, (_m, url) => `隧道已启用:${url}`],
[/^Tunnel error: (.+)$/, (_m, error) => `隧道错误:${error}`],
[/^Tunnel not stopped: ([\s\S]+)$/, (_m, why) => `隧道未停止:${why}`],
[/^Update to v(.+)$/, (_m, version) => `更新到 v${version}`],
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[
/^Codeman updated to v([^:\s]+): (.+?)(?: \(\+(\d+) more\))?$/,
(_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`,
],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
[/^Case renamed to "(.+)"$/, (_m, name) => `案例已重命名为“${name}”`],
[/^New name for "(.+)"$/, (_m, name) => `“${name}”的新名称`],
[
/^These sessions have the folder open: (.+)\. They will be closed \(their tabs go away and nothing is restarted\) before the folder is renamed\.$/,
(_m, list) => `这些会话正在使用该文件夹:${list}。重命名前将关闭它们(其标签页会消失,不会重新启动)。`,
],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
[/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`],
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Git worktree toasts (session-ui.js): worktree and branch names and git's own
// words pass through untranslated.
[/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`],
[
/^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/,
(_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`,
],
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
[
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
],
// Tile grid: counts, exit codes and durations pass through.
[/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
[/^Tiles \u00B7 (\d+)$/, (_m, n) => `平铺 · ${n}`],
[
/^This window fits (\d+) tiles?: a click opens (\d+)$/,
(_m, n, m) => `此窗口可容纳 ${n} 个窗格:单击将打开 ${m} 个`,
],
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
[
/^The grid already holds what this window fits \((\d+)\)$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n})`,
],
[
/^The grid holds at most (\d+) tiles: the new session opens on its own$/,
(_m, n) => `平铺网格最多容纳 ${n} 个窗格:新会话将单独打开`,
],
[
/^The grid already holds what this window fits \((\d+)\): the new session opens on its own$/,
(_m, n) => `平铺网格已达到此窗口可容纳的数量(${n}):新会话将单独打开`,
],
[
/^The window is too small for (\d+) tiles: showing the focused one$/,
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A settings write the server did not store (settings-ui.js). The server's reason passes
// through untranslated; the one the page supplies itself has its own entry.
[
/^Saved on this device, not on the server: ([\s\S]+)$/,
(_m, why) => `已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
],
[
/^(Run mode|Respawn presets) saved on this device, not on the server: ([\s\S]+)$/,
(_m, what, why) =>
`${what === 'Run mode' ? '运行模式' : '重生预设'}已保存到本设备,但未保存到服务器:${ZH_CN[why] || why}`,
],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
// and model names pass through untranslated.
[/^(.+) \(set at launch\)$/, (_m, names) => `${names}(启动时设定)`],
[/^(.+) \(custom endpoint\)$/, (_m, names) => `${names}(自定义端点)`],
[/^(.+) \(from config\)$/, (_m, names) => `${names}(来自配置)`],
// The Run button's mode codes ("Run CC", "Run SH", "Run OC" ...; a registry
// CLI's shortBadge too). Exact entries win first ("Run Shell", "Run OMP").
[/^Run ([A-Z][A-Z0-9]{1,5})$/, (_m, code) => `运行 ${code}`],
// The tab's exited-agent badge, and the tab's accessible name carrying it.
// The session name is user text: it passes through untranslated.
[/^exited \((-?\d+)\)$/, (_m, code) => `已退出(${code})`],
[/^exited \(signal (\d+)\)$/, (_m, signal) => `已退出(信号 ${signal})`],
[
/^(.+) session, agent exited \(signal (\d+)\)$/,
(_m, name, signal) => `${name} 会话,智能体已退出(信号 ${signal})`,
],
[/^(.+) session, agent exited \((-?\d+)\)$/, (_m, name, code) => `${name} 会话,智能体已退出(${code})`],
[/^(.+) session, agent exited$/, (_m, name) => `${name} 会话,智能体已退出`],
// A session name is user text: it passes through untranslated.
[
/^(.+) was stopped after crashing repeatedly\. Restart it\?$/,
(_m, name) => `${name} 因反复崩溃已被停止。要重启吗?`,
],
// A tile header's tooltip: a state and how long ("idle 3m"). The duration
// is required: bare state words stay out of the table, they collide with
// state strings on other surfaces (see mobile-overview.js).
[
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
],
// The same while tiles can move, with the drag hint on a second line.
// Anchored on the hint, so a bare state word is safe here.
[
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
],
];
for (const [pattern, replacement] of patterns) {
const match = source.match(pattern);
@@ -986,6 +1602,23 @@
return !element || Boolean(element.closest(SKIP_SELECTOR));
}
// xterm's DOM renderer rewrites its rows (`.xterm-rows > div`) on every frame
// a pane changes: thousands of mutation records a second with a grid of tiles,
// each paying a closest() over the whole skip list. All rows of one terminal
// share that parent, so its own shouldSkip() verdict is kept once it says
// skip; a skip verdict cannot lapse, since xterm keeps `.xterm-rows` inside
// its `.xterm`. A rows container that is not skipped is never kept: its rows
// go through the full check below like any other node.
const skippedRows = new WeakSet();
function isSkippedRow(node) {
const rows = node.parentNode;
if (!rows?.classList?.contains('xterm-rows')) return false;
if (skippedRows.has(rows)) return true;
if (!shouldSkip(rows)) return false;
skippedRows.add(rows);
return true;
}
function shouldSkipText(node) {
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return shouldSkip(node) || Boolean(element?.closest(USER_TEXT_SELECTOR));
@@ -1081,6 +1714,13 @@
observer = new MutationObserver((mutations) => {
if (applying) return;
for (const mutation of mutations) {
// A change inside a skipped surface cannot need translating: every
// node it adds or edits sits under the same skip ancestor, so both
// translators would return on their own closest() check anyway. One
// check per record instead of one per text node and attribute matters
// for xterm's DOM renderer, which replaces rows every frame (the split
// pane, every tile of the grid).
if (isSkippedRow(mutation.target) || shouldSkip(mutation.target)) continue;
if (mutation.type === 'characterData') translateNode(mutation.target);
if (mutation.type === 'attributes') translateAttributes(mutation.target);
for (const added of mutation.addedNodes) translateNode(added);
+245 -35
View File
@@ -14,6 +14,69 @@
Object.assign(CodemanApp.prototype, {
initImageInput() {
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
// sits invisibly over the button, and the browser opens the picker itself.
//
// Nothing here may open the picker from script. While the keyboard is up,
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
// re-dispatches it as a scripted click, and iOS will not open a file picker
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
// milliseconds after pointerdown, no picker, no error, and no real click after
// it either because the gesture was cancelled. A native tap on the input is not
// a button tap, so the shim leaves it alone and the click stays trusted. It also
// anchors the picker popover to the input's own frame (a `hidden` input has
// none, which is why the composer's picker floated loose).
//
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
// so the fixed toolbar never reflows out from under the finger mid-tap.
//
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
// responder to present the picker, and on DEVICE it does not hand the keyboard
// back when the picker closes, even though focus never left the textarea.
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
// not take a keyboard measurement from one.) A blur-then-focus on the close
// of the picker, cancel and change alike, restores DOM focus and still leaves
// the keyboard down, because iOS only raises it inside a user gesture. So the
// user taps the prompt when ready to type, which is the keyboard policy
// everywhere else in the app.
//
// Do not try to hide the keyboard's exit by freezing the page's keyboard
// state across the picker. That was tried (holdKeyboardState, reverted) to
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
// bar strand in the MIDDLE of the screen over a dead black band, which is far
// worse than the reflow. The page must follow the keyboard out.
const attachBtn = document.getElementById('attachBtnMobile');
const attachInput = document.getElementById('attachFileInput');
if (attachBtn && attachInput) {
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
// The button is the ACCESSIBLE control (the input is aria-hidden and not
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
// the input. That click is trusted, so opening the picker from it is allowed.
attachBtn.addEventListener('click', () => attachInput.click());
attachInput.addEventListener('click', (event) => {
if (!this.activeSessionId) {
event.preventDefault(); // refuse before the picker opens, not after a pick
this.showToast('Open a session to attach a file', 'error');
return;
}
// Captured before the picker opens: the session can be deleted while it is
// up, and the pick must not land on whatever replaced it.
this._attachSessionId = this.activeSessionId;
});
attachInput.addEventListener('change', () => {
const files = Array.from(attachInput.files || []);
// Reset so picking the same file again still fires change.
attachInput.value = '';
// Captured when the picker opened (see the click handler above).
const sessionId = this._attachSessionId;
this._attachSessionId = null;
if (files.length > 0) {
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
}
});
}
// Drag-and-drop handlers on terminal container
const container = document.getElementById('terminalContainer');
if (!container) return;
@@ -38,20 +101,106 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return;
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) {
this.showToast('Only image files are supported', 'error');
return;
}
this._uploadAndInsertImages(imageFiles);
const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files));
if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles);
});
},
/** Upload the toolbar Attach selection and put the paths on the prompt. The
* Compose dialog is deliberately not involved (see initImageInput).
*
* With local echo on (every phone by default) the paths go into the OVERLAY as
* pending text rather than to the PTY. That is what makes them part of "what the
* user typed": they paint at the prompt, anything typed after them appends in
* order, Enter flushes the whole line, and opening Compose adopts path and prose
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
* instead would strand the path on the terminal while Compose took only the prose.
* Without the overlay (local echo off) there is nothing to buffer into, so the
* text goes straight out. The trailing space keeps the next word off the path. */
async _attachFromToolbar(files, sessionId) {
const media = this._admitPromptAttachments(files);
if (media.length === 0) return;
if (!sessionId) return; // no captured session: refuse rather than guess at one
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
if (paths.length === 0) return;
// Deleted while the upload ran: a write would go to a dead session, and the
// flushed maps below would gain an entry whose cleanup has already run.
if (this.sessions && !this.sessions.has(sessionId)) return;
const text = paths.join(' ');
// The prompt composer, if it opened for this session while the upload ran, is
// where the prompt now lives: it took the terminal's pending text with it, so
// appending behind it would strand the path outside the text being composed.
// _insertComposerText adds its own separator and fires the draft save.
const openComposer = KeyboardAccessoryBar._composerOverlay;
const composerTextarea =
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
? openComposer.querySelector('.prompt-composer-textarea')
: null;
if (composerTextarea) {
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
return;
}
const stillActive = this.activeSessionId === sessionId;
// The overlay holds the prompt only while local echo actually owns it. A
// session handed back to plain PTY echo by a composer nav key (echo
// passthrough) skips the whole overlay branch in onData, Enter included, so
// text buffered there would sit on screen and never be submitted. Nor while
// the tile grid owns the terminal area: the main terminal is parked and
// hidden then, and a tile's Enter never reaches its overlay, so the path
// goes to the PTY like any other background write.
const overlayOwnsPrompt =
stillActive &&
!this._tilesOwnTerminal?.() &&
this._localEchoEnabled &&
this._localEchoOverlay &&
!this._echoPassthroughSessions?.has(sessionId);
if (overlayOwnsPrompt) {
// Prompt text the overlay has not adopted yet (a tab completion, a restored
// tab) only lands in its flushed half once detection runs, and appendText
// runs it AFTER we would have measured. Detect first, or the separator is
// computed against an empty string and the path fuses onto a real word.
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
this._localEchoOverlay.detectBufferText();
}
const flushed = this._localEchoOverlay.getFlushed().text || '';
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
this._localEchoOverlay.appendText(gap + text + ' ');
} else {
// No buffer to measure, so lead with a separator unconditionally rather than
// risk fusing onto whatever the CLI's own composer already holds.
const out = ` ${text} `;
this._sendInputAsync(sessionId, out);
// A background session's prompt is tracked in the flushed maps, which is what
// a later composer open adopts and erases. An untracked write leaves that
// count short, so the erase would eat the tail of this path instead. Not for
// a passthrough session: its cursor can sit mid-text, nothing tracks its
// composer, and Enter there never clears the maps, so a record would offset
// the overlay for the next prompt instead.
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
}
}
},
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
// Creates a hidden paste trap, lets the browser paste into it, then inspects
// the result for images. Works on plain HTTP (no Clipboard API needed).
_handleImagePaste() {
// `target` names the terminal the Ctrl+V came from and its session; both
// default to the primary pane. A second terminal (the split pane) passes its
// own. Images upload to THAT session. Plain text goes to that pane's session
// as a Codeman-built bracketed-paste frame: the terminal is only refocused
// and receives input(framed). onData delivers the frame; it is not written
// into the xterm buffer.
_handleImagePaste(target = {}) {
const self = this;
const terminal = target.terminal || this.terminal;
const sessionId = target.sessionId || this.activeSessionId;
// Create a hidden contenteditable div to receive the paste
const trap = document.createElement('div');
@@ -77,36 +226,41 @@ Object.assign(CodemanApp.prototype, {
if (pasteConsumed) return;
pasteConsumed = true;
// Check for images in clipboard items
var imageFiles = [];
// Check for images, videos or documents in clipboard items
var pastedFiles = [];
var items = e.clipboardData && e.clipboardData.items;
if (items) {
for (var i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
var blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
var blob = items[i].getAsFile();
if (blob) pastedFiles.push(blob);
}
}
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text });
// Clean up the trap
setTimeout(function() {
if (trap.parentNode) trap.parentNode.removeChild(trap);
// Refocus the terminal
if (self.terminal) self.terminal.focus();
if (terminal) terminal.focus();
}, 0);
if (imageFiles.length > 0) {
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
// has enabled bracketed-paste mode (Claude Code does). Sending text
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (text && self.terminal) self.terminal.paste(text);
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
} else if (text && terminal) {
// No image. xterm's paste() emits CSI 200~/201~ only while its DECSET
// 2004 mirror is on, and a terminal replay clears that mirror, so the
// markers never arrive and a copied newline submits (#716). Build the
// frame here and hand it to this terminal through input(). That fires
// onData as user input (scroll to the bottom, clear the selection).
// The main pane's bracketed-paste branch and a tile's _onTerminalData
// deliver it, including the local-echo flush (#219). Do not also write
// the frame from here. Markers are stripped until none is left: one pass over
// `ESC[20` + `ESC[201~` + `1~` leaves an `ESC[201~` that would end the frame early.
let body = text;
while (/\x1b\[20[01]~/.test(body)) body = body.replace(/\x1b\[20[01]~/g, '');
body = body.replace(/\r?\n/g, '\r');
terminal.input(`\x1b[200~${body}\x1b[201~`);
}
});
@@ -115,6 +269,46 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
* clips (the server streams those to disk under their own cap), 'document'
* for the office and text files the server admits, else null. The one filter
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
* handlers. For media the MIME type decides, but a video MIME only for the
* containers the server admits; some Android file managers hand over a File
* with none, so the name's extension is the fallback. A document is known by
* its extension alone, as on the server (the MIME a file manager gives a .md
* or .csv is anyone's guess). Mirrors the server's allowlists. */
_promptAttachKind(file) {
if (file.type.startsWith('image/')) return 'image';
// A video MIME counts only for the five containers the server accepts on
// ?kind=video (its own set in session-routes.ts); a 3gp or mkv falls to the
// extension check below and is refused up front, not after a 1GB upload and a 400.
if (/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/i.test(file.type)) return 'video';
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
return 'document';
}
return null;
},
/** The files of a pick, drop or paste that _promptAttachKind admits, telling the
* user about any it refuses: all of them is "Unsupported file type", some of
* them a count of what was skipped while the rest still upload. A clipboard
* paste that also carries text passes `textPasted`: when none of its files is
* admitted the text is pasted instead, so that case stays quiet. */
_admitPromptAttachments(files, { textPasted = false } = {}) {
const admitted = files.filter((f) => this._promptAttachKind(f));
const skipped = files.length - admitted.length;
if (skipped > 0 && admitted.length > 0) {
this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info');
} else if (skipped > 0 && !textPasted) {
this.showToast('Unsupported file type', 'error');
}
return admitted;
},
// Max images accepted in one batch (paste / drop / mobile picker). Each is
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
// rate limit while covering "select a bunch of photos at once".
@@ -126,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
/** Upload a batch and normally insert its paths into the active terminal.
* The prompt composer passes `{ insert: false }` so it can put those paths
* into its textarea instead. Returns successful paths in selection order. */
* into its textarea instead. `options.sessionId` names the session to upload
* to (default: the active one). Returns successful paths in selection order. */
async _uploadAndInsertImages(fileList, options = {}) {
const sessionId = this.activeSessionId;
const sessionId = options.sessionId || this.activeSessionId;
if (!sessionId) return [];
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
let files = Array.from(fileList || []);
if (files.length === 0) return [];
@@ -144,9 +340,10 @@ Object.assign(CodemanApp.prototype, {
const total = files.length;
let done = 0;
let failed = 0;
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
const results = new Array(total); // preserve selection order for insertion
const progress = () =>
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
progress();
// Bounded-concurrency worker pool over the file list.
@@ -167,6 +364,7 @@ Object.assign(CodemanApp.prototype, {
results[i] = await this._uploadPasteImage(sessionId, normalized);
} catch (err) {
failed++;
if (!failReason && err && err.message) failReason = err.message;
console.warn('Image upload failed:', err);
results[i] = null;
} finally {
@@ -179,17 +377,21 @@ Object.assign(CodemanApp.prototype, {
const paths = results.filter(Boolean);
if (paths.length > 0 && options.insert !== false) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
// Insert all paths in one shot, space-separated, in selection order, into
// the session the batch was uploaded TO. Not sendInput(): it re-reads
// activeSessionId, and after the awaits above that is whatever tab the
// user switched to mid-upload, so the paths landed in the wrong session.
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
}
// Final status: successes, plus any failures / cap so nothing is silent.
const parts = [];
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(`${failed} failed`);
if (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
return paths;
},
@@ -197,7 +399,10 @@ Object.assign(CodemanApp.prototype, {
const form = new FormData();
form.append('image', file);
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
// A video takes the server's disk-streamed path and its larger cap; the flag
// has to ride the URL because the size budget is fixed before the part is read.
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
method: 'POST',
body: form,
});
@@ -219,9 +424,14 @@ Object.assign(CodemanApp.prototype, {
// decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) {
if (file.type === 'image/gif') return file;
// Only an image goes through the canvas; a video is uploaded as picked. With no
// MIME (some Android file managers) the name's extension stands in, as it does in
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
const name = (file.name || '').toLowerCase();
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
const toPng = file.type === 'image/png';
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
const url = URL.createObjectURL(file);
try {
const img = new Image();
+959 -51
View File
File diff suppressed because it is too large. Load diff
+241 -57
View File
@@ -80,7 +80,7 @@ const PathPicker = {
/**
* Open the lazy filesystem browser.
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
* title?: string, onSelect: (path: string) => void}} options
* allowNewFolder?: boolean, title?: string, onSelect: (path: string) => void}} options
*/
open(options) {
this.close(false);
@@ -114,9 +114,15 @@ const PathPicker = {
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
</form>
<button type="button" class="path-picker-newfolder" title="New folder" aria-label="New folder" hidden>+&#x1F4C1;</button>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<form class="path-picker-newfolder-form" hidden>
<input type="text" class="path-picker-newfolder-name" aria-label="New folder name" placeholder="New folder name" maxlength="255" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="done">
<button type="submit" class="path-picker-newfolder-create">Create</button>
<button type="button" class="path-picker-newfolder-cancel">Cancel</button>
</form>
<div class="path-picker-toolbar">
<div class="path-picker-status" aria-live="polite">Loading...</div>
<label class="path-picker-sort-label">Sort
@@ -147,6 +153,7 @@ const PathPicker = {
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
this._wireNewFolder(overlay, !!options.allowNewFolder);
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
// never on each keystroke: a half-typed path is a 404 the server has to
// answer for nothing, and jumping mid-edit would yank the field around.
@@ -183,6 +190,70 @@ const PathPicker = {
this.load(options.initialPath || '');
},
/**
* "New folder": opt-in per caller (`allowNewFolder`), so a picker that browses somewhere the host
* filesystem route cannot create in (the container-workdir picker) never shows it. The folder is made
* inside the folder being viewed and is selected once it exists.
*/
_wireNewFolder(overlay, enabled) {
const button = overlay.querySelector('.path-picker-newfolder');
const form = overlay.querySelector('.path-picker-newfolder-form');
const input = overlay.querySelector('.path-picker-newfolder-name');
if (!enabled) return;
button.hidden = false;
const close = () => {
form.hidden = true;
input.value = '';
};
button.addEventListener('click', () => {
form.hidden = !form.hidden;
if (!form.hidden) input.focus();
});
overlay.querySelector('.path-picker-newfolder-cancel').addEventListener('click', close);
input.addEventListener('keydown', (event) => {
// Escape closes the form first; the document handler would close the whole picker.
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
close();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const name = input.value.trim();
if (!name || !this._currentPath) return;
const status = overlay.querySelector('.path-picker-status');
const create = overlay.querySelector('.path-picker-newfolder-create');
const parent = this._currentPath;
// One request at a time: a double tap would send a second one whose 409 shows after the first succeeded.
create.disabled = true;
try {
const res = await fetch('/api/filesystem/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: parent,
name,
sessionId: this._options?.sessionId || undefined,
showHidden: this._showHidden ? 'true' : undefined,
}),
});
const result = await res.json();
if (!result?.success) throw new Error(result?.error || 'Could not create the folder');
if (!this.overlay) return;
close();
await this.load(parent);
// The listing may have moved on while the request ran; only select it if it is still on screen.
if (this._currentPath === parent) this.select(result.data.path);
} catch (error) {
if (!this.overlay) return;
status.textContent = error.message || 'Could not create the folder';
status.classList.add('error');
} finally {
create.disabled = false;
}
});
},
_loadShowHidden() {
try {
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
@@ -636,16 +707,20 @@ function applyOneShotCtrl(data) {
return { data, consumed: true };
}
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
// an ACK, which would wedge the durable input queue. So the prompt budget is
// that limit minus the two markers, derived here once so the refusal in
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
// The composer's Send, and the shell Paste dialog's, goes out as ONE
// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a
// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB,
// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable
// input queue. So the budget is that limit minus the two markers, derived here
// once so the refusal in _sendBracketedPaste() and the toast that names the
// maximum cannot drift.
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~';
// Accessory keys that act on the terminal and leave the keyboard up.
const ACCESSORY_REFOCUS_ACTIONS = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
/**
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
*/
@@ -664,7 +739,7 @@ const KeyboardAccessoryBar = {
_composerDrafts: new Map(),
_composerUploads: new Map(),
_composerOverlay: null,
// Longest prompt Send accepts: the input frame limit minus both markers.
// Longest prompt or shell paste Send accepts: the input frame limit minus both markers.
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
@@ -801,6 +876,21 @@ const KeyboardAccessoryBar = {
this.syncCodexKeys();
this._syncComposerDraftIndicator();
// A key that keeps the keyboard up must not take focus on press at all.
// Letting the tap blur the terminal and refocusing it in the click handler
// (kept below as the fallback) made every such key start a keyboard
// dismissal: a blur/focus pair per tap. Same pattern as the Attach input
// (image-input.js). A first tap on clear/compact only arms the confirm.
this.element.addEventListener('mousedown', (e) => {
const btn = e.target.closest('.accessory-btn');
if (!btn) return;
const action = btn.dataset.action;
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction !== action)) {
e.preventDefault();
}
});
// Add click handlers — preventDefault stops event from reaching terminal
this.element.addEventListener('click', (e) => {
const btn = e.target.closest('.accessory-btn');
@@ -812,8 +902,7 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
if (refocusActions.has(action) ||
if (ACCESSORY_REFOCUS_ACTIONS.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
@@ -1205,23 +1294,39 @@ const KeyboardAccessoryBar = {
textarea.dispatchEvent(new Event('input', { bubbles: true }));
},
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
* its DECSET 2004 mirror after terminal replay, even though the CLI still
* expects bracketed input, so build the byte-identical sequence directly on
* the durable session-bound path. Enter stays a separate delayed write
* because Codex drops keys sharing a PTY read with a bracketed paste. */
/** Deliver one complete prompt as an explicit bracketed paste
* (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror
* after terminal replay, even though the CLI still expects bracketed input,
* so the frame is built directly rather than through terminal.paste().
* Enter stays a separate delayed write because Codex drops keys sharing a
* PTY read with a bracketed paste. */
_sendComposedPrompt(sessionId, text) {
if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false;
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
/** One bracketed-paste write on the durable session-bound path, the same
* bytes a terminal paste produces, so the receiving shell or CLI takes a
* multi-line block as one edit. Never presses Enter: callers decide.
* `label` names the payload in the too-long toast. */
_sendBracketedPaste(sessionId, text, label) {
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false;
app._predictiveEcho?.clearPredictions();
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted.
const pasteText = text.replace(/\r?\n/g, '\r');
// Paste markers inside the text are dropped first: a clipboard carrying
// `ESC[201~` would end the bracket early and run the rest without Enter.
// Strip until none is left: one pass over `ESC[20` + `ESC[201~` + `1~` leaves
// an `ESC[201~` behind.
let pasteText = text;
while (/\x1b\[20[01]~/.test(pasteText)) pasteText = pasteText.replace(/\x1b\[20[01]~/g, '');
pasteText = pasteText.replace(/\r?\n/g, '\r');
if (pasteText.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
}
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
@@ -1247,7 +1352,11 @@ const KeyboardAccessoryBar = {
const sessionId = app.activeSessionId;
const pending = this._takePendingLocalEcho(sessionId);
const saved = this._composerDrafts.get(sessionId) || '';
const initial = saved + pending;
// A parked draft and text typed (or attached) on the prompt since are two
// separate pieces of prose; one space keeps the draft's last word off the
// prompt's first, unless either side already supplies the gap.
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
const overlay = document.createElement('div');
@@ -1265,11 +1374,11 @@ const KeyboardAccessoryBar = {
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">🖼 Image</button>
<button type="button" class="paste-image">📎 Attach</button>
<button type="button" class="paste-cancel">Cancel</button>
<button type="button" class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1285,6 +1394,10 @@ const KeyboardAccessoryBar = {
const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send');
// Disabled alongside Send while an upload runs: this button deletes the draft
// and closes the overlay, and the upload would then land its paths in a FRESH
// draft, which the next open would prepend to the handed-back text.
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
const focusTrap = new FocusTrap(overlay);
textarea.value = initial;
if (initial) this._composerDrafts.set(sessionId, initial);
@@ -1292,6 +1405,7 @@ const KeyboardAccessoryBar = {
const initialUploads = this._composerUploads.get(sessionId) || 0;
imageButton.disabled = initialUploads > 0;
sendButton.disabled = initialUploads > 0;
terminalButton.disabled = initialUploads > 0;
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
const saveDraft = () => {
@@ -1317,8 +1431,9 @@ const KeyboardAccessoryBar = {
this._syncComposerDraftIndicator();
close({ preserveDraft: false });
};
const handleImages = async (files) => {
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
// Takes files already admitted by app._admitPromptAttachments (the picker and
// the paste below), which also toasts whatever it refused.
const handleImages = async (images) => {
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
saveDraft();
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
@@ -1332,9 +1447,11 @@ const KeyboardAccessoryBar = {
const currentSendButton = currentOverlay?.querySelector('.paste-send');
if (currentImageButton) {
currentImageButton.disabled = count > 0;
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
}
if (currentSendButton) currentSendButton.disabled = count > 0;
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
};
syncUploadUi();
try {
@@ -1357,7 +1474,6 @@ const KeyboardAccessoryBar = {
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
else this._composerUploads.delete(sessionId);
syncUploadUi();
if (overlay.isConnected) fileInput.value = '';
}
};
@@ -1365,10 +1481,11 @@ const KeyboardAccessoryBar = {
textarea.addEventListener('paste', (event) => {
const items = event.clipboardData?.items;
if (!items) return;
const images = Array.from(items)
.filter((item) => item.type.startsWith('image/'))
const files = Array.from(items)
.map((item) => item.getAsFile())
.filter(Boolean);
// Pasted text with no admitted file lands in the textarea natively: no toast.
const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') });
if (images.length > 0) {
event.preventDefault();
void handleImages(images);
@@ -1381,8 +1498,62 @@ const KeyboardAccessoryBar = {
}
});
imageButton.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files || []);
// Reset so picking the same refused file again still fires change (and its toast).
fileInput.value = '';
void handleImages(app._admitPromptAttachments(files));
});
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
// Hand the text BACK to the terminal prompt so both surfaces hold the same
// prompt, the mirror of the adoption composePrompt() does on open. Only a
// single-line draft can make the trip: the terminal prompt is one line and
// its text reaches the PTY as typed input, where an embedded newline is a
// key, so a multi-line draft would arrive mangled and instead stays parked
// as a draft (the dot on the Compose key shows it).
const text = textarea.value;
if (!text || text.includes('\n')) {
close({ focusTerminal: true });
return;
}
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
// as DOM lines on every repaint, so the transport's larger paste cap is not
// the bound that matters here.
if (text.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return;
}
// Cleared, never kept: the text now lives on the prompt, and a surviving
// draft would be prepended to it the next time the composer opens.
this._composerDrafts.delete(sessionId);
close({ focusTerminal: true, preserveDraft: false });
if (
app._localEchoEnabled &&
app._localEchoOverlay &&
app.activeSessionId === sessionId &&
!app._tilesOwnTerminal?.() &&
!app._echoPassthroughSessions?.has(sessionId)
) {
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
// Passthrough is excluded deliberately: such a session skips the overlay
// branch on Enter, so text parked there would never be submitted. The tile
// grid is excluded for the same reason _attachFromToolbar is: the main
// terminal is parked and hidden then, and a tile's Enter cannot flush its
// overlay, so the text goes to the PTY below instead of stranding there.
app._localEchoOverlay.appendText(text);
} else {
// Write-through echo (codex) or no local echo at all: there is no buffer
// to park text in, because the prompt the user sees IS the CLI's own
// composer on the PTY. Checking for the overlay alone left this branch
// doing nothing on codex, so the button looked dead there. That composer
// may already hold text the open adopted nothing of, so lead with a
// separator rather than fuse onto its last word (harmless when empty).
app._predictiveEcho?.clearPredictions();
app._sendInputAsync(sessionId, ` ${text}`);
}
this._syncComposerDraftIndicator();
});
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
sendButton.addEventListener('click', send);
overlay.addEventListener('click', (event) => {
@@ -1398,29 +1569,32 @@ const KeyboardAccessoryBar = {
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* - File (picker): the "Attach" button opens a native file picker
* (accept=image/*,video/* plus the document extensions → camera / photo
* library / files), the most reliable way to attach anything on mobile.
* - File (paste): if the browser exposes file blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
// The shell this overlay was opened for: a tab switch while it is open must
// not deliver the paste to whatever session is active at Send time.
const sessionId = app.activeSessionId;
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-image">📎 Attach</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
</div>
`;
@@ -1429,44 +1603,54 @@ const KeyboardAccessoryBar = {
const close = () => overlay.remove();
// Only shell sessions reach this overlay (agent bars get Compose instead),
// so the text lands at the prompt as one bracketed paste and is NOT run:
// sent raw, every newline was an Enter and each line ran on its own as it
// arrived. The user reviews the block and presses Enter themselves. A shell
// that never enabled bracketed paste (dash, bash before 5.1) gets the
// markers stripped by tmux, so there every line but the last still runs.
const sendText = () => {
const text = textarea.value;
close();
if (text) {
app.sendInput(text);
setTimeout(() => app.sendInput('\r'), 80);
if (!text) {
close();
return;
}
// A refused (too-long) paste keeps the dialog and its text for trimming.
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
};
// Filter to images, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
// Filter to what the server admits (app._admitPromptAttachments toasts what it
// refuses), close the dialog, and hand off to the shared upload+insert
// pipeline. Returns true if any file was handled.
const handleImages = (files, options) => {
const images = app._admitPromptAttachments(Array.from(files || []), options);
if (images.length === 0) return false;
close();
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
// Upload to, and insert into, the shell this overlay was opened for.
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId });
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
// File picker (camera / photo library / files) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
fileInput.addEventListener('change', () => {
const files = Array.from(fileInput.files || []);
// Reset so picking the same refused file again still fires change (and its toast).
fileInput.value = '';
handleImages(files);
});
// Best-effort: capture images pasted straight into the textarea.
textarea.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const imageFiles = [];
const files = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
handleImages(imageFiles);
const blob = items[i].getAsFile();
if (blob) files.push(blob);
}
// Pasted text with no admitted file lands in the textarea natively: no toast.
if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault();
});
overlay.querySelector('.paste-cancel').addEventListener('click', close);
+92 -16
View File
@@ -9,7 +9,7 @@
* toolbar/accessory bar repositioning, terminal resize on keyboard open/close,
* and input scroll-into-view. Uses 100px threshold for iOS address bar drift.
* - SwipeHandler — Horizontal swipe detection on the terminal area for session switching.
* 80px minimum distance, 300ms maximum time, 100px max vertical drift.
* 80px minimum distance, 500ms maximum time, 50px max vertical drift.
*
* All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE
* reconnect (in handleInit) to prevent stale closures.
@@ -344,6 +344,13 @@ const KeyboardHandler = {
// branches are skipped on a shape change, whichever way the height moved.
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
// Hold the PTY's rows for as long as the keyboard covers the terminal
// (keyboardHoldsRows, keyboardClipPx in constants.js): every height
// change makes Claude Code clear and reprint only the tail that fits.
// Captured before anything refits, so these are the keyboard-free rows.
if (typeof app !== 'undefined' && app.terminal && app._keyboardHoldEligible?.()) {
app._keyboardHeldRows = app.terminal.rows;
}
document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so
// xterm's bottom row and cursor sit above the OS keyboard.
@@ -355,6 +362,7 @@ const KeyboardHandler = {
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) {
this.keyboardVisible = false;
this._releaseHeldRows();
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
// Re-sync --app-height now that keyboard is gone (MobileDetection skipped
@@ -381,6 +389,12 @@ const KeyboardHandler = {
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
// Held rows belong to the display that is gone: release them and arm the
// settle, which refits and tells the PTY. Nothing else would: with the
// keyboard up throttledResize stands aside, and a rotation alone arms no
// settle, so xterm kept the held rows with no clip and the composer sat
// under the keyboard until it closed.
if (this._releaseHeldRows()) this._scheduleViewportSettle();
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
@@ -394,6 +408,11 @@ const KeyboardHandler = {
this.lastViewportHeight = currentHeight;
},
/** End a keyboard row hold (app._releaseKeyboardHold); true when one was held. */
_releaseHeldRows() {
return typeof app !== 'undefined' && !!app._releaseKeyboardHold?.();
},
/** Update layout when keyboard shows/hides */
updateLayoutForKeyboard() {
if (!window.visualViewport) return;
@@ -410,6 +429,12 @@ const KeyboardHandler = {
const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
// The mobile case picker is a third position:fixed bottom-anchored
// surface, and since it gained a search field the keyboard can open over
// it. iOS does not shrink the layout viewport, so an unlifted sheet sits
// BEHIND the keyboard with its own search box out of sight.
const caseSheet = document.querySelector('.mobile-case-picker.active .mobile-case-picker-sheet');
if (isSmallMedium) {
// Phones/small tablets: toolbar and accessory bar are position:fixed
// via CSS. Use translateY to lift them above the keyboard.
@@ -426,6 +451,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (caseSheet) {
caseSheet.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
main.style.paddingBottom = `${84 + cjkInputHeight}px`;
@@ -436,6 +464,9 @@ const KeyboardHandler = {
if (accessoryBar) {
accessoryBar.style.bottom = `${keyboardHeight}px`;
}
if (caseSheet) {
caseSheet.style.bottom = `${keyboardHeight}px`;
}
}
// CJK textarea positioning (always position:fixed on touch devices).
@@ -464,6 +495,10 @@ const KeyboardHandler = {
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
const cjkInput = document.getElementById('cjkInput');
const main = document.querySelector('.main');
// Not scoped to `.active`, unlike the lift above: a sheet closed while the
// keyboard was still up must still have its inline offset cleared, or the
// next open slides in already displaced.
const caseSheet = document.querySelector('.mobile-case-picker-sheet');
if (toolbar) {
toolbar.style.transform = '';
@@ -476,6 +511,10 @@ const KeyboardHandler = {
cjkInput.style.transform = '';
cjkInput.style.bottom = '';
}
if (caseSheet) {
caseSheet.style.transform = '';
caseSheet.style.bottom = '';
}
if (main) {
main.style.paddingBottom = '';
}
@@ -586,6 +625,9 @@ const KeyboardHandler = {
// geometry xterm will actually keep (issue #464).
app.syncTerminalGeometry?.();
if (this.keyboardVisible) this._shrinkPaddingToFit();
// Rows held: nothing re-renders on its own (the size did not change),
// so place the screen for the settled box here.
app._applyKeyboardClip?.();
// Following live output → bottom, as before. Reading history → back to
// the pre-reflow anchor instead of being yanked down (#259).
if (shouldRestoreScroll) {
@@ -738,14 +780,24 @@ const SwipeHandler = {
startY: 0,
startTime: 0,
minSwipeDistance: 80, // Minimum pixels for a valid swipe
maxSwipeTime: 300, // Maximum ms for a swipe gesture
maxVerticalDrift: 100, // Max vertical movement allowed
// 300ms rejected an unhurried but deliberate swipe outright, which read as an
// unresponsive gesture rather than as "too slow".
maxSwipeTime: 500, // Maximum ms for a swipe gesture
maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture
_touchStartHandler: null,
_touchMoveHandler: null,
_touchEndHandler: null,
_element: null,
_ignoreGesture: false,
/**
* Surfaces inside `.main` that own their own gestures or controls: a swipe
* there must not switch sessions. The phone overview and welcome screen are
* deliberately NOT listed: swiping from home into a session is a feature.
*/
IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar',
/** Initialize swipe handling */
init() {
// Only on touch devices
@@ -756,8 +808,10 @@ const SwipeHandler = {
this._element = terminal;
this._touchStartHandler = (e) => this.onTouchStart(e);
this._touchMoveHandler = (e) => this.onTouchMove(e);
this._touchEndHandler = (e) => this.onTouchEnd(e);
terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true });
terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true });
terminal.addEventListener('touchend', this._touchEndHandler, { passive: true });
},
@@ -765,9 +819,11 @@ const SwipeHandler = {
cleanup() {
if (this._element && this._touchStartHandler) {
this._element.removeEventListener('touchstart', this._touchStartHandler);
this._element.removeEventListener('touchmove', this._touchMoveHandler);
this._element.removeEventListener('touchend', this._touchEndHandler);
}
this._touchStartHandler = null;
this._touchMoveHandler = null;
this._touchEndHandler = null;
this._element = null;
},
@@ -777,20 +833,43 @@ const SwipeHandler = {
// here. Swiping across the open session drawer — the natural "dismiss it"
// gesture — would otherwise fire nextSession() and drop the user into a
// session they never tapped.
// The terminal's touch handler publishes the axis it locks for a gesture
// that starts on it; a touch that starts elsewhere in `.main` (the home
// screen) must not inherit the last one, or a terminal scroll a moment
// earlier vetoes a swipe from home. Reset before any early return.
if (typeof app !== 'undefined') app._terminalGestureAxis = null;
this._ignoreGesture = !!e.target?.closest?.('.session-sidebar');
if (this._ignoreGesture) return;
if (!e.touches || e.touches.length !== 1) return;
// A second finger makes this a different gesture (pinch, two-finger scroll):
// never measure a later lift against the first finger's start. Stays set
// until the next touchstart, so the first finger's lift cannot clear it.
if (!e.touches || e.touches.length !== 1) {
this._ignoreGesture = true;
return;
}
this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR);
this.startX = e.touches[0].clientX;
this.startY = e.touches[0].clientY;
this.startTime = Date.now();
},
onTouchMove(e) {
if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return;
if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true;
},
onTouchEnd(e) {
if (this._ignoreGesture) {
this._ignoreGesture = false;
return;
}
if (this._ignoreGesture) return;
if (!e.changedTouches || e.changedTouches.length !== 1) return;
if (typeof app === 'undefined') return;
// The terminal's own touch handler (terminal-ui.js) runs first, on the same
// gesture: a drag it locked as a scroll or a pan is not a session swipe,
// and neither is a lift it consumed (its long-press selection gesture
// preventDefaults the touchend, even on blank space where no selection
// survives the lift). A swipe must not extend or drop a touch selection.
if (e.defaultPrevented) return;
if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return;
if (app._touchSelecting || app._touchSelectionActive) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
@@ -804,13 +883,10 @@ const SwipeHandler = {
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
// Valid swipe detected
if (deltaX > 0) {
// Swipe right -> previous session
if (typeof app !== 'undefined') app.prevSession();
} else {
// Swipe left -> next session
if (typeof app !== 'undefined') app.nextSession();
}
// Valid swipe detected. Swipe right -> previous session, left -> next.
// In the order the strip draws, which a grouped layout ('case', 'state', a
// sorted rail) makes differ from the canonical session order.
if (deltaX > 0) app.prevSession({ visual: true });
else app.nextSession({ visual: true });
},
};
+297
View File
@@ -0,0 +1,297 @@
/**
* @fileoverview In-terminal preview of IME composition text on iOS Safari.
*
* On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
* Korean, dictation) is not visible inside the terminal until it commits, so
* the user types blind. The controller listens to the helper textarea's
* composition events and asks the caller to render the latest composition
* (`phase: 'provisional'`), coalesced to one render per animation frame and
* capped at 2048 characters. When xterm emits the committed text through
* onData, the caller hands it to `consumeTerminalData()`, which switches the
* preview to `phase: 'committed'` until something else shows the text: the
* local echo overlay or a prediction (`completeCommit`), authoritative
* terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
* same 2 s bound applies while waiting for a commit that never reaches onData
* (the user deleted the whole composition), so a later unrelated chunk is never
* mistaken for it.
*
* Keydown ordering: xterm registers its textarea keydown listener in the
* capture phase inside terminal.open() and finalizes the composition there
* (CompositionHelper.keydown), emitting the commit through onData
* synchronously. The controller therefore observes keydown in the capture
* phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
* before any listener on the textarea itself, and finalizes on exactly the
* keys xterm does.
*
* VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
* and every callback is wrapped so a failing render cannot block the wire.
* `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
* composition view untouched.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
*/
(function (global) {
'use strict';
const COMMITTED_VISUAL_TTL = 2000;
const PREVIEW_CAP = 2048;
// keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
// (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
// keydown during a composition finalizes it.
const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
function isIosWebKitTouch(nav = navigator) {
const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
const platform = String(nav && nav.platform ? nav.platform : '');
const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
}
function create(options) {
const textarea = options.textarea;
// Must be the textarea or an ancestor of it, so its capture listener runs
// before xterm's capture listener on the textarea.
const keydownTarget = options.keydownTarget || textarea;
const render = typeof options.render === 'function' ? options.render : function () {};
const clear = typeof options.clear === 'function' ? options.clear : function () {};
const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
const setTimer = options.setTimer || global.setTimeout.bind(global);
const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
let generation = 0;
let composing = false;
let awaitingCommit = false;
let committed = false;
let latestValue = '';
let renderPhase = null;
let frameToken = null;
let timerToken = null;
let finalizedByKeydown = false;
let destroyed = false;
let invokingClear = false;
function safely(callback, ...args) {
try {
return callback(...args);
} catch (_error) {
return undefined;
}
}
function cancelScheduledFrame() {
const token = frameToken;
frameToken = null;
if (token && token.id !== undefined) safely(cancelFrame, token.id);
}
function cancelCommittedTimer() {
const token = timerToken;
timerToken = null;
if (token && token.id !== undefined) safely(clearTimer, token.id);
}
function clearVisual() {
if (invokingClear) return;
invokingClear = true;
safely(clear);
invokingClear = false;
}
function cleanup() {
generation += 1;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = false;
latestValue = '';
renderPhase = null;
finalizedByKeydown = false;
clearVisual();
}
function scheduleLatestPreview(phase) {
if (destroyed) return;
renderPhase = phase;
if (frameToken) return;
const token = { generation, id: undefined };
frameToken = token;
const callback = function () {
if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
frameToken = null;
const value = latestValue.slice(0, PREVIEW_CAP);
const phaseToRender = renderPhase;
safely(render, { text: value, phase: phaseToRender });
};
const id = safely(scheduleFrame, callback);
if (frameToken === token) {
if (id === undefined) frameToken = null;
else token.id = id;
}
}
function beginComposition() {
cleanup();
if (destroyed) return;
composing = true;
}
function updateComposition(event) {
if (!composing) return;
latestValue = event.data == null ? '' : String(event.data);
scheduleLatestPreview('provisional');
}
function onComposingInput(event) {
if (!event.isComposing) return;
updateComposition({ data: event.data == null ? textarea.value : event.data });
}
function armFallbackTimer(isCurrent) {
const token = { generation, id: undefined };
timerToken = token;
const callback = function () {
if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
timerToken = null;
cleanup();
};
const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
if (timerToken === token) {
if (id === undefined) {
timerToken = null;
return false;
}
token.id = id;
}
return true;
}
function finalizeComposition(value, fromKeydown) {
if (!composing) return;
composing = false;
awaitingCommit = true;
committed = false;
finalizedByKeydown = fromKeydown;
latestValue = value == null ? latestValue : String(value);
scheduleLatestPreview('provisional');
// A commit that never reaches onData (the composition was deleted, so
// xterm emits nothing) must not leave the controller waiting forever.
cancelCommittedTimer();
const owner = generation;
armFallbackTimer(function () {
return awaitingCommit && generation === owner;
});
}
function onCompositionEnd(event) {
if (finalizedByKeydown) {
finalizedByKeydown = false;
return;
}
finalizeComposition(event.data, false);
}
// Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
// (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
// 20/229 and 16/17/18 keep the composition open and every other keyCode
// finalizes it. `isComposing` and `key` are deliberately not consulted,
// because xterm does not consult them.
function onKeydown(event) {
if (keydownTarget !== textarea && event.target !== textarea) return;
if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
finalizeComposition(latestValue, true);
}
function reset() {
if (destroyed) return;
cleanup();
}
function consumeTerminalData(data) {
if (
destroyed ||
!awaitingCommit ||
typeof data !== 'string' ||
data.length === 0 ||
CONTROL_OR_LINE_BREAK.test(data)
) {
return false;
}
generation += 1;
const owner = generation;
cancelScheduledFrame();
cancelCommittedTimer();
composing = false;
awaitingCommit = false;
committed = true;
latestValue = data;
renderPhase = 'committed';
safely(onCommit, data);
if (destroyed || generation !== owner || !committed) return true;
scheduleLatestPreview('committed');
if (destroyed || generation !== owner || !committed) return true;
const armed = armFallbackTimer(function () {
return committed;
});
if (!armed && !destroyed && generation === owner && committed) cleanup();
return true;
}
function completeCommit(result) {
if (destroyed || !result || result.predicted !== true || !committed) return;
cleanup();
}
function noteAuthoritativeOutput() {
if (destroyed || !committed) return;
cleanup();
}
const listeners = [
[textarea, 'compositionstart', beginComposition],
[textarea, 'compositionupdate', updateComposition],
[textarea, 'input', onComposingInput],
[textarea, 'compositionend', onCompositionEnd],
[keydownTarget, 'keydown', onKeydown, true],
[textarea, 'blur', reset],
];
for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
function destroy() {
if (destroyed) return;
destroyed = true;
for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
cleanup();
}
return {
consumeTerminalData,
completeCommit,
noteAuthoritativeOutput,
reset,
destroy,
get state() {
return {
generation,
composing,
awaitingCommit,
committed,
latest: latestValue,
framePending: frameToken !== null,
timerPending: timerToken !== null,
};
},
};
}
global.MobileImePreview = { create, isIosWebKitTouch };
})(globalThis);
+12 -2
View File
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
@@ -804,7 +805,7 @@ Object.assign(CodemanApp.prototype, {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-row-wrap';
wrap.appendChild(item);
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval, row));
return wrap;
}
@@ -959,7 +960,7 @@ Object.assign(CodemanApp.prototype, {
},
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
_buildMobileOverviewApprovalStrip(approval) {
_buildMobileOverviewApprovalStrip(approval, row) {
const strip = document.createElement('div');
strip.className = 'mobile-overview-approval-strip';
strip.setAttribute('data-i18n-skip', '');
@@ -974,6 +975,15 @@ Object.assign(CodemanApp.prototype, {
});
strip.appendChild(btn);
};
// Alert-only (DeepSeek Harness / supervised hooks): open the session instead
// of Approve/Deny that the answer route would refuse.
if (approval.answerInTerminal) {
addBtn('Answer in terminal', 'primary', () => {
if (typeof this.selectSession === 'function') this.selectSession(approval.sessionId);
else if (typeof this.openApprovalSession === 'function') this.openApprovalSession(approval.id);
});
return strip;
}
if (approval.options && approval.options.length) {
for (const o of approval.options) {
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
+262 -51
View File
@@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel {
/* No "open in new window" (detach) on phones/tablets — popped-out browser
windows aren't usable there. !important beats the hover/detached reveal
rules in styles.css */
.session-tab .tab-detach {
rules in styles.css. A native wrapper that opens windows of its own (side
by side on a foldable) keeps it at tablet widths: app.js sets
html.host-windows. Phone widths hide it again in the 599px block. */
html:not(.host-windows) .session-tab .tab-detach {
display: none !important;
}
}
@@ -332,6 +334,34 @@ html.mobile-init .file-browser-panel {
}
}
/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to
767px). The strip is the tablet block's one scrolling row and
updateTabOverflowMode() never wraps it here, so the boxes, which may shrink
below 768px, squeezed and wrapped their tabs inside themselves instead of
overflowing: the strip never scrolled, and every tab past a box's first
line was clipped under the fixed header. The boxes dissolve into the chip
row as on phones (the 599px block), which keeps the tablet's own 40px chip
geometry; a 44px box would hang below the 48px header. Ends at 767px, not
at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the
desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the
strip. With the boxes, their labels and their case-colour borders gone, the
`-<case>` part of a generated name (.tab-name-case, which styles.css hides
in the clustered strip) is the only cue left to which case a chip is in: the
w<n> counter is per case, so w1-alpha and w1-beta would both read "w1". */
@media (min-width: 600px) and (max-width: 767px) {
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: contents;
}
:where(.header) .tab-cluster-label {
display: none;
}
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
display: inline;
}
}
/* Edge fade for the phone's header tab strip (used in the block below).
Registered so the keyframes can interpolate them as lengths; @property is
only valid at the top level, hence out here. */
@@ -556,6 +586,8 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer,
/* Edit history: a desktop panel; phones never show the button (opt-in, default OFF anyway). */
.btn-icon-header.btn-edit-history,
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
@@ -584,7 +616,9 @@ html.mobile-init .file-browser-panel {
/* Mobile voice input button in toolbar — initially hidden via inline style,
shown by VoiceInput._showButtons() clearing the inline display */
.btn-voice-mobile {
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
.btn-voice-mobile,
.btn-attach-mobile {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -596,15 +630,48 @@ html.mobile-init .file-browser-panel {
color: #93c5fd;
cursor: pointer;
flex-shrink: 0;
order: 5; /* Between Run Shell and settings gear */
}
.btn-voice-mobile svg {
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
equal order source order keeps Attach on the left of the mic. */
.btn-voice-mobile,
.btn-attach-mobile-wrap {
order: 5;
}
/* Relative box for the invisible file input that sits over the button and TAKES
THE TAPS (see initImageInput): a native tap on the input is the only thing that
opens the picker while the keyboard is up, and iOS anchors the picker popover to
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
.btn-attach-mobile-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
overflow: hidden;
}
.btn-attach-mobile-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
}
.btn-voice-mobile svg,
.btn-attach-mobile svg {
width: 13px;
height: 13px;
}
.btn-voice-mobile:active {
.btn-voice-mobile:active,
.btn-attach-mobile:active,
/* The input above the button swallows the press, so the wrapper is what goes
:active. Without this the only phone button with no press feedback is Attach. */
.btn-attach-mobile-wrap:active .btn-attach-mobile {
background: #2a3a5f;
}
@@ -755,6 +822,31 @@ html.mobile-init .file-browser-panel {
font-weight: 500;
}
/* Grouped by state (tabArrangement 'state'), the phone strip keeps its one scrolling
row: the tabs still come in state order, most urgent first, but the
headings would cost chips and the dots already say which state is which.
The phone overview is where the labelled sections live. */
:where(.header) .tab-triage-head {
display: none;
}
/* Clustered by case, the same holds: the boxes dissolve into the one chip
row (in cluster order) and the labels go, since every chip still names
its session. It does so in full: the `-<case>` part of a generated name,
which styles.css hides in the clustered strip, shows again, or w1-alpha
and w1-beta (the w<n> counter is per case) would both read "w1". */
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
display: contents;
}
:where(.header) .tab-cluster-label {
display: none;
}
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
display: inline;
}
/* Only the active tab shows its action icons on a phone (see below), so on
every other tab the container is empty but still a flex item, and its gap
made the chip visibly wider on the right than on the left. */
@@ -852,6 +944,36 @@ html.mobile-init .file-browser-panel {
display: none;
}
/* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones),
so the pop-out icon stays off phone tabs even under a window-opening host,
which offers the pop-out from its own chrome (app.detachSession). */
.session-tab .tab-detach {
display: none !important;
}
/* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new).
Switched on it joins gear + close on the active tab only, and the label
reserve above grows to clear all three (test/mobile-tab-tap-zones). */
.session-tab .tab-new {
display: none;
}
html.tabs-show-new .session-tab.active .tab-new {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
width: 28px;
height: 32px;
padding: 0;
opacity: 0.8;
}
html.tabs-show-new .session-tab.active .tab-name {
min-width: 68px;
}
/* Gear icon on active tab - tiny, subtle */
.session-tab.active .tab-gear {
display: inline-flex;
@@ -1143,6 +1265,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(129, 140, 248, 0.5) !important;
}
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
.btn-toolbar.btn-run.mode-copilot,
.btn-toolbar.btn-run-gear.mode-copilot {
background: #2e1065 !important;
border-color: rgba(163, 113, 247, 0.3) !important;
color: #ede9fe !important;
}
.btn-toolbar.btn-run.mode-copilot:active,
.btn-toolbar.btn-run-gear.mode-copilot:active {
background: #6d28d9 !important;
border-color: rgba(163, 113, 247, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -1449,15 +1585,6 @@ html.mobile-init .file-browser-panel {
padding: 6px 14px;
}
/* Add case button - compact (keeping for when shown via menu) */
.btn-case-add {
display: none !important;
}
.btn-case-settings {
display: none !important;
}
/* When keyboard is visible, also move accessory bar up */
.keyboard-visible .keyboard-accessory-bar.visible {
/* Position is handled by JS transform along with toolbar */
@@ -1471,33 +1598,6 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Keep btn-case-add styling for tablet/future use */
.toolbar .btn-case-add {
min-width: 26px !important;
max-width: 26px !important;
width: 26px !important;
min-height: 26px !important;
max-height: 26px !important;
height: 26px !important;
padding: 0 !important;
font-size: 0.9rem;
font-weight: bold;
display: inline-flex !important;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
.btn-case-add:hover,
.btn-case-add:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Panels on mobile - full width, positioned above toolbar, visibility controlled by JS */
.monitor-panel,
.subagents-panel {
@@ -1722,17 +1822,24 @@ html.mobile-init .file-browser-panel {
}
.welcome-actions {
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.welcome-btn {
/* Only reached with the phone overview switched off. The primary spans the
column; the chips keep wrapping at the finger size styles.css gives touch
screens (40px, its pointer: coarse rule). This rule loads later at equal
specificity, so a lower value here made a phone's chips shorter than a
tablet's; restating 40px also covers a narrow window with a mouse. */
.welcome-primary {
width: 100%;
justify-content: center;
min-height: 44px;
padding: 0.75rem 1rem;
font-size: 0.85rem;
font-size: 0.9rem;
}
.welcome-chip {
height: 40px;
border-radius: 20px;
}
.history-show-more {
@@ -2216,6 +2323,19 @@ html.mobile-init .file-browser-panel {
border-right: none;
}
/* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh,
or a px size left by a drag in landscape), so a phone ignores both. */
.file-preview-window,
.file-preview-window.expanded {
width: 100vw !important;
height: var(--app-height, 100vh) !important;
}
#filePreviewExpandBtn,
.file-preview-resize {
display: none;
}
.file-preview-editbar {
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
}
@@ -2289,6 +2409,18 @@ html.mobile-init .file-browser-panel {
font-size: 1.5rem;
}
/* With the keyboard up the sheet is lifted above it (mobile-handlers.js), so
what is left to fit is much shorter than 80dvh of the layout viewport. Cap
the list rather than the sheet, so the search row and the Create button
stay on screen and only the rows scroll. */
.keyboard-visible .mobile-case-picker-sheet {
max-height: 45vh;
}
.keyboard-visible .mobile-case-picker-body {
max-height: 28vh;
}
.mobile-case-picker-footer {
padding-bottom: calc(12px + var(--safe-area-bottom));
}
@@ -3200,13 +3332,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
both stylesheets and repainted the armed modifier back to a resting button on
all four light skins. Excluding the state here fixes phone and tablet at once;
adding a class to the armed rules would only have moved the tie. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn:not(.armed)) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
background: var(--control-bg);
border-color: var(--control-border);
color: var(--text-dim);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
/* Attach's press feedback comes from its wrapper (the file input over the button
takes the press). Kept out of the :is() list above on purpose: a descendant
selector there would raise that list's specificity for every button in it. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
@@ -3248,6 +3389,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
border-color: #5b21b6;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
@@ -3305,7 +3452,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
@media (hover: none) and (pointer: coarse) {
/* Remove hover-dependent UI patterns */
.session-tab .tab-close,
.session-tab .tab-gear {
.session-tab .tab-gear,
.session-tab .tab-new {
opacity: 1;
width: auto;
padding: 0.15rem 0.25rem;
@@ -3327,6 +3475,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.subagent-dropdown-trigger:focus + .subagent-dropdown-menu {
display: block;
}
/* Press feedback for the touch bars. The pressed look is transform + filter,
never a background or a shadow: the skin block (html:not([data-skin="og"]),
specificity 0,2,1) and the per-run-mode colors own the background and its
hover rules own box-shadow, so a background or shadow :active lost to them
on every non-og skin. Nothing on these buttons sets transform or filter
(the base .btn-toolbar:active nudge sits at lower specificity), so one rule
reads the same on all of them. 80ms so a quick tap shows the press at all,
on the press's own properties rather than `all`. */
.toolbar .btn-toolbar,
.btn-voice-mobile,
.btn-attach-mobile,
.btn-settings-mobile,
.accessory-btn {
-webkit-tap-highlight-color: transparent;
transition: transform 80ms ease, filter 80ms ease, background-color 80ms ease, border-color 80ms ease;
}
.toolbar .btn-toolbar:active,
.btn-voice-mobile:active,
.btn-attach-mobile-wrap:active .btn-attach-mobile,
.btn-settings-mobile:active,
.accessory-btn:active {
transform: scale(0.94);
filter: brightness(1.25);
}
/* Light skins darken on press instead of brightening (white on white reads as nothing). */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.toolbar .btn-toolbar:active, .btn-voice-mobile:active, .btn-attach-mobile-wrap:active .btn-attach-mobile, .btn-settings-mobile:active, .accessory-btn:active) {
filter: brightness(0.9);
}
}
@media (hover: none) and (pointer: coarse) and (prefers-reduced-motion: reduce) {
.toolbar .btn-toolbar:active,
.btn-voice-mobile:active,
.btn-attach-mobile-wrap:active .btn-attach-mobile,
.btn-settings-mobile:active,
.accessory-btn:active {
transform: none;
}
}
@media (max-width: 599px) {
@@ -3507,7 +3696,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
}
/* Save + Close are the two ways out of the sheet (save-and-close vs
discard-and-close), hit in the same corner with the same thumb, so here —
discard-and-close; Apply saves but keeps the sheet open), hit in the same corner with the same thumb, so here —
and only here, since Save is header-only below 860px — they share a
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
@@ -4005,6 +4194,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab {
Matches the (hover: none) block above, but has to be repeated here because
the phone block hides them on non-active tabs with (0,2,0). */
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
display: inline-flex;
align-items: center;
@@ -4050,3 +4240,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
touch-scrollable grid on phones. */
@media (max-width: 700px) {
.spreadsheet-sheet-tabs {
padding-inline: 4px;
scroll-snap-type: x proximity;
}
.spreadsheet-sheet-tab {
min-width: 96px;
min-height: 40px;
scroll-snap-align: start;
}
.spreadsheet-grid {
min-height: 55vh;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
}
+19 -2
View File
@@ -4,7 +4,7 @@
* The NotificationManager class implements five notification layers:
* 1. In-app notification drawer (slide-out panel with grouped notifications)
* 2. Tab title flash (alternating "⚠️ (N) codeman:<host>" / "codeman:<host>" when tab is hidden; uses this.originalTitle so it tracks any per-host title)
* 3. Browser Notification API (desktop push with auto-close after 8s)
* 3. Browser Notification API (desktop push; auto-closes after 8s by default, configurable per device in Settings → Notifications)
* 4. Web Push via service worker (OS-level notifications when tab is closed)
* 5. Audio alerts (Web Audio API beep, user-opt-in)
*
@@ -93,6 +93,10 @@ class NotificationManager {
browserNotifications: !isMobile,
audioAlerts: false,
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
// How long a corner toast stays on screen, and how long a browser notification
// stays up before Codeman closes it (ms; per-device like the rest of these)
toastDurationMs: DEFAULT_TOAST_DURATION_MS,
browserAutoCloseMs: AUTO_CLOSE_NOTIFICATION_MS,
// Legacy urgency muting (keep for backwards compat)
muteCritical: false,
muteWarning: false,
@@ -167,11 +171,24 @@ class NotificationManager {
return {
...defaults,
...prefs,
toastDurationMs: this.clampDuration(prefs.toastDurationMs, defaults.toastDurationMs),
browserAutoCloseMs: this.clampDuration(prefs.browserAutoCloseMs, defaults.browserAutoCloseMs),
eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
_version: 5,
};
}
/** A display time in ms kept within [1s, 5min]; anything unusable falls back to the default. */
clampDuration(value, fallback) {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(MAX_NOTIFICATION_DURATION_MS, Math.max(MIN_NOTIFICATION_DURATION_MS, Math.round(value)));
}
/** Display time for corner toasts that do not set their own `duration`. */
getToastDurationMs() {
return this.clampDuration(this.preferences?.toastDurationMs, DEFAULT_TOAST_DURATION_MS);
}
loadPreferences() {
try {
const storageKey = this.getStorageKey();
@@ -403,7 +420,7 @@ class NotificationManager {
};
// Auto-close
setTimeout(() => notif.close(), AUTO_CLOSE_NOTIFICATION_MS);
setTimeout(() => notif.close(), this.clampDuration(this.preferences.browserAutoCloseMs, AUTO_CLOSE_NOTIFICATION_MS));
}
async requestPermission() {
File diff suppressed because it is too large. Load diff
+2
View File
@@ -1038,6 +1038,7 @@ Object.assign(CodemanApp.prototype, {
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const advisorModel = this.getAdvisorSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1050,6 +1051,7 @@ Object.assign(CodemanApp.prototype, {
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
}),
});
const data = await res.json();
+12 -2
View File
@@ -597,8 +597,18 @@ Object.assign(CodemanApp.prototype, {
// Update local cache + legacy localStorage
this._serverRespawnPresets = custom;
localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom));
// Persist to server (cross-device sync)
this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {});
// Persist to server (cross-device sync). Not for a non-admin in multi-user mode: the
// presets are a key of the server-wide settings.json there, and its PUT is admin-only
// (403), so they are kept as their own on this device and win over the server's on the
// next load (_rememberOwnSetting, settings-ui.js). A PUT refused that way (sent before the
// role was known) marks the page read-only for the writes after it; any other failure is toasted.
if (this._settingsReadOnly?.()) {
this._rememberOwnSetting?.('respawnPresets', custom);
return;
}
this._apiPut('/api/settings', { respawnPresets: custom })
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
.catch(() => {});
},
renderPresetDropdown() {
+4 -2
View File
@@ -84,7 +84,10 @@
/**
* Attributes allowed on the tags above. `style` is intentionally absent (CSS-based vectors).
* `class`/`id` survive because the response viewer adds wrapper classes downstream and code
* blocks may carry `language-*` classes from marked.
* blocks may carry `language-*` classes from marked. `name` is absent on purpose: marked never
* emits it, and `<img name="app">` would make `document.app` that image, which every inline
* `onclick="app.…()"` handler resolves before the global (DOM clobbering), so one rendered
* README could break every button until a reload.
*/
var ALLOWED_ATTR = [
'href',
@@ -93,7 +96,6 @@
'title',
'class',
'id',
'name',
'colspan',
'rowspan',
'align',
+1
View File
@@ -0,0 +1 @@
{ "notices": [] }
+239 -80
View File
@@ -1,29 +1,41 @@
/**
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
*
* A session that starts another session (the `codeman` agent skill spawning a worker,
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
* state server-side. This module turns that field into the same kind of glowing
* connection line the subagent windows use, but tab → tab, so the strip shows at a
* glance which tab spawned which.
* state server-side. This module turns that field into a quiet orthogonal tree, one per
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
*
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
* active tab spawned, and the family it belongs to as a child, get the
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
* it). Drawing ONLY the selected family was tried first and rejected by the owner
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
* lineage exists (`_lineageTotalEdges`).
*
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
*
* Two constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
* Constraints that are not obvious from the code:
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
* strip is also a scroller where both endpoints are rarely on screen at once.
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
* family exists, never on the selection, so switching tabs never resizes the header
* (and with it the terminal and the PTY).
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
* `_applyLineEntrances()` queries, so the draw-in animation and its
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
*/
@@ -62,49 +74,99 @@ Object.assign(CodemanApp.prototype, {
applyLineageLineSettings() {
const prev = this._lineageLinesOn;
const next = this._syncLineageLinesEnabled();
if (prev !== next) this.updateConnectionLines();
if (prev !== next) {
// The reserved routing room follows the setting; updateTabOverflowMode()
// re-syncs it and re-measures the wrap with the new padding.
this.updateTabOverflowMode?.();
this.updateConnectionLines();
}
},
/**
* Every parent → child pair worth drawing, with the child's index among its siblings
* (that index is what nests sibling arcs instead of overprinting them).
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
* channel on the left of a wrapped strip (styles.css). Called at the top of
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
* measured.
*
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
* strip's own left-to-right order, which is what the user sees.
* Keyed on whether any family exists at all, never on which one is selected: a
* class that followed the selection would grow and shrink the header on every tab
* switch, and the header's height is the terminal's height.
*
* Only the header strip routes through reserved gaps. The vertical rail keeps its
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
*/
_syncLineageGutter() {
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
this._lineageTotalEdges = edges.length;
const strip = document.getElementById('sessionTabs');
if (!strip) return;
const want = edges.length > 0 && !this._isVerticalTabList?.();
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
},
/**
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
* sessions Map so sibling order follows the strip's own left-to-right order, which
* is what the user sees.
*/
_collectLineageEdges() {
const edges = [];
if (!this.sessions || this.sessions.size < 2) return edges;
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
const seenPerParent = new Map();
for (const id of order) {
const session = this.sessions.get(id);
const parentId = session && session.parentSessionId;
// A parent that is gone (closed, or never came back after a restart) draws
// nothing: the field is decoration, so a dangling one is simply not rendered.
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
const depth = seenPerParent.get(parentId) || 0;
seenPerParent.set(parentId, depth + 1);
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
edges.push({ parentId, childId: id, status: session.status || 'idle' });
}
return edges;
},
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
_lineageFamilies(edges) {
const families = new Map();
for (const edge of edges) {
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
families.get(edge.parentId).push(edge);
}
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
},
/**
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
* Parent ids of the families the selection emphasizes: the family the selected tab
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
* when a web tab holds the stage, or the selected tab has no lineage at all.
*/
_lineageFocusParents(edges) {
const parents = new Set();
const focus = this.activeWebviewId ? null : this.activeSessionId;
if (!focus) return parents;
for (const edge of edges) {
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
}
return parents;
},
/**
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
*
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
* matter how many workers it spawns, so the strip reads as "these five came from
* w1, those two came from w2". Keying it per child instead gave one tab's own
* children a different colour each, which is the thing the colours exist to tell
* apart. A child that goes on to spawn its own workers is a parent in its turn and
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
* gets its own colour for the lines BELOW it, so a chain changes colour at each
* generation while each generation's fan-out stays uniform.
*
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
* flicker). An empty string means "no override": the CSS falls back to
* flicker). The draw pass claims a colour for EVERY family in strip order before it
* draws anything, so neither the draw order (the selected family goes last) nor
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
* --session-blue, so the first spawning tab keeps the skin-aware blue.
*/
_lineageColorFor(parentId) {
@@ -140,20 +202,20 @@ Object.assign(CodemanApp.prototype, {
_appendLineageConnectionLines(svg, rects) {
this._lineageEdgeCount = 0;
if (!svg || !this._lineageLinesEnabled()) return;
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
// tab row. Against a vertical list the "strip bottom" is the bottom of the
// sidebar, so every arc would draw a giant loop to the foot of the list.
// Parent/child adjacency reads fine in a vertical list without arcs; a
// sideways lineage shape is a follow-up with its own visual tuning, not a
// by-product of a layout port.
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
// rail. The sidebar is a vertical list with its own scroller and no reserved
// channel; parent/child adjacency reads fine there without lines.
if (this.isSessionSidebarActive?.()) return;
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
if (!compute) return;
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
if (!computeTree) return;
const edges = this._collectLineageEdges();
this._lineageTotalEdges = edges.length;
if (edges.length === 0) return;
this._lineageEdgeCount = edges.length;
// Claim colours in strip order for every family before drawing (_lineageColorFor).
for (const edge of edges) this._lineageColorFor(edge.parentId);
const families = this._lineageFamilies(edges);
const focusParents = this._lineageFocusParents(edges);
if (!rects) rects = new Map();
// PHASE 1 — reads.
@@ -162,66 +224,150 @@ Object.assign(CodemanApp.prototype, {
const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id;
if (rects.has(key)) continue;
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
rects.set(key, tab ? tab.getBoundingClientRect() : null);
// A session hidden inside a collapsed group of the grouped rail has no row
// to anchor to, so its end of the line moves to that group's header (a
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
// its parent would be a line from a row to itself: skipped.
const resolveEndpoint = (id) => {
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
const groupId = this._hiddenTabGroupByRef?.get('session:' + id);
if (!groupId) return { key: 'tab:' + id, element: null, proxied: false };
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
return { key: 'group:' + groupId, element: header, proxied: !!header };
};
const measure = (endpoint) => {
if (!rects.has(endpoint.key)) {
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
}
return rects.get(endpoint.key);
};
const resolvedFamilies = [];
for (const family of families) {
const parentEndpoint = resolveEndpoint(family.parentId);
const parentRect = measure(parentEndpoint);
if (!parentRect) continue;
const children = [];
for (const edge of family.edges) {
const childEndpoint = resolveEndpoint(edge.childId);
if (childEndpoint.key === parentEndpoint.key) continue;
const rect = measure(childEndpoint);
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
}
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
}
if (resolvedFamilies.length === 0) return;
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
// gap under its tallest tab), web tabs included. The rail needs none.
const tabRects = [];
let spineLeft;
if (orientation === 'horizontal') {
// Where the spine channel is: the reserved --lineage-spine-channel just left
// of the strip's content edge. Usually that is the strip's own left edge,
// but grouped by state the label column comes first (styles.css), and a
// spine at the edge ran through every label. Read back from the padding
// the CSS laid out, so geometry and stylesheet cannot disagree.
const style = typeof getComputedStyle === 'function' ? getComputedStyle(strip) : null;
const channel = style ? parseFloat(style.getPropertyValue('--lineage-spine-channel')) : NaN;
if (Number.isFinite(channel)) {
const inset = (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0);
spineLeft = stripRect.left + inset - channel;
}
for (const tab of strip.querySelectorAll('.session-tab')) {
const id = tab.getAttribute('data-id');
const key = id ? 'tab:' + id : null;
if (key && rects.has(key)) tabRects.push(rects.get(key));
else {
const rect = tab.getBoundingClientRect();
if (key) rects.set(key, rect);
tabRects.push(rect);
}
}
}
// PHASE 2 — writes, from the cache only.
for (const edge of edges) {
const parentRect = rects.get('tab:' + edge.parentId);
const childRect = rects.get('tab:' + edge.childId);
if (!parentRect || !childRect) continue;
const geom = compute({
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
// only the DRAW order changes (the emphasized families last, on top). A row gap
// fits a few lanes, so they cycle: families that share one are told apart by colour.
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
const drawOrder = resolvedFamilies
.map((resolved, index) => ({
...resolved,
lane: index % laneCount,
focus: focusParents.has(resolved.family.parentId),
}))
.sort((a, b) => a.focus - b.focus);
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
const geom = computeTree({
parent: parentRect,
child: childRect,
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
strip: stripRect,
depth: edge.depth,
tabs: tabRects,
spineLeft,
orientation,
lane,
laneCount,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', geom.d);
// The working class marches the dashes, so an active worker is visible along
// the line itself. `status` is the CHILD's, which is the interesting end.
const working = edge.status === 'working' ? ' lineage-line--working' : '';
line.setAttribute('class', 'connection-line lineage-line' + working);
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
// Every arc out of one tab shares it — see _lineageColorFor().
const color = this._lineageColorFor(edge.parentId);
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
line.setAttribute('data-parent-tab', edge.parentId);
line.setAttribute('data-child-tab', edge.childId);
svg.appendChild(line);
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
// markers need a <defs> block and fight the dash pattern.
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(geom.endX));
dot.setAttribute('cy', String(geom.endY));
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
// works, so the two have to be changed together.
dot.setAttribute('r', '3.5');
dot.setAttribute('class', 'lineage-line-dot' + working);
dot.setAttribute('data-child-tab', edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
svg.appendChild(dot);
if (!geom || geom.routes.length === 0) continue;
const byId = new Map(children.map((c) => [c.edge.childId, c]));
const color = this._lineageColorFor(family.parentId);
// One group per family: it carries the translucency, so the stretches its
// routes share (the trunk) do not stack into a brighter line than the branches,
// and the emphasis for the selected tab's families (styles.css).
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
group.setAttribute('data-parent-tab', family.parentId);
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
// shared trunk and only the working child's own branch shows its dashes.
const routes = geom.routes
.map((route) => ({ route, child: byId.get(route.id) }))
.filter((r) => r.child)
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
for (const { route, child } of routes) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', route.d);
// `status` is the CHILD's, which is the interesting end: a working child's
// route is dashed (and marches, motion permitting).
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
line.setAttribute(
'class',
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
);
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
// owning weight and dash; an empty colour leaves the --session-blue fallback.
if (color) line.style.setProperty('--lineage-color', color);
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
line.setAttribute('data-parent-tab', family.parentId);
line.setAttribute('data-child-tab', child.edge.childId);
group.appendChild(line);
}
// Direction marker at each CHILD end, after every path so no stroke covers it.
for (const { route, child } of routes) {
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
dot.setAttribute('cx', String(route.endX));
dot.setAttribute('cy', String(route.endY));
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
dot.setAttribute('r', '2.5');
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
dot.setAttribute('data-child-tab', child.edge.childId);
if (color) dot.style.setProperty('--lineage-color', color);
group.appendChild(dot);
}
svg.appendChild(group);
this._lineageEdgeCount += routes.length;
}
},
/**
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
* scroll moves both endpoints without firing any render, so the arcs would slide off
* their tabs. Passive listener, and the redraw is the normal coalesced one.
* scroll moves both endpoints without firing any render, so the lines would slide
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
*
* Installed once; the guard also keeps a re-init from stacking listeners.
*/
@@ -232,11 +378,24 @@ Object.assign(CodemanApp.prototype, {
this._lineageScrollHandler = () => {
// Sidebar layout and the vertical rail scroll the SAME element
// vertically, and there the subagent/ultracode connectors anchor to tab
// rects too (the sidebar skips lineage arcs entirely, and the rail can
// show connectors with zero lineage edges, so _lineageEdgeCount alone
// would never redraw them).
// rects too (the sidebar skips lineage entirely, and the rail can show
// connectors with zero lineage edges, so _lineageEdgeCount alone would
// never redraw them).
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
};
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
// and close icons by transitioning their padding (styles.css), so it keeps
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
// which left a family's trunk hanging under the tab's OLD position. Redraw once
// a size transition inside the strip ends (coalesced, like every other redraw).
this._lineageTransitionHandler = (event) => {
const prop = event.propertyName || '';
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
};
strip.addEventListener('transitionend', this._lineageTransitionHandler);
},
});
+1569 -169
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,301 @@
/**
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
*
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
* given a STORE-only archive rebuilt from the entries admission inflated, never
* the fetched bytes, so it can only parse what admission counted. The page never
* loads either vendor file. Cell values are sent back as plain strings; the
* renderer writes them with `textContent`. Formulas are never evaluated (the
* cached result is shown, else the formula text), and nothing here fetches:
* external links, images and drawings are reported as unsupported features.
*
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
* keeps a reverse-proxy `--base-url` mount working.
*/
'use strict';
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
// Per sheet: its populated rows in order, each with its populated cells in
// column order, built once at load from the keys that exist (`populatedRowIndex`).
let populatedRowsById = new Map();
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
// which is far too much to pay on every tile.
let mergesById = new Map();
let normalizedStyles = [];
let styleIds = new Map();
let themePalette = core.DEFAULT_THEME_PALETTE;
function postError(error) {
self.postMessage({
type: 'error',
code: error?.code || 'parse-failed',
message: error?.message || 'Spreadsheet preview failed',
});
}
// Maximum cells in one tile reply; the renderer draws at most this many too.
const MAX_TILE_CELLS = 2500;
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
// entry is the fallback and the default Office palette is the last resort.
function readThemeXml(loadedWorkbook, admittedEntries) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
const theme = admittedEntries?.['xl/theme/theme1.xml'];
return theme ? new TextDecoder().decode(theme) : '';
}
function normalizeStyle(cell) {
// Colours are resolved and contrast-checked as a PAIR. Emitting a
// font colour without its background lets workbook text land on the skin's
// `var(--bg-primary)` and disappear.
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
const style = {
font: {
bold: Boolean(cell.font?.bold),
italic: Boolean(cell.font?.italic),
color: colors.foreground,
},
fill: colors.background,
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
wrapText: Boolean(cell.alignment?.wrapText),
};
const key = JSON.stringify(style);
if (styleIds.has(key)) return styleIds.get(key);
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
}
const id = normalizedStyles.length;
normalizedStyles.push(style);
styleIds.set(key, id);
return id;
}
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
function presentIndices(sparse) {
const indices = [];
for (const key of Object.keys(sparse || [])) {
const index = Number(key);
if (Number.isInteger(index) && index >= 0) indices.push(index);
}
return indices.sort((a, b) => a - b);
}
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
// cell counts when it exists and its type is not `ValueType.Null`, and a row
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
function populatedRowIndex(sheet) {
const nullType = self.ExcelJS.ValueType.Null;
const rows = [];
for (const rowIndex of presentIndices(sheet._rows)) {
const row = sheet._rows[rowIndex];
if (!row) continue;
const cells = [];
for (const cellIndex of presentIndices(row._cells)) {
const cell = row._cells[cellIndex];
if (cell && cell.type !== nullType) cells.push(cell);
}
if (cells.length > 0) rows.push({ number: row.number, row, cells });
}
return rows;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const rowOverrides = [];
const populatedRows = populatedRowIndex(sheet);
for (const { number, row, cells } of populatedRows) {
for (const cell of cells) {
cellRefs.push(cell.address);
normalizeStyle(cell);
}
if (row.hidden) rowOverrides.push([number, 0]);
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
}
// `sheet.model` rebuilds every row and cell model, so merges come straight
// from ExcelJS's own merge map, in the order the model getter would list them.
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
const extent = core.deriveExtent(cellRefs, merges);
const columnOverrides = [];
for (let col = 1; col <= extent.cols; col += 1) {
const column = sheet.getColumn(col);
if (column.hidden) columnOverrides.push([col, 0]);
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
}
return {
populatedRows,
merges,
metadata: {
id: String(sheet.id),
name: sheet.name,
rows: extent.rows,
cols: extent.cols,
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
rowOverrides,
columnOverrides,
merges,
},
};
}
function cellDisplay(cell, date1904, warnings) {
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
if (formatted.warning) warnings.add(formatted.warning);
return formatted.text;
}
async function loadWorkbook(bytes) {
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
const admitted = core.buildAdmittedArchive(admission, self.fflate);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
// ExcelJS's DefinedNames model setter expands every range into one object per
// cell (a whole-sheet name exhausts the heap), and admission reads only the
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
// stored at all; print areas and titles are split off before this setter runs.
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
// than silently expanding again.
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
// ExcelJS follows a worksheet's comment, VML, table and drawing links
// unchecked, and openpyxl spells them so the lookup finds nothing: one comment,
// table or chart failed the whole preview. `load()` calls `this.reconcile()`
// once on the parsed model; repair the model first (core.repairExcelJsModel).
// Throws if a future ExcelJS renames reconcile, rather than failing again.
const reconcile = nextWorkbook.xlsx.reconcile;
if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable');
nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) {
core.repairExcelJsModel(model);
return reconcile.call(this, model, options);
};
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
// backstop behind admission's row count, which also caps the workbook total.
await nextWorkbook.xlsx.load(admitted, {
ignoreNodes: ['dataValidations'],
maxRows: core.LIMITS.maxRowsPerSheet,
});
const nextSheets = new Map();
const nextRows = new Map();
const nextMerges = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
const sheets = [];
for (const sheet of nextWorkbook.worksheets) {
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
const sheetResult = worksheetMetadata(sheet);
const metadata = sheetResult.metadata;
nextSheets.set(metadata.id, sheet);
nextRows.set(metadata.id, sheetResult.populatedRows);
nextMerges.set(metadata.id, sheetResult.merges);
sheets.push(metadata);
}
workbook = nextWorkbook;
sheetsById = nextSheets;
populatedRowsById = nextRows;
mergesById = nextMerges;
self.postMessage({
type: 'metadata',
sheets,
styles: normalizedStyles,
date1904: Boolean(workbook.properties?.date1904),
empty: sheets.length === 0,
warnings: admission.features,
});
}
function sendTile(message) {
if (!workbook) throw new Error('Workbook is not loaded');
const sheet = sheetsById.get(String(message.sheetId));
if (!sheet) throw new Error('Worksheet is unavailable');
const range = message.range;
const warnings = new Set();
const cells = [];
const seenCells = new Set();
let truncated = false;
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
// (a filtered sheet); they are never drawn, so never sent.
const hiddenColumns = new Map();
const columnHidden = (col) => {
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
return hiddenColumns.get(col);
};
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
if (cells.length >= MAX_TILE_CELLS) {
truncated = true;
return;
}
seenCells.add(key);
cells.push({
row: cell.row,
col: cell.col,
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
styleId: normalizeStyle(cell),
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const populated of populatedRows) {
if (truncated) break;
if (populated.number < range.r1) continue;
if (populated.number > range.r2) break;
if (populated.row.hidden) continue;
for (const cell of populated.cells) {
if (cell.col < range.c1) continue;
if (cell.col > range.c2) break;
addCell(cell);
}
}
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
for (const merge of merges) {
const anchor = core.parseRange(merge);
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
}
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
self.postMessage({
type: 'tile',
requestId: message.requestId,
sheetId: String(message.sheetId),
cells,
merges,
// One counted entry for many unsupported number formats keeps the notice bar short.
warnings: core.foldWarnings(Array.from(warnings)),
});
}
self.onmessage = async (event) => {
try {
const message = event.data || {};
if (message.type === 'load') await loadWorkbook(message.bytes);
else if (message.type === 'tile') sendTile(message);
else if (message.type === 'dispose') {
workbook = null;
sheetsById = new Map();
populatedRowsById = new Map();
mergesById = new Map();
themePalette = core.DEFAULT_THEME_PALETTE;
}
} catch (error) {
postError(error);
}
};
self.postMessage({ type: 'ready' });
+555
View File
@@ -0,0 +1,555 @@
/**
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
*
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
* the visible tile of cells. Parsing happens entirely in the browser worker; the
* server just streams the file through its existing confined raw routes.
*
* Every workbook string (cell text, sheet names) is written with `textContent`,
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
* or closed.
*
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
*/
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = 'fd81c01bef7b';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
const ROW_HEADING_WIDTH = 36;
const COLUMN_HEADING_HEIGHT = 20;
const assets = Object.freeze({
version: SPREADSHEET_ASSET_VERSION,
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
});
// What a refusal from the worker says on screen. The core's own messages
// (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to
// one sentence with a zh-CN entry in i18n.js, and the raw message goes to the
// console. Any other code (parse-failed carries ExcelJS's own exception text)
// shows the generic failure.
const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.';
const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.';
const WORKER_ERROR_TEXT = Object.freeze({
encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.',
zip64: 'This workbook uses ZIP64, which the preview does not support.',
malformed: UNREADABLE,
'number-format': UNREADABLE,
'entry-limit': TOO_LARGE_OR_COMPLEX,
'entry-size': TOO_LARGE_OR_COMPLEX,
'inflated-size': TOO_LARGE_OR_COMPLEX,
'compression-ratio': TOO_LARGE_OR_COMPLEX,
'worksheet-limit': TOO_LARGE_OR_COMPLEX,
'element-limit': TOO_LARGE_OR_COMPLEX,
'row-limit': TOO_LARGE_OR_COMPLEX,
'cell-limit': TOO_LARGE_OR_COMPLEX,
'merge-limit': TOO_LARGE_OR_COMPLEX,
'style-limit': TOO_LARGE_OR_COMPLEX,
});
// The workbook features the preview leaves out, as the core names them
// (featureForName), in words for the notice bar.
const FEATURE_LABELS = Object.freeze({
charts: 'charts',
drawings: 'drawings',
pivotTables: 'pivot tables',
externalLinks: 'external links',
macros: 'macros',
});
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
function translate(text) {
return global.codemanT?.(text) || text;
}
function own(map, key) {
return Object.prototype.hasOwnProperty.call(map, key);
}
function workerErrorText(payload) {
const code = String(payload?.code || '');
if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`);
return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed';
}
// One notice bar item, translated on its own (the bar is one text node, which
// the i18n layer could only match whole; the bar itself carries
// data-i18n-skip). A number format's code is workbook text: it is appended
// as is, never passed through the translator, which would read a `{…}` in it
// as a placeholder. A feature word goes through its scoped
// 'Spreadsheet feature: <word>' key and reads as the plain word when that key
// has no translation: a bare 'charts' key would also rename a charts/ folder.
function warningText(warning) {
const text = String(warning);
if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) {
return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`;
}
if (own(FEATURE_LABELS, text)) {
const label = FEATURE_LABELS[text];
const key = `Spreadsheet feature: ${label}`;
const translated = translate(key);
return translated !== key ? translated : label;
}
return translate(text);
}
function message(container, text, kind) {
container.textContent = '';
const state = document.createElement('div');
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
state.textContent = text;
container.appendChild(state);
}
function safePreviewUrl(candidate) {
const url = new URL(candidate, global.location.href);
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
url.searchParams.set('preview', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
function open(options) {
const container = options.container;
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
let disposed = false;
let worker = null;
let controller = null;
let timer = null;
let metadata = null;
let activeSheetId = null;
let latestRequestId = 0;
let grid = null;
let spacer = null;
let cellsLayer = null;
let headingsLayer = null;
let emptySheetState = null;
let resizeObserver = null;
let latestRange = null;
let latestAxes = null;
let scrollFrame = null;
const current = () => !disposed && isCurrent();
const clearTimer = () => {
if (timer !== null) global.clearTimeout(timer);
timer = null;
};
const fail = (text) => {
if (!current()) return;
clearTimer();
message(container, text || 'Spreadsheet preview failed', 'error');
};
function sheetMetadata() {
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
// Prefix sums per override list, built once per sheet's axis: renderTile
// asks for several offsets per cell, so a linear walk over every override
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
const axisDeltas = new WeakMap();
function overrideDeltas(overrides, defaultSize) {
let entry = axisDeltas.get(overrides);
if (!entry || entry.defaultSize !== defaultSize) {
const deltas = [];
let delta = 0;
for (const [, size] of overrides) {
delta += size - defaultSize;
deltas.push(delta);
}
entry = { defaultSize, deltas };
axisDeltas.set(overrides, entry);
}
return entry.deltas;
}
function axisOffset(count, defaultSize, overrides, index) {
const bounded = Math.max(1, Math.min(count + 1, index));
const list = overrides || [];
let low = 0;
let high = list.length;
while (low < high) {
const mid = (low + high) >> 1;
if (list[mid][0] < bounded) low = mid + 1;
else high = mid;
}
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
}
function axisIndex(count, defaultSize, overrides, offset) {
let low = 1;
let high = Math.max(1, count);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
else high = mid - 1;
}
return low;
}
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
// range, so the last row stays reachable) and the tile is laid out at real
// sizes from there. `shift` is the logical offset minus the scroll offset,
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
function scrollAxis(logical, scroll, viewport, heading) {
const shown = Math.min(MAX_SCROLL_PX, logical);
const scrollRange = Math.max(0, heading + shown - viewport);
const logicalRange = Math.max(0, heading + logical - viewport);
const virtual =
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
return { virtual, shift: virtual - scroll, end: heading + shown };
}
function requestTile() {
if (!current() || !worker || !grid) return;
const sheet = sheetMetadata();
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
const viewHeight = grid.clientHeight || 500;
const viewWidth = grid.clientWidth || 800;
const y = scrollAxis(
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
grid.scrollTop,
viewHeight,
COLUMN_HEADING_HEIGHT
);
const x = scrollAxis(
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
grid.scrollLeft,
viewWidth,
ROW_HEADING_WIDTH
);
const r1 = Math.max(
1,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
) - 2
);
const c1 = Math.max(
1,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
) - 2
);
const r2 = Math.min(
sheet.rows,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
) + 2
);
const c2 = Math.min(
sheet.cols,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
) + 2
);
latestRequestId += 1;
latestRange = { r1, c1, r2, c2 };
latestAxes = { y, x };
worker.postMessage({
type: 'tile',
requestId: latestRequestId,
sheetId: String(activeSheetId),
range: { r1, c1, r2, c2 },
});
}
function renderWarnings(tileWarnings) {
const notice = container.querySelector('.spreadsheet-preview-notice');
if (!notice) return;
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
notice.hidden = warnings.length === 0;
const warningLabel = translate('Some workbook features are not shown');
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : '';
}
function pinHeadings() {
if (!grid || !headingsLayer) return;
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
heading.style.left = `${grid.scrollLeft}px`;
});
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
heading.style.top = `${grid.scrollTop}px`;
});
}
function renderTile(tile) {
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
const sheet = sheetMetadata();
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
const { y, x } = latestAxes;
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
// it never grows the scroll area.
const rowTop = (row) =>
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
const colLeft = (col) =>
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
cellsLayer.textContent = '';
headingsLayer.textContent = '';
const mergeByAnchor = new Map();
for (const merge of tile.merges || []) {
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
if (!match) continue;
const column = (letters) =>
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
r2: Number(match[4]),
c2: column(match[3]),
});
}
for (const cell of tile.cells.slice(0, 2500)) {
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
const height = rowSpan(cell.row, merge?.r2 || cell.row);
const width = colSpan(cell.col, merge?.c2 || cell.col);
// A cell clipped to nothing at the spacer's edge (or sized 0 px) is
// skipped like its heading: padding and border would still draw it as a
// small box below the spacer and grow the scroll area.
if (height <= 0 || width <= 0) continue;
const element = document.createElement('div');
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
element.dataset.row = String(cell.row);
element.dataset.col = String(cell.col);
element.textContent = String(cell.text ?? '');
element.style.top = `${rowTop(cell.row)}px`;
element.style.left = `${colLeft(cell.col)}px`;
element.style.height = `${height}px`;
element.style.width = `${width}px`;
cellsLayer.appendChild(element);
}
// Headings take their size from the same axis math as the cells, so custom
// widths/heights line up; hidden (0 px) rows and columns get no heading and
// do not count against the heading caps.
let rowHeadings = 0;
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
const height = rowSpan(row, row);
if (height <= 0) continue;
rowHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-row-heading';
heading.textContent = String(row);
heading.style.top = `${rowTop(row)}px`;
heading.style.height = `${height}px`;
heading.style.left = `${grid.scrollLeft}px`;
headingsLayer.appendChild(heading);
}
let columnHeadings = 0;
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
const width = colSpan(col, col);
if (width <= 0) continue;
columnHeadings += 1;
const heading = document.createElement('div');
heading.className = 'spreadsheet-column-heading';
let label = '';
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
heading.textContent = label;
heading.style.left = `${colLeft(col)}px`;
heading.style.width = `${width}px`;
heading.style.top = `${grid.scrollTop}px`;
headingsLayer.appendChild(heading);
}
renderWarnings(tile.warnings);
}
function selectSheet(sheetId) {
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
activeSheetId = String(sheetId);
latestRequestId += 1;
latestRange = null;
latestAxes = null;
if (cellsLayer) cellsLayer.textContent = '';
if (headingsLayer) headingsLayer.textContent = '';
container.querySelectorAll('[role="tab"]').forEach((tab) => {
const selected = tab.dataset.sheetId === activeSheetId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
if (grid) {
grid.scrollTop = 0;
grid.scrollLeft = 0;
}
const sheet = sheetMetadata();
if (sheet && spacer) {
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
}
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
renderWarnings([]);
requestTile();
}
function renderMetadata(nextMetadata) {
if (!current()) return;
metadata = nextMetadata;
container.textContent = '';
if (!metadata.sheets?.length) {
message(container, 'This workbook has no visible worksheets.', 'empty');
return;
}
const shell = document.createElement('div');
shell.className = 'spreadsheet-preview-shell';
const styleSheet = document.createElement('style');
styleSheet.textContent = (metadata.styles || [])
.map((style, id) => {
const declarations = [];
if (style.font?.bold) declarations.push('font-weight:700');
if (style.font?.italic) declarations.push('font-style:italic');
// Colour and background are emitted together or not at all.
// The worker already contrast-checked them as a pair; contributing
// one half would drop the cell back onto the skin's own background.
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
}
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
if (style.wrapText) declarations.push('white-space:normal');
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
})
.join('');
const tabs = document.createElement('div');
tabs.className = 'spreadsheet-sheet-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('data-i18n-skip', '');
for (const sheet of metadata.sheets) {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'spreadsheet-sheet-tab';
tab.setAttribute('role', 'tab');
tab.dataset.sheetId = String(sheet.id);
tab.textContent = sheet.name;
tab.addEventListener('click', () => selectSheet(sheet.id));
tabs.appendChild(tab);
}
const notice = document.createElement('div');
notice.className = 'spreadsheet-preview-notice';
// Written already translated, item by item (renderWarnings), and it ends
// with workbook text (a number format's code): the observer's t() over
// the whole line would rewrite a `{name}` or a "Codeman" in that code.
notice.setAttribute('data-i18n-skip', '');
notice.hidden = true;
emptySheetState = document.createElement('div');
emptySheetState.className = 'spreadsheet-empty-sheet';
emptySheetState.textContent = 'This worksheet is empty.';
emptySheetState.hidden = true;
grid = document.createElement('div');
grid.className = 'spreadsheet-grid';
grid.setAttribute('data-i18n-skip', '');
spacer = document.createElement('div');
spacer.className = 'spreadsheet-grid-spacer';
cellsLayer = document.createElement('div');
cellsLayer.className = 'spreadsheet-cells';
headingsLayer = document.createElement('div');
headingsLayer.className = 'spreadsheet-headings';
grid.append(spacer, cellsLayer, headingsLayer);
grid.addEventListener(
'scroll',
() => {
pinHeadings();
if (scrollFrame !== null) return;
scrollFrame = global.requestAnimationFrame(() => {
scrollFrame = null;
requestTile();
});
},
{ passive: true }
);
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
container.appendChild(shell);
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
resizeObserver?.observe(grid);
selectSheet(metadata.sheets[0].id);
}
function dispose() {
if (disposed) return;
disposed = true;
clearTimer();
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
controller?.abort();
resizeObserver?.disconnect();
try {
worker?.postMessage({ type: 'dispose' });
worker?.terminate();
} catch {
// A worker that failed during startup may already be unavailable.
}
worker = null;
}
async function start() {
if (Number(options.size) > MAX_PREVIEW_BYTES) {
fail('This workbook is too large to preview (10 MB limit).');
return;
}
message(container, 'Loading spreadsheet…', 'loading');
let previewUrl;
try {
previewUrl = safePreviewUrl(options.url);
controller = new AbortController();
// Root-absolute paths ignore <base href>; route through the mount prefix.
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
const ready = new Promise((resolve, reject) => {
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
worker.onmessage = (event) => {
if (event.data?.type === 'ready') resolve();
};
});
const responsePromise = fetch(previewUrl, { signal: controller.signal });
const [response] = await Promise.all([responsePromise, ready]);
if (!current()) return;
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
const bytes = await response.arrayBuffer();
if (!current()) return;
worker.onmessage = (event) => {
if (!current()) return;
const payload = event.data || {};
if (payload.type === 'metadata') {
clearTimer();
renderMetadata(payload);
} else if (payload.type === 'tile') renderTile(payload);
else if (payload.type === 'error') fail(workerErrorText(payload));
};
worker.onerror = () => fail('Spreadsheet parser failed.');
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
timer = global.setTimeout(() => {
worker?.terminate();
fail('Spreadsheet preview timed out.');
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
worker.postMessage({ type: 'load', bytes }, [bytes]);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
}
}
void start();
return Object.freeze({ dispose, selectSheet, resize: requestTile });
}
global.CodemanSpreadsheetPreviewAssets = assets;
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
})(window);
File diff suppressed because it is too large. Load diff
+5158 -527
View File
File diff suppressed because it is too large. Load diff
+7 -5
View File
@@ -283,12 +283,14 @@ Object.assign(CodemanApp.prototype, {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed)
// Read tab rects for normal mode (only tabs that are actually needed).
// Only a painted row is cached: a row a search or filter hid has no line
// (_paintedSessionTab), rather than one drawn from the viewport's corner.
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
@@ -397,7 +399,7 @@ Object.assign(CodemanApp.prototype, {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not in DOM (might be scrolled out or session closed)
// Tab not painted (session closed, collapsed group, or hidden by a search)
continue;
}
@@ -691,8 +693,8 @@ Object.assign(CodemanApp.prototype, {
}
}
// Get parent TAB element for spawn animation
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Get parent TAB element for spawn animation (a hidden row spawns normally)
const parentTab = this._paintedSessionTab(parentSessionId);
// Create window element
const win = document.createElement('div');
+4
View File
@@ -101,6 +101,10 @@ self.addEventListener('fetch', (event) => {
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) return;
// A rendered HTML page is served `no-store` behind a capability that logout
// revokes; caching a popped-out page here would let the offline fallback
// serve it after the capability is gone.
if (request.url.includes('/html-view/')) return;
event.respondWith(
fetch(request)
+936
View File
@@ -0,0 +1,936 @@
/**
* @fileoverview Browser projection and editing of the owner tab layout.
*
* `GET /api/tab-layout` returns the owner's named tab GROUPS (`src/tab-layout.ts`
* is the server model). Browser assets cannot import that TypeScript, so this
* module is a small, dependency-free mirror that owns four things:
*
* 1. Projection: which live sessions and open web tabs land in which group,
* and which rows a collapsed group hides.
* 2. Rendering: the grouped markup for the vertical tab rail. Rows themselves
* are rendered by the caller (app.js, webview-tabs.js), so a grouped row is
* byte-identical to the flat rail's row.
* 3. Load sequencing: concurrent layout reads settle newest-wins, and a failed
* read degrades to the flat rail with a capped, backed-off retry.
* 4. Editing: named operations (create/rename/delete/reorder a group, move a
* row, pin a row to Focus) applied optimistically and saved through ONE serialized
* `PUT /api/tab-layout` at a time, rebased onto the server's layout on a
* version conflict.
*
* The server stays the only authority for layout content. Collapse is a
* per-device view preference and lives in localStorage only.
*
* Grouped rendering is opt-in by construction: a layout with no groups (every
* owner until they create one) projects to `null`, and the caller keeps the flat
* rail exactly as it was.
*
* @dependency none
* @loadorder 5.9 (before app.js, which reads window.CodemanTabLayout)
*/
(function initCodemanTabLayout(global) {
'use strict';
const COLLAPSED_STORAGE_KEY = 'codeman:tab-groups-collapsed';
// The Focus section's collapse, per device. Its own key, not an entry in the
// group list above: that list is garbage-collected against the layout's group
// ids, and Focus is not a group.
const FOCUS_COLLAPSED_STORAGE_KEY = 'codeman:tab-focus-collapsed';
const refKey = (ref) => `${ref.kind}:${ref.id}`;
const validRef = (ref) =>
!!ref && (ref.kind === 'session' || ref.kind === 'webview') && typeof ref.id === 'string' && ref.id.length > 0;
const asIds = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string' && id) : []);
const stableIds = (value) => [...new Set(asIds(value))];
// `placement: 'manual'` and `focus: true` must survive the round trip: the
// browser writes whole layouts back, so a field dropped here is erased on the
// next save, by whoever saves. Losing placement re-attaches a hand-placed child
// session to its parent's subtree; losing focus unpins every Focus shortcut.
const copyRef = (r) => {
const copy = { kind: r.kind, id: r.id };
if (r.placement === 'manual') copy.placement = 'manual';
if (r.focus === true) copy.focus = true;
return copy;
};
const copyRefs = (value) => (Array.isArray(value) ? value.filter(validRef).map(copyRef) : []);
/** Server limits (src/tab-layout.ts), mirrored so a bad edit fails before the PUT. */
const MAX_GROUPS = 32;
const MAX_NAME_LENGTH = 60;
/**
* Defensive copy of a server layout. Unknown fields are dropped, so a newer
* server adding model fields cannot leak half-understood state into the view.
*/
function normalizeLayout(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid tab layout');
const groups = Array.isArray(value.groups) ? value.groups : [];
return {
version: Number.isSafeInteger(value.version) && value.version >= 0 ? value.version : 0,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '',
groups: groups
.filter((group) => group && typeof group.id === 'string' && group.id.length > 0)
.map((group) => ({
id: group.id,
name: typeof group.name === 'string' ? group.name : '',
refs: copyRefs(group.refs),
})),
ungrouped: copyRefs(value.ungrouped),
};
}
function hasGroups(layout) {
return !!layout && Array.isArray(layout.groups) && layout.groups.length > 0;
}
/** Stored collapse ids, or null when the stored value is not a JSON array. */
function parseCollapsedIds(raw) {
if (raw === null) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? stableIds(parsed) : null;
} catch (_error) {
return null;
}
}
/**
* Read the per-device collapse ids. `ok: false` means the STORE failed (a read
* or write threw), and the caller then keeps every group expanded. A malformed
* VALUE is not a store failure: it reads as "nothing collapsed" and is
* rewritten, or a shape left behind by another build (a rollback) would leave
* collapse disabled on this device for good.
*/
function loadCollapsedGroupIds(storage, validGroupIds) {
try {
const parsed = parseCollapsedIds(storage.getItem(COLLAPSED_STORAGE_KEY));
const loaded = parsed || [];
if (validGroupIds === undefined) return { ids: loaded, ok: true };
// Garbage-collect ids of groups that no longer exist, so a deleted group's
// id cannot silently collapse a future group that reuses it.
const valid = new Set(stableIds(validGroupIds));
const kept = loaded.filter((id) => valid.has(id));
if (!parsed || kept.length !== loaded.length) storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(kept));
return { ids: kept, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* The Focus section's per-device collapse. `ok: false` means the store threw;
* any value other than 'true' reads as expanded.
*/
function loadFocusCollapsed(storage) {
try {
return { collapsed: storage.getItem(FOCUS_COLLAPSED_STORAGE_KEY) === 'true', ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
function saveFocusCollapsed(storage, collapsed) {
try {
storage.setItem(FOCUS_COLLAPSED_STORAGE_KEY, collapsed === true ? 'true' : 'false');
return { collapsed: collapsed === true, ok: true };
} catch (_error) {
return { collapsed: false, ok: false };
}
}
/**
* The refs pinned to the Focus section that this browser can draw: focused,
* a live session or an open web tab, each once, in stored order (groups in
* order, then Ungrouped). Works with or without named groups, so Focus also
* shows above the flat rail. A ref whose session closed or whose web tab is
* not open here simply drops out; the server prunes deleted refs on its own.
*/
function focusRefs(layoutInput, options = {}) {
if (!layoutInput) return [];
const layout = normalizeLayout(layoutInput);
const live = new Set(asIds(options.liveSessionIds));
const open = new Set(asIds(options.openWebviewIds));
const seen = new Set();
const result = [];
for (const ref of [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]) {
if (ref.focus !== true) continue;
if (!(ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id))) continue;
const key = refKey(ref);
if (seen.has(key)) continue;
seen.add(key);
result.push({ kind: ref.kind, id: ref.id });
}
return result;
}
function saveCollapsedGroupIds(storage, groupIds) {
const ids = stableIds(groupIds);
try {
storage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(ids));
return { ids, ok: true };
} catch (_error) {
return { ids: [], ok: false };
}
}
/**
* Project a layout onto what is live in this browser.
*
* Every live session and open web tab appears exactly once: stored refs keep
* their group and stored order; anything the layout has not caught up with yet
* (a session created a moment ago, a web tab opened on this device only) is
* appended to the ungrouped section in the caller's order. Saved web tabs that
* are not open here are skipped, as are refs to sessions that are gone.
*
* A collapsed group hides its rows, EXCEPT the highlighted one (the active web
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* `options.parents` (child session id -> parent session id) places a live
* session the layout has not stored yet right after its parent's rows when
* the parent IS stored: that is where the server puts a child that follows its
* parent, so a spawned session appears in its parent's group at once instead
* of jumping there from Ungrouped once the layout re-read lands.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
* collapse hides (`hidden`); `sectionByRef` maps every placed row
* (`<kind>:<id>`) to its section id (null = Ungrouped), shown or hidden.
* `focusRefs` is focusRefs() for the same options: shortcuts, never rows,
* so they are in none of the other fields.
*/
function project(layoutInput, options = {}) {
if (!layoutInput) return null;
const layout = normalizeLayout(layoutInput);
if (!hasGroups(layout)) return null;
const liveSessionIds = stableIds(options.liveSessionIds);
const openWebviewIds = stableIds(options.openWebviewIds);
const live = new Set(liveSessionIds);
const open = new Set(openWebviewIds);
const collapsed = new Set(asIds(options.collapsedGroupIds));
const highlighted = options.activeWebviewId
? `webview:${options.activeWebviewId}`
: options.activeSessionId
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const withFollowers = followingChildren(layout, liveSessionIds, options.parents);
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
const sectionByRef = {};
const sections = [];
const place = (refs, sectionId, isCollapsed) => {
const shown = [];
const hidden = [];
let count = 0;
for (const ref of refs) {
const key = refKey(ref);
if (placed.has(key) || !renderable(ref)) continue;
placed.add(key);
sectionByRef[key] = sectionId;
count++;
const copy = { kind: ref.kind, id: ref.id };
if (isCollapsed && key !== highlighted) {
hiddenTabGroupByRef[key] = sectionId;
hidden.push(copy);
continue;
}
shown.push(copy);
visibleRefs.push(copy);
}
return { shown, hidden, count };
};
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
name: '',
refs: ungrouped.shown,
hidden: [],
count: ungrouped.count,
collapsed: false,
});
}
return { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef, focusRefs: focusRefs(layout, options) };
}
const ALERT_RANK = { action: 2, idle: 1 };
/**
* The most urgent alert behind each COLLAPSED header: `{ [groupId]: 'action' |
* 'idle' }` over the session rows the collapse hides. A shown row (the kept
* selection, any expanded group) draws its own alert, so it is not counted
* here. `alertOf(sessionId)` is the caller's tab alert lookup.
*/
function hiddenGroupAlerts(projection, alertOf) {
const result = {};
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
for (const section of sections) {
if (section.id === null || !Array.isArray(section.hidden)) continue;
let best = null;
for (const ref of section.hidden) {
if (ref.kind !== 'session') continue;
const alert = alertOf(ref.id);
if (ALERT_RANK[alert] && (!best || ALERT_RANK[alert] > ALERT_RANK[best])) best = alert;
}
if (best) result[section.id] = best;
}
return result;
}
/**
* For project(): returns `expand(refs)`, which inserts each live, not-yet-stored
* child session right after its parent's rows in the parent's container. A
* child of such a child follows it the same way. Without `parents` it is the
* identity, so callers that pass no lineage see no change.
*/
function followingChildren(layout, liveSessionIds, parents) {
if (!parents || typeof parents !== 'object') return (refs) => refs;
const stored = new Set(refLocations(layout).map((item) => refKey(item.ref)));
const accepted = new Set();
const pending = new Map();
// Accept parents before their children (bounded: one pass per accepted child).
for (let changed = true; changed; ) {
changed = false;
for (const id of liveSessionIds) {
const key = `session:${id}`;
const parentId = parents[id];
if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue;
const parentKey = `session:${parentId}`;
if (!stored.has(parentKey) && !accepted.has(parentKey)) continue;
accepted.add(key);
if (!pending.has(parentKey)) pending.set(parentKey, []);
pending.get(parentKey).push({ kind: 'session', id });
changed = true;
}
}
if (!pending.size) return (refs) => refs;
return (refs) => {
const out = [...refs];
for (const [parentKey, children] of pending) {
const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents);
let at = -1;
out.forEach((ref, index) => {
if (block.has(refKey(ref))) at = index;
});
if (at >= 0) out.splice(at + 1, 0, ...children);
}
return out;
};
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
* status/name/badges. The incremental render path only patches rows in place,
* so a change here forces a full rebuild.
*
* Deliberately NOT the layout version: the server bumps it on every session
* create/close and order PUT, and a bump that moves nothing visible must not
* cost every client a full tab-strip rebuild. `layout` is accepted for
* signature stability only.
*/
function structureKey(_layout, projection, collapsedGroupIds) {
if (!projection) return null;
return JSON.stringify({
collapsed: stableIds(collapsedGroupIds).sort(),
sections: projection.sections.map((section) => [
section.id,
section.name,
section.count,
section.refs.map(refKey),
(section.hidden || []).map(refKey),
]),
});
}
/**
* Grouped rail markup. `renderRef(ref)` returns one row's HTML ('' to skip it);
* `escapeHtml` is the caller's escaper. Group names are user content, so they
* are escaped and marked `data-i18n-skip`.
*
* The caller makes the list itself the `tree` and marks rows up as treeitems
* (app.js `_applyTabTreeSemantics`); this markup supplies the structure:
* - a named group's header is a level-1 `treeitem` carrying `aria-expanded`.
* Its rows are a sibling `group`, so the header OWNS it via `aria-owns`
* (the rows sit below the header visually, not inside it).
* - a COLLAPSED group owns nothing: the one row it still shows (the
* selection) is a level-1 sibling, never the child of a closed node.
* - a group with NO open rows is a leaf: no `aria-expanded`, no owned group,
* so it is not announced as an expanded parent of an empty group.
* - Ungrouped rows are level-1 items. Their "Ungrouped" heading is a visual
* divider only, hidden from assistive tech, and its rows are not a group.
*/
function renderProjection(projection, renderRef, escapeHtml) {
const sections = projection && Array.isArray(projection.sections) ? projection.sections : [];
return sections
.map((section, index) => {
const rows = section.refs.map((ref) => renderRef(ref)).join('');
if (section.id === null) {
return (
'<section class="tab-layout-group tab-layout-ungrouped" role="presentation" data-tab-group-id="">' +
`<div class="tab-layout-group-header tab-layout-ungrouped-header" aria-hidden="true"><span class="tab-layout-group-name">Ungrouped</span><span class="tab-layout-group-count">${section.count}</span></div>` +
`<div class="tab-layout-group-refs" role="presentation">${rows}</div></section>`
);
}
const id = escapeHtml(section.id);
const refsId = `tab-layout-group-refs-${index}`;
const nameId = `tab-layout-group-name-${index}`;
const leaf = section.count === 0;
const expanded = !section.collapsed && !leaf;
const expandedAttr = leaf ? '' : ` aria-expanded="${expanded ? 'true' : 'false'}"`;
return (
`<section class="tab-layout-group${section.collapsed ? ' tab-layout-group--collapsed' : ''}" role="presentation" data-tab-group-id="${id}">` +
`<div class="tab-layout-group-header tab-layout-group-toggle" role="treeitem" tabindex="-1" data-tab-group-header="${id}"${expandedAttr}${expanded ? ` aria-owns="${refsId}"` : ''} onclick="app.toggleTabGroupCollapsed(this.dataset.tabGroupHeader)" oncontextmenu="event.preventDefault(); app.openTabGroupMenu(event, this.dataset.tabGroupHeader)">` +
'<span class="tab-layout-group-chevron" aria-hidden="true"></span>' +
`<span class="tab-layout-group-name" id="${nameId}" data-i18n-skip>${escapeHtml(section.name)}</span>` +
`<span class="tab-layout-group-count">${section.count}</span>` +
// Pointer path to the group menu (right-click on the header works too).
// Deliberately NOT a button and not focusable: a treeitem holds no
// interactive children, and the keyboard path is Shift+F10 /
// ContextMenu on the header itself. aria-hidden keeps the glyph out of
// the header's accessible name.
'<span class="tab-layout-group-menu" aria-hidden="true" title="Group actions" ' +
'onclick="event.stopPropagation(); app.openTabGroupMenu(event, this.closest(\'[data-tab-group-header]\').dataset.tabGroupHeader)">&#x22EF;</span></div>' +
`<div class="tab-layout-group-refs" id="${refsId}" ${expanded ? `role="group" aria-labelledby="${nameId}"` : 'role="presentation"'}>${rows}</div></section>`
);
})
.join('');
}
/**
* Newest-wins layout loading. A response that was overtaken by a later load is
* dropped; a failure applies the fallback (the flat rail) and schedules ONE
* retry, replacing any retry already pending.
*
* Retries back off and stop: the delay doubles from `retryDelayMs` up to
* `maxRetryDelayMs`, and after `maxRetries` consecutive failures nothing more
* is scheduled (`scheduleRetry(fn, delayMs)`). The next outside load (an SSE
* reconnect re-runs init, a `tab:layoutChanged` re-reads) tries again, and
* any success resets the count.
*/
function createLoadCoordinator(options) {
const baseDelay = Number.isFinite(options.retryDelayMs) ? options.retryDelayMs : 5000;
const maxDelay = Number.isFinite(options.maxRetryDelayMs) ? options.maxRetryDelayMs : 60000;
const maxRetries = Number.isSafeInteger(options.maxRetries) ? options.maxRetries : 4;
let generation = 0;
let disposed = false;
let retryHandle = null;
let failures = 0;
const clearRetry = () => {
if (retryHandle !== null && options.cancelRetry) options.cancelRetry(retryHandle);
retryHandle = null;
};
const load = async () => {
if (disposed) return false;
const requestGeneration = ++generation;
clearRetry();
try {
const layout = await options.fetchLayout();
if (disposed || requestGeneration !== generation) return false;
failures = 0;
options.applyLayout(layout);
return true;
} catch (_error) {
if (disposed || requestGeneration !== generation) return false;
failures++;
options.applyFallback();
if (failures <= maxRetries) {
const delay = Math.min(maxDelay, baseDelay * 2 ** (failures - 1));
retryHandle = options.scheduleRetry(() => load(), delay);
}
return false;
}
};
return {
load,
dispose() {
disposed = true;
generation++;
clearRetry();
},
};
}
// ─── Editing ────────────────────────────────────────────────────────────
//
// The browser edits through NAMED operations, not by diffing arrays: a write
// that loses a version race (409) is rebased by replaying the same operations
// on the layout the server returned, so a concurrent edit elsewhere survives.
// The server stays the authority: it re-validates and normalizes every PUT.
function editError(message) {
throw new Error(`Tab layout edit failed: ${message}`);
}
const clampIndex = (value, length) => (Number.isInteger(value) ? Math.max(0, Math.min(value, length)) : length);
function groupName(value) {
const name = typeof value === 'string' ? value.trim() : '';
if (!name || name.length > MAX_NAME_LENGTH) editError(`group name must be 1-${MAX_NAME_LENGTH} characters`);
return name;
}
function refLocations(layout) {
return [
...layout.groups.flatMap((group) => group.refs.map((ref) => ({ groupId: group.id, ref }))),
...layout.ungrouped.map((ref) => ({ groupId: null, ref })),
];
}
function containerRefs(layout, groupId) {
if (groupId === null) return layout.ungrouped;
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) editError('unknown group');
return group.refs;
}
/**
* The rows that move together with `ref`: the session plus every descendant
* that still follows its parent (non-manual, parent stored). Mirrors the
* server's moveRef block so the optimistic rail matches what it will store.
* `parents` maps a session id to its parent session id.
*/
function lineageBlock(layout, ref, parents) {
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const children = new Map();
for (const [childId, parentId] of Object.entries(parents || {})) {
const child = stored.get(`session:${childId}`);
if (!child || child.placement === 'manual' || !stored.has(`session:${parentId}`)) continue;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(childId);
}
const keys = new Set();
const visit = (key) => {
if (keys.has(key)) return;
keys.add(key);
if (key.startsWith('session:')) for (const id of children.get(key.slice(8)) || []) visit(`session:${id}`);
};
visit(refKey(ref));
return keys;
}
/**
* How a child session is placed, for the row menu. null for a session with no
* parent (or not stored). Otherwise `{ state, parentId, canFollow }`:
* - inherited: it follows its parent (moves with it).
* - manual: placed by hand; "Follow parent again" puts it back.
* - dangling: its parent is not in this layout (closed, or another owner's),
* so it stands on its own; nothing re-adopts it automatically.
* - cycle: the parent chain loops back to it; it stands on its own.
*/
function placementState(layoutInput, ref, parents) {
const layout = normalizeLayout(layoutInput);
if (!validRef(ref) || ref.kind !== 'session') return null;
const parentId = parents?.[ref.id];
if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null;
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const own = stored.get(refKey(ref));
if (!own) return null;
if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false };
// Walk up the stored ancestors; meeting this session again is a loop.
const seen = new Set([ref.id]);
for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) {
if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false };
seen.add(id);
}
return own.placement === 'manual'
? { state: 'manual', parentId, canFollow: true }
: { state: 'inherited', parentId, canFollow: false };
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
* end when there is no anchor.
*/
function moveDestination(layoutInput, ref, groupId, anchor, placement, parents) {
const layout = normalizeLayout(layoutInput);
const block = lineageBlock(layout, ref, parents);
const remaining = containerRefs(layout, groupId).filter((candidate) => !block.has(refKey(candidate)));
// No anchor means "at the end", and an operation with no index keeps
// meaning that when it is replayed onto a layout that has changed since.
if (!anchor) return { groupId };
const at = remaining.findIndex((candidate) => refKey(candidate) === refKey(anchor));
if (at < 0) return { groupId };
return { groupId, index: placement === 'after' ? at + 1 : at };
}
/**
* Map a finished drag to ONE operation (or null for a drop that changes
* nothing). Pure, so the drop -> PUT mapping is testable without a pointer.
*
* source: { type: 'ref', ref } | { type: 'group', groupId }
* target: { type: 'ref', ref, groupId, placement: 'before' | 'after' }
* | { type: 'group', groupId } (a named group's header or empty body)
* | { type: 'ungrouped' }
*
* A group dropped on another group (or any row in it) takes that group's slot;
* dropped on the Ungrouped section it goes last. A row dropped on a row lands
* before/after it, on a header it is appended to that group.
*/
function dropOperation(layoutInput, source, target, parents) {
const layout = normalizeLayout(layoutInput);
if (!source || !target) return null;
if (source.type === 'group') {
const from = layout.groups.findIndex((group) => group.id === source.groupId);
if (from < 0) return null;
const targetId = target.type === 'ungrouped' ? null : (target.groupId ?? null);
const to = targetId === null ? layout.groups.length - 1 : layout.groups.findIndex((g) => g.id === targetId);
if (to < 0 || to === from) return null;
return { type: 'reorderGroup', groupId: source.groupId, index: to };
}
if (source.type !== 'ref' || !validRef(source.ref)) return null;
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(source.ref));
if (!location) return null;
let groupId;
let anchor = null;
let placement = 'before';
if (target.type === 'ref' && validRef(target.ref)) {
// Onto itself or onto a row that moves with it: nowhere to go.
if (lineageBlock(layout, source.ref, parents).has(refKey(target.ref))) return null;
groupId = target.groupId ?? null;
anchor = target.ref;
placement = target.placement === 'after' ? 'after' : 'before';
} else if (target.type === 'group') {
groupId = target.groupId ?? null;
if (groupId === location.groupId) return null;
} else if (target.type === 'ungrouped') {
groupId = null;
if (location.groupId === null) return null;
} else return null;
if (groupId !== null && !layout.groups.some((group) => group.id === groupId)) return null;
const destination = moveDestination(layout, source.ref, groupId, anchor, placement, parents);
const operation = {
type: 'moveRef',
ref: { kind: source.ref.kind, id: source.ref.id },
groupId: destination.groupId,
...(destination.index === undefined ? {} : { index: destination.index }),
parents: parents || {},
};
return contentKey(applyOperation(layout, operation)) === contentKey(layout) ? null : operation;
}
/**
* Apply one operation to a copy of the layout. Throws when the operation no
* longer makes sense (an unknown group or row); a rebase drops that one
* operation and keeps the rest. Replays are idempotent where it matters for
* recovery: creating a group that already exists is a no-op.
*/
function applyOperation(layoutInput, operation) {
const layout = normalizeLayout(layoutInput);
const op = operation || {};
const groupIndex = layout.groups.findIndex((group) => group.id === op.groupId);
switch (op.type) {
case 'createGroup': {
if (typeof op.id !== 'string' || !op.id) editError('invalid group id');
const name = groupName(op.name);
if (layout.groups.some((group) => group.id === op.id)) return layout;
if (layout.groups.length >= MAX_GROUPS) editError('group limit reached');
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, { id: op.id, name, refs: [] });
return layout;
}
case 'renameGroup':
if (groupIndex < 0) editError('unknown group');
layout.groups[groupIndex].name = groupName(op.name);
return layout;
case 'deleteGroup': {
// Already gone (deleted elsewhere): nothing left to do.
if (groupIndex < 0) return layout;
const [removed] = layout.groups.splice(groupIndex, 1);
layout.ungrouped.push(...removed.refs);
return layout;
}
case 'reorderGroup': {
if (groupIndex < 0) editError('unknown group');
const [moved] = layout.groups.splice(groupIndex, 1);
layout.groups.splice(clampIndex(op.index, layout.groups.length), 0, moved);
return layout;
}
case 'setFocus': {
// Pin or unpin one row. It never moves: Focus draws a shortcut, and the
// row stays in its group. Idempotent, so a replay after a 409 is safe.
if (!validRef(op.ref)) editError('invalid row');
if (typeof op.focused !== 'boolean') editError('invalid Focus state');
const location = refLocations(layout).find((item) => refKey(item.ref) === refKey(op.ref));
if (!location) editError('unknown row');
if (op.focused) location.ref.focus = true;
else delete location.ref.focus;
return layout;
}
case 'moveRef': {
if (!validRef(op.ref)) editError('invalid row');
const targetKey = refKey(op.ref);
if (!refLocations(layout).some((item) => refKey(item.ref) === targetKey)) editError('unknown row');
const destinationId = op.groupId ?? null;
containerRefs(layout, destinationId);
const keys = lineageBlock(layout, op.ref, op.parents);
const block = refLocations(layout)
.filter((item) => keys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
// A hand-moved child stops following its parent (server moveRef does the same).
const head = block.find((item) => refKey(item) === targetKey);
if (op.ref.kind === 'session' && op.parents?.[op.ref.id]) head.placement = 'manual';
block.sort((a, b) => (a === head ? -1 : b === head ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !keys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !keys.has(refKey(ref)));
const destination = containerRefs(layout, destinationId);
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
case 'followParent': {
// Clear a hand placement: the child (with the sessions that follow it)
// goes back after its parent's rows. The server re-derives the exact
// order on save; this only has to put the row in the right group now.
const state = placementState(layout, op.ref, op.parents);
if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow');
if (state.state !== 'manual') return layout;
const targetKey = refKey(op.ref);
// Drop the placement only: a Focus pin stays (the whole layout is PUT,
// so a field left off here would be erased on save).
const clear = (ref) => {
if (refKey(ref) !== targetKey) return ref;
const copy = copyRef(ref);
delete copy.placement;
return copy;
};
for (const group of layout.groups) group.refs = group.refs.map(clear);
layout.ungrouped = layout.ungrouped.map(clear);
const childKeys = new Set();
for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key);
const block = refLocations(layout)
.filter((item) => childKeys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref)));
const parentRef = { kind: 'session', id: state.parentId };
const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef));
const container = containerRefs(layout, home.groupId);
const parentBlock = lineageBlock(layout, parentRef, op.parents);
let at = -1;
container.forEach((ref, index) => {
if (parentBlock.has(refKey(ref))) at = index;
});
container.splice(at + 1, 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
}
/** Layout content without version metadata: equal keys mean "nothing to save". */
function contentKey(layoutInput) {
const layout = normalizeLayout(layoutInput);
return JSON.stringify([layout.groups, layout.ungrouped]);
}
/** Replay operations, dropping (and counting) the ones that no longer apply. */
function replayOperations(base, operations) {
let layout = normalizeLayout(base);
const kept = [];
let dropped = 0;
for (const operation of operations) {
try {
layout = applyOperation(layout, operation);
kept.push(operation);
} catch (_error) {
dropped++;
}
}
return { layout, kept, dropped };
}
/**
* Serialized, optimistic writer for `PUT /api/tab-layout`.
*
* - enqueue() applies an operation at once (the rail repaints optimistically)
* and schedules a flush; operations enqueued in the same turn share a PUT.
* - Exactly ONE write is in flight. Operations enqueued meanwhile wait and are
* sent on top of the version that write returns.
* - A 409 carries the server's current layout: the in-flight operations are
* replayed onto it and re-sent with its version (bounded attempts). A 400
* (a row vanished between read and write) re-reads and rebases the same way.
* - Anything else, or attempts exhausted, drops the batch and reports it; the
* caller re-reads so the rail shows the server's truth.
*
* options: { initialLayout, put({ baseVersion, layout }) -> { ok, status,
* layout }, fetchLayout?(), applyLayout(layout, meta), reportError?(message),
* onSettled?(), onFailure?(), schedule?(fn), cancel?(handle), maxAttempts? }
*/
function createEditCoordinator(options) {
let authoritative = normalizeLayout(options.initialLayout);
let optimistic = authoritative;
let pending = [];
let inFlight = [];
let writing = false;
let timer = null;
let disposed = false;
const schedule = options.schedule || ((fn) => setTimeout(fn, 0));
const cancel = options.cancel || ((handle) => clearTimeout(handle));
const maxAttempts = options.maxAttempts || 3;
const report = (message) => options.reportError?.(message);
const publish = (meta) => options.applyLayout(normalizeLayout(optimistic), meta);
const queue = () => {
if (timer === null) timer = schedule(flush);
};
async function flush() {
timer = null;
if (disposed || writing || pending.length === 0) return;
writing = true;
inFlight = pending;
pending = [];
let failed = false;
let reportedDrop = false;
let rereadFor400 = false;
try {
for (let attempt = 0; attempt < maxAttempts && inFlight.length; attempt++) {
const desired = replayOperations(authoritative, inFlight);
inFlight = desired.kept;
if (desired.dropped && !reportedDrop) {
reportedDrop = true;
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
// Nothing left to change (dropped, or already true on the server).
if (!inFlight.length || contentKey(desired.layout) === contentKey(authoritative)) {
inFlight = [];
break;
}
const response = await options.put({ baseVersion: authoritative.version, layout: desired.layout });
if (disposed) return;
if (response?.ok && response.layout) {
authoritative = normalizeLayout(response.layout);
inFlight = [];
} else if (response?.status === 409 && response.layout) {
authoritative = normalizeLayout(response.layout);
} else if (response?.status === 400 && options.fetchLayout && !rereadFor400) {
// Maybe our base was stale in a way the server reports as invalid:
// re-read once. A 400 that survives that is a refusal, not a race.
rereadFor400 = true;
authoritative = normalizeLayout(await options.fetchLayout());
if (disposed) return;
} else {
throw new Error('Tab layout save failed');
}
}
if (inFlight.length) {
failed = true;
report('Tab groups kept changing elsewhere; your edit was not saved.');
}
} catch (_error) {
failed = true;
report('Could not save tab groups.');
} finally {
inFlight = [];
writing = false;
if (!disposed) {
const rebased = replayOperations(authoritative, pending);
// Edits made while the write was in flight are rebased here, so one the
// conflict made inapplicable is dropped here too, and says so (once).
if (rebased.dropped && !failed && !reportedDrop) {
report('Tab groups changed elsewhere; part of your edit no longer applies.');
}
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true });
if (failed) options.onFailure?.();
if (pending.length) queue();
else options.onSettled?.();
}
}
}
return {
/** Apply now, save soon. Throws (and changes nothing) for an invalid edit. */
enqueue(operation) {
optimistic = applyOperation(optimistic, operation);
pending.push(operation);
publish({ optimistic: true });
queue();
return normalizeLayout(optimistic);
},
/**
* Re-apply operations recovered after a reload. Returns false (and queues
* nothing) when the layout already reflects them, e.g. the keepalive save
* landed before the page went away.
*/
restore(operations) {
const replayed = replayOperations(optimistic, Array.isArray(operations) ? operations : []);
if (!replayed.kept.length || contentKey(replayed.layout) === contentKey(optimistic)) return false;
optimistic = replayed.layout;
pending.push(...replayed.kept);
publish({ optimistic: true });
queue();
return true;
},
/**
* Adopt a layout read from the server (SSE reload). Pending operations are
* rebased onto it. Refused while a write is in flight (its result decides)
* and for a layout older than the one already held.
*/
adoptExternal(layout) {
if (disposed || writing) return false;
const next = normalizeLayout(layout);
if (next.version < authoritative.version) return false;
authoritative = next;
const rebased = replayOperations(next, pending);
if (rebased.dropped) report('Tab groups changed elsewhere; part of your edit no longer applies.');
pending = rebased.kept;
optimistic = rebased.layout;
publish({ authoritative: true, external: true });
return true;
},
flush,
isWriting: () => writing,
hasPending: () => writing || pending.length > 0,
/** Every operation not yet confirmed by the server, oldest first. */
pendingOperations: () => JSON.parse(JSON.stringify([...inFlight, ...pending])),
baseVersion: () => authoritative.version,
getLayout: () => normalizeLayout(optimistic),
dispose() {
disposed = true;
if (timer !== null) cancel(timer);
timer = null;
pending = [];
},
};
}
global.CodemanTabLayout = {
normalizeLayout,
hasGroups,
project,
hiddenGroupAlerts,
structureKey,
renderProjection,
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
placementState,
dropOperation,
contentKey,
createEditCoordinator,
createLoadCoordinator,
loadCollapsedGroupIds,
saveCollapsedGroupIds,
focusRefs,
loadFocusCollapsed,
saveFocusCollapsed,
MAX_GROUPS,
};
})(typeof window !== 'undefined' ? window : globalThis);
+32 -3
View File
@@ -298,7 +298,9 @@ Object.assign(CodemanApp.prototype, {
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
// The group menu borrows this class for its look but has its own owner
// (closeTabGroupMenu); removing its DOM here would strand its listeners.
const menu = document.querySelector('.tab-rail-action-menu:not(.tab-layout-group-action-menu)');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
@@ -317,6 +319,7 @@ Object.assign(CodemanApp.prototype, {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
this.closeTabGroupMenu?.();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
@@ -325,12 +328,38 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
// The row's "+" (showTabNewButton on) as a menu item: a keyboard route beside
// Enter or Space on the "+" itself, and its only route in the compact rail,
// which hides it. Session rows only: tiles share this menu and carry no "+".
...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab')
? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }]
: []),
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
// A tile (Detach Tiles on) opens its own size, over where it is: the
// keyboard's way to what dragging its header out of the window does.
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
// A note (a child session's placement) is plain text, not a menu item: it
// takes no focus and no arrow stop, and describes the menu instead.
if (action.note) {
const note = document.createElement('div');
note.className = 'tab-rail-action-menu-note';
note.setAttribute('role', 'none');
note.id = 'tabRailActionMenuNote';
note.textContent = action.label;
menu.setAttribute('aria-describedby', note.id);
menu.appendChild(note);
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
+155 -3
View File
@@ -7,6 +7,17 @@
* that guard, so xterm returns without emitting and the committed character is
* silently dropped.
*
* ⚠ Desktop fine pointers must not run this (#716). xterm already forwards the
* composed character, and a second forward doubles a dead-key backtick.
* `create()` returns null unless `matchMedia('(pointer: coarse)')` matches or
* the browser is Android, so soft keyboards keep the recovery and a desktop
* does not attach it. Android is checked by user agent because Chromium reports
* its PRIMARY pointer as fine once a mouse, a trackpad cover or a stylus is
* attached, while the text still comes from the soft keyboard (#441, #541 were
* measured there); Android always ran this before #716, so the check keeps it.
* A missing or throwing `matchMedia` stands down elsewhere: a missed recovery is
* acceptable, a duplicated keystroke is not.
*
* ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
* controller that looks useful while doing nothing. For a keydown that really
* does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
@@ -18,6 +29,15 @@
* the case this module exists for, and the case its browser test asserts by
* checking WHO delivered the byte rather than merely that one arrived.
*
* A SECOND failure lives in that same self-rescue: xterm diffs `newValue.replace(oldValue, '')`,
* which only works when the keyboard APPENDED. A soft keyboard that autocorrects on space
* (SwiftKey, Gboard) rewrites the tail instead: it deletes the word and inserts the corrected
* one, and xterm answers by sending the WHOLE new textarea value (the old value is not a
* substring of it), then sends the inserted text AGAIN from the second keydown's timer. One
* autocorrect turned `testing the peompt` + <space> into
* `testing the peompttesting the prompt rompt `. A multi-character delete is also sent as ONE
* DEL. `installEditSync` below replaces that diff with an edit-based one.
*
* The recovery never guesses the character: the `input` event already carries
* the real committed text in `ev.data`, which is exactly what xterm itself
* would have forwarded. We only decide WHETHER to forward it, by asking
@@ -33,13 +53,44 @@
* also be a CAPTURE listener; see the measured table at the addEventListener
* call below.
*
* @dependency none (standalone IIFE; consumed by terminal-ui.js)
* @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
* @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by
* terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm)
* @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers)
*/
(function (global) {
'use strict';
/**
* What turns `previous` into `next`, as a terminal sees it: how many characters to delete from
* the END of the line, then what to type. Everything after the common prefix is treated as
* replaced, because a terminal can only edit at its cursor. Counts are code points, so an
* emoji is one DEL, as it is one backspace.
*/
function editBetween(previous, next) {
const a = Array.from(previous);
const b = Array.from(next);
let prefix = 0;
const max = Math.min(a.length, b.length);
while (prefix < max && a[prefix] === b[prefix]) prefix += 1;
return { deleted: a.length - prefix, inserted: b.slice(prefix).join('') };
}
/**
* Touch / coarse pointer, or any Android browser. See the file header: on a
* desktop fine pointer this forward runs on top of xterm's own composition
* handling.
*/
function isSoftKeyboardPlatform() {
try {
if (/Android/i.test(global.navigator?.userAgent || '')) return true;
return global.matchMedia?.('(pointer: coarse)')?.matches === true;
} catch {
return false;
}
}
function create(options) {
if (!isSoftKeyboardPlatform()) return null;
const textarea = options?.textarea;
const emitRecovered = options?.emitRecovered;
if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
@@ -65,6 +116,9 @@
let keydownSnapshot = 0;
let composing = false;
const pending = [];
// Applies any edit-sync diff still waiting on its timer. Assigned by installEditSync() below; a
// no-op when xterm's internals are not available.
let settleEdit = () => {};
/**
* Resolve every candidate still pending, right now, instead of waiting for
@@ -147,6 +201,13 @@
// reach the PTY — see flushPending(). This runs from xterm's custom key
// handler, i.e. before xterm processes the key, so a recovered character
// is always ordered ahead of the bytes this keydown produces.
// ORDER MATTERS. Settle the edit-sync diff first: it bumps `canonicalCount` for the
// keystroke it belongs to, so flushPending() then stands that keystroke's orphan candidate
// down. Swapped, the candidate would resolve first and the character would be sent twice.
// It also has to happen BEFORE xterm handles THIS key: for Enter, xterm clears the textarea
// in its own keydown, and a timer left pending would then diff the whole line against ''
// and send one DEL per character ahead of the submitted line.
settleEdit(event);
flushPending();
keydownSnapshot = canonicalCount;
}
@@ -176,6 +237,92 @@
}
}
/**
* Replace xterm's `_handleAnyTextareaChanges` (see the header) with an edit-based diff against
* the value the PTY side has already been told about. `synced` is that value; it is shared by
* every keydown's timer, so two timers that both see the final textarea value cannot both
* send it. Returns an uninstall function, or null when xterm's internals are not as expected
* (then xterm's own, flawed, behaviour is left in place).
*/
function installEditSync() {
let helper;
try {
helper = options.getCompositionHelper?.();
} catch {
return null;
}
const original = helper?._handleAnyTextareaChanges;
const coreService = helper?._coreService;
if (typeof original !== 'function' || typeof coreService?.triggerDataEvent !== 'function') return null;
let synced = textarea.value;
const waiting = new Set();
/** Send what changed since `synced`, once, and remember it. */
function applyEdit() {
if (destroyed || helper._isComposing) return; // xterm's composition path owns this one
const current = textarea.value;
if (current === synced) return;
const { deleted, inserted } = editBetween(synced, current);
synced = current;
try {
// One DEL per character, like repeated backspace presses: the local-echo composer and
// the PTY both treat each as a single edit.
for (let i = 0; i < deleted; i += 1) coreService.triggerDataEvent('\x7f', true);
if (inserted) {
helper._dataAlreadySent = inserted;
coreService.triggerDataEvent(inserted, true);
}
} catch {
// Delivery is best effort; never throw into the browser's timer queue.
}
}
helper._handleAnyTextareaChanges = function handleAnyTextareaChanges() {
if (destroyed) return original.call(this);
// No edit in flight and the value is not what we last sent: something outside the IME
// changed it (xterm clears it after Enter, a composition committed). Nothing to send;
// resynchronise.
if (waiting.size === 0 && synced !== textarea.value) synced = textarea.value;
const entry = { id: null };
waiting.add(entry);
entry.id = setTimer(() => {
waiting.delete(entry);
applyEdit();
}, 0);
};
// Apply the pending edit NOW instead of on its timer (see handleKeyEvent).
settleEdit = (event) => {
if (waiting.size === 0) return;
for (const entry of waiting) {
try {
clearTimer(entry.id);
} catch {
// A broken timer host must not break input handling.
}
}
// Cleared BEFORE the return below, on purpose: left pending, the timer would fire after
// Enter clears the textarea and send one DEL per character ahead of the submitted line.
waiting.clear();
// A composition just ended and this key makes xterm finalize it SYNCHRONOUSLY, through
// `_finalizeComposition(false)`, which ignores `_dataAlreadySent`: that text is xterm's, and
// sending the edit too would deliver it twice. On 229, CapsLock and the modifiers xterm keeps
// the composition on its async path, which honours `_dataAlreadySent`, so the edit still
// applies there. Only this settle path is guarded: on the timer path xterm always finalizes
// asynchronously, and skipping the edit there would drop a byte master delivers.
if (helper._isSendingComposition && ![229, 20, 16, 17, 18].includes(event?.keyCode)) return;
applyEdit();
};
return () => {
settleEdit = () => {};
if (helper._handleAnyTextareaChanges !== original) helper._handleAnyTextareaChanges = original;
};
}
const uninstallEditSync = installEditSync();
function onCompositionStart() {
if (destroyed) return;
composing = true;
@@ -191,6 +338,11 @@
if (destroyed) return;
destroyed = true;
cancelPending();
try {
uninstallEditSync?.();
} catch {
// Best effort.
}
try {
textarea.removeEventListener('input', onInput, true);
textarea.removeEventListener('compositionstart', onCompositionStart, true);
@@ -228,5 +380,5 @@
return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
}
global.CodemanKeyCode229Recovery = Object.freeze({ create });
global.CodemanKeyCode229Recovery = Object.freeze({ create, editBetween });
})(typeof window !== 'undefined' ? window : globalThis);
+146 -416
View File
@@ -1,407 +1,16 @@
// src/web/public/terminal-split.js
/**
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
* feature by nature — see docs/split-pane-sessions-plan.md.
* @fileoverview Split-pane orchestration: opens a second live session
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
* a draggable divider, a session picker, and auto-collapse when either
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
*
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
* @dependency terminal-tile.js (window.TerminalTile)
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before tile-grid.js
*/
(function (global) {
/**
* Minimal chunked write for Pane B's own xterm instance — write() in
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
* giant synchronous write that blocks the main thread while parsing a long
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
* (terminal-ui.js): that one is wired into session-switch generation
* counters and the live-output gate this simpler, independently
* created/destroyed pane has no equivalent of.
*/
function writeChunked(terminal, buffer, isDestroyed) {
if (!buffer) return Promise.resolve();
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
terminal.write(buffer);
return Promise.resolve();
}
// Resolves once the LAST chunk is written (or the pane was destroyed
// mid-replay), so _loadBuffer() below can hold its single-flight flag
// across the whole replay rather than just the fetch that precedes it.
return new Promise((resolve) => {
let offset = 0;
const writeNext = () => {
if (isDestroyed() || !terminal) {
resolve();
return;
}
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
offset += chunk.length;
terminal.write(chunk);
if (offset < buffer.length) {
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
else setTimeout(writeNext, 16);
} else {
resolve();
}
};
writeNext();
});
}
class SplitTerminalPane {
constructor(sessionId, mountEl, opts = {}) {
this.sessionId = sessionId;
this.mountEl = mountEl;
this.sessionMode = opts.mode;
this.fontSettings = opts.fontSettings || {};
// Live reference (not a snapshot) to the app's detachedSessions Set —
// detaching this session AFTER the split is already open must still be
// seen by _sendResize() below, or it re-creates the exact PTY-size
// fight the split picker already refuses to open at pick time.
this.detachedSessions = opts.detachedSessions;
this.terminal = null;
this.fitAddon = null;
this.ws = null;
this._wsReady = false;
this._destroyed = false;
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
this._bufferLoading = false;
this._bufferRefreshPending = false;
}
async connect() {
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
this.terminal.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ t: 'i', d: data }));
}
});
// Pane B has no gates of its own by default, so every app-level chord
// that the document capture-phase handler (app.js) only preventDefault()s
// — never stopPropagation()s — reaches xterm here too and writes its raw
// byte/escape sequence into THIS session's PTY on top of whatever the app
// action already did to Pane A (COD-153; mirrors the primary pane's own
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
// registry-aware predicates so a rebind or a disable restores plain
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
// default (plain-text paste): Pane B has no image-paste trap to route it
// to, so intercepting it here would only break paste.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
return false;
}
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
// swallow — in a plain shell session this is the user's own
// job-control tool and must reach the PTY, but in every other mode
// (claude/omp/pi/codex/...) it silently stops an unattended agent
// loop dead. Pane B has its own PTY/session and must not send a
// suspend into a non-shell one just because the primary pane's own
// gate lives elsewhere.
if (
ev.type === 'keydown' &&
ev.key.toLowerCase() === 'z' &&
ev.ctrlKey &&
!ev.altKey &&
!ev.metaKey &&
!ev.shiftKey &&
this.sessionMode !== 'shell'
) {
return false;
}
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
// every Enter variant, so an Ink app (Claude Code) can't tell a
// newline from a submit. Without this gate, Pane B's onData would
// send that bare \r straight over the WS and submit an incomplete
// prompt instead of adding a line to it. Targets THIS pane's own
// session (this.sessionId), never the primary pane's
// activeSessionId, and has no local-echo overlay of its own to flush
// first (Pane B is deliberately plainer — see the fileoverview).
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
fetch(`/api/sessions/${this.sessionId}/send-key`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
}).catch(() => {
/* Best-effort, matching this pane's tolerance elsewhere. */
});
return false;
}
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
// selection, Ctrl+C copies THIS pane's own selection instead of
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
// explicit, never-falls-through copy chord, and the predicate above
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
// a branch that assigns c.key only for a couple of special cases
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
// for Ctrl+Shift+C either way — this is not about an accidental
// interrupt byte reaching the PTY (verified live: it does not).
// Gating this whole block on hasSelection() (an earlier draft) meant
// that with no selection Ctrl+Shift+C skipped straight to `return
// true`, silently ceding the keystroke to the BROWSER's own handling
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
// attempt to copy, unlike Pane A, which always intercepts it.
// Re-implemented against this.terminal rather than reusing
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
// and would copy the wrong pane's selection.
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
const raw = this.terminal?.getSelection?.() || '';
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
// Both clean options are read for THIS pane, never the primary one:
// the gutter width comes from this.sessionId's own run mode, and the
// partial-first-line flag from this terminal's own selection range.
// Passing neither left Pane B keeping a margin Pane A dropped, on the
// same split and the same keystroke.
const range = global.app?._normalisedSelectionRange?.(this.terminal);
const selection = isColumnSelection
? raw
: (global.CodemanCopySelection?.clean?.(raw, {
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
firstLinePartial: !!range && range.start.x > 0,
}) ?? raw);
if (selection.trim()) {
ev.preventDefault();
void global.app._copyText?.(selection).then((ok) => {
this.terminal?.clearSelection?.();
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
});
return false;
}
// Nothing worth copying — clear for feedback (a padding-only
// selection cleans to '' and this press still falls through to the
// PTY as 0x03, matching the primary pane's own rule).
if (this.terminal?.hasSelection?.()) {
this.terminal.clearSelection?.();
global.app.showToast?.('Nothing to copy', 'warning');
}
// Ctrl+Shift+C never falls through, even with nothing to copy —
// matches terminal-ui.js's own ev.shiftKey branch.
if (ev.shiftKey) {
ev.preventDefault();
return false;
}
}
return true;
});
// Load existing scrollback before going live. The WS below is
// subscribe-only (ws-routes.ts sends nothing on connect, only future
// 'terminal' events), so without this Pane B stays blank until the
// target session happens to produce new output. It LOOKED
// intermittent rather than always-broken because _sendResize() below
// often nudges the shared session's real tmux window to a new size,
// and tmux repaints its current screen on resize — that repaint was
// getting captured and streamed here, incidentally populating the
// pane. When Pane B's computed dimensions happened to already match
// the session's last-known size, Session.resize() (session.ts) skips
// the resize as a no-op, no repaint fires, and the pane stayed blank.
// The await covers the whole chunked replay, not just the fetch, so a
// live frame from the socket below can never land in the middle of it.
await this._loadBuffer();
if (this._destroyed) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this._wsReady = true;
this._sendResize();
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
this.terminal.write(msg.d);
} else if (msg.t === 'c') {
this.terminal.clear();
} else if (msg.t === 'r') {
// Server-triggered refresh (SSE backpressure cleared, terminal
// data was dropped). The primary pane routes this to
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
// buffer loader for the same reason connect() does.
this._refreshBuffer();
}
} catch {
/* Malformed frame — ignore, matches primary pane's tolerance. */
}
};
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
// must go false on a drop or fit()/_sendResize() silently no-ops on a
// closed socket per the WebSocket spec (no exception, no log). No
// reconnect logic here — Pane B is deliberately plainer than the
// primary pane (see the fileoverview above); a drop just stops
// resizing until the parent recreates the pane. But onData already
// silently drops keystrokes while _wsReady is false (below), so
// without a visible marker a dropped socket left Pane B looking
// normal while it quietly ate everything typed into it. v1 scope is
// "say so", not reconnect — collapsing the split would lose the
// user's place in Pane B's scrollback for a transient blip.
this.ws.onclose = () => {
this._wsReady = false;
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
};
this.ws.onerror = () => {
// onclose fires after onerror — cleanup happens there.
};
}
// Fetches and writes the session's current scrollback. Used both by
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
// scoped to `this.activeSessionId` and clears/rewrites the primary
// terminal, neither of which applies to this independent pane, so this is
// a standalone equivalent rather than a call into it.
//
// Mirrors the primary pane's own mode check (app.js's selectSession /
// _onSessionNeedsRefresh): a shell session can retain hundreds of
// thousands of plain scrollback lines, so pulling `?full=1` there parses
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
// get one full replay. `fetch` here goes through the global wrapper
// (constants.js), which already prefixes CodemanBase — unlike the raw
// WebSocket URL above, which does not.
//
// Single-flight: the flag is held across the fetch AND the chunked write
// (writeChunked resolves after its last chunk), so two replays can never
// interleave their chunks into one terminal. A second call while one is
// in flight is dropped here; _refreshBuffer() is the caller that queues
// a trailing re-run instead.
async _loadBuffer() {
if (this._bufferLoading) return;
this._bufferLoading = true;
try {
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
const payload = (await res.json())?.data ?? {};
if (payload.terminalBuffer && this.terminal) {
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
}
} catch {
/* Best-effort — live output still arrives once the socket connects. */
} finally {
this._bufferLoading = false;
}
if (this._bufferRefreshPending && !this._destroyed) {
this._bufferRefreshPending = false;
this._refreshBuffer();
}
}
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
// frames in a row used to start two concurrent replays, each clearing
// the terminal under the other's chunked write. A refresh that arrives
// mid-replay is COALESCED into one trailing re-run rather than ignored:
// the in-flight fetch may predate the drop the new frame is reporting,
// and no further frame is coming to correct stale content.
_refreshBuffer() {
if (this._bufferLoading) {
this._bufferRefreshPending = true;
return;
}
this.terminal?.clear();
void this._loadBuffer();
}
// Local reflow only — no PTY resize frame. Split out so a divider drag
// can reflow both panes at the browser's paint rate (rAF) while sending
// the actual `{t:'z'}` resize once, at drag end, matching the primary
// pane's own convention (throttledResize in terminal-ui.js).
localFit() {
if (!this.fitAddon) return;
this.fitAddon.fit();
}
fit() {
this.localFit();
this._sendResize();
}
_sendResize() {
if (!this._wsReady || !this.fitAddon) return;
// One PTY cannot hold two sizes (mirrors sendResize's own
// detachedElsewhere yield in terminal-ui.js): the session got detached
// to its own window AFTER this split was opened, so its own window now
// owns the PTY's size and Pane B must stand aside.
if (this.detachedSessions?.has(this.sessionId)) return;
const dims = this.fitAddon.proposeDimensions();
if (!dims) return;
// Send the real proposed dimensions unclamped, matching the primary
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
// A 40/10 floor here misreported Pane B's real width to the PTY at the
// divider's own reachable 20% floor position, causing real
// output-wrapping bugs.
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
}
destroy() {
this._destroyed = true;
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
// onclose fires asynchronously AFTER close(); without this it ran
// its "disconnected" write against a pane already torn down.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}
global.SplitTerminalPane = SplitTerminalPane;
})(window);
Object.assign(CodemanApp.prototype, {
/**
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
@@ -437,6 +46,9 @@ Object.assign(CodemanApp.prototype, {
// the old exact-node check below) bubbled straight through to
// `document` and self-closed the menu it just opened.
event?.stopPropagation();
// The tile grid and the split are never open together (tile-grid.js); the
// Split button shows as unavailable meanwhile.
if (this._tilesOwnTerminal?.()) return;
if (this._splitPane) {
this.closeSplitPane();
return;
@@ -530,6 +142,8 @@ Object.assign(CodemanApp.prototype, {
// see _applySplitButtonVisibility's comment for why both a JS check and
// a CSS backstop exist.
if (window.innerWidth < SPLIT_PANE_MIN_WIDTH) return;
// Never beside the tile grid: the main terminal is parked while it is open.
if (this._tilesOwnTerminal?.()) return;
// No active session means there is no `.terminal-wrap` to split against
// (the welcome overlay is showing) — without this, a split opened from
// the home screen still created the container and connected Pane B, just
@@ -544,7 +158,7 @@ Object.assign(CodemanApp.prototype, {
// B's own session tab while split can otherwise land here with
// sessionId === activeSessionId: two live WebSockets to the same
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
// resize frame. Refuse before creating any DOM or TerminalTile.
if (sessionId === this.activeSessionId) return;
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
// re-applied here: the menu can sit open while a listed session's CLI
@@ -570,22 +184,38 @@ Object.assign(CodemanApp.prototype, {
const paneB = document.createElement('div');
paneB.className = 'terminal-pane-b';
paneB.innerHTML = `
<div class="terminal-pane-b-header">
<span class="session-name">${escapeHtml(session?.name || 'Session')}</span>
<button type="button" class="terminal-pane-b-close" onclick="app.closeSplitPane()" aria-label="Close split">&times;</button>
</div>
<div class="terminal-pane-b-container"></div>
`;
// Pane B's header: the harness logo, the name and the model, as on a grid
// tile, and the close button at a tile button's size.
const headerB = this._buildSplitPaneHeader();
const close = document.createElement('button');
close.type = 'button';
close.className = 'tile-btn tile-remove terminal-pane-b-close';
close.title = 'Close split';
close.setAttribute('aria-label', 'Close split');
close.textContent = '\u00D7';
close.addEventListener('click', () => this.closeSplitPane());
headerB.el.appendChild(close);
const bodyB = document.createElement('div');
bodyB.className = 'terminal-pane-b-container';
paneB.append(headerB.el, bodyB);
// Pane A is the main terminal, which has no header of its own: while the
// split is open it gets the same strip, so each session in the split view
// names its harness and model. It takes height from the main terminal,
// which the opening resize below fits through syncTerminalGeometry (#464);
// closeSplitPane gives it back.
const headerA = this._buildSplitPaneHeader();
headerA.el.classList.add('terminal-pane-a-header');
this._splitHeaders = { a: headerA, b: headerB };
parent.insertBefore(container, wrap);
wrap.insertBefore(headerA.el, wrap.firstChild);
container.appendChild(wrap);
wrap.style.flexBasis = '50%';
container.appendChild(divider);
container.appendChild(paneB);
paneB.style.flexBasis = '50%';
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
this._splitPane = new window.TerminalTile(sessionId, bodyB, {
mode: session?.mode,
fontSettings: this.loadAppSettingsFromStorage?.() || {},
detachedSessions: this.detachedSessions,
@@ -595,6 +225,7 @@ Object.assign(CodemanApp.prototype, {
initial load — live output still arrives once/if the socket connects. */
});
this._splitSessionId = sessionId;
this._renderSplitChrome();
// Pane A just went from full width to 50%, but nothing has told its
// session's PTY/tmux window about it yet — the passive ResizeObserver in
@@ -622,6 +253,9 @@ Object.assign(CodemanApp.prototype, {
this._splitPane.destroy();
this._splitPane = null;
this._splitSessionId = null;
// Before the refit below, so the main terminal gets its full height back.
this._splitHeaders?.a.el.remove();
this._splitHeaders = null;
this._updateSplitButtonState(false);
const container = document.querySelector('.terminal-split-container');
@@ -632,14 +266,16 @@ Object.assign(CodemanApp.prototype, {
parent.insertBefore(wrap, container);
container.remove();
if (this.fitAddon) this.fitAddon.fit();
// Pane A takes the whole width back and, with its header strip gone, its
// whole height: through syncTerminalGeometry (#464), never a bare
// fitAddon.fit(). sendResize fits that way as its first step.
// The Pane-A-ends branch of the _onSessionDeleted wrapper below collapses the split
// while activeSessionId is still the id the server just removed, so a
// resize from here would be aimed at a session that no longer exists;
// the promoted session gets its own resize from selectSession().
if (!options.skipPrimaryResize) {
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
}
// the promoted session gets its own resize from selectSession(), and the
// terminal is only refitted here.
if (options.skipPrimaryResize) this.syncTerminalGeometry?.();
else this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
},
// A click on .btn-split does one of two things — open the picker, or
@@ -647,6 +283,92 @@ Object.assign(CodemanApp.prototype, {
// nothing on the button said which. `.split-open` + aria-pressed give it
// the same active-state language as the codebase's other toggle buttons
// (keyboard-accessory's Ctrl key, the voice-input mic).
/**
* A split pane's header strip: the harness logo, the session name and the
* model, the three a grid tile's header shows. Built from nodes (the name
* and the model are untrusted text) and painted by _renderSplitChrome.
*/
_buildSplitPaneHeader() {
const el = document.createElement('div');
el.className = 'terminal-pane-b-header';
const harness = document.createElement('span');
harness.className = 'split-harness run-mode-dot';
harness.setAttribute('role', 'img');
const title = document.createElement('span');
title.className = 'split-title';
const name = document.createElement('span');
// `.session-name` is one of the translator's skipped surfaces (user text).
name.className = 'session-name';
// As on a tile: the name inside is never translated, the tooltip may be,
// and screen readers hear the model once, in the logo's accessible name.
const model = document.createElement('span');
model.className = 'split-model';
model.setAttribute('aria-hidden', 'true');
model.hidden = true;
const modelName = document.createElement('span');
modelName.setAttribute('data-i18n-skip', '');
model.appendChild(modelName);
title.append(name, model);
el.append(harness, title);
return { el, harness, name, model, modelName };
},
/**
* Both split headers from their sessions: Pane A shows the active session,
* Pane B its own. Runs after every tab render, so a rename or a model
* change reaches them; unchanged values write nothing.
*/
_renderSplitChrome() {
const headers = this._splitHeaders;
if (!headers || !this._splitPane) return;
for (const [parts, id] of [
[headers.a, this.activeSessionId],
[headers.b, this._splitSessionId],
]) {
const session = id ? this.sessions.get(id) : null;
if (!session) continue;
const name = this.getSessionName?.(session) || session.name || 'Session';
if (parts.nameValue !== name) {
parts.nameValue = name;
parts.name.textContent = name;
}
this._paintSessionHarness(parts, session, 'split-harness');
}
},
/**
* Paints a session header's harness logo and model: a grid tile's, and the
* split panes'. The logo is PR #532's `run-mode-dot <cliId>` slot (the id is
* data, never a branch), the model is text (describeSessionHarness,
* constants.js). Diffs against the values it last wrote, kept on `parts`,
* never against the DOM, which the translator may have rewritten: an
* unchanged session writes nothing, and this runs on every tab render.
*
* @param {{harness: HTMLElement, model: HTMLElement, modelName: HTMLElement}} parts - the
* header's nodes (the model's box and the name inside it); the memo lives here too
* @param {object} session - the session the header shows
* @param {string} logoClass - the header's own class for its logo
*/
_paintSessionHarness(parts, session, logoClass) {
const harness = window.CodemanSessionHarness.describeSessionHarness(session, window.__codemanCliCatalog);
const cls = `${logoClass} run-mode-dot${harness.id ? ` ${harness.id}` : ''}`;
if (parts.harnessClass !== cls) {
parts.harnessClass = cls;
parts.harness.className = cls;
}
if (parts.harnessTitle !== harness.title) {
parts.harnessTitle = harness.title;
parts.harness.title = harness.title;
parts.harness.setAttribute('aria-label', harness.title);
parts.model.title = harness.title;
}
if (parts.modelValue !== harness.model) {
parts.modelValue = harness.model;
parts.modelName.textContent = harness.model;
parts.model.hidden = !harness.model;
}
},
_updateSplitButtonState(open) {
const btn = document.querySelector('.btn-split');
if (!btn) return;
@@ -667,10 +389,9 @@ Object.assign(CodemanApp.prototype, {
// frame). Coalesced to one call per animation frame below — a raw
// mousemove stream fires far faster than the browser repaints, and
// without the rAF gate each event did a full xterm reflow on BOTH
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
// no client-side "dims unchanged" skip), which fanned out into a
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
// of each dragging across half a wide viewport.
// panes AND sent Pane B a `{t:'z'}` resize frame, which fanned out
// into a `tmux resize-window` child plus a SIGWINCH per frame, roughly
// fifty of each dragging across half a wide viewport.
const applyDragPercent = (clientX) => {
const container = divider.parentElement;
// The split can auto-collapse mid-drag (the other pane's session
@@ -806,6 +527,15 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
// this, Pane A rebinds to a session that Pane B's independent WebSocket is
// still attached to — two live WebSockets to one session, each claiming PTY
// dimensions via its own `{t:'z',...}` resize frame.
// Every tab render (any session change: a rename, a model switch) refreshes
// the split headers too, the way tile-grid.js refreshes the tile headers.
const _splitOriginalRenderSessionTabsImmediate = CodemanApp.prototype._renderSessionTabsImmediate;
CodemanApp.prototype._renderSessionTabsImmediate = function (...args) {
const result = _splitOriginalRenderSessionTabsImmediate.apply(this, args);
this._renderSplitChrome?.();
return result;
};
const _originalSelectSession = CodemanApp.prototype.selectSession;
CodemanApp.prototype.selectSession = function (sessionId, ...args) {
if (this._splitPane && this._splitSessionId === sessionId) {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -194,8 +194,8 @@ Object.assign(CodemanApp.prototype, {
</div>
`;
// Position: spawn from the parent tab if we can find it, else cascade.
const parentTab = parentSessionId ? document.querySelector(`.session-tab[data-id="${parentSessionId}"]`) : null;
// Position: spawn from the parent tab if it is painted, else cascade.
const parentTab = this._paintedSessionTab(parentSessionId);
if (parentTab) {
// _tabAnchor() puts the spawn point below the tab in header layout and to
// the RIGHT of it in sidebar layout, so the window never lands on the
@@ -312,9 +312,12 @@ Object.assign(CodemanApp.prototype, {
});
},
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
/**
* Genie the window toward the center of its tab, then invoke `done` to tear it
* down. A tab that is not painted (hidden by a search) tears down at once.
*/
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
const tab = this._paintedSessionTab(sessionId);
if (!tab || !element) {
done();
return;
@@ -781,7 +784,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
winList.push({ runId, parentSessionId, winRect: data.element.getBoundingClientRect() });
@@ -830,7 +833,7 @@ Object.assign(CodemanApp.prototype, {
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
const tab = this._paintedSessionTab(parentSessionId);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
-155
View File
@@ -1,155 +0,0 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Upload Screenshot - Codeman</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0d1117; color: #c9d1d9;
min-height: 100vh; min-height: 100dvh;
display: flex; align-items: center; justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
-webkit-text-size-adjust: 100%;
}
.container { max-width: 480px; width: 100%; padding: 24px; }
h1 { font-size: 1.4em; margin-bottom: 16px; color: #58a6ff; }
.drop-zone {
border: 2px dashed #30363d; border-radius: 12px;
padding: 48px 20px; text-align: center;
-webkit-tap-highlight-color: transparent;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone.active { border-color: #58a6ff; background: #161b22; }
.drop-zone p { margin-bottom: 12px; font-size: 1.1em; }
.drop-zone small { color: #8b949e; }
input[type=file] { display: none; }
.preview { margin-top: 16px; text-align: center; display: none; }
.preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #30363d; }
.preview .name { margin-top: 6px; font-size: 0.85em; color: #8b949e; word-break: break-all; }
button {
width: 100%; padding: 14px; margin-top: 16px;
background: #238636; color: #fff; border: none;
border-radius: 8px; font-size: 1.05em;
font-weight: 600; -webkit-appearance: none;
opacity: 0.4; pointer-events: none;
}
button.ready { opacity: 1; pointer-events: auto; }
button:active { background: #2ea043; }
.status { margin-top: 12px; padding: 10px; border-radius: 6px; text-align: center; display: none; }
.status.ok { display: block; background: #1a3a2a; color: #3fb950; }
.status.err { display: block; background: #3a1a1a; color: #f85149; }
.files { margin-top: 24px; }
.files h2 { font-size: 0.95em; color: #8b949e; margin-bottom: 8px; }
.files a { display: block; color: #58a6ff; text-decoration: none; padding: 6px 0; font-size: 0.9em; word-break: break-all; }
.files a:active { color: #79c0ff; }
.back { display: inline-block; margin-bottom: 12px; color: #8b949e; text-decoration: none; font-size: 0.9em; }
.back:active { color: #c9d1d9; }
</style>
</head><body>
<div class="container">
<a class="back" href="/">&larr; Back to Codeman</a>
<h1>Upload Screenshot</h1>
<div class="drop-zone" id="drop">
<p>Tap to select image</p>
<small>PNG, JPG, WebP &mdash; up to 10 MB</small>
</div>
<input type="file" id="file" accept="image/*">
<div class="preview" id="preview">
<img id="previewImg" alt="Preview">
<div class="name" id="previewName"></div>
</div>
<button id="btn">Upload</button>
<div class="status" id="status"></div>
<div class="files" id="files"></div>
</div>
<script>
(function() {
var drop = document.getElementById('drop');
var fileInput = document.getElementById('file');
var preview = document.getElementById('preview');
var previewImg = document.getElementById('previewImg');
var previewName = document.getElementById('previewName');
var btn = document.getElementById('btn');
var status = document.getElementById('status');
var filesDiv = document.getElementById('files');
var selectedFile = null;
drop.addEventListener('click', function() { fileInput.click(); });
fileInput.addEventListener('change', function() {
if (fileInput.files && fileInput.files[0]) pick(fileInput.files[0]);
});
// Drag-and-drop (desktop fallback)
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('active'); });
drop.addEventListener('dragleave', function() { drop.classList.remove('active'); });
drop.addEventListener('drop', function(e) {
e.preventDefault(); drop.classList.remove('active');
if (e.dataTransfer && e.dataTransfer.files[0]) pick(e.dataTransfer.files[0]);
});
function pick(f) {
selectedFile = f;
previewImg.src = URL.createObjectURL(f);
previewName.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
preview.style.display = 'block';
btn.classList.add('ready');
status.className = 'status';
status.style.display = 'none';
}
btn.addEventListener('click', function() {
if (!selectedFile) return;
btn.classList.remove('ready');
btn.textContent = 'Uploading\u2026';
var form = new FormData();
form.append('file', selectedFile);
fetch('/api/screenshots', { method: 'POST', body: form })
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.success) {
status.className = 'status ok';
status.textContent = 'Saved: ' + j.filename;
status.style.display = 'block';
selectedFile = null;
preview.style.display = 'none';
btn.textContent = 'Upload';
loadFiles();
} else {
status.className = 'status err';
status.textContent = j.error || 'Upload failed';
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
}
})
.catch(function(e) {
status.className = 'status err';
status.textContent = e.message;
status.style.display = 'block';
btn.classList.add('ready');
btn.textContent = 'Upload';
});
});
function loadFiles() {
fetch('/api/screenshots')
.then(function(r) { return r.json(); })
.then(function(j) {
if (j.files && j.files.length) {
var html = '<h2>Recent uploads</h2>';
j.files.forEach(function(f) {
html += '<a href="/api/screenshots/' + encodeURIComponent(f.name) + '" target="_blank">' + f.name + '</a>';
});
filesDiv.innerHTML = html;
}
})
.catch(function() {});
}
loadFiles();
})();
</script>
</body></html>
+64 -10
View File
@@ -554,6 +554,11 @@ const VoiceInput = {
_analyserSource: null, // MediaStreamSource for level meter
_audioContext: null, // AudioContext for level meter
_levelAnimFrame: null, // rAF handle for level meter
// The session dictation was started FOR, captured in start(). Transcripts
// arrive seconds later and the green send button / compose overlay can be
// used later still; reading app.activeSessionId at that point sent the text
// to whatever tab the user had switched to in the meantime.
_targetSessionId: null,
init() {
this._initRecognition();
@@ -663,10 +668,12 @@ const VoiceInput = {
start() {
if (this.isRecording) return;
if (!app.activeSessionId) {
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
if (!target) {
app.showToast('No active session', 'warning');
return;
}
this._targetSessionId = target;
this._retryCount = 0;
const provider = this._resolveProvider();
@@ -959,8 +966,31 @@ const VoiceInput = {
this.stop();
},
/** The session this dictation belongs to (see _targetSessionId). */
_targetSession() {
return this._targetSessionId || app.activeSessionId;
},
/**
* Send text to the dictation's own session. The active session keeps going
* through app.sendInput() exactly as before; any other session goes straight
* to the durable queue with the same useMux flag sendInput() passes.
*/
_sendToTarget(target, text) {
if (target === app.activeSessionId) return app.sendInput(text);
// Closed while dictating: say so instead of queueing text for a session
// that will only answer 404 (and never typing it into some other tab).
if (app.sessions && !app.sessions.has(target)) {
app.showToast?.('That session has closed; dictation not sent', 'warning');
return Promise.resolve();
}
app._sendInputAsync(target, text, { useMux: true });
return Promise.resolve();
},
_insertText(text) {
if (!app.activeSessionId || !text.trim()) return;
const target = this._targetSession();
if (!target || !text.trim()) return;
const trimmed = text.trim();
const mode = this._getDeepgramConfig().insertMode || 'direct';
@@ -975,14 +1005,32 @@ const VoiceInput = {
this._showComposeOverlay(trimmed);
}
} else {
// Direct mode: inject into local echo overlay if available, else send to PTY
if (app._localEchoEnabled && app._localEchoOverlay) {
// Direct mode: inject into local echo overlay if available, else send to PTY.
// The overlay belongs to the ACTIVE session's terminal, so text dictated
// for any other session must not be typed into it. It also belongs to the
// MAIN terminal, which the tile grid parks (display: none): with tiles
// open the text would sit in an invisible overlay the focused tile never
// sees, so it goes straight to the session instead.
const isActive = target === app.activeSessionId;
const tilesOpen = !!app._tilesOwnTerminal?.();
if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) {
app._localEchoOverlay.appendText(trimmed);
} else {
app.sendInput(trimmed).catch(() => {});
this._sendToTarget(target, trimmed).catch(() => {});
}
this._showVoiceSendBtn();
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
setTimeout(() => {
if (!isActive) return;
// With the grid open the keyboard belongs to the focused tile; the
// parked main terminal cannot take focus. Split view keeps the main
// terminal, even if Pane B took focus meanwhile (it is not the target).
if (app._tilesOwnTerminal?.()) {
const pane = app._focusedPane?.();
if (pane?.sessionId === target) pane.terminal?.focus();
} else if (app.terminal) {
app.terminal.focus();
}
}, 150);
}
},
@@ -1008,10 +1056,16 @@ const VoiceInput = {
// Click handler
this._voiceSendHandler = () => {
if (!app.activeSessionId) return;
const target = this._targetSession();
if (!target) return;
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
// otherwise just send \r directly to the PTY
if (app._localEchoEnabled && app._localEchoOverlay) {
// otherwise just send \r directly to the PTY. Both the overlay and the
// predictions belong to the ACTIVE session's MAIN terminal, so a
// dictation for another session, or for a tile while the grid has the
// main terminal parked, just sends its Enter there.
if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) {
this._sendToTarget(target, '\r').catch(() => {});
} else if (app._localEchoEnabled && app._localEchoOverlay) {
const text = app._localEchoOverlay.pendingText || '';
app._localEchoOverlay.clear();
app._localEchoOverlay.suppressBufferDetection();
@@ -1065,7 +1119,7 @@ const VoiceInput = {
const send = () => {
const val = textarea.value.trim();
overlay.remove();
if (val) app.sendInput(val + '\r').catch(() => {});
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
};
const cancel = () => overlay.remove();
const newInput = () => {
+57 -15
View File
@@ -274,7 +274,10 @@ Object.assign(CodemanApp.prototype, {
// one `/`, and refuse whatever still opens a second one. The proxied form
// is refused server-side as well (resolveUpstreamUrl).
const path = data.path.replace(/[\t\n\r]/g, '').replace(/^[/\\]+/, '/');
void this.openWebview(id, { path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/' });
void this.openWebview(id, {
path: path.startsWith('/') && !/^\/[/\\]/.test(path) ? path : '/',
auto: true,
});
return;
}
};
@@ -301,14 +304,28 @@ Object.assign(CodemanApp.prototype, {
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
if (!this.webviews.get(id)) continue;
parts.push(this.renderWebviewTab(id, idx));
idx++;
}
return parts.join('');
},
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
/**
* One web tab's HTML; `idx` is its zero-based Alt+N slot (no badge from 9 up).
* The grouped vertical rail places single web tabs into their group with this,
* so a web tab's markup is the same in every layout.
*/
renderWebviewTab(id, idx) {
const webview = this.webviews.get(id);
if (!webview) return '';
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
return `<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
oncontextmenu="app.handleWebviewTabContextMenu(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
@@ -319,10 +336,7 @@ Object.assign(CodemanApp.prototype, {
</span>
</span>
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span><span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span></span>
</div>`);
idx++;
}
return parts.join('');
</div>`;
},
_webviewGlobeIcon() {
@@ -334,6 +348,16 @@ Object.assign(CodemanApp.prototype, {
return this.openWebview(id);
},
/**
* Right-click (or the menu key) on a web tab in the vertical rail opens its
* actions (Focus, group moves, settings), the flat rail included, where it is
* the only way in. Everywhere else the browser keeps its own menu.
*/
handleWebviewTabContextMenu(event, id) {
if (!this._tabLayoutEditable?.()) return false;
return this.openTabWebviewMenu?.(event, id);
},
/** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() {
const container = this.$('sessionTabs');
@@ -345,6 +369,8 @@ Object.assign(CodemanApp.prototype, {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
this._syncTabTreeSelection?.(container);
this._renderTabFocus?.();
},
// ── Opening / closing ─────────────────────────────────────────────────────
@@ -356,15 +382,23 @@ Object.assign(CodemanApp.prototype, {
*/
/**
* @param {string} id
* @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
* @param {{path?: string, auto?: boolean}} [options] `path` (pathname+search+hash) opens a
* deep link inside the dashboard: appended to the proxy prefix, or resolved
* against the real URL in direct mode. A mounted frame is navigated there
* rather than left on whatever page it was showing.
* rather than left on whatever page it was showing. `auto: true` marks an
* open the APP made (a frame recovering itself, the fallback after the
* active web tab closes), as on selectSession().
*/
async openWebview(id, options = {}) {
const webview = this.webviews.get(id);
if (!webview) return;
// Opening a web tab yourself is choosing something else, so a
// `#session=<id>` link still waiting for its session must not take the
// screen from this tab later. Retired before the await below, which a
// session:created could otherwise land inside.
if (options.auto !== true) this._retireUrlSession?.();
if (!this.webviewOrder.includes(id)) {
this.webviewOrder.push(id);
this._persistWebviewOrder();
@@ -492,7 +526,15 @@ Object.assign(CodemanApp.prototype, {
openWebviewExternal(id) {
const webview = this.webviews.get(id || this.activeWebviewId);
if (webview) window.open(webview.url, '_blank', 'noopener');
if (!webview) return;
// A host that refuses must not fall through to window.open, which in a
// WebView can replace the dashboard page.
const hosted = this.openInHostWindow?.(webview.url) ?? null;
if (hosted !== null) {
if (!hosted) this.showToast('Could not open a new window for this dashboard', 'error');
return;
}
window.open(webview.url, '_blank', 'noopener');
},
closeWebviewTab(id) {
@@ -513,7 +555,7 @@ Object.assign(CodemanApp.prototype, {
this.activeWebviewId = null;
const next = this.webviewOrder[0];
if (next) {
this.openWebview(next);
this.openWebview(next, { auto: true });
} else {
this._hideWebviewLayer();
// Fall back to whatever session was last shown, or the welcome screen.
+13
View File
@@ -0,0 +1,13 @@
{
"version": "1.48.0",
"highlights": [
"Talk to your agents from Discord (#741)",
"Edit history panel (#693)",
"Custom endpoints go further (#752, #755, #757)",
"Clone into a custom folder, and make a folder from the picker (#744, #745)",
"Relative links follow your `cd` (#740)",
"File links keep the whole name (#735)",
"A failed settings save says so (#736)",
"Phone keys show a press (#737)"
]
}