diff --git a/src/web/public/app.js b/src/web/public/app.js index 05eb2521..3922f083 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'; } diff --git a/src/web/public/foreign-sessions.js b/src/web/public/foreign-sessions.js index 246baa35..caeac88e 100644 --- a/src/web/public/foreign-sessions.js +++ b/src/web/public/foreign-sessions.js @@ -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); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 9aa28a44..07b7e9bc 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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])); diff --git a/test/foreign-sessions-i18n.test.ts b/test/foreign-sessions-i18n.test.ts new file mode 100644 index 00000000..e95a385f --- /dev/null +++ b/test/foreign-sessions-i18n.test.ts @@ -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): 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('', { 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); + } + }); +}); diff --git a/test/foreign-sessions-ui.test.ts b/test/foreign-sessions-ui.test.ts index 0cbdeede..b0970de0 100644 --- a/test/foreign-sessions-ui.test.ts +++ b/test/foreign-sessions-ui.test.ts @@ -41,7 +41,7 @@ function stubEl(): Record { } /** The module is a classic script that mixes into CodemanApp.prototype. */ -function loadApp() { +function loadApp(window: Record = {}) { const host = stubEl(); const ctx: Record = { 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; _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'); + }); +});