fix(adopt): zh-CN, registry labels, and the outside-sessions block for non-admins

Every string the block shows now has a zh-CN entry, harvested by a test from the
real module. The row's mode comes from the CLI catalog, not a list of ids that
had already missed omp and copilot. The close dialog's line is static, so the
translator can reach it.

A non-admin's poll answers 403, which read as a failed poll: "Could not reach
the server" under a heading they cannot use, and a request every eight seconds.
That now stops the polling and shows no block.

Wide-scan notes are kept apart from local ones, so the next local poll no longer
wipes "remote X: unreachable" while its rows stay on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 03:13:52 +01:00
1 parent 7de4201955
commit d62fa10c01
5 files changed
+220 -66

No files matched your search

+4 -4
View File
@@ -10267,11 +10267,11 @@ class CodemanApp {
// response re-reports the count, so a pane that starts keeping history
// (claude switched to its inline view) goes back to the tail on the next
// switch.
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
// alternate screen, so it reports 0 while its byte tail is real scrollback.
// A remote (ssh), Docker or adopted session's local pane hosts a nested tmux client in
// the alternate screen, so it reports 0 while its byte tail is real scrollback.
const shellSession = session?.mode === 'shell';
const paneKeepsNoHistory =
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
@@ -10929,7 +10929,7 @@ class CodemanApp {
if (keepTitle) keepTitle.textContent = adopted ? 'Close Tab' : 'Remove Tab';
if (keepDesc) {
keepDesc.textContent = adopted
? `Detaches only — "${session.adopt.targetSession}" keeps running for whoever started it`
? 'Detaches only. The tmux session keeps running for whoever started it.'
: 'Tmux session keeps running in background';
}
+57 -55
View File
@@ -38,27 +38,27 @@
* (`_onSessionCreated` then `selectSession`) — never by inserting a tab here.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, _onSessionCreated, selectSession, _apiJson)
* @dependency api-client.js (_apiJson / _apiPost envelope unwrapping)
* @dependency app.js (this.sessions, _onSessionCreated, selectSession)
* @dependency api-client.js (_api)
* @dependency ralph-panel.js (formatRelativeTime)
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
* @dependency i18n.js (codemanT, optional)
* @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
*/
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
const FOREIGN_POLL_FALLBACK_MS = 8000;
/** Mode label shown on a row. Deliberately the same words the tab strip uses. */
const FOREIGN_MODE_LABEL = {
claude: 'Claude',
codex: 'Codex',
opencode: 'OpenCode',
gemini: 'Gemini',
antigravity: 'Antigravity',
pi: 'Pi',
grok: 'Grok',
deepseek: 'DeepSeek',
shell: 'Shell',
};
/** i18n.js's translator, or the English source where it has not loaded. */
function foreignT(source) {
return typeof codemanT === 'function' ? codemanT(source) : source;
}
/** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
function foreignModeLabel(mode) {
const catalog =
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
return catalog.find((entry) => entry.id === mode)?.label || mode;
}
const FOREIGN_LOCATION_LABEL = {
local: 'this machine',
@@ -77,7 +77,7 @@ Object.assign(CodemanApp.prototype, {
* stack another interval on top of the last.
*/
startForeignPolling() {
if (this._foreignPollTimer) return;
if (this._foreignPollTimer || this._foreignRefused) return;
void this.loadForeignSessions();
this._foreignPollTimer = setInterval(() => {
// Local only — see @fileoverview. The wide scan never rides the timer.
@@ -128,15 +128,24 @@ Object.assign(CodemanApp.prototype, {
* ONLY an explicit user action ever passes it — never the interval.
*/
async loadForeignSessions({ wide = false } = {}) {
if (this._foreignRefused) return;
const qs = wide ? '?docker=1&remote=1' : '';
if (wide) this._foreignWideLoading = true;
this.renderAllForeignSessions();
let data;
try {
data = await this._apiJson(`/api/mux/foreign${qs}`);
} finally {
if (wide) this._foreignWideLoading = false;
const res = await this._api(`/api/mux/foreign${qs}`);
if (wide) this._foreignWideLoading = false;
// ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
// mode (it shows other people's processes), so a non-admin has nothing to see here
// and nothing later on this page changes that: stop asking and show no block,
// instead of "Could not reach the server" under a heading they cannot use.
if (res?.status === 403) {
this._foreignRefused = true;
this.stopForeignPolling();
this.renderAllForeignSessions();
return;
}
const body = res?.ok ? await this._readJsonBody(res) : null;
const data = body?.success === true ? body.data : body?.success === false ? null : body;
if (!data) {
// A failed poll must not blank a list the user is looking at: keep the
// last good result and let the next tick recover.
@@ -147,18 +156,21 @@ Object.assign(CodemanApp.prototype, {
this._foreignError = false;
const rows = Array.isArray(data.sessions) ? data.sessions : [];
const notes = Array.isArray(data.notes) ? data.notes : [];
// Every note opens with its location (foreign-tmux-discovery.ts). A local one is
// re-read on each poll; a container or host one only by the next explicit scan,
// so it has to outlive the local polls in between.
const isLocalNote = (note) => String(note).startsWith('local:');
this._foreignNotes = notes.filter(isLocalNote);
if (wide) {
// Split by location: the local rows refresh on every tick, the rest only
// here, so they have to be stored apart or the next local-only payload
// (which cannot contain them) would read as "those sessions are gone".
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
this._foreignNotes = notes;
this._foreignWideNotes = [];
this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
this._foreignWideAt = Date.now();
} else {
this._foreignSessions = rows;
this._foreignNotes = notes;
}
this._foreignCanScanWide = data.canScanWide === true;
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
@@ -176,16 +188,16 @@ Object.assign(CodemanApp.prototype, {
/**
* Adopt one candidate.
*
* The in-flight lock is per BUTTON, not global: two different foreign sessions
* can legitimately be adopted back to back. The server holds the real
* one-wrapper-per-target guarantee; this only stops a double-click.
* The in-flight lock is global, one adoption at a time from this page: it only
* stops a double-click. The server holds the real one-wrapper-per-target
* guarantee.
*/
async adoptForeignSession(id, buttonEl) {
if (this._foreignAdoptInFlight) return;
this._foreignAdoptInFlight = true;
if (buttonEl) {
buttonEl.disabled = true;
buttonEl.textContent = codemanT ? codemanT('Opening…') : 'Opening…';
buttonEl.textContent = foreignT('Opening…');
}
try {
const body = { id };
@@ -202,9 +214,7 @@ Object.assign(CodemanApp.prototype, {
const payload = await this._readJsonBody(res);
const session = payload && payload.success !== false ? payload.data?.session : null;
if (!session) {
const fallback = codemanT
? codemanT('Could not open that session. Refreshing the list.')
: 'Could not open that session. Refreshing the list.';
const fallback = foreignT('Could not open that session. Refreshing the list.');
this.showToast?.(payload?.error || fallback, 'error');
await this.loadForeignSessions();
return;
@@ -221,7 +231,7 @@ Object.assign(CodemanApp.prototype, {
this._foreignAdoptInFlight = false;
if (buttonEl) {
buttonEl.disabled = false;
buttonEl.textContent = codemanT ? codemanT('Open') : 'Open';
buttonEl.textContent = foreignT('Open');
}
}
},
@@ -258,6 +268,10 @@ Object.assign(CodemanApp.prototype, {
renderForeignSessions(container) {
const rows = this._allForeignRows();
container.innerHTML = '';
if (this._foreignRefused) {
container.hidden = true;
return;
}
if (!rows.length) {
// Hidden rather than shown-empty: on a first run there is usually nothing,
@@ -279,12 +293,8 @@ Object.assign(CodemanApp.prototype, {
empty.className = 'foreign-empty';
this._appendForeignNotes(container);
empty.textContent = this._foreignError
? codemanT
? codemanT('Could not reach the server.')
: 'Could not reach the server.'
: codemanT
? codemanT('No sessions found outside Codeman.')
: 'No sessions found outside Codeman.';
? foreignT('Could not reach the server.')
: foreignT('No sessions found outside Codeman.');
container.appendChild(empty);
}
return;
@@ -339,7 +349,7 @@ Object.assign(CodemanApp.prototype, {
const title = document.createElement('h3');
title.className = 'foreign-title';
title.textContent = codemanT ? codemanT('Opened outside Codeman') : 'Opened outside Codeman';
title.textContent = foreignT('Opened outside Codeman');
header.appendChild(title);
if (count) {
@@ -355,9 +365,7 @@ Object.assign(CodemanApp.prototype, {
scan.className = 'foreign-scan-toggle';
scan.dataset.foreignAction = 'toggle-scan';
scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
scan.title = codemanT
? codemanT('Also scan containers and remote hosts (slower)')
: 'Also scan containers and remote hosts (slower)';
scan.title = foreignT('Also scan containers and remote hosts (slower)');
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
header.appendChild(scan);
@@ -369,16 +377,10 @@ Object.assign(CodemanApp.prototype, {
rescan.className = 'foreign-scan-toggle';
rescan.dataset.foreignAction = 'rescan-wide';
rescan.disabled = this._foreignWideLoading === true;
rescan.title = codemanT
? codemanT('Scan containers and remote hosts again')
: 'Scan containers and remote hosts again';
rescan.title = foreignT('Scan containers and remote hosts again');
rescan.textContent = this._foreignWideLoading
? codemanT
? codemanT('Scanning…')
: 'Scanning…'
: codemanT
? codemanT('Rescan')
: 'Rescan';
? foreignT('Scanning…')
: foreignT('Rescan');
header.appendChild(rescan);
}
@@ -406,8 +408,8 @@ Object.assign(CodemanApp.prototype, {
const line2 = document.createElement('span');
line2.className = 'foreign-row-sub';
line2.setAttribute('data-i18n-skip', '');
const bits = [FOREIGN_MODE_LABEL[row.mode] || row.mode];
const where = row.hostLabel || FOREIGN_LOCATION_LABEL[row.location] || row.location;
const bits = [foreignModeLabel(row.mode)];
const where = row.hostLabel || foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location);
if (where) bits.push(where);
if (row.workingDir) bits.push(row.workingDir);
line2.textContent = bits.join(' · ');
@@ -422,11 +424,11 @@ Object.assign(CodemanApp.prototype, {
if (row.adoptedBy) {
action.dataset.foreignAction = 'select';
action.dataset.foreignSession = row.adoptedBy;
action.textContent = codemanT ? codemanT('Go to tab') : 'Go to tab';
action.textContent = foreignT('Go to tab');
} else {
action.dataset.foreignAction = 'adopt';
action.dataset.foreignId = row.id;
action.textContent = codemanT ? codemanT('Open') : 'Open';
action.textContent = foreignT('Open');
}
item.appendChild(action);
+24
View File
@@ -1372,6 +1372,30 @@
'Remove Tab': '移除标签页',
'Remove All Tabs': '移除所有标签页',
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
// Sessions opened outside Codeman (foreign-sessions.js, the close dialog and Session Options).
'Opened outside Codeman': '在 Codeman 之外打开',
'Also scan containers and remote hosts (slower)': '同时扫描容器和远程主机(较慢)',
'Scan containers and remote hosts again': '重新扫描容器和远程主机',
'⟳ all': '⟳ 全部',
'⟳ local': '⟳ 本机',
'Scanning…': '扫描中…',
'Rescan': '重新扫描',
'Open': '打开',
'Opening…': '正在打开…',
'Go to tab': '转到标签页',
'this machine': '本机',
'container': '容器',
'remote': '远程',
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
'Could not reach the server.': '无法连接服务器。',
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
'Close Tab': '关闭标签页',
'Detaches only. The tmux session keeps running for whoever started it.':
'仅断开连接。该 tmux 会话会继续运行,供其启动者使用。',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.':
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
'Git status is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The strings of the outside-sessions block in Chinese (zh-CN).
*
* Harvested from the real module rather than listed here, so a new string without a
* dictionary entry fails this test instead of showing up English on one surface. The
* block's row text carries data-i18n-skip (a session name and a command line are
* someone else's), so its location words only translate through the module's own
* foreignT() call, and the DOM translator would never reach them.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const MODULE = readFileSync(resolve(PUBLIC, 'foreign-sessions.js'), 'utf8');
type Api = { configure(o: Record<string, string>): void; t(source: string): string };
/** Everything the module sends through foreignT, plus the toggle's two literal labels. */
const FROM_MODULE = [...MODULE.matchAll(/foreignT\('([^']+)'\)/g)].map((m) => m[1]);
const LOCATION_WORDS = ['this machine', 'container', 'remote'];
const TOGGLE = ['⟳ all', '⟳ local'];
const ELSEWHERE = [
'Close Tab',
'Detaches only. The tmux session keeps running for whoever started it.',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.',
'Git status is not available for a tmux session opened outside Codeman.',
];
const ALL = [...FROM_MODULE, ...LOCATION_WORDS, ...TOGGLE, ...ELSEWHERE];
const dom = new JSDOM('<!doctype html><html><body></body></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language: 'zh-CN' });
afterAll(() => {
dom.window.close();
});
describe('the outside-sessions block in zh-CN', () => {
it('harvests the module (the check is not vacuous)', () => {
expect(FROM_MODULE).toContain('Opened outside Codeman');
expect(FROM_MODULE).toContain('Go to tab');
expect(FROM_MODULE.length).toBeGreaterThanOrEqual(10);
});
it('wraps every location word, whose row the DOM translator skips', () => {
expect(MODULE).toContain('foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location)');
for (const word of LOCATION_WORDS) expect(MODULE).toContain(`: '${word}'`);
});
it.each(ALL)('translates %s', (source) => {
const chinese = api.t(source);
expect(chinese).not.toBe(source);
expect(chinese.replace(/\b(Codeman|tmux|Git)\b/g, '').match(/[A-Za-z]+/g) ?? []).toEqual([]);
});
it('adds each key to the dictionary once', () => {
for (const source of new Set(ALL)) {
expect(I18N.split(`'${source}':`).length - 1, source).toBe(1);
}
});
});
+67 -7
View File
@@ -41,7 +41,7 @@ function stubEl(): Record<string, unknown> {
}
/** The module is a classic script that mixes into CodemanApp.prototype. */
function loadApp() {
function loadApp(window: Record<string, unknown> = {}) {
const host = stubEl();
const ctx: Record<string, unknown> = {
console,
@@ -55,6 +55,7 @@ function loadApp() {
setInterval: () => 1,
clearInterval() {},
codemanT: null,
window,
document: {
createElement: () => stubEl(),
getElementById: (id: string) => (id === 'foreignSessions' ? host : null),
@@ -72,9 +73,17 @@ function loadApp() {
adoptForeignSession(id: string, el?: unknown): Promise<void>;
_allForeignRows(): Array<{ id: string }>;
renderAllForeignSessions(): void;
startForeignPolling(): void;
renderForeignSessions(el: unknown): void;
_foreignRow(row: object): { children: Array<{ children: Array<{ textContent: string }> }> };
};
}
/** A response as `_api` hands it over, the server's envelope included. */
function ok(data: unknown) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
const LOCAL = { id: 'L1', location: 'local', sessionName: 'work', mode: 'claude' };
const DOCKER = { id: 'D1', location: 'docker', sessionName: 'inbox', mode: 'codex' };
@@ -82,11 +91,13 @@ describe('the wide scan is one-shot', () => {
function wired() {
const app = loadApp();
const calls: string[] = [];
app._apiJson = vi.fn(async (path: string) => {
app._api = vi.fn(async (path: string) => {
calls.push(path);
return path.includes('docker=1')
? { sessions: [LOCAL, DOCKER], notes: [], canScanWide: true }
: { sessions: [LOCAL], notes: [], canScanWide: true };
return ok(
path.includes('docker=1')
? { sessions: [LOCAL, DOCKER], notes: ['docker box: not running, no tmux, or engine unreachable'], canScanWide: true }
: { sessions: [LOCAL], notes: ['local: 1 session(s) skipped'], canScanWide: true }
);
});
return { app, calls };
}
@@ -113,6 +124,17 @@ describe('the wide scan is one-shot', () => {
expect(app._allForeignRows().map((r) => r.id)).toEqual(['L1', 'D1']);
});
it('keeps a container or host note across the local polls, and refreshes the local ones', async () => {
// Only a wide scan can re-read a container's note, so a local poll wiping it
// left "unreachable" gone while its stale rows stayed on the list.
const { app } = wired();
app.toggleForeignScanRemote();
await vi.waitFor(() => expect(app._allForeignRows()).toHaveLength(2));
await app.loadForeignSessions();
expect(app._foreignWideNotes).toEqual(['docker box: not running, no tmux, or engine unreachable']);
expect(app._foreignNotes).toEqual(['local: 1 session(s) skipped']);
});
it('discards the wide rows when the toggle goes off', async () => {
// Nothing re-confirms them, so leaving them up would show a container
// session that may be long gone.
@@ -141,7 +163,6 @@ describe('an adoption failure reports what the server said', () => {
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => ({ ok: status < 400, status, json: async () => body }));
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
app._onSessionCreated = vi.fn();
app.selectSession = vi.fn(async () => {});
return { app, toasts };
@@ -169,7 +190,6 @@ describe('an adoption failure reports what the server said', () => {
const toasts: string[] = [];
app.showToast = (msg: string) => void toasts.push(msg);
app._api = vi.fn(async () => null); // network failure
app._apiJson = vi.fn(async () => ({ sessions: [], notes: [], canScanWide: false }));
await app.adoptForeignSession('cand-1');
expect(toasts[0]).toMatch(/Could not open that session/);
});
@@ -182,3 +202,43 @@ describe('an adoption failure reports what the server said', () => {
expect(app.selectSession).toHaveBeenCalledWith('new-1');
});
});
describe('a non-admin in multi-user mode', () => {
// The list is admin-only, so every poll answers 403. That used to read as a failed
// poll: a "Could not reach the server" block, and a request every eight seconds.
it('stops asking and shows no block at all', async () => {
const app = loadApp();
const calls: string[] = [];
app._api = vi.fn(async (path: string) => {
calls.push(path);
return { ok: false, status: 403, json: async () => ({ success: false, errorCode: 'FORBIDDEN' }) };
});
await app.loadForeignSessions();
const box = stubEl();
app.renderForeignSessions(box);
expect(box.hidden).toBe(true);
expect(box.children).toEqual([]);
await app.loadForeignSessions();
app.startForeignPolling();
expect(calls).toHaveLength(1);
});
it('still reads any other failure as a failed poll', async () => {
const app = loadApp();
app._api = vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ success: false }) }));
await app.loadForeignSessions();
expect(app._foreignError).toBe(true);
expect(app._foreignRefused).toBeFalsy();
});
});
describe('a row names its CLI in the registry\u2019s own word', () => {
it('has no list of CLI ids to fall behind: omp and copilot read like the rest', () => {
const app = loadApp({ __codemanCliCatalog: [{ id: 'copilot', label: 'GitHub Copilot' }] });
const sub = (mode: string) =>
app._foreignRow({ sessionName: 'x', mode, location: 'local' }).children[1].children[1].textContent;
expect(sub('copilot')).toBe('GitHub Copilot \u00b7 this machine');
// Absent from the catalog: the id itself, never a blank row.
expect(sub('newcli')).toBe('newcli \u00b7 this machine');
});
});