fix: apply the review items for #714 at merge

- Session update no longer resets "How often" while typing: updateGitFetchStatus
  (session-ui.js) leaves a control alone while it is document.activeElement, and
  autoSaveGitFetch writes the clamped interval back itself (Enter saves with the
  field still focused).
- Partial override shows the app values: enabled = session ?? app switch,
  interval = session ?? app interval ?? 300 (session-ui.js updateGitFetchStatus).
- runFetch (git-fetch.ts) arms the spawn with what the core.sshCommand read left
  of timeoutMs, and answers ETIMEDOUT without spawning when nothing is left, so a
  git that never answers costs one timeout, not two.
- fetchEnvFor keeps a GIT_SSH from the server's environment (repoSshCommand ||
  base.GIT_SSH), fileoverview and CLAUDE.md line updated to say so.
- i18n.js: the zh-CN "Git fetch on an interval" description scopes the only
  writing git command to the Git window.
- styles.css: the disabled/busy rules are scoped to .git-status-actions
  .btn-icon-sm (moved beside .git-status-actions), and the busy pulse stops
  under prefers-reduced-motion.
- Comments that called the override per case now say per session: session.ts
  (field, restore and getter docs, setGitFetchSettings), git-fetch-scheduler.ts:2,
  server-timing.ts:78, server.ts (cleanup and restore), app.js _onSessionUpdated,
  session-ui.js (openSessionOptions and updateGitFetchStatus).
- Tests: test/git-fetch-session-options-ui.test.ts (new, JSDOM: typing survives
  an SSE update, the Enter write-back, both partial cases); test/git-fetch.test.ts
  (GIT_SSH kept; one bound for the read and the fetch, stub git on PATH).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 01:58:14 +01:00
1 parent 3411d7f35a
commit 6539bc6c82
12 files changed
+299 -68

No files matched your search

