mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge origin/master into relprep/pr-753
Conflict in src/session-cli-registry-bridge.ts: kept the PR's gateVersion() (gates measured against the gated CLI's own version) and added master's Discord startup hint engine value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
35ff4e886c
138 files changed
+17441
-502
No files matched your search
+35
-5
@@ -286,6 +286,10 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
|
||||
// Edit history (edit-history-ui.js): an open panel follows its session's tool ends
|
||||
// (and its turn ends, from _onSessionIdle: one handler per event in this map)
|
||||
[SSE_EVENTS.TRANSCRIPT_TOOL_END, '_onEditHistoryTranscriptEvent'],
|
||||
|
||||
// Tunnel
|
||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||
[SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'],
|
||||
@@ -3587,6 +3591,9 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// An open Edit history panel re-reads at the end of its session's turn (edit-history-ui.js),
|
||||
// not after Codeman's own keep-alive turn, which edits nothing.
|
||||
if (!data.keepAlive) this.onEditHistorySessionIdle?.(data.id);
|
||||
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
|
||||
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
|
||||
// turn, whose timer is still running or has already fired.
|
||||
@@ -7035,6 +7042,8 @@ class CodemanApp {
|
||||
container.classList.remove('tabs-corner');
|
||||
container.style.removeProperty('--tab-corner-width');
|
||||
container.style.removeProperty('--tab-corner-height');
|
||||
container.style.removeProperty('--tab-corner-first-max');
|
||||
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit');
|
||||
};
|
||||
// A strip past its max-height scrolls, and the float would scroll its tabs
|
||||
// under the toolbar, which stays put: keep the plain wrap. The plain strip
|
||||
@@ -7071,14 +7080,33 @@ class CodemanApp {
|
||||
const contentTop = strip.top + parseFloat(style.paddingTop);
|
||||
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
|
||||
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
|
||||
// A first tab that cannot fit beside the toolbar would drop the whole first
|
||||
// line below it and leave an empty band: keep the plain strip instead.
|
||||
const firstTab = container.querySelector(':scope > .session-tab');
|
||||
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
|
||||
// The first tab must fit beside the toolbar, or the whole first line drops
|
||||
// below it and leaves an empty band. A selected tab is wider (gear and
|
||||
// close show), so falling back to the plain strip here made the layout
|
||||
// follow the selection; the first tab's name is trimmed to the room
|
||||
// instead. Only a toolbar that leaves no usable room keeps the plain strip.
|
||||
// The room is what the float (rounded up below) leaves, less the tab's 2px
|
||||
// right margin and a pixel for subpixel layout; a tab at the exact width
|
||||
// still wrapped under the float.
|
||||
const floatWidth = Math.max(0, Math.ceil(contentRight - box.left + 8));
|
||||
const room = Math.floor(contentRight - floatWidth - contentLeft) - 3;
|
||||
if (room < TAB_CORNER_MIN_FIRST_TAB) {
|
||||
off();
|
||||
return;
|
||||
}
|
||||
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
|
||||
const firstTab = container.querySelector(':scope > .session-tab');
|
||||
// Capped every time, not only once it measures too wide: selectSession()
|
||||
// widens a newly selected tab at once and this pass is debounced, so a cap
|
||||
// set only here afterwards would leave the first row below the toolbar until
|
||||
// it runs. The cap changes nothing on a tab narrower than the room.
|
||||
for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) {
|
||||
if (tab !== firstTab) tab.classList.remove('tab-corner-fit');
|
||||
}
|
||||
firstTab?.classList.add('tab-corner-fit');
|
||||
if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) {
|
||||
container.style.setProperty('--tab-corner-first-max', `${room}px`);
|
||||
}
|
||||
const width = `${floatWidth}px`;
|
||||
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
|
||||
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
|
||||
container.style.setProperty('--tab-corner-width', width);
|
||||
@@ -9987,6 +10015,8 @@ class CodemanApp {
|
||||
// Remote-host reachability banner: only meaningful for a remote session, so this
|
||||
// also clears it when the newly active tab is local.
|
||||
this.refreshHostWakeBanner?.(sessionId);
|
||||
// An open Edit history panel follows the active session (edit-history-ui.js; no poll).
|
||||
this.onEditHistoryActiveSession?.(sessionId);
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
// the async buffer load. Without this, the offset is 0 during the
|
||||
|
||||
+114
-25
@@ -281,6 +281,11 @@ function resolveTabRailKeyboardWidth(input = {}) {
|
||||
// the gap is what tells the user there is more strip to swipe to.
|
||||
const TAB_SCROLL_REVEAL_PX = 16;
|
||||
|
||||
// Narrowest room beside the toolbar the L-shaped header strip trims its first
|
||||
// tab into (`_syncTabStripCorner()`, app.js). Less than this keeps the plain
|
||||
// wrap: a tab squeezed further shows its number and buttons but no name.
|
||||
const TAB_CORNER_MIN_FIRST_TAB = 120;
|
||||
|
||||
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
|
||||
// the strip horizontally (desktop wraps to a second row instead and never
|
||||
// scrolls), so the active tab can sit entirely outside the visible slice with
|
||||
@@ -2106,7 +2111,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -2159,14 +2164,14 @@ function previewsInFileViewer(filePath) {
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
|
||||
/**
|
||||
* Every linkable file path in `text`, sorted by position. Spaced candidates
|
||||
* reserve their whole span first, even when unlinkable; rooted, `~/`, relative
|
||||
* reserve their whole span first, including paths bounded by parentheses, even when unlinkable; rooted, `~/`, relative
|
||||
* and folder detectors then skip reserved spans, earlier matches and URLs.
|
||||
*
|
||||
* The one entry point for both consumers (terminal link provider, response
|
||||
@@ -2195,19 +2200,26 @@ function findFilePathLinks(text) {
|
||||
// A path with spaces is taken whole, or the span it covers links nothing
|
||||
// (spacedFilePathOnLine). Either way no detector below may cut a fragment out of it.
|
||||
const reserved = [];
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
const reserve = (spaced, index) => {
|
||||
if (!spaced) return;
|
||||
const start = index + spaced.index;
|
||||
if (inUrl(start, spaced.path.length) || isReserved(start, spaced.path.length)) return;
|
||||
reserved.push({ index: start, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index: start });
|
||||
};
|
||||
let lineStart = 0;
|
||||
for (const line of value.split('\n')) {
|
||||
const spaced = spacedFilePathOnLine(line);
|
||||
if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) {
|
||||
const index = lineStart + spaced.index;
|
||||
reserved.push({ index, path: spaced.path });
|
||||
if (spaced.link) found.push({ path: spaced.path, index });
|
||||
reserve(spacedFilePathOnLine(line), lineStart);
|
||||
// Parentheses bound an inline path just as a whole line bounds a standalone path.
|
||||
const parenthesised = /\(([^()\r\n]{1,1000})\)/g;
|
||||
while ((match = parenthesised.exec(line)) !== null) {
|
||||
if (opensBashCall(line, match.index)) continue;
|
||||
reserve(spacedFilePathOnLine(match[1], { parenthesised: true }), lineStart + match.index + 1);
|
||||
}
|
||||
lineStart += line.length + 1;
|
||||
}
|
||||
const isReserved = (index, length) =>
|
||||
reserved.some((r) => index < r.index + r.path.length && r.index < index + length);
|
||||
|
||||
const absolute = absoluteFilePathPattern();
|
||||
while ((match = absolute.exec(value)) !== null) {
|
||||
if (!inUrl(match.index, match[1].length) && !isReserved(match.index, match[1].length)) {
|
||||
@@ -2235,14 +2247,29 @@ function findFilePathLinks(text) {
|
||||
const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
/^(?:path|files?|saved (?:to|as)|wrote|written to|output|created|location|attachment):[ \t]*$/i;
|
||||
|
||||
/**
|
||||
* True when the `(` at `open` starts a `Bash(` tool row, which holds a command rather than
|
||||
* a path: `Bash(/tmp/a.out input.txt)` is a program and its argument, so the parentheses do
|
||||
* not bound one spaced path and the ordinary detectors keep the row. Only `Bash(`: the path
|
||||
* tools (`Read(`, `Update(`, `Write(`) do hold a path, spaces included.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} open Index of the `(`.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function opensBashCall(text, open) {
|
||||
return /(?:^|\W)Bash\($/.test(text.slice(Math.max(0, open - 5), open + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* A file path WITH SPACES on one line, or null.
|
||||
*
|
||||
* Every path pattern ends a path at whitespace, so `/mnt/c/OneDrive - Contoso/a.docx`
|
||||
* linked `/mnt/c/OneDrive` plus a relative `Contoso/a.docx`, two links to the
|
||||
* wrong targets. A space is only safe inside a path where the line itself bounds
|
||||
* the path, so a spaced path is recognised in exactly these shapes:
|
||||
* the path or a parenthesised group bounds it, so a spaced path is recognised in these shapes:
|
||||
*
|
||||
* Updated (/mnt/c/My Docs/report.docx) inside parentheses in prose
|
||||
* /mnt/c/My Docs/report.docx the whole line is the path
|
||||
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
|
||||
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN)
|
||||
@@ -2256,7 +2283,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* it ends on a known extension, a rooted one has a directory before its first
|
||||
* space, no earlier word holds an extension (`a.md,`, `a.ts#L12`) or ends in `/`,
|
||||
* and none holds a `:` and a digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:`, is an arrow or has
|
||||
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` (or `,` inside parentheses), is an arrow or has
|
||||
* no letter or digit (a prompt glyph such as `❯` or `$`; a bare `-` is kept), and
|
||||
* a run of spaces (a column gap) is refused, so two paths on a line are never
|
||||
* merged. A relative candidate whose only `/` word is its last is prose after the
|
||||
@@ -2278,9 +2305,10 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
|
||||
* Linear: one bounded prefix match, then string scans.
|
||||
*
|
||||
* @param {string} line One line, no newline.
|
||||
* @param {{parenthesised?: boolean}} [options] Refuse comma-separated lists inside parentheses only.
|
||||
* @returns {{path: string, index: number, link: boolean} | null}
|
||||
*/
|
||||
function spacedFilePathOnLine(line) {
|
||||
function spacedFilePathOnLine(line, options = {}) {
|
||||
const text = String(line || '');
|
||||
const prefix = /^[ \t]*((?:\d{1,3}[.)]|[-*•])[ \t]+)?((?:[A-Za-z][\w-]{0,30}(?: [\w-]{1,30}){0,3}):[ \t]+)?/.exec(text);
|
||||
const index = prefix[0].length;
|
||||
@@ -2304,6 +2332,7 @@ function spacedFilePathOnLine(line) {
|
||||
}
|
||||
for (const word of words.slice(0, -1)) {
|
||||
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-|=)$/.test(word)) return null;
|
||||
if (options.parenthesised && word.endsWith(',')) return null;
|
||||
// A shell prompt that prints its working directory in full (`/home/me/proj$ cat
|
||||
// notes.md`; WSL's `/mnt/c/...` is outside `$HOME`) is a command line too, and so is
|
||||
// one whose directory has a space in it (`/mnt/c/My Docs$ ls notes.md`): the `$`
|
||||
@@ -2397,6 +2426,31 @@ function resolveLinkedFilePath(filePath, workingDir) {
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory a RELATIVE path printed for a session is relative TO.
|
||||
*
|
||||
* `workingDir` is recorded once at create time and never follows a `cd`, so it
|
||||
* stops describing where a shell (or an agent that changed directory) really
|
||||
* is. `session.paneCwd` is the pane's live `#{pane_current_path}`, published by the
|
||||
* server from its pane read. It only changes which BASE a relative path joins;
|
||||
* the result is then routed like any other path, so a target outside the
|
||||
* workspace still goes through the attachment routes.
|
||||
*
|
||||
* Remote and Docker sessions are excluded (the server never sends them a paneCwd,
|
||||
* and this checks again): their local pane runs the ssh / docker-exec CLIENT,
|
||||
* so its directory says nothing about the agent. An unknown paneCwd falls back to
|
||||
* `workingDir`.
|
||||
*
|
||||
* @param {{workingDir?: string, paneCwd?: string, remote?: unknown, docker?: unknown}} [session]
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function relativeResolutionBase(session) {
|
||||
if (!session) return undefined;
|
||||
const paneCwd = session.paneCwd;
|
||||
if (!session.remote && !session.docker && typeof paneCwd === 'string' && paneCwd.startsWith('/')) return paneCwd;
|
||||
return session.workingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions the file preview renders as a PAGE, from the sandboxed
|
||||
* `/html-view/<cap>/` route (src/web/routes/html-view-routes.ts, whose
|
||||
@@ -2557,9 +2611,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0]
|
||||
* top directory the broken token did (the next entry of a listing).
|
||||
*/
|
||||
function pathContinuation(prevText, nextText) {
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const previous = String(prevText || '').trimEnd();
|
||||
const next = String(nextText || '').replace(/^\s+/, '');
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(previous)?.[0] || '';
|
||||
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
|
||||
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
|
||||
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
|
||||
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
|
||||
@@ -2586,7 +2641,9 @@ function pathContinuation(prevText, nextText) {
|
||||
* A following logical line is glued on (its indent dropped, cell mapping kept
|
||||
* exact) when {@link pathContinuation} says it carries the path on and the row it
|
||||
* broke off is at least as long as the row it continues on. Bounded by the same
|
||||
* `maxRows` as the logical line.
|
||||
* `maxRows` as the logical line. Hard breaks concatenate without inserting spaces.
|
||||
* An ambiguous parenthesised filename also carries `pathAlternatives` for a
|
||||
* guarded lookup on activation; its text and cell mapping remain unchanged.
|
||||
*
|
||||
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
|
||||
*/
|
||||
@@ -2607,6 +2664,7 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
|
||||
const extend = (first) => {
|
||||
const parts = [first];
|
||||
const alternatives = [];
|
||||
let text = first.text;
|
||||
let rows = rowsOf(first);
|
||||
let confirmed = 1;
|
||||
@@ -2616,25 +2674,41 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
const kind = continues(parts[parts.length - 1], next, text);
|
||||
if (!kind) break;
|
||||
parts.push(next);
|
||||
if (kind === 'join') {
|
||||
const opening = text.lastIndexOf('(');
|
||||
const closing = next.text.indexOf(')');
|
||||
if (opening >= 0 && closing > 0 && !opensBashCall(text, opening)) {
|
||||
const before = text.slice(opening + 1);
|
||||
const after = next.text.slice(0, closing).trimStart();
|
||||
const spaced = spacedFilePathOnLine(`${before} ${after}`, { parenthesised: true });
|
||||
if (!before.includes(')') && !/[/-]$/.test(before) && !after.includes('/') && spaced?.link) {
|
||||
alternatives.push({ path: before.slice(spaced.index) + after, spacedPath: spaced.path });
|
||||
}
|
||||
}
|
||||
}
|
||||
text += next.text.replace(/^\s+/, '');
|
||||
rows += rowsOf(next);
|
||||
if (kind === 'join') confirmed = parts.length;
|
||||
}
|
||||
return parts.slice(0, confirmed);
|
||||
return { parts: parts.slice(0, confirmed), alternatives };
|
||||
};
|
||||
|
||||
// The hovered row may be the second or third line of a broken path: walk back
|
||||
// to where it could start, then let the forward pass decide what really joins.
|
||||
// A middle fragment may have no slash, so only a forward pass from its
|
||||
// rooted start can confirm a path spanning three or more rows.
|
||||
let extended = extend(base);
|
||||
let first = base;
|
||||
let rows = rowsOf(base);
|
||||
while (first.startRow > 0) {
|
||||
const prev = lineAt(first.startRow - 1);
|
||||
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
|
||||
if (!prev || rows + rowsOf(prev) > bound) break;
|
||||
first = prev;
|
||||
rows += rowsOf(prev);
|
||||
const tail = /[^\s"'<>|;&]+$/.exec(first.text.trimEnd())?.[0] || '';
|
||||
if (!tail.includes('/')) continue;
|
||||
const candidate = extend(first);
|
||||
if (candidate.parts[candidate.parts.length - 1].endRow >= base.startRow) extended = candidate;
|
||||
}
|
||||
let parts = extend(first);
|
||||
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
|
||||
const { parts, alternatives } = extended;
|
||||
if (parts.length === 1) return parts[0];
|
||||
|
||||
const segments = [];
|
||||
@@ -2659,7 +2733,14 @@ function terminalPathLine(buffer, row, cols, maxRows) {
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow: parts[0].startRow,
|
||||
endRow: parts[parts.length - 1].endRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(alternatives.length ? { pathAlternatives: alternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2915,7 +2996,14 @@ function terminalLinkLine(buffer, row, cols, maxRows) {
|
||||
for (const s of segments) if (s.row === targetRow && (!seg || s.colStart <= targetCol)) seg = s;
|
||||
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
|
||||
};
|
||||
return { startRow, endRow: lastRow, text, offsetToCell, cellToOffset };
|
||||
return {
|
||||
startRow,
|
||||
endRow: lastRow,
|
||||
text,
|
||||
offsetToCell,
|
||||
cellToOffset,
|
||||
...(base.pathAlternatives ? { pathAlternatives: base.pathAlternatives } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4025,6 +4113,7 @@ if (typeof window !== 'undefined') {
|
||||
absoluteFilePathPattern,
|
||||
findFilePathLinks,
|
||||
resolveLinkedFilePath,
|
||||
relativeResolutionBase,
|
||||
previewsInFileViewer,
|
||||
FILE_PREVIEW_EXTENSIONS,
|
||||
rendersAsPage,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -553,6 +553,39 @@
|
||||
'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': '项目洞察',
|
||||
@@ -615,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)',
|
||||
@@ -624,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': '仅活动标签页',
|
||||
@@ -917,6 +959,8 @@
|
||||
'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': '推送通知已启用',
|
||||
@@ -1379,6 +1423,7 @@
|
||||
[/^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}`],
|
||||
@@ -1447,6 +1492,17 @@
|
||||
(_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} 正在终端中等待你的回答`],
|
||||
|
||||
+147
-1
@@ -216,6 +216,13 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<!-- Edit history of the active session (edit-history-ui.js), next to Attachments: both show
|
||||
what the session produced, and the panel opens right below. Optional and per-device
|
||||
(App Settings → Header & Panels → Header, default OFF): hidden by its marker class,
|
||||
never on phones (mobile.css). -->
|
||||
<button type="button" class="btn-icon-header btn-edit-history btn-edit-history--hidden" id="editHistoryBtn" aria-expanded="false" aria-controls="editHistoryPanel" title="Edit history" aria-label="Open edit history" onclick="app.toggleEditHistoryPanel()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="icon-folder-closed" d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><g class="icon-folder-open"><path d="M3 17V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1.5"/><path d="M3 17l2.3-5.4A2 2 0 0 1 7.2 10.5H20a1.5 1.5 0 0 1 1.4 2l-1.9 5.2A2 2 0 0 1 17.6 19H5a2 2 0 0 1-2-2z"/></g></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
|
||||
@@ -633,6 +640,22 @@
|
||||
<div class="git-status-footer" id="gitStatusFooter" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- Edit history panel (edit-history-ui.js): every file edit the active session's agents made. -->
|
||||
<div class="edit-history-panel" id="editHistoryPanel" role="dialog" aria-label="Edit history">
|
||||
<div class="edit-history-header">
|
||||
<span class="edit-history-title">Edit history <span class="edit-history-count" id="editHistoryCount" data-i18n-skip></span></span>
|
||||
<div class="edit-history-actions">
|
||||
<button class="btn-icon-sm" onclick="app.refreshEditHistory()" title="Refresh" aria-label="Refresh edit history">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeEditHistoryPanel()" title="Close" aria-label="Close edit history">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-history-controls">
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryGroupToggle" onchange="app.setEditHistoryGrouped(this.checked)"><span>Group by file</span></label>
|
||||
<label class="edit-history-toggle"><input type="checkbox" id="editHistoryMainOnlyToggle" onchange="app.setEditHistoryMainOnly(this.checked)"><span>Main agent only</span></label>
|
||||
</div>
|
||||
<div class="edit-history-body" id="editHistoryBody" data-i18n-skip></div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Overlay -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay">
|
||||
<div class="file-preview-window">
|
||||
@@ -1033,6 +1056,20 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="form-hint" id="customModelPickHint"></p>
|
||||
<!-- Case-insensitive filter over the rendered rows (app.filterCustomModelPick):
|
||||
a display layer only, the rows themselves are never re-rendered. Reset on every
|
||||
open and focused only on non-touch devices, same in-memory rule as the rail's
|
||||
search box. -->
|
||||
<input type="search" id="customModelPickSearch" class="session-sidebar-filter-input custom-model-pick-search"
|
||||
placeholder="Search models" aria-label="Search models"
|
||||
autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search"
|
||||
oninput="app.filterCustomModelPick(this.value)">
|
||||
<p class="form-hint custom-model-pick-no-matches" id="customModelPickNoMatches" role="status" hidden>No models match</p>
|
||||
<!-- Shown only when no row carries a loaded/in-use/last-used mark AND the
|
||||
running-status probe answered `isLlamaSwap: false` (see
|
||||
_openCustomModelPickModal): it says out loud WHY nothing is marked, in the user's language, rather than leaving an
|
||||
alphabetical wall that looks like the question was ignored. -->
|
||||
<p class="form-hint custom-model-pick-unknown" id="customModelPickUnknown" role="status" hidden>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.</p>
|
||||
<div id="customModelPickList" class="run-mode-custom-models"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2191,6 +2228,7 @@
|
||||
<label class="set-chip" data-preview="header" data-preview-order="5"><input type="checkbox" id="appSettingsShowAwayDigestButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg><span>Away Digest</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="6"><input type="checkbox" id="appSettingsShowSessionButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg><span>Session Manager</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="9"><input type="checkbox" id="appSettingsShowAttachmentsButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>Attachments</span></label>
|
||||
<label class="set-chip" data-search="edit history edits changed files diffs" data-preview="header" data-preview-order="9.5"><input type="checkbox" id="appSettingsShowEditHistory"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg><span>Edit history</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="10"><input type="checkbox" id="appSettingsShowFileViewerButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span>File Viewer</span></label>
|
||||
<label class="set-chip" data-preview="header" data-preview-order="11"><input type="checkbox" id="appSettingsShowMultiMonitorButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg><span>Multi-monitor</span></label>
|
||||
<label class="set-chip" data-search="split pane side by side two sessions" data-preview="header" data-preview-order="11.5"><input type="checkbox" id="appSettingsShowSplitButton"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg><span>Split</span></label>
|
||||
@@ -2791,6 +2829,10 @@
|
||||
<div class="set-row-text"><span class="set-row-label">Base URL</span></div>
|
||||
<input type="text" id="customModelHostBaseUrl" class="set-input" placeholder="http://192.168.1.50:8080">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Default context length (tokens)</span><span class="set-row-desc">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.</span></div>
|
||||
<input type="number" id="customModelHostDefaultContextLength" class="set-input" min="1" max="100000000" step="1" placeholder="131072">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">API key</span><span class="set-row-desc">Optional. Left blank on edit keeps the existing key.</span></div>
|
||||
<input type="password" id="customModelHostApiKey" class="set-input" autocomplete="new-password">
|
||||
@@ -3241,6 +3283,97 @@
|
||||
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-group" id="discordGroup" style="display:none">
|
||||
<div class="set-group-head"><h4>Discord (two-way, one thread per tab)</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="discord bot thread reply phone notification blocked">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Talk to your sessions from Discord</span>
|
||||
<span class="set-row-desc">Posts alerts into a thread per tab and types your reply in that thread into the tab. Only the user IDs listed below can reply. Setup (bot, least-privilege permissions) is in docs/discord-bridge.md. This group has its own Save button.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bot token</span>
|
||||
<span class="set-row-desc" id="discordTokenHint">Nothing saved yet.</span>
|
||||
</div>
|
||||
<input type="password" id="discordToken" class="set-input" autocomplete="off" spellcheck="false" placeholder="Bot token from the Developer Portal">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Guild (server) ID</span></div>
|
||||
<input type="text" id="discordGuildId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Parent channel ID</span><span class="set-row-desc">Threads are created inside this channel.</span></div>
|
||||
<input type="text" id="discordParentChannelId" class="set-input" autocomplete="off" spellcheck="false" inputmode="numeric">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Allowed user IDs</span><span class="set-row-desc">Comma separated. Only these Discord users can reply into a session.</span></div>
|
||||
<input type="text" id="discordAllowedUserIds" class="set-input" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Create threads</span></div>
|
||||
<select id="discordThreadCreation" class="set-select">
|
||||
<option value="lazy">On the first alert</option>
|
||||
<option value="onSessionStart">For every new tab</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">When a tab is deleted</span><span class="set-row-desc">Deleting a thread needs the Manage Threads permission on the channel.</span></div>
|
||||
<select id="discordOnSessionDelete" class="set-select">
|
||||
<option value="keep">Keep the thread</option>
|
||||
<option value="archive">Archive the thread</option>
|
||||
<option value="delete">Delete the thread</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. This includes threads that "When a tab is deleted" kept or archived. Threads under an hour old are skipped. Use one channel per Codeman.</span></div>
|
||||
<select id="discordStaleThreads" class="set-select">
|
||||
<option value="keep">Leave them</option>
|
||||
<option value="archive">Archive them</option>
|
||||
<option value="delete">Delete them</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
|
||||
<select id="discordPingMode" class="set-select">
|
||||
<option value="off">Never</option>
|
||||
<option value="critical">Critical events only</option>
|
||||
<option value="all">Every post</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Tell Claude how to reach you</span>
|
||||
<span class="set-row-desc">Adds a short note about the codeman notify command to Claude's system prompt at launch, for when it is blocked on something only you can do. Nothing appears in the conversation. Local tabs only: remote and Docker tabs have no codeman command.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="discordStartupHint"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Which events</span>
|
||||
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
|
||||
</div>
|
||||
<select id="discordScope" class="set-select">
|
||||
<option value="attention">Needs attention</option>
|
||||
<option value="all">Everything</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Save and test</span>
|
||||
<span class="set-row-desc">Send test saves first, then posts to the parent channel.</span>
|
||||
</div>
|
||||
<span>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="discordSaveBtn" onclick="app.saveDiscord()">Save</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordTestBtn" onclick="app.testDiscord()">Send test</button>
|
||||
<button class="btn-toolbar btn-sm" id="discordClearBtn" onclick="app.clearDiscord()" style="display:none">Remove token</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="discordResult" class="set-note" style="display:none" data-i18n-skip></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Voice ════════════════════════════════════════════════════ -->
|
||||
@@ -3661,7 +3794,19 @@
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
|
||||
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathToggleRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
|
||||
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathRow" style="display:none">
|
||||
<label>Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
@@ -4527,6 +4672,7 @@
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="home-sessions.js"></script>
|
||||
<script defer src="git-status-ui.js"></script>
|
||||
<script defer src="edit-history-ui.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -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>+📁</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">↻</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';
|
||||
|
||||
@@ -586,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. */
|
||||
@@ -3473,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) {
|
||||
|
||||
@@ -4195,7 +4195,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an out-of-workspace path as a live external attachment and return
|
||||
* Register a file path as a live external attachment and return
|
||||
* its id, so the preview can render it through the by-id attachment routes.
|
||||
*
|
||||
* `notify: false` keeps this quiet: the caller is already opening the file in
|
||||
@@ -4204,7 +4204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* full attachment guard (blocked secret trees, extension allowlist, symlinks
|
||||
* resolved), so a refusal here is a policy answer worth showing verbatim.
|
||||
*
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string}>}
|
||||
* @returns {Promise<{attachmentId?: string, size?: number, error?: string, status?: number}>}
|
||||
*/
|
||||
async _registerExternalPreview(filePath, sessionId) {
|
||||
try {
|
||||
@@ -4220,14 +4220,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const reason = result?.error || `Cannot open this file (HTTP ${res.status})`;
|
||||
// The registry's type answer is a policy term, not an explanation, and the
|
||||
// user just clicked a file they can see on disk. Say what IS previewable
|
||||
// from outside the workspace instead.
|
||||
// through this lookup instead, using the path's actual workspace scope.
|
||||
if (/unsupported/i.test(reason)) {
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
const target = this._isExternalPreviewPath(filePath, sessionId)
|
||||
? `.${ext} from outside the session workspace`
|
||||
: `"${filePath}" through the attachment lookup`;
|
||||
return {
|
||||
error: `Cannot preview .${ext} from outside the session workspace (images, video, audio, PDF, Office documents and text files only).`,
|
||||
status: res.status,
|
||||
error: `Cannot preview ${target} (images, video, audio, PDF, Office documents and text files only).`,
|
||||
};
|
||||
}
|
||||
return { error: reason };
|
||||
return { error: reason, status: res.status };
|
||||
} catch (err) {
|
||||
return { error: err.message || 'Cannot open this file' };
|
||||
}
|
||||
|
||||
@@ -601,13 +601,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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.
|
||||
// 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._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Respawn presets'))
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
{
|
||||
"notices": [
|
||||
{
|
||||
"id": "opencode-console-breach-2026-10",
|
||||
"severity": "warning",
|
||||
"cli": "opencode",
|
||||
"published": "2026-10-17",
|
||||
"expires": "2026-12-31",
|
||||
"title": "OpenCode Console breach: check your API keys",
|
||||
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
|
||||
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
|
||||
"i18n": {
|
||||
"zh-CN": {
|
||||
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
|
||||
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
|
||||
"label": "查看 OpenCode 公告"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
{ "notices": [] }
|
||||
+214
-26
@@ -735,12 +735,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// where settings.json is server-wide and its PUT admin-only: the mode is kept as their own
|
||||
// on this device and wins 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 (_noteSettingsWriteRefusal).
|
||||
// read-only for the writes after it (_noteSettingsWriteRefusal); any other failure is toasted.
|
||||
if (this._settingsReadOnly?.()) {
|
||||
this._rememberOwnSetting?.('runMode', mode);
|
||||
} else {
|
||||
this._apiPut('/api/settings', { runMode: mode })
|
||||
.then((res) => this._noteSettingsWriteRefusal?.(res))
|
||||
.then((res) => this._noteSettingsWriteResult?.(res, 'Run mode'))
|
||||
.catch(() => {});
|
||||
}
|
||||
// Close menu
|
||||
@@ -1002,12 +1002,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the model llama-swap currently has loaded and ready on this
|
||||
* endpoint, so the picker can offer it first instead of making the user remember what
|
||||
* they picked last time it mattered. Mirrors `_watchLlamaSwapLoading`'s own
|
||||
* `state === 'ready'` check. Returns null for a plain (non-llama-swap) server, an
|
||||
* unreachable endpoint, or a loaded model this host no longer lists as discovered —
|
||||
* never throws, since a failed probe should just skip promotion, not break the picker.
|
||||
* Best-effort lookup of what the endpoint itself says it has loaded and ready, so the
|
||||
* picker can offer it first instead of making the user remember what they picked last
|
||||
* time it mattered. Only llama-swap answers this question (the route reads its
|
||||
* `/running`); every other OpenAI-compatible server (a bare llama.cpp, Ollama, vLLM,
|
||||
* or a gateway such as Bifrost fronting a whole fleet) replies `isLlamaSwap: false`,
|
||||
* which is why `_getCustomModelInUseBySession` exists. `state === 'ready'` mirrors
|
||||
* `_watchLlamaSwapLoading`'s own check.
|
||||
*
|
||||
* Resolves the raw route payload rather than a model id so the caller can tell the
|
||||
* three answers apart: llama-swap naming a model; `isLlamaSwap: false`, which is what
|
||||
* the route answers for EVERY failure it catches (a server without a `/running`
|
||||
* route, a refused or unresolvable endpoint, a 401 from a wrong key): safe to
|
||||
* explain in the UI as long as the wording also fits an endpoint that is down; and
|
||||
* `null`, only when Codeman's own request to the route failed or the client-side
|
||||
* bound below won the race, which stays silent, since the endpoint may merely have
|
||||
* been asleep. Never throws: a failed probe should
|
||||
* just skip promotion, not break the picker.
|
||||
*
|
||||
* ⚠️ Client-side bounded to ~800ms via Promise.race, on top of (never instead of) the
|
||||
* route's own 5s server-side timeout (`RUNNING_TIMEOUT_MS`, custom-model-routes.ts) —
|
||||
@@ -1024,17 +1035,58 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `vi.useFakeTimers()` patches, so a param is the only way to test the timeout without
|
||||
* actually waiting on it. Real callers never pass it.
|
||||
*/
|
||||
async _getCustomModelCurrentlyLoaded(host, timeoutMs = 800) {
|
||||
async _getCustomModelRunningStatus(host, timeoutMs = 800) {
|
||||
const probe = this._apiJson(`/api/model-endpoints/${encodeURIComponent(host.id)}/running-status`).catch(
|
||||
() => null
|
||||
);
|
||||
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs));
|
||||
const status = await Promise.race([probe, timeout]);
|
||||
return Promise.race([probe, timeout]);
|
||||
},
|
||||
|
||||
/**
|
||||
* The model llama-swap reports `ready` on this endpoint AND that this host still lists
|
||||
* as discovered (an undropped-but-undiscovered model has no row to promote), else null.
|
||||
*/
|
||||
_pickReadyRunningModel(status, host) {
|
||||
if (!status?.isLlamaSwap) return null;
|
||||
const ready = (status.running || []).find((r) => r.state === 'ready' && (host.models || []).includes(r.model));
|
||||
return ready?.model || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* The model a LIVE session of this instance is pointed at on `host`, or null. It is the
|
||||
* fallback for every endpoint that cannot answer "what is loaded?" about itself.
|
||||
* A session already running on the endpoint is first-hand evidence of a model in use
|
||||
* there: Codeman itself injected that endpoint and model into it, so it earns the same
|
||||
* promotion llama-swap's own answer gets, under its own honest tag.
|
||||
*
|
||||
* ⚠️ Reads ONLY `customModel` (what Codeman injected), never `displayModel`: that field
|
||||
* is display text scraped off a CLI's footer or statusline for a session header (pane
|
||||
* derived, `source: 'screen'`), and promoting a row because a terminal printed a
|
||||
* similar string is worse than showing nothing.
|
||||
* ⚠️ Requires a live pane: `status: 'stopped'`, a null `pid` (the PTY exited; the
|
||||
* same live-pane test keep-warm applies, `src/prompt-cache-keepwarm.ts`) or a recorded
|
||||
* `paneExit` means nothing is on the endpoint any more, and a dead session's stale pin
|
||||
* is exactly what an "in use" claim must not resurrect. `session:updated` keeps it
|
||||
* current, so a session cleared back to the CLI's own backend or moved to another
|
||||
* endpoint stops counting.
|
||||
* ⚠️ A since-deleted endpoint simply matches no host, so no extra liveness check is
|
||||
* needed here.
|
||||
*/
|
||||
_getCustomModelInUseBySession(host) {
|
||||
const models = host?.models || [];
|
||||
if (models.length === 0) return null;
|
||||
let newest = null;
|
||||
for (const [, session] of this.sessions || []) {
|
||||
if (!session || session.status === 'stopped' || session.pid === null || session.paneExit) continue;
|
||||
const pinned = session.customModel;
|
||||
if (!pinned || pinned.endpointId !== host.id) continue;
|
||||
if (!models.includes(pinned.modelId)) continue; // undiscovered since: no row to promote
|
||||
if (!newest || (newest.createdAt || 0) <= (session.createdAt || 0)) newest = session;
|
||||
}
|
||||
return newest?.customModel?.modelId || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the "which model" picker for a (harness, endpoint) pair with more than one
|
||||
* discovered model. Async since it now awaits the currently-loaded-model probe below,
|
||||
@@ -1055,15 +1107,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const generation = (this._customModelPickGeneration = (this._customModelPickGeneration || 0) + 1);
|
||||
const isCurrent = () => this._customModelPickGeneration === generation;
|
||||
|
||||
// Whichever model llama-swap actually has loaded right now beats a merely
|
||||
// remembered choice — it's what a launch would attach to with zero wait, while
|
||||
// "last used" might have been swapped out by another session since. Neither
|
||||
// reorders past the top: exactly one model is promoted, everything else keeps
|
||||
// its discovery order.
|
||||
const currentlyLoaded = await this._getCustomModelCurrentlyLoaded(host);
|
||||
// Three kinds of knowledge, best first: what llama-swap says it has loaded right
|
||||
// now (zero wait to attach to), what a live session of this instance is pointed at
|
||||
// on that endpoint (the endpoint itself cannot say, but Codeman put it there), and
|
||||
// only then this device's memory of the last launch. One wins, and it never reorders
|
||||
// anything else: every other row keeps its discovery order.
|
||||
const runningStatus = await this._getCustomModelRunningStatus(host);
|
||||
if (!isCurrent()) return; // a newer pick opened (and possibly already rendered) while this probe was in flight
|
||||
const lastUsed = currentlyLoaded ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || lastUsed;
|
||||
const currentlyLoaded = this._pickReadyRunningModel(runningStatus, host);
|
||||
const inUse = currentlyLoaded ? null : this._getCustomModelInUseBySession(host);
|
||||
const lastUsed = currentlyLoaded || inUse ? null : this._getCustomModelLastUsed(mode, host.id);
|
||||
const promoted = currentlyLoaded || inUse || lastUsed;
|
||||
const models = [...(host.models || [])];
|
||||
if (promoted && models.includes(promoted)) {
|
||||
models.splice(models.indexOf(promoted), 1);
|
||||
@@ -1078,23 +1132,91 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelPickTitle').textContent = 'Choose a model';
|
||||
document.getElementById('customModelPickHint').textContent =
|
||||
`${cliLabel} → ${host.label} — ${(host.models || []).length} models discovered.`;
|
||||
// A plain static line (its markup lives in index.html), shown when NOTHING is known
|
||||
// about any row: 40 alphabetical rows with no mark on any of them otherwise read as
|
||||
// the app ignoring the question. `isLlamaSwap: false` is what the route answers for
|
||||
// every failure it catches: a server that cannot report AND a down or refused one
|
||||
// alike (`getLlamaSwapStatus`, custom-model-routes.ts), so the line's wording
|
||||
// promises only that nothing was reported, which stays true either way. A probe
|
||||
// that never got an answer at all (`runningStatus === null`: Codeman's own request
|
||||
// failed or the 800ms bound won) keeps the line hidden, the box may merely be
|
||||
// asleep.
|
||||
// A remembered id that has since vanished from host.models is no promotion: count
|
||||
// only a promoted id that is actually a row, or a stale "last used" would both skip
|
||||
// tagging any row and hide this line, leaving 40 unmarked rows with no explanation.
|
||||
const nothingKnown = !(promoted && models.includes(promoted)) && runningStatus?.isLlamaSwap === false;
|
||||
const unknownLine = document.getElementById('customModelPickUnknown');
|
||||
if (unknownLine) unknownLine.hidden = !nothingKnown;
|
||||
list.innerHTML = models
|
||||
.map((m) => {
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what
|
||||
// llama-swap (or this device's history) knows about the model, the Default pill
|
||||
// says what the saved endpoint says about it, and on a single-purpose GPU box the
|
||||
// promoted model IS the default more often than not. One slot holding whichever
|
||||
// applied first silently dropped the Default marking for exactly that row.
|
||||
const promotion = m === currentlyLoaded ? 'Currently loaded' : m === lastUsed ? 'Last used' : null;
|
||||
// Two independent tags, never one exclusive slot: the promotion tag says what the
|
||||
// endpoint, a live session, or this device's history knows about the model, the
|
||||
// Default pill says what the saved endpoint says about it, and on a
|
||||
// single-purpose GPU box the promoted model IS the default more often than not.
|
||||
// One slot holding whichever applied first silently dropped the Default marking
|
||||
// for exactly that row.
|
||||
const promotion =
|
||||
m === currentlyLoaded
|
||||
? 'Currently loaded'
|
||||
: m === inUse
|
||||
? 'In use by a session'
|
||||
: m === lastUsed
|
||||
? 'Last used'
|
||||
: null;
|
||||
const tags = [promotion, m === host.defaultModelId ? 'Default' : null].filter(Boolean);
|
||||
const arg = escapeHtml(JSON.stringify(m));
|
||||
// data-model-id is the filter's haystack (escapeHtml covers `"` as well as
|
||||
// `<>&`, so a hostile model id cannot break out of the attribute); the
|
||||
// filter reads it back through dataset, which decodes it.
|
||||
return `
|
||||
<button class="run-mode-option" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<button class="run-mode-option" data-model-id="${escapeHtml(m)}" onclick="app.chooseCustomModelAndRun(${arg})">
|
||||
<span class="run-mode-dot ${escapeHtml(mode)}"></span>${escapeHtml(m)}${tags.map((t) => ` <span class="set-scope">${escapeHtml(t)}</span>`).join('')}
|
||||
</button>`;
|
||||
})
|
||||
.join('');
|
||||
modal.classList.add('active');
|
||||
// A fresh open always starts unfiltered with the caret in the search box: the
|
||||
// list was just rebuilt, so clearing the input is the whole reset; focus is the
|
||||
// user's next keystroke away. Inside the generation guard above, so a
|
||||
// superseded probe can neither focus nor clear a modal that now shows another
|
||||
// endpoint's models.
|
||||
const search = document.getElementById('customModelPickSearch');
|
||||
if (search) {
|
||||
search.value = '';
|
||||
// readmymind-ui.js's rule: on touch devices never focus an input unprompted, or
|
||||
// the OS keyboard pops over the very list the picker just opened to show. Desktop
|
||||
// keeps the caret in the box, one keystroke from filtering.
|
||||
if (!(typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice())) {
|
||||
search.focus();
|
||||
}
|
||||
}
|
||||
this.filterCustomModelPick('');
|
||||
},
|
||||
|
||||
/**
|
||||
* View filter for the open "Choose a model" picker: case-insensitive substring
|
||||
* over each row's model id, driven from the search input's own `oninput`.
|
||||
* Deliberately toggles `hidden` on the already-rendered buttons rather than
|
||||
* re-rendering from a second copy of the model list: the loaded/last-used
|
||||
* promotion, the Default pill, the escaped inline click argument and the async
|
||||
* generation guard all then stay exactly where they are, and clearing the
|
||||
* query restores the list byte for byte. ⚠️ `.run-mode-option` carries its own
|
||||
* `display: flex` (styles.css, restated in mobile.css), which outranks the UA
|
||||
* `[hidden]` rule, so styles.css re-asserts `display: none !important` for
|
||||
* these rows: without it the filter would silently do nothing.
|
||||
*/
|
||||
filterCustomModelPick(value) {
|
||||
const list = document.getElementById('customModelPickList');
|
||||
if (!list) return;
|
||||
const query = String(value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
for (const btn of list.querySelectorAll('button[data-model-id]')) {
|
||||
const match = !query || btn.dataset.modelId.toLowerCase().includes(query);
|
||||
btn.hidden = !match;
|
||||
if (match) visible += 1;
|
||||
}
|
||||
const noMatches = document.getElementById('customModelPickNoMatches');
|
||||
if (noMatches) noMatches.hidden = visible > 0;
|
||||
},
|
||||
|
||||
closeCustomModelPickModal() {
|
||||
@@ -4097,6 +4219,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
set('cloneRepoUrl', '');
|
||||
set('cloneCaseName', '');
|
||||
set('cloneRepoRef', '');
|
||||
set('cloneCasePath', '');
|
||||
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
|
||||
// server refuses it (it writes outside the cases directory and into the shared registry).
|
||||
const customToggle = document.getElementById('cloneCustomPathToggle');
|
||||
if (customToggle) customToggle.checked = false;
|
||||
const customRow = document.getElementById('cloneCustomPathToggleRow');
|
||||
const me = window.__codemanUser || {};
|
||||
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
this.toggleCloneCustomPath();
|
||||
const shallow = document.getElementById('cloneShallow');
|
||||
if (shallow) shallow.checked = false;
|
||||
const start = document.getElementById('cloneStartSession');
|
||||
@@ -4124,6 +4255,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */
|
||||
toggleCloneCustomPath() {
|
||||
const custom = document.getElementById('cloneCustomPathToggle');
|
||||
const row = document.getElementById('cloneCustomPathRow');
|
||||
if (!custom || !row) return;
|
||||
row.style.display = custom.checked ? '' : 'none';
|
||||
const hint = document.getElementById('cloneCaseNameHint');
|
||||
if (hint) {
|
||||
hint.textContent = custom.checked
|
||||
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
|
||||
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
|
||||
}
|
||||
this.updateCloneCasePathPreview();
|
||||
},
|
||||
|
||||
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
|
||||
_cloneTargetPath() {
|
||||
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
|
||||
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
|
||||
},
|
||||
|
||||
updateCloneCasePathPreview() {
|
||||
const hint = document.getElementById('cloneCasePathPreview');
|
||||
if (!hint) return;
|
||||
const target = this._cloneTargetPath();
|
||||
hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.';
|
||||
},
|
||||
|
||||
openCloneCasePathPicker() {
|
||||
const input = document.getElementById('cloneCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Choose the folder to clone into',
|
||||
initialPath: input.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
input.value = path;
|
||||
this.updateCloneCasePathPreview();
|
||||
input.focus();
|
||||
input.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
_setCloneStatus(message, kind) {
|
||||
const el = document.getElementById('cloneRepoStatus');
|
||||
if (!el) return;
|
||||
@@ -4272,6 +4447,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
|
||||
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
|
||||
this.showToast('Choose the folder to clone into', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, '');
|
||||
try {
|
||||
const res = await fetch('/api/cases/clone', {
|
||||
@@ -4279,7 +4460,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one
|
||||
// on the wire — omit the empty fields instead of sending null.
|
||||
body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
repository: url,
|
||||
...(ref ? { ref } : {}),
|
||||
...(shallow ? { shallow: true } : {}),
|
||||
...(customFolder ? { path: this._cloneTargetPath() } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
@@ -4315,6 +4502,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
allowNewFolder: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
|
||||
+214
-16
@@ -570,6 +570,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
this.loadDiscord();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
|
||||
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
|
||||
@@ -609,6 +610,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// below and are not stripped from a save: a stripped key would never reach the scheduler.
|
||||
document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;
|
||||
document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;
|
||||
document.getElementById('appSettingsShowEditHistory').checked = settings.showEditHistory ?? defaults.showEditHistory ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1734,6 +1736,115 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Discord bridge (Settings → Notifications). Server-side config behind /api/discord, not a
|
||||
* settings-payload field: the bot token is a secret, so it never round-trips through settings.json
|
||||
* or this page. The token box is write-only. Unlike the webhook group it has its own Save button
|
||||
* only (the modal's main Save does not touch it), so a half-typed token is never sent by accident.
|
||||
*/
|
||||
_discordSay(text, bad = false) {
|
||||
const out = document.getElementById('discordResult');
|
||||
if (!out) return;
|
||||
out.textContent = text;
|
||||
out.style.display = text ? 'block' : 'none';
|
||||
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
|
||||
},
|
||||
|
||||
async loadDiscord() {
|
||||
const group = document.getElementById('discordGroup');
|
||||
if (!group) return;
|
||||
const res = await this._api('/api/discord');
|
||||
let body = null;
|
||||
try { body = res && res.ok ? await res.json() : null; } catch { /* leave hidden */ }
|
||||
if (!body || body.success === false) { group.style.display = 'none'; return; } // not an admin, or an older server
|
||||
const d = body.data;
|
||||
group.style.display = '';
|
||||
document.getElementById('discordEnabled').checked = d.enabled === true;
|
||||
const token = document.getElementById('discordToken');
|
||||
token.value = '';
|
||||
token.placeholder = d.hasToken ? 'Saved. Paste a new token to replace it' : 'Bot token from the Developer Portal';
|
||||
document.getElementById('discordTokenHint').textContent = d.hasToken ? `Saved: ${d.tokenMasked}` : 'Nothing saved yet.';
|
||||
document.getElementById('discordGuildId').value = d.guildId || '';
|
||||
document.getElementById('discordParentChannelId').value = d.parentChannelId || '';
|
||||
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
|
||||
document.getElementById('discordThreadCreation').value = d.threadCreation;
|
||||
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
|
||||
document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep';
|
||||
document.getElementById('discordPingMode').value = d.pingMode;
|
||||
document.getElementById('discordScope').value = d.scope;
|
||||
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
|
||||
const clearBtn = document.getElementById('discordClearBtn');
|
||||
if (clearBtn) clearBtn.style.display = d.hasToken ? '' : 'none';
|
||||
const gw = { off: 'Not connected (disabled).', connecting: 'Connecting to Discord…', connected: 'Connected: replies are being read.', error: `Not connected: ${d.gatewayError || 'error'}` };
|
||||
let line = gw[d.gateway] || '';
|
||||
if (d.lastResult) {
|
||||
const when = new Date(d.lastResult.at).toLocaleString();
|
||||
line += d.lastResult.ok ? ` Last post succeeded (${when}).` : ` Last post failed (${when}): ${d.lastResult.error}`;
|
||||
}
|
||||
this._discordSay(line.trim(), d.gateway === 'error' || (d.lastResult && !d.lastResult.ok));
|
||||
},
|
||||
|
||||
/** PUT the group's state. Resolves to '' on success, else the error (also shown in the group). */
|
||||
async saveDiscord() {
|
||||
const ids = document.getElementById('discordAllowedUserIds').value.split(/[\s,]+/).filter(Boolean);
|
||||
const payload = {
|
||||
enabled: document.getElementById('discordEnabled').checked,
|
||||
guildId: document.getElementById('discordGuildId').value.trim(),
|
||||
parentChannelId: document.getElementById('discordParentChannelId').value.trim(),
|
||||
allowedUserIds: ids,
|
||||
threadCreation: document.getElementById('discordThreadCreation').value,
|
||||
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
|
||||
staleThreads: document.getElementById('discordStaleThreads').value,
|
||||
pingMode: document.getElementById('discordPingMode').value,
|
||||
scope: document.getElementById('discordScope').value,
|
||||
startupHint: document.getElementById('discordStartupHint').checked,
|
||||
};
|
||||
const token = document.getElementById('discordToken').value.trim();
|
||||
if (token) payload.token = token; // blank = keep the saved one (Remove token is the way to clear it)
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: payload });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
const error = body?.error || 'Could not save the Discord settings.';
|
||||
this._discordSay(error, true);
|
||||
return error;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
return '';
|
||||
},
|
||||
|
||||
async clearDiscord() {
|
||||
if (!confirm('Remove the saved bot token from the server? The Discord bridge stops until you save a new one.')) return;
|
||||
const res = await this._api('/api/discord', { method: 'PUT', body: { token: '', enabled: false } });
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not remove the token.', true);
|
||||
return;
|
||||
}
|
||||
await this.loadDiscord();
|
||||
},
|
||||
|
||||
async testDiscord() {
|
||||
const btn = document.getElementById('discordTestBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (await this.saveDiscord()) return; // saves first, so it never tests the old values; the error is shown
|
||||
this._discordSay('Sending…');
|
||||
const res = await this._apiPost('/api/discord/test', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this._discordSay(body?.error || 'Could not send the test.', true);
|
||||
return;
|
||||
}
|
||||
const r = body.data;
|
||||
this._discordSay(r.ok ? 'Test sent. Check the channel.' : `Delivery failed: ${r.error}`, !r.ok);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
|
||||
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
|
||||
@@ -2301,6 +2412,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store (a 409 for a settings.json that does not parse) left the
|
||||
// tunnel running: say so, and leave the button showing it on.
|
||||
const stopError = newEnabled ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (newEnabled) {
|
||||
this._showTunnelConnecting();
|
||||
// Poll tunnel status as fallback in case SSE event is missed
|
||||
@@ -2662,9 +2781,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Shows the server's (actionable) message as an error toast. Since #706 this is the
|
||||
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
|
||||
* @param {Response|null} res - the fetch Response from the settings PUT
|
||||
* @param {object} [sent] - what that PUT sent, resent with the acknowledgement: the refusal
|
||||
* rejected all of it, so an App Settings save retried with the tunnel keys alone lost the rest
|
||||
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
|
||||
*/
|
||||
async _handleTunnelEnableRefusal(res) {
|
||||
async _handleTunnelEnableRefusal(res, sent = { tunnelEnabled: true }) {
|
||||
if (!res || res.ok) return false;
|
||||
let message = 'Tunnel refused: set CODEMAN_PASSWORD before exposing Codeman publicly.';
|
||||
let errorCode = '';
|
||||
@@ -2696,7 +2817,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const retry = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }),
|
||||
body: JSON.stringify({ ...sent, acknowledgeUnauthTunnel: true }),
|
||||
});
|
||||
if (retry.ok) {
|
||||
this.showToast('Public tunnel enabling — no password set ⚠️', 'warning');
|
||||
@@ -2746,6 +2867,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
// A stop the server did not store left the tunnel running (see toggleTunnelFromWelcome).
|
||||
const stopError = enable ? '' : await this._settingsWriteError(res);
|
||||
if (stopError) {
|
||||
this.showToast(`Tunnel not stopped: ${stopError}`, 'error', { duration: 0 });
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
if (enable) {
|
||||
this._updateTunnelIndicator(false);
|
||||
const indicator = document.getElementById('tunnelIndicator');
|
||||
@@ -2964,7 +3092,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
// After a save the server did not store, this device's blob is ahead of the server, so the
|
||||
// two server-side changes judged against it (the gesture reload, the plan-usage collection
|
||||
// flip) are judged against the blob that save started from until a save lands.
|
||||
const _prevServer = this._unsavedSettingsBaseline ?? _prev;
|
||||
const _prevGestureEnabled = (_prevServer.gestureControlEnabled ?? false) === true;
|
||||
// WebGL toggle: default ON (desktop), so only an explicit stored false counts
|
||||
// as "previously off" — used below to detect a real OFF→ON flip.
|
||||
const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
|
||||
@@ -3028,6 +3160,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
3600,
|
||||
Math.max(30, parseInt(document.getElementById('appSettingsGitFetchIntervalSeconds').value, 10) || 300)
|
||||
),
|
||||
showEditHistory: document.getElementById('appSettingsShowEditHistory').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -3284,7 +3417,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// switch collection off for every desktop, whose chip then went stale with
|
||||
// no error anywhere. An explicit toggle on any device still writes it, in
|
||||
// either direction.
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prev, settings.showPlanUsageLimits);
|
||||
const _chipFlip = this.planUsageCollectionFlip(_prevServer, settings.showPlanUsageLimits);
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
// Per-device (it is about this screen's keyboard) and absent from
|
||||
@@ -3336,6 +3469,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusTree: _gst,
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
// Per-device header button (edit-history-ui.js), likewise absent from the .strict() schema.
|
||||
showEditHistory: _seh,
|
||||
showTabDetachButton: _tdb,
|
||||
showTabNewButton: _tnb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
@@ -3357,6 +3492,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
...serverSettings
|
||||
} = settings;
|
||||
let webhookError = '';
|
||||
let saveError = '';
|
||||
let serverSaved = false;
|
||||
try {
|
||||
// #706: a save that turns the tunnel on asks first when the server has no login
|
||||
@@ -3365,18 +3501,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
? await this._confirmTunnelEnable()
|
||||
: { proceed: true, acknowledge: false };
|
||||
if (!tunnelGate.proceed) {
|
||||
this._unsavedSettingsBaseline = _prevServer;
|
||||
this._abandonTunnelEnable(settings, keepOpen);
|
||||
return;
|
||||
}
|
||||
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
|
||||
// would refuse it with a 403, the plan-usage collection flip included.
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
|
||||
const body = {
|
||||
...serverSettings,
|
||||
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
|
||||
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
};
|
||||
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', body);
|
||||
|
||||
// Multi-user mode, a non-admin whose role was not known when this save started
|
||||
// (/api/me had not answered, or failed and admin-ui.js fell back to a single-user
|
||||
@@ -3391,15 +3529,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
|
||||
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
|
||||
// so it doesn't look enabled.
|
||||
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
|
||||
// so it doesn't look enabled. Only a 403: any other failure with the switch on is
|
||||
// not about the tunnel, and turning the switch off for it would be wrong too.
|
||||
const tunnelRefused = !refusedAsNonAdmin && settings.tunnelEnabled && res?.status === 403;
|
||||
if (tunnelRefused && (await this._handleTunnelEnableRefusal(res, body))) {
|
||||
this._unsavedSettingsBaseline = _prevServer;
|
||||
this._abandonTunnelEnable(settings, keepOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
// `_apiPut` answers null or a non-ok response instead of throwing, so this is the only
|
||||
// evidence the server kept the flags the Apply refresh below reads.
|
||||
serverSaved = !!res?.ok;
|
||||
// evidence the server kept the flags the Apply refresh below reads (a tunnel 403 still
|
||||
// here was retried with the whole save and accepted).
|
||||
serverSaved = !!res?.ok || tunnelRefused;
|
||||
// Any other failure (a 409 for a settings.json that does not parse, a 400, a 500, a dropped
|
||||
// connection) stored nothing on the server, so the save must not say it did. A known
|
||||
// non-admin sent nothing (res is null).
|
||||
saveError =
|
||||
settingsReadOnly || refusedAsNonAdmin || tunnelRefused ? '' : await this._settingsWriteError(res);
|
||||
this._unsavedSettingsBaseline = saveError ? _prevServer : null;
|
||||
|
||||
// Save model configuration separately. For a non-admin it is not sent either, and the
|
||||
// answer says whether this save would have changed it.
|
||||
@@ -3410,7 +3558,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// it. Only when the group was edited: an untouched group must not re-PUT. A refusal (bad URL,
|
||||
// enabled with no URL) keeps the modal open below, with the pasted URL still in the box.
|
||||
webhookError = this._webhookPending() ? await this.saveWebhook() : '';
|
||||
if (webhookError) {
|
||||
if (saveError) {
|
||||
this.showToast(`Saved on this device, not on the server: ${saveError}`, 'warning', { duration: 0 });
|
||||
} else if (webhookError) {
|
||||
this.showToast(`Settings saved, but not the webhook: ${webhookError}`, 'warning');
|
||||
} else if (syncedChangeRefused || modelConfigRefused || refusedAsNonAdmin) {
|
||||
// ONE toast for the whole save, and only when the user really changed a server-wide
|
||||
@@ -3423,7 +3573,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Show tunnel-specific feedback if toggled on (never for a non-admin, whose save
|
||||
// starts nothing: the box only shows what the server already has)
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin) {
|
||||
if (settings.tunnelEnabled && !settingsReadOnly && !refusedAsNonAdmin && !saveError) {
|
||||
this.showToast('Tunnel starting — QR code will appear when ready...', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3435,9 +3585,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// old flags, and a webhook-only failure still saved the rest, so this runs ahead of that branch.
|
||||
if (keepOpen && serverSaved) this._refreshSettingsAfterApply(settings);
|
||||
|
||||
// A failed settings PUT keeps the modal open too, with the form as the user left it.
|
||||
if (webhookError) {
|
||||
document.getElementById('webhookGroup')?.scrollIntoView({ block: 'center' });
|
||||
} else if (!keepOpen) {
|
||||
} else if (!keepOpen && !saveError) {
|
||||
this.closeAppSettings();
|
||||
}
|
||||
|
||||
@@ -3448,10 +3599,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// The gesture overlay is injected at page render (server reads
|
||||
// gestureControlEnabled from settings.json), so a change only takes effect on
|
||||
// reload. Reload when it actually changed — the server PUT above already
|
||||
// persisted the new value.
|
||||
// persisted the new value (not after a failed PUT: the reload would render the old one).
|
||||
// `?? false`: a non-admin's save puts a synced key the server never stored back to
|
||||
// absent (_keepServerSyncedSettings), and absent means off, as in _prevGestureEnabled.
|
||||
if ((settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
if (!saveError && (settings.gestureControlEnabled ?? false) !== _prevGestureEnabled) {
|
||||
this.showToast(
|
||||
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
|
||||
'info'
|
||||
@@ -3500,6 +3651,35 @@ Object.assign(CodemanApp.prototype, {
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Why a settings write did not land, or '' when it did: the server's `error` (a 409 when
|
||||
* settings.json does not parse, a 400, ...), else the HTTP status, else, for the null that
|
||||
* `_apiPut` answers a dropped connection with, that the server could not be reached. Reads a
|
||||
* clone, like _noteSettingsWriteRefusal.
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async _settingsWriteError(res) {
|
||||
if (!res) return 'the server could not be reached';
|
||||
if (res.ok) return '';
|
||||
let body = null;
|
||||
try {
|
||||
body = await res.clone().json();
|
||||
} catch {
|
||||
/* not JSON: the status is all there is */
|
||||
}
|
||||
return body?.error || `HTTP ${res.status}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* The end of a background settings write (run mode, respawn presets): the admin-only refusal is
|
||||
* _noteSettingsWriteRefusal's, and any other failure says that `what` stayed on this device only.
|
||||
*/
|
||||
async _noteSettingsWriteResult(res, what) {
|
||||
if (await this._noteSettingsWriteRefusal(res)) return;
|
||||
const error = await this._settingsWriteError(res);
|
||||
if (error) this.showToast(`${what} saved on this device, not on the server: ${error}`, 'warning', { duration: 0 });
|
||||
},
|
||||
|
||||
/**
|
||||
* Where a non-admin's OWN run mode and respawn presets live (multi-user mode). Both are keys of
|
||||
* the server-wide settings.json, whose PUT is admin-only, so this user's picks are kept on this
|
||||
@@ -3811,6 +3991,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('customModelHostApiKey').value = ''; // the server never returns the real value (apiKeySet is a bool)
|
||||
document.getElementById('customModelHostApiKey').placeholder = host?.apiKeySet ? '•••••••• (unchanged if left blank)' : '';
|
||||
document.getElementById('customModelHostAuthStyle').value = host?.authStyle || 'bearer';
|
||||
// User-set fallback, round-tripped verbatim (discovery never touches it, so the
|
||||
// cached copy is authoritative); blank means "no default", cleared on save.
|
||||
document.getElementById('customModelHostDefaultContextLength').value = host?.defaultContextLength ?? '';
|
||||
this._populateCustomModelDefaultSelect(host);
|
||||
document.getElementById('customModelHostEditor').style.display = '';
|
||||
},
|
||||
@@ -3836,6 +4019,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const baseUrl = document.getElementById('customModelHostBaseUrl').value.trim();
|
||||
const apiKeyInput = document.getElementById('customModelHostApiKey').value;
|
||||
const authStyle = document.getElementById('customModelHostAuthStyle').value;
|
||||
// Blank means "no default" (key omitted, so PUT clears it); a value must satisfy
|
||||
// the same bound the server schema enforces, caught here for a friendlier message.
|
||||
const defaultContextLengthInput = document.getElementById('customModelHostDefaultContextLength').value.trim();
|
||||
let defaultContextLength;
|
||||
if (defaultContextLengthInput !== '') {
|
||||
const parsed = Number(defaultContextLengthInput);
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100000000) {
|
||||
this.showToast('Default context length must be a whole number of tokens between 1 and 100,000,000', 'warning');
|
||||
return;
|
||||
}
|
||||
defaultContextLength = parsed;
|
||||
}
|
||||
const defaultModelId = document.getElementById('customModelHostDefaultModel').value || undefined;
|
||||
if (!id || !label || !baseUrl) {
|
||||
this.showToast('Id, label and base URL are all required', 'warning');
|
||||
@@ -3856,6 +4051,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
baseUrl,
|
||||
authStyle,
|
||||
defaultModelId,
|
||||
defaultContextLength,
|
||||
apiKey: apiKeyInput || undefined,
|
||||
models: existing?.models,
|
||||
lastDiscoveredAt: existing?.lastDiscoveredAt,
|
||||
@@ -4636,6 +4832,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Bottom-bar Git indicator (git-status-ui.js): opt-in, per-device. Starts or stops its poll to
|
||||
// match the setting, so a live toggle needs no reload.
|
||||
this.applyGitStatusVisibility?.();
|
||||
// Header Edit history button (edit-history-ui.js): opt-in, per-device, marker-class hidden.
|
||||
this.applyEditHistoryVisibility?.();
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
@@ -4999,7 +5197,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'autoCopySelection', 'copyStripMargin',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds', 'showEditHistory',
|
||||
'showTabDetachButton',
|
||||
'showTabNewButton',
|
||||
'mobileOverviewEnabled',
|
||||
|
||||
@@ -651,6 +651,13 @@ body {
|
||||
margin: 0 2px var(--lineage-row-gap, 4px) 0;
|
||||
}
|
||||
|
||||
/* The first tab is capped to the room beside the toolbar: one wider than that (a
|
||||
selected one shows its gear and close) has its name trimmed to fit, so the
|
||||
layout never depends on which tab is selected. */
|
||||
.session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit {
|
||||
max-width: var(--tab-corner-first-max, none);
|
||||
}
|
||||
|
||||
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
|
||||
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
|
||||
}
|
||||
@@ -7807,6 +7814,52 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The picker's search box reuses .session-sidebar-filter-input's theme-token
|
||||
styling (same reasoning as the .set-scope pill above): only its own spacing
|
||||
and the no-matches line are added here. 0.35rem is the standard gap the
|
||||
modal's neighbouring form fields already use (`.form-hint`'s own margin-top):
|
||||
the global `* { margin: 0 }` reset left the box sitting flush under the CLI
|
||||
hint line above it, and the old 0.5rem read as too big a gap above the list
|
||||
(compounded by the always-visible no-matches line the rule below fixes). */
|
||||
#customModelPickModal .custom-model-pick-search {
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-no-matches {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* "Nothing is marked: this endpoint did not report which model it has loaded": the same
|
||||
`.form-hint` `display: block` beats `[hidden]` trap the two rules below fix for the
|
||||
no-matches line and the filtered rows, so the attribute needs the same re-assertion. */
|
||||
#customModelPickModal .custom-model-pick-unknown {
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
#customModelPickModal .custom-model-pick-unknown[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Same class of bug as the row rule below: the no-matches line carries
|
||||
`.form-hint`, whose `display: block` outranks the UA `[hidden]` rule, so
|
||||
`filterCustomModelPick()` toggling `hidden` changed nothing on screen and
|
||||
"No models match" showed while every model was listed with an empty search
|
||||
box. The `[hidden]` re-assertion makes the attribute hide the line again. */
|
||||
#customModelPickModal .custom-model-pick-no-matches[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* A filtered-out row's `hidden` must actually hide it: `.run-mode-option` sets
|
||||
`display: flex` (and mobile.css restates the class in a media query), which
|
||||
outranks the UA `[hidden] { display: none }` rule, so the filter would
|
||||
silently do nothing without this re-assertion. Scoped to this modal's rows;
|
||||
the Run menu's own .run-mode-option entries never take `hidden`. */
|
||||
#customModelPickModal .run-mode-option[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom Model Endpoint Profiles: llama-swap model-swap confirmation — replaces a native
|
||||
confirm() popup (docs/custom-model-endpoints-plan.md) so it looks and feels like the
|
||||
rest of the app instead of a browser chrome dialog. Shares the context-window-too-small
|
||||
@@ -15764,6 +15817,7 @@ body.touch-device.cjk-input-visible .main {
|
||||
|
||||
.path-picker-up,
|
||||
.path-picker-refresh,
|
||||
.path-picker-newfolder,
|
||||
.path-picker-hidden {
|
||||
flex: 0 0 38px;
|
||||
height: 38px;
|
||||
@@ -15788,6 +15842,45 @@ body.touch-device.cjk-input-visible .main {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-newfolder {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-newfolder[hidden],
|
||||
.path-picker-newfolder-form[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-newfolder-create,
|
||||
.path-picker-newfolder-cancel {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-up:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
@@ -22586,6 +22679,297 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
/* ── Edit history: header button + panel (edit-history-ui.js) ───────────────── */
|
||||
|
||||
/* "Edit history" header button: opt-in (App Settings → Header & Panels), hidden by default.
|
||||
Same marker pattern as the Attachments button: a base inline-flex !important so an inline
|
||||
style can't override it, and a more-specific marker rule to hide. Phones hide it regardless
|
||||
(mobile.css). */
|
||||
.btn-edit-history {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-edit-history.btn-edit-history--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-edit-history[aria-expanded='true'] {
|
||||
background: color-mix(in srgb, currentColor 16%, transparent);
|
||||
}
|
||||
|
||||
/* Same frame as the Git and Files windows: draggable by the header, resizable at the corner. */
|
||||
.edit-history-panel {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 10px);
|
||||
/* Left of the Git window's default spot; on a narrow viewport slide right so the left edge stays on screen. */
|
||||
right: clamp(8px, calc(100vw - 428px), 410px);
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 16px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px);
|
||||
max-height: 640px;
|
||||
min-width: 260px;
|
||||
min-height: 220px;
|
||||
background: var(--floating-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
resize: both;
|
||||
}
|
||||
|
||||
.edit-history-panel.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.edit-history-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-count {
|
||||
margin-left: 0.4rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 0.9rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-history-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.edit-history-empty,
|
||||
.edit-history-note,
|
||||
.edit-history-more {
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.edit-history-note--warn {
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-entry:hover,
|
||||
.edit-history-entry:focus-visible,
|
||||
.edit-history-entry--open {
|
||||
background: var(--bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-history-time {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-badge {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #e0a030;
|
||||
}
|
||||
|
||||
.edit-history-badge--new {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-badge--image {
|
||||
color: var(--text-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.edit-history-badge--image svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Only the name itself opens the preview: the rest of the row (the spacer) toggles the diff. */
|
||||
/* The folder part gives way first, so the file name stays readable (…/icons/bremse_alt1.png). */
|
||||
.edit-history-path {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-history-path-dir {
|
||||
/* Shrinks long before the name does; the name only gives way once the folder is at min-width. */
|
||||
flex: 0 10000 auto;
|
||||
min-width: 1.2em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* Ellipsis at the START of the folder (rtl); the inner <bdi dir="ltr"> keeps its text in order. */
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.edit-history-path-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.edit-history-path:hover,
|
||||
.edit-history-path:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-history-spacer {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
min-width: 0.5rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.edit-history-stats {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.edit-history-add {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.edit-history-del {
|
||||
color: #e5534b;
|
||||
}
|
||||
|
||||
.edit-history-agent {
|
||||
flex: 0 1 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-thumb {
|
||||
display: block;
|
||||
max-width: 160px;
|
||||
max-height: 90px;
|
||||
margin: 0.15rem 0 0.35rem 3.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.edit-history-diff {
|
||||
margin: 0.2rem 0 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.edit-history-group {
|
||||
margin-bottom: 0.45rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.edit-history-group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.edit-history-group-head .edit-history-path {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-history-group-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.64rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-history-older {
|
||||
margin: 0.5rem auto 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tab grouping by state (tabArrangement: 'state', opt-in;
|
||||
Discussion #426 option C)
|
||||
|
||||
@@ -2146,13 +2146,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* A clicked path as the preview needs it: relative paths resolved against the
|
||||
* session's working directory, absolute and `~/` paths unchanged
|
||||
* directory the session's pane is in now, else its working directory
|
||||
* (`relativeResolutionBase`), absolute and `~/` paths unchanged
|
||||
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
|
||||
* provider and the response viewer, so one path opens the same file in both.
|
||||
*/
|
||||
_resolveLinkedFilePath(filePath, sessionId) {
|
||||
if (typeof resolveLinkedFilePath !== 'function') return filePath;
|
||||
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
|
||||
const session = this.sessions?.get?.(sessionId);
|
||||
const base = typeof relativeResolutionBase === 'function' ? relativeResolutionBase(session) : session?.workingDir;
|
||||
return resolveLinkedFilePath(filePath, base);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2318,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
const activateFilePath = (text, folder = false) => {
|
||||
const activateFilePath = (text, folder = false, verifiedSessionId = null) => {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images, PDFs, documents and media inline — and it
|
||||
// now reaches files outside the workspace too, which is where an
|
||||
@@ -2336,8 +2339,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A relative path is resolved against the session's working
|
||||
// directory first, and a `~/` path always previews (only the
|
||||
// server knows that host's home, and the log viewer cannot ask).
|
||||
const sessionId = getSessionId();
|
||||
const sessionId = verifiedSessionId || getSessionId();
|
||||
if (!sessionId) return;
|
||||
const target = self._resolveLinkedFilePath(text, sessionId);
|
||||
const alternative = !folder && logical.pathAlternatives?.find((candidate) => candidate.path === text);
|
||||
if (alternative && !verifiedSessionId) {
|
||||
return (async () => {
|
||||
const original = await self._registerExternalPreview(target, sessionId);
|
||||
// Attachment policy must not gate the workspace's normal opener.
|
||||
if (original.status !== 404) return activateFilePath(target, false, sessionId);
|
||||
const path = self._resolveLinkedFilePath(alternative.spacedPath, sessionId);
|
||||
const registered = await self._registerExternalPreview(path, sessionId);
|
||||
if (registered.status === 404) return activateFilePath(target, false, sessionId);
|
||||
if (!registered.attachmentId) {
|
||||
self.showToast(registered.error || 'Cannot open this file', 'error');
|
||||
return;
|
||||
}
|
||||
// The normal opener rechecks its guards; attachment registration deduplicates the path.
|
||||
return activateFilePath(path, false, sessionId);
|
||||
})();
|
||||
}
|
||||
// A folder path opens the folder grid (which falls back to the
|
||||
// preview if the server finds a file there after all).
|
||||
if (folder) {
|
||||
@@ -2372,7 +2393,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
underline: true,
|
||||
},
|
||||
activate(_event, text) {
|
||||
activateFilePath(text, folder);
|
||||
return activateFilePath(text, folder);
|
||||
},
|
||||
hover() {
|
||||
setHovered(true);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"version": "1.47.0",
|
||||
"version": "1.48.0",
|
||||
"highlights": [
|
||||
"The wrapped tab strip uses the room under the toolbar (#721)",
|
||||
"Bodyless requests work through Cloudflare Tunnel (#725)",
|
||||
"A desktop dead key is typed once, and Ctrl+V stays a paste after a replay (#726, #716)",
|
||||
"Remote sessions no longer touch the local disk at their path (#722, #727, #728)",
|
||||
"Rendered Markdown fills a resized File Viewer (#730)",
|
||||
"A middle click on a file path opens the file (#731)",
|
||||
"No more duplicate-key warning on build (#729)"
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user