+3 -2
View File
@@ -2455,8 +2455,9 @@ class CodemanApp {
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// A scheduled fetch publishes the case's result on this same event, so an open Session Options modal on
// that case shows it without polling its own (session-ui.js: updateGitFetchStatus).
// A scheduled fetch publishes the session's result on this same event, so an open Session Options modal
// on that session shows it without polling its own (session-ui.js: updateGitFetchStatus, which leaves the
// control being typed in alone).
if (this.editingSessionId === session.id) this.updateGitFetchStatus(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
+1 -1
View File
@@ -538,7 +538,7 @@
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the only git command the Git panel runs that writes, and the only one Codeman runs on a timer (cloning and worktree management write too, but only when you asked for them at the moment you asked), so nothing runs until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 Git 窗口运行的唯一会写入的 git 命令,也是 {name} 唯一会按间隔自行运行的命令(克隆和工作树管理也会写入,但只在您当时要求时才运行),所以在您开启之前不会有任何运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.':
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。',
+23 -16
View File
@@ -2685,7 +2685,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
// Git: the case's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// Git: the session's own choice if it has one, otherwise the app setting. updateGitFetchStatus is also
// what SSE calls when a scheduled run lands, so the rows stay current while the modal is open.
this.updateGitFetchStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
@@ -3032,11 +3032,15 @@ Object.assign(CodemanApp.prototype, {
},
/**
* The Git tab: the case's last scheduled run, and which values its rows show. A case with no choice of its
* own follows the app setting, so its switch and interval display the app's numbers and go inert — the same
* inert-row pattern as updateKeepWarmStatus above — because changing them here is what makes the case stop
* following. The app numbers come from the settings cache, so what this shows can be one settings load
* The Git tab: the session's last scheduled run, and which values its rows show. A session with no choice of
* its own follows the app setting, so its switch and interval display the app's numbers and go inert (the
* same inert-row pattern as updateKeepWarmStatus above), because changing them here is what makes the session
* stop following. The app numbers come from the settings cache, so what this shows can be one settings load
* behind; the server decides from a fresh read, not from what the page displays.
*
* SSE calls this on every `session:updated` for the open session (app.js `_onSessionUpdated`), and a working
* session sends one on each working/idle change, so a control the user is in (`document.activeElement`) is
* left as it is: rewriting it put a half-typed interval back to the stored one before `onchange` could save it.
*/
updateGitFetchStatus(sessionId) {
const statusEl = document.getElementById('gitFetchStatus');
@@ -3048,15 +3052,17 @@ Object.assign(CodemanApp.prototype, {
if (!session) return;
const app = this.loadAppSettingsFromStorage() || {};
// No choice of its own on either field means the session follows the app. A partial one — its own switch,
// the app's interval — means it does not, and the row that still follows shows the app's value.
// No choice of its own on either field means the session follows the app. A partial one (its own switch,
// the app's interval) means it does not, and the row that still follows shows the app's value, which is
// also the value the scheduler runs it with (a default here would show one number and fetch at another).
const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined;
const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false;
const interval = follows ? app.gitFetchIntervalSeconds ?? 300 : session.gitFetchIntervalSeconds ?? 300;
const enabled = session.gitFetchEnabled ?? (app.gitFetchEnabled === true);
const interval = session.gitFetchIntervalSeconds ?? app.gitFetchIntervalSeconds ?? 300;
followBox.checked = follows;
box.checked = enabled;
num.value = interval;
const focused = document.activeElement;
if (followBox !== focused) followBox.checked = follows;
if (box !== focused) box.checked = enabled;
if (num !== focused) num.value = interval;
for (const el of [box, num]) {
el.closest('.set-row')?.classList.toggle('set-row-disabled', follows);
el.disabled = follows;
@@ -3085,16 +3091,17 @@ Object.assign(CodemanApp.prototype, {
async autoSaveGitFetch() {
if (!this.editingSessionId) return;
const follow = document.getElementById('modalGitFetchFollow').checked;
const num = document.getElementById('modalGitFetchInterval');
// `null` on the route means "no choice of your own", which is what following the app setting is.
const body = follow
? { enabled: null, intervalSeconds: null }
: {
enabled: document.getElementById('modalGitFetchEnabled').checked,
intervalSeconds: Math.min(
3600,
Math.max(30, parseInt(document.getElementById('modalGitFetchInterval').value, 10) || 300)
),
intervalSeconds: Math.min(3600, Math.max(30, parseInt(num.value, 10) || 300)),
};
// Enter fires `onchange` with the field still focused, and updateGitFetchStatus leaves a focused field
// alone, so the clamped number is written back here or the field would keep showing what was typed.
if (!follow) num.value = body.intervalSeconds;
try {
// `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a
// save. `_apiJson` is the one that says whether the call went through, and null here means it did not.
+36 -27
View File
@@ -7151,33 +7151,6 @@ html[data-skin="og"] .welcome-primary:hover {
background: var(--bg-hover);
}
/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session
there is nothing to fetch for (no repository, remote or Docker). */
.btn-icon-sm[disabled] {
color: var(--text-dim);
opacity: 0.45;
cursor: default;
}
.btn-icon-sm[disabled]:hover {
background: none;
}
.btn-icon-sm[aria-busy='true'] {
color: var(--text);
animation: btn-icon-busy 1.1s ease-in-out infinite;
}
@keyframes btn-icon-busy {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
.settings-section {
margin-bottom: 1.25rem;
padding-bottom: 1rem;
@@ -22175,6 +22148,42 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
gap: 0.25rem;
}
/* A Fetch button cannot be clicked twice while its fetch is running, and is not offered for a session
there is nothing to fetch for (no repository, remote or Docker). Scoped to the Git panel's header: the
File Viewer's Back/Forward and the folder grid's Up button are .btn-icon-sm too, and keep their own look. */
.git-status-actions .btn-icon-sm[disabled] {
color: var(--text-dim);
opacity: 0.45;
cursor: default;
}
.git-status-actions .btn-icon-sm[disabled]:hover {
background: none;
}
.git-status-actions .btn-icon-sm[aria-busy='true'] {
color: var(--text);
animation: btn-icon-busy 1.1s ease-in-out infinite;
}
@keyframes btn-icon-busy {
0%,
100% {
opacity: 0.55;
}
50% {
opacity: 0.9;
}
}
/* No pulse under reduced motion: the button holds the pulse's brighter step, still dimmer than at rest. */
@media (prefers-reduced-motion: reduce) {
.git-status-actions .btn-icon-sm[aria-busy='true'] {
animation: none;
opacity: 0.7;
}
}
.git-status-body {
flex: 1;
overflow-y: auto;
+3 -3
View File
@@ -1550,7 +1550,7 @@ export class WebServer extends EventEmitter {
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
// A closed case takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id
// A closed session takes its place in the fetch schedule with it: otherwise a detached-and-forgotten id
// would still count as tracked, and its next run would land on a session that no longer exists.
this.gitFetchScheduler.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
@@ -3819,9 +3819,9 @@ export class WebServer extends EventEmitter {
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// The case's own fetch switch and interval, carried over rather than reset to the app default:
// The session's own fetch switch and interval, carried over rather than reset to the app default:
// the choice is the user's and outlives the run that made it. The scheduler reads them fresh
// on its next tick, so a restored case is back on its own schedule without a restart.
// on its next tick, so a restored session is back on its own schedule without a restart.
gitFetchEnabled: savedState?.gitFetchEnabled,
gitFetchIntervalSeconds: savedState?.gitFetchIntervalSeconds,
// Conversations this pane provably owned, oldest first. Its tail is