mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
Merge the final checkup's fixes into the 1.40.0 release
From the read-only final review of the release, adversarially verified, then reviewed again: - tiles: a file dropped on the grid uploads to that tile's session instead of navigating away; app-driven tile changes no longer move the keyboard into another session; popping out the last tile no longer leaves a frozen view; "Open group as tiles" no longer merges an open split; the Tiles button defaults off on touch tablets (opt-in) - voice: dictation with the grid open reaches the focused tile - css: By case stays one scrolling strip on 600-767px tablets, the needs-you pulse animates opacity only, phone welcome chips are 40px - i18n: zh-CN for the case picker rows, the git status settings, new toasts, tile and spreadsheet texts - cli registry: codex launch defaults are registry data, not an id branch; the codex footer reads an ultra effort - build and docs: a dependency preflight runs before the build deletes dist; docs no longer name 1.36.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -805,7 +805,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w
|
||||
|
||||
### Tile grid
|
||||
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
||||
**Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, resolved in `getDefaultSettings()`, never `isTouchDevice()`, so a touchscreen laptop keeps ON), per-device: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; desktop-only at 1180px by a JS width check with a live media listener plus a `@media (max-width: 1179px)` backstop, never in a solo window). 1 to 6 live sessions side by side in one window, each a `TerminalTile` (terminal-tile.js), orchestrated by tile-grid.js (load order 7.6, `CodemanApp.prototype` methods like the split's). Pure helpers live in constants.js as `window.CodemanTileGrid`: `computeTileLayout` (1x1, 2x1, 3x1 on a grid area at least 1800px wide else 2x2, 2x2, 3x2, and 3x3 up to `TILE_LAYOUT_MAX` (9), unreachable today; `fits` against a 480x240 minimum tile), `tileGridCapacity` (never more than `TILE_GRID_MAX`), `sanitizeTileGridState` (truncates a stored grid to the cap, keeps focus only if it survives), `buildTilePickerSessions`, `dragTrackFractions`, `tileNeighbor`, `tileInDirection`, `cycleTile`, `TILE_SCROLLBACK` (10,000), and `TILE_GRID_MAX` (6), the ONE cap (owner decision 7: six tested smooth on a real desktop, nine missed the headless frame bar). ⚠️ Every limit reads the cap through `_tileGridLimit()` (tile-grid.js: the window's capacity, at most the cap), never a literal, and its texts say which binds ("at most 6 tiles" vs "what this window fits"). The grid is a `<section class="tile-grid">` SIBLING of `.terminal-wrap`, swapped in by `.main.tiles-active` (no reparenting); `.main.webview-active .tile-grid` hides it like the single view.
|
||||
|
||||
⚠️ **The main terminal is parked while the grid is open.** Opening runs `_cleanupPreviousSession()` ONCE (its snapshot is right at that moment, and it closes the main socket), and `activeSessionId` is always the FOCUSED tile's session, so everything keyed on it (files panel, git status, respawn and Ralph panels, subagent windows, voice, image paste, the tab highlight) follows focus. With the main socket closed `_wsReady` is false, so every SSE terminal handler would write the focused tile's output into the hidden xterm: `_tilesOwnTerminal()` turns `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh` (returns `false`, which the drop recovery reads), `_scheduleDroppedOutputRecovery`, the `writeln` in `_onSessionCompletion`/`_onSessionError`, `sendResize`, `throttledResize`'s fit and `_maybeRefetchFullHistory` into no-ops; `retryConnection` and `handleInit` re-arm the TILES' sockets instead of the main one (`handleInit` keeps live tiles, drops dead ones through `_reconcileTileGrid`, and re-selects only when the focused tile is gone, so an SSE blip never hides an open web tab). ⚠️ The page's SSE filter names `TILE_GRID_SSE_FILTER` (constants.js, an id no session takes) instead of the focused session while tiles own the terminal: the server's filter gates only `session:terminal` batches, which the parked terminal could only parse and drop. Both places that set the filter ask `_sseFilterSessionId()`: the live re-subscribe every tile focus runs (`_updateSseSubscription`) and the connect URL an SSE reconnect rebuilds (`connectSSE`); leaving the grid re-subscribes the shown session through `selectSession`. `test/sse-tile-grid-filter.test.ts` pins the server's side in multi-user mode (the id is accepted on connect and on re-subscribe, and session/hook events still reach their owner through it). ⚠️ The WebGL long-task observer watches the WHOLE page: it counts nothing while tiles own the terminal, or tile renders would write the sticky 7-day WebGL disable. The header connection dot reads the tile sockets (`_tileGridSocketState`; a tile stopped for good does not count). `_focusedPane()` answers with the focused tile even when DOM focus left every terminal, and `_forEachTile` reaches every grid tile (`{ grid: false }` skips them where tiles keep their own font size). Leaving the grid destroys every tile, resets `_lastResizeDims`, invalidates the main terminal's cached content for EVERY tiled id (`_xtermSnapshots`, `codeman-xs-<id>`, `terminalBufferCache`: written before the grid opened, and selectSession paints a snapshot as its first frame) and replays the focused session through `selectSession(id, { forceReload: true, auto: true })`.
|
||||
|
||||
|
||||
@@ -71,17 +71,21 @@ We tested three browser automation frameworks against the Codeman web UI:
|
||||
|
||||
## Test File Structure
|
||||
|
||||
### Port Allocation
|
||||
### Ports
|
||||
|
||||
| Port Range | Test File |
|
||||
|------------|-----------|
|
||||
| 3150-3153 | browser-e2e.test.ts (existing) |
|
||||
| 3154 | file-link-click.test.ts |
|
||||
| 3155 | browser-playwright.test.ts |
|
||||
| 3156 | browser-puppeteer.test.ts |
|
||||
| 3157 | browser-agent.test.ts |
|
||||
| 3158-3160 | browser-comparison.test.ts |
|
||||
| 3180-3182 | scripts/browser-comparison.mjs |
|
||||
A test that starts a server binds an ephemeral port, never a fixed one:
|
||||
|
||||
- `WebServer`: `new WebServer(0, false, true)`, then read the port the OS handed out from
|
||||
`server.boundPort` after `await server.start()`. `test/test-ports-guard.test.ts` fails
|
||||
any `WebServer` built under `test/` on a non-zero port (a shrink-only legacy list
|
||||
excepted).
|
||||
- A raw Fastify or `ws` server: `listen({ port: 0 })`, then `address().port`.
|
||||
- The mobile suite (`test/mobile/**`, via `createTestServer(PORT)`) keeps the fixed-port
|
||||
convention in `test/mobile/README.md` for now.
|
||||
- Never port 3000: that is the live instance.
|
||||
|
||||
`scripts/browser-comparison.mjs` is a standalone script outside the guard and still uses
|
||||
fixed ports 3180-3182.
|
||||
|
||||
### File Purposes
|
||||
|
||||
@@ -106,7 +110,7 @@ const browser = await chromium.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Auto-waiting selectors
|
||||
await page.click('.btn-claude');
|
||||
@@ -140,7 +144,7 @@ const browser = await puppeteer.launch({
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.goto(BASE_URL);
|
||||
|
||||
// Manual waiting often needed
|
||||
await page.click('.btn-claude');
|
||||
@@ -186,7 +190,7 @@ function agentBrowserJson<T>(cmd: string): T {
|
||||
}
|
||||
|
||||
// Usage
|
||||
agentBrowser('open http://localhost:3000');
|
||||
agentBrowser(`open ${BASE_URL}`);
|
||||
agentBrowser('click ".btn-claude"');
|
||||
const title = agentBrowserJson<{title: string}>('get title');
|
||||
|
||||
@@ -246,11 +250,14 @@ npx playwright install chromium
|
||||
### 4. Wait for Server Startup
|
||||
|
||||
```typescript
|
||||
const server = new WebServer(PORT);
|
||||
const server = new WebServer(0, false, true); // port 0 (the OS picks one), no TLS, testMode
|
||||
await server.start();
|
||||
await new Promise(r => setTimeout(r, 1000)); // Allow server to stabilize
|
||||
const BASE_URL = `http://localhost:${server.boundPort}`;
|
||||
```
|
||||
|
||||
`boundPort` holds the real port only once `start()` has resolved. The `BASE_URL` used by the
|
||||
other snippets on this page is this one.
|
||||
|
||||
### 5. Clean Up Sessions
|
||||
|
||||
Track created sessions for cleanup:
|
||||
|
||||
@@ -51,6 +51,9 @@ interface CliEntry {
|
||||
// that ended waiting for workers it will resume from
|
||||
// .modelDetect?: { screenLine, screenLines? }
|
||||
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
|
||||
// .launchDefaults?: { [launchParam]: settingsKey }
|
||||
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
|
||||
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
|
||||
@@ -124,7 +124,7 @@ environment is injected through socket-scoped `tmux setenv` rather than the comm
|
||||
Working and idle come from the screen: while a turn runs, OpenCode draws a small spinner at
|
||||
the start of its footer (`⬝■■■■■■⬝ esc interrupt`), and Codeman reads that to tell a working
|
||||
session from an idle one. A pending permission prompt shows as idle, since it is waiting on
|
||||
you. Before 1.36.0 an OpenCode session that had run a tool showed as working for good.
|
||||
you. Before 1.40.0 an OpenCode session that had run a tool showed as working for good.
|
||||
|
||||
Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/opencode-integration.md).
|
||||
|
||||
@@ -164,7 +164,7 @@ only the CLI you spawned yourself.
|
||||
|
||||
Working and idle come from the screen: while a turn runs, Gemini CLI draws a spinner line
|
||||
(`⠦ Thinking... (esc to cancel, 6s)`) above its composer, and Codeman reads that. A tool
|
||||
confirmation that waits for you shows as idle. Before 1.36.0 a Gemini session showed as
|
||||
confirmation that waits for you shows as idle. Before 1.40.0 a Gemini session showed as
|
||||
working for good after its first turn.
|
||||
|
||||
### Antigravity
|
||||
@@ -190,7 +190,7 @@ Pi needs the opposite instincts from every other CLI here.
|
||||
once. They stay out.
|
||||
- **Work detection reads Pi's composer rule.** Pi has no prompt glyph; while a turn runs it
|
||||
puts a spinner into the rule above the composer (`── ⠏ Working ───`), and Codeman reads
|
||||
that to tell working from idle. Before 1.36.0 a Pi session that had started a turn showed
|
||||
that to tell working from idle. Before 1.40.0 a Pi session that had started a turn showed
|
||||
as working for good.
|
||||
|
||||
Guide: [`docs/pi-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/pi-integration.md).
|
||||
@@ -246,7 +246,7 @@ flag from Codeman; change that in OMP's own config, not here.
|
||||
|
||||
OMP conversations appear in Past Sessions and can be resumed, and a respawn continues the
|
||||
same conversation with `--continue`. Codeman tells working from idle by reading OMP's status
|
||||
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.36.0
|
||||
bar, where a spinner and the elapsed time replace the `π` while a turn runs. Before 1.40.0
|
||||
an OMP session that had started a turn showed as working for good.
|
||||
|
||||
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||
|
||||
@@ -62,7 +62,9 @@ codeman web # then open http://localhost:3000
|
||||
| Page | What it answers |
|
||||
| ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| [The Dashboard](The-Dashboard) | What is the UI telling me? |
|
||||
| [Tile Grid](Tile-Grid) | How do I watch and drive several sessions side by side? |
|
||||
| [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? |
|
||||
| [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? |
|
||||
| [Working With Files](Working-With-Files) | How do I read, edit, and attach files? |
|
||||
| [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? |
|
||||
| [Mobile Guide](Mobile-Guide) | How well does this work on a phone? |
|
||||
|
||||
@@ -10,7 +10,8 @@ never offered in a popped-out session window.
|
||||
## Turning it on
|
||||
|
||||
**App Settings → Header & Panels → Tiles.** This is a per-device setting, on by default
|
||||
everywhere except phones, and the button only appears in a window at least 1180px wide.
|
||||
on desktops and laptops and off on phones and tablets, and the button only appears in a
|
||||
window at least 1180px wide.
|
||||
It shows a **Tiles** button in the header, beside Split, and enables `Ctrl+Shift+G`.
|
||||
|
||||
## Opening a grid
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Extracted from the package.json one-liner for readability and debuggability.
|
||||
*
|
||||
* Steps:
|
||||
* 0. Preflight: the build-time packages resolve (nothing is touched before it)
|
||||
* 1. TypeScript compilation
|
||||
* 2. Copy static assets (web/public, templates)
|
||||
* 3. Build vendor xterm bundles
|
||||
@@ -13,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -25,6 +27,31 @@ function run(label, cmd) {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: ROOT, shell: true });
|
||||
}
|
||||
|
||||
// 0. Preflight: resolve the build-time packages the asset stage reads only AFTER it has
|
||||
// deleted dist/web/public (step 2), before anything is touched. A tree whose node_modules
|
||||
// predate them (a deploy that pulled but never ran `npm install`) used to fail mid-build
|
||||
// with dist/web/public already wiped, so the running server kept serving an index.html
|
||||
// whose hashed assets were gone. Keep the list in step with every require.resolve in
|
||||
// scripts/prepare-spreadsheet-assets.mjs (test/spreadsheet-assets.test.ts checks it).
|
||||
// Only specifiers that resolve without an exports map in the way: a subpath of a package
|
||||
// that has one (@xterm/*) can throw ERR_PACKAGE_PATH_NOT_EXPORTED while installed.
|
||||
// A hand-run of the asset stage alone (past a blocked tsc) skips this check.
|
||||
const BUILD_TIME_MODULES = ['exceljs/dist/exceljs.min.js', 'fflate'];
|
||||
const requireFromBuild = createRequire(import.meta.url);
|
||||
const missingModules = BUILD_TIME_MODULES.filter((specifier) => {
|
||||
try {
|
||||
requireFromBuild.resolve(specifier);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (missingModules.length > 0) {
|
||||
console.error(`[build] missing build dependency: ${missingModules.join(', ')}`);
|
||||
console.error('[build] run `npm install` first, then `npm run build` again. Nothing was built or deleted.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. TypeScript compilation
|
||||
run('tsc', 'tsc');
|
||||
run('chmod dist/index.js', 'chmod +x dist/index.js');
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { z } from 'zod';
|
||||
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
|
||||
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
|
||||
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
|
||||
|
||||
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
|
||||
const cliId = z
|
||||
@@ -409,6 +409,17 @@ const capabilitiesSchema = z
|
||||
'rejectWords has nothing to filter without a screenLine'
|
||||
)
|
||||
.optional(),
|
||||
// Launch param -> synced App Settings key. The values are a closed enum, like
|
||||
// configResolver: a clis.json override names one of the settings this build knows
|
||||
// how to validate, never an arbitrary key. Params are checked against the declared
|
||||
// ones in the superRefine below.
|
||||
launchDefaults: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
|
||||
)
|
||||
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
|
||||
.optional(),
|
||||
privilegedParams: z
|
||||
.array(
|
||||
z
|
||||
@@ -627,6 +638,30 @@ export const CliEntrySchema = z
|
||||
}
|
||||
});
|
||||
|
||||
// Same silent-no-op class again: a launch default for a param the entry never declared
|
||||
// would be filled into the config object and then read by nothing. And without a
|
||||
// `legacyConfigField` the entry's params are read off the request body itself, where a
|
||||
// filled `model` would be a different field (claude's per-session one), so refuse it.
|
||||
const { launchDefaults } = entry.capabilities;
|
||||
if (launchDefaults !== undefined) {
|
||||
if (entry.launch.legacyConfigField === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'launchDefaults needs launch.legacyConfigField to fill',
|
||||
path: ['capabilities', 'launchDefaults'],
|
||||
});
|
||||
}
|
||||
for (const param of Object.keys(launchDefaults)) {
|
||||
if (!declaredParams.has(param)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `launchDefaults param "${param}" is not a declared launch param`,
|
||||
path: ['capabilities', 'launchDefaults', param],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { setenvProfile } = entry.env;
|
||||
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -671,6 +671,11 @@ const CODEX: CliEntry = {
|
||||
// popup or a bare line of prose does not have that shape. A footer without an effort
|
||||
// word (a model with no reasoning setting) is not read, and the session keeps its
|
||||
// last known or launch model.
|
||||
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
|
||||
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
|
||||
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
|
||||
// that level never named its model. Every word is plain letters, so the join adds no
|
||||
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
|
||||
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
|
||||
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
|
||||
// drops it again while a prompt is being typed. With a one-row window the footer was
|
||||
@@ -681,9 +686,13 @@ const CODEX: CliEntry = {
|
||||
// and the `›` composer, and a forged footer-shaped transcript line is not followed by
|
||||
// an indented row, so it is not read.
|
||||
modelDetect: {
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
|
||||
screenLines: 2,
|
||||
},
|
||||
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
|
||||
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
|
||||
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
|
||||
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
|
||||
@@ -96,6 +96,14 @@ export type NewlineSequence = 'line-feed' | 'esc-enter';
|
||||
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
|
||||
export type ModelConfigResolverName = 'deepseek-route';
|
||||
|
||||
/**
|
||||
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
|
||||
* A closed list rather than any settings key, so a clis.json override cannot feed an
|
||||
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
|
||||
* key, which the resolver's typing enforces.
|
||||
*/
|
||||
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
|
||||
|
||||
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
|
||||
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
|
||||
|
||||
@@ -529,6 +537,20 @@ export interface CliCapabilities {
|
||||
rejectWords?: string[];
|
||||
configResolver?: ModelConfigResolverName;
|
||||
};
|
||||
/**
|
||||
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
|
||||
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
|
||||
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
|
||||
*
|
||||
* Filled into the entry's `launch.legacyConfigField` object at create time by
|
||||
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
|
||||
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
|
||||
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
|
||||
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
|
||||
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
|
||||
* Absent = no launch defaults.
|
||||
*/
|
||||
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
|
||||
/**
|
||||
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
|
||||
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Launch-time defaults for Codex sessions.
|
||||
*
|
||||
* Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the
|
||||
* `codexConfig` a launch uses, filling ONLY the fields the caller left unset.
|
||||
* Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited
|
||||
* settings.json can never smuggle an unchecked value onto the codex command line.
|
||||
*
|
||||
* Scope is the caller's decision: the create and quick-start routes apply it to
|
||||
* local launches only, never to remote, Docker or custom-endpoint launches.
|
||||
* Nothing here writes Codex's own config files.
|
||||
*/
|
||||
|
||||
import type { CodexConfig } from '../types.js';
|
||||
import { SettingsUpdateSchema } from './schemas.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
/** Resolve launch-only defaults without changing Codex's own configuration files. */
|
||||
export async function resolveCodexLaunchDefaults(
|
||||
config: CodexConfig | undefined,
|
||||
customEndpoint = false
|
||||
): Promise<CodexConfig | undefined> {
|
||||
if (customEndpoint) return config;
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'Codex launch defaults', {});
|
||||
const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel);
|
||||
const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort);
|
||||
const defaultModel = model.success ? model.data || undefined : undefined;
|
||||
const defaultEffort = effort.success ? effort.data || undefined : undefined;
|
||||
if (!defaultModel && !defaultEffort) return config;
|
||||
return {
|
||||
...config,
|
||||
model: config?.model ?? defaultModel,
|
||||
reasoningEffort: config?.reasoningEffort ?? defaultEffort,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
|
||||
*
|
||||
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
|
||||
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
|
||||
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
|
||||
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
|
||||
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
|
||||
* unchecked value onto the command line.
|
||||
*
|
||||
* Scope is the caller's decision: the create and quick-start routes apply it to local
|
||||
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
|
||||
* a CLI's own config files.
|
||||
*/
|
||||
|
||||
import { getCli } from '../config/cli-registry/registry.js';
|
||||
import { SettingsUpdateSchema } from './schemas.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
/**
|
||||
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
|
||||
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
|
||||
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
|
||||
* declares no defaults, `customEndpoint` is set, or no setting names a value.
|
||||
*/
|
||||
export async function applyLaunchDefaults<T extends object>(
|
||||
mode: string,
|
||||
configs: T,
|
||||
customEndpoint = false
|
||||
): Promise<T> {
|
||||
const entry = getCli(mode);
|
||||
const declared = entry?.capabilities.launchDefaults;
|
||||
const field = entry?.launch.legacyConfigField;
|
||||
if (customEndpoint || !declared || field === undefined) return configs;
|
||||
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
|
||||
const aliases = entry.launch.legacyConfigAliases ?? {};
|
||||
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const [param, settingKey] of Object.entries(declared)) {
|
||||
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
|
||||
// '' is the settings' "leave it to the CLI" value, the same as unset.
|
||||
const value = parsed.success ? parsed.data || undefined : undefined;
|
||||
const wireKey = aliases[param] ?? param;
|
||||
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
|
||||
}
|
||||
if (Object.keys(defaults).length === 0) return configs;
|
||||
return { ...configs, [field]: { ...current, ...defaults } };
|
||||
}
|
||||
+22
-1
@@ -985,6 +985,7 @@ class CodemanApp {
|
||||
// Last rendered connection-indicator tuple; the hot input path skips DOM
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
this._lastIndicatorLanguage = null; // the UI language it was rendered in
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||
// session they are bound to: the split pane's second terminal registers its
|
||||
@@ -4354,6 +4355,21 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile's value word as shown: i18n.js's scoped 'Connection tile: <word>'
|
||||
* entry in Chinese, the English word otherwise. Never a bare-word key: those
|
||||
* would also translate other text ("retry" is the orchestrator's Retry button),
|
||||
* which is why the value span carries data-i18n-skip.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
_connectionTileValueText(value) {
|
||||
if (!value) return '';
|
||||
const key = `Connection tile: ${value}`;
|
||||
const translated = typeof window.codemanT === 'function' ? window.codemanT(key) : key;
|
||||
return translated && translated !== key ? translated : value;
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
@@ -4365,8 +4381,12 @@ class CodemanApp {
|
||||
// writes when nothing changed (COD-136) — the compute above is DOM-free.
|
||||
const next = this._computeConnectionDescriptor();
|
||||
const prev = this._lastIndicatorDescriptor;
|
||||
// The tile's value word is written in the UI language, so a language
|
||||
// switch counts as a change too.
|
||||
const language = window.CodemanI18n?.language || 'en';
|
||||
if (
|
||||
prev &&
|
||||
language === this._lastIndicatorLanguage &&
|
||||
prev.display === next.display &&
|
||||
prev.dotClass === next.dotClass &&
|
||||
prev.text === next.text &&
|
||||
@@ -4375,6 +4395,7 @@ class CodemanApp {
|
||||
return;
|
||||
}
|
||||
this._lastIndicatorDescriptor = next;
|
||||
this._lastIndicatorLanguage = language;
|
||||
|
||||
indicator.style.display = next.display;
|
||||
if (next.display !== 'none') {
|
||||
@@ -4386,7 +4407,7 @@ class CodemanApp {
|
||||
if (tileLabel && tileValue) {
|
||||
const words = this._connectionTileWords(next);
|
||||
tileLabel.textContent = words.label;
|
||||
tileValue.textContent = words.value;
|
||||
tileValue.textContent = this._connectionTileValueText(words.value);
|
||||
tileValue.className = `connection-tile-value ${words.state}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
@@ -228,6 +230,11 @@
|
||||
'Create New Case': '新建案例',
|
||||
'Create new case': '新建案例',
|
||||
'Link Existing': '关联现有目录',
|
||||
// The toolbar case picker's action rows (session-ui.js CASE_PICKER_ACTIONS),
|
||||
// which replaced the "+" and gear buttons, and its empty state.
|
||||
'New or link a case…': '新建或关联案例…',
|
||||
'Case settings…': '案例设置…',
|
||||
'No cases match': '没有匹配的案例',
|
||||
'Add Case': '添加案例',
|
||||
'Open sessions': '打开会话',
|
||||
'Recent Sessions': '最近会话',
|
||||
@@ -377,6 +384,32 @@
|
||||
'As before (bars)': '与以前相同(进度条)',
|
||||
'Compact (default)': '紧凑(默认)',
|
||||
'Tiles (label over value)': '磁贴(标签在数值上方)',
|
||||
// The connection tile's value word in that style (app.js
|
||||
// _connectionTileValueText). Scoped keys on purpose: the bare words also
|
||||
// name other things ("retry" is the orchestrator's Retry button, "LIVE" a
|
||||
// badge in the resume list), and a bare key would translate those too.
|
||||
'Connection tile: live': '已连接',
|
||||
'Connection tile: fallback': '回退',
|
||||
'Connection tile: offline': '离线',
|
||||
'Connection tile: queued': '已排队',
|
||||
'Connection tile: retry': '重连中',
|
||||
// App Settings → Bottom bar, translated as one group (the Git status rows,
|
||||
// #543's two included). Keys are the trimmed label text, without the scope tag.
|
||||
'Bottom bar': '底部栏',
|
||||
'Git status': 'Git 状态',
|
||||
"Shows, at the right of the bottom bar, when the active session's repository (or each repository inside its folder, up to two levels down) has uncommitted files or commits that are not pushed. Click it for the list. Read-only: Codeman never fetches or changes the repository. Not shown for Docker or remote sessions. Off by default.":
|
||||
'在底部栏右侧显示当前会话的仓库(或其文件夹内向下两层以内的每个仓库)是否有未提交的文件或未推送的提交。点击可查看列表。只读:{name} 从不拉取或更改仓库。Docker 和远程会话不显示。默认关闭。',
|
||||
'Git status: group files by folder': 'Git 状态:按文件夹分组显示文件',
|
||||
'In the Git window, show changed files under their folders, collapsed until you click a folder. Off lists every file by its full path. On by default.':
|
||||
'在 Git 窗口中,将更改的文件显示在各自的文件夹下,点击文件夹前保持折叠。关闭时按完整路径列出每个文件。默认开启。',
|
||||
'Git status: max repositories': 'Git 状态:最多仓库数',
|
||||
"When the session's folder holds several projects instead of being one, the Git window lists up to this many (1 to 50, default 12). Each one costs a few git commands per refresh.":
|
||||
'当会话的文件夹包含多个项目(而不是本身就是一个项目)时,Git 窗口最多列出这么多个(1 到 50,默认 12)。每个仓库每次刷新都要运行几条 git 命令。',
|
||||
'Git status: git timeout': 'Git 状态:git 超时',
|
||||
'Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.':
|
||||
'单条 git 命令可运行的秒数,超时后该仓库会被报告为无法读取(5 到 120,默认 30)。仓库位于较慢的网络共享上时请调高此值。',
|
||||
'Refresh git status': '刷新 Git 状态',
|
||||
'Close git status': '关闭 Git 状态',
|
||||
Panels: '面板',
|
||||
Monitor: '监视器',
|
||||
'Project Insights': '项目洞察',
|
||||
@@ -697,6 +730,13 @@
|
||||
'Nothing to copy': '没有可复制的内容',
|
||||
// A `#session=<id>` link whose session never appeared (app.js _armUrlSessionWait).
|
||||
'Session not found': '未找到会话',
|
||||
// A native host that would not open a window (app.js openInHostWindow); the
|
||||
// "dashboard" is a web tab.
|
||||
'Could not open a new window for this session': '无法在新窗口中打开此会话',
|
||||
'Could not open a new window for this preview': '无法在新窗口中打开此预览',
|
||||
'Could not open a new window for this dashboard': '无法在新窗口中打开此网页标签',
|
||||
// Dictation whose session closed before the text was sent (voice-input.js).
|
||||
'That session has closed; dictation not sent': '该会话已关闭,语音输入未发送',
|
||||
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||
Copy: '复制',
|
||||
@@ -870,6 +910,28 @@
|
||||
'Spreadsheet parser message failed.': '电子表格解析器消息出错。',
|
||||
'Spreadsheet parser message failed': '电子表格解析器消息出错',
|
||||
'Spreadsheet preview is unavailable.': '电子表格预览不可用。',
|
||||
'Spreadsheet preview must use a same-origin URL': '电子表格预览必须使用同源 URL',
|
||||
// Worker refusals, one sentence per error code (spreadsheet-preview.js
|
||||
// WORKER_ERROR_TEXT), and the notice bar's items (renderWarnings). The
|
||||
// counted ones are patterns in translateDynamic below.
|
||||
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.':
|
||||
'此工作簿受密码保护或为旧版 .xls 格式,无法预览。',
|
||||
'This workbook uses ZIP64, which the preview does not support.': '此工作簿使用 ZIP64 格式,预览不支持该格式。',
|
||||
'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。',
|
||||
'This workbook could not be read. The file may be damaged or not a valid .xlsx file.':
|
||||
'无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。',
|
||||
// The features the preview leaves out (spreadsheet-preview.js warningText).
|
||||
// Scoped keys on purpose: a bare 'charts' or 'macros' key would also
|
||||
// translate a folder or case of that name (a Helm chart's charts/, a dbt
|
||||
// project's macros/) in the Files panel and the case picker.
|
||||
'Spreadsheet feature: charts': '图表',
|
||||
'Spreadsheet feature: drawings': '绘图',
|
||||
'Spreadsheet feature: pivot tables': '数据透视表',
|
||||
'Spreadsheet feature: external links': '外部链接',
|
||||
'Spreadsheet feature: macros': '宏',
|
||||
'Formula has no cached result': '公式没有缓存的计算结果',
|
||||
'Unsupported cell value': '不支持的单元格值',
|
||||
'Unsupported number format': '不支持的数字格式',
|
||||
'Export as JSON': '导出为 JSON',
|
||||
'Export as Markdown': '导出为 Markdown',
|
||||
'Mark all read': '全部标为已读',
|
||||
@@ -1097,6 +1159,10 @@
|
||||
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
|
||||
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
|
||||
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
|
||||
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
|
||||
[/^Spreadsheet preview failed \((\d+)\)$/, (_m, status) => `电子表格预览失败(${status})`],
|
||||
[/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`],
|
||||
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
[
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue"></span></span>
|
||||
<span class="connection-tile"><span class="connection-tile-label" id="connectionTileLabel"></span><span class="connection-tile-value" id="connectionTileValue" data-i18n-skip></span></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
|
||||
@@ -334,6 +334,34 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab Layout "By case" at tablet widths, getDeviceType()'s 'tablet' (600 to
|
||||
767px). The strip is the tablet block's one scrolling row and
|
||||
updateTabOverflowMode() never wraps it here, so the boxes, which may shrink
|
||||
below 768px, squeezed and wrapped their tabs inside themselves instead of
|
||||
overflowing: the strip never scrolled, and every tab past a box's first
|
||||
line was clipped under the fixed header. The boxes dissolve into the chip
|
||||
row as on phones (the 599px block), which keeps the tablet's own 40px chip
|
||||
geometry; a 44px box would hang below the 48px header. Ends at 767px, not
|
||||
at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the
|
||||
desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the
|
||||
strip. With the boxes, their labels and their case-colour borders gone, the
|
||||
`-<case>` part of a generated name (.tab-name-case, which styles.css hides
|
||||
in the clustered strip) is the only cue left to which case a chip is in: the
|
||||
w<n> counter is per case, so w1-alpha and w1-beta would both read "w1". */
|
||||
@media (min-width: 600px) and (max-width: 767px) {
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:where(.header) .tab-cluster-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge fade for the phone's header tab strip (used in the block below).
|
||||
Registered so the keyframes can interpolate them as lengths; @property is
|
||||
only valid at the top level, hence out here. */
|
||||
@@ -767,7 +795,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Clustered by case, the same holds: the boxes dissolve into the one chip
|
||||
row (in cluster order) and the labels go, since every chip still names
|
||||
its session. */
|
||||
its session. It does so in full: the `-<case>` part of a generated name,
|
||||
which styles.css hides in the clustered strip, shows again, or w1-alpha
|
||||
and w1-beta (the w<n> counter is per case) would both read "w1". */
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
display: contents;
|
||||
}
|
||||
@@ -776,6 +806,10 @@ html.mobile-init .file-browser-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Only the active tab shows its action icons on a phone (see below), so on
|
||||
every other tab the container is empty but still a flex item, and its gap
|
||||
made the chip visibly wider on the right than on the left. */
|
||||
@@ -1718,7 +1752,10 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
/* Only reached with the phone overview switched off. The primary spans the
|
||||
column; the chips keep wrapping, a little taller for a thumb. */
|
||||
column; the chips keep wrapping at the finger size styles.css gives touch
|
||||
screens (40px, its pointer: coarse rule). This rule loads later at equal
|
||||
specificity, so a lower value here made a phone's chips shorter than a
|
||||
tablet's; restating 40px also covers a narrow window with a mouse. */
|
||||
.welcome-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
@@ -1727,8 +1764,8 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.welcome-chip {
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.history-show-more {
|
||||
|
||||
@@ -3549,7 +3549,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
// Desktop defaults - rely on ?? operators in apply functions
|
||||
// This allows desktop to have different defaults without duplication
|
||||
return {};
|
||||
// A touch-primary tablet (iPad, an Android tablet: not a handheld, so it
|
||||
// lands here) keeps the Tiles button opt-in, as Split is: a tile has none of
|
||||
// the main terminal's touch, IME and soft-keyboard handling. The PRIMARY
|
||||
// pointer decides, never MobileDetection.isTouchDevice(), which is true on a
|
||||
// touchscreen laptop too (fine primary pointer: the desktop default stays).
|
||||
const coarsePrimaryPointer =
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(pointer: coarse)')?.matches === true;
|
||||
return coarsePrimaryPointer ? { showTileGridButton: false } : {};
|
||||
},
|
||||
|
||||
loadAppSettingsFromStorage() {
|
||||
@@ -3566,8 +3573,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch (err) {
|
||||
console.error('Failed to load app settings:', err);
|
||||
}
|
||||
// Return device-specific defaults
|
||||
this._cachedAppSettings = this.getDefaultSettings();
|
||||
// Return device-specific defaults, without showTileGridButton: its default
|
||||
// on a non-handheld follows the LIVE primary pointer (getDefaultSettings),
|
||||
// and this object is what a fresh device caches and the server-settings
|
||||
// merge then persists, which would freeze a 2-in-1's first-load posture
|
||||
// into a stored value. Every reader resolves the absent key through a
|
||||
// fresh getDefaultSettings() (?? defaults.showTileGridButton ?? true).
|
||||
const defaults = { ...this.getDefaultSettings() };
|
||||
delete defaults.showTileGridButton;
|
||||
this._cachedAppSettings = defaults;
|
||||
return this._cachedAppSettings;
|
||||
},
|
||||
|
||||
@@ -3613,6 +3627,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (result && this.notificationManager) {
|
||||
this.notificationManager.originalTitle = document.title;
|
||||
}
|
||||
// The connection tile's value word (Header Stats Style Tiles) is written
|
||||
// already translated into a data-i18n-skip span, so the translator above
|
||||
// cannot revert it; its own language compare makes this one call repaint it.
|
||||
this._updateConnectionIndicator?.();
|
||||
},
|
||||
|
||||
// Resolved per-device state of the plan-usage chip. Desktop defaults ON,
|
||||
|
||||
@@ -31,6 +31,76 @@
|
||||
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
|
||||
});
|
||||
|
||||
// What a refusal from the worker says on screen. The core's own messages
|
||||
// (spreadsheet-xlsx-core.js) are developer detail, so each error code maps to
|
||||
// one sentence with a zh-CN entry in i18n.js, and the raw message goes to the
|
||||
// console. Any other code (parse-failed carries ExcelJS's own exception text)
|
||||
// shows the generic failure.
|
||||
const TOO_LARGE_OR_COMPLEX = 'This workbook is too large or complex to preview.';
|
||||
const UNREADABLE = 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.';
|
||||
const WORKER_ERROR_TEXT = Object.freeze({
|
||||
encrypted: 'This workbook is password-protected or in the old .xls format, so it cannot be previewed.',
|
||||
zip64: 'This workbook uses ZIP64, which the preview does not support.',
|
||||
malformed: UNREADABLE,
|
||||
'number-format': UNREADABLE,
|
||||
'entry-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'entry-size': TOO_LARGE_OR_COMPLEX,
|
||||
'inflated-size': TOO_LARGE_OR_COMPLEX,
|
||||
'compression-ratio': TOO_LARGE_OR_COMPLEX,
|
||||
'worksheet-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'element-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'row-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'cell-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'merge-limit': TOO_LARGE_OR_COMPLEX,
|
||||
'style-limit': TOO_LARGE_OR_COMPLEX,
|
||||
});
|
||||
|
||||
// The workbook features the preview leaves out, as the core names them
|
||||
// (featureForName), in words for the notice bar.
|
||||
const FEATURE_LABELS = Object.freeze({
|
||||
charts: 'charts',
|
||||
drawings: 'drawings',
|
||||
pivotTables: 'pivot tables',
|
||||
externalLinks: 'external links',
|
||||
macros: 'macros',
|
||||
});
|
||||
const UNSUPPORTED_FORMAT_PREFIX = 'Unsupported number format: ';
|
||||
|
||||
function translate(text) {
|
||||
return global.codemanT?.(text) || text;
|
||||
}
|
||||
|
||||
function own(map, key) {
|
||||
return Object.prototype.hasOwnProperty.call(map, key);
|
||||
}
|
||||
|
||||
function workerErrorText(payload) {
|
||||
const code = String(payload?.code || '');
|
||||
if (payload?.message) console.warn(`Spreadsheet preview: ${code || 'error'}: ${payload.message}`);
|
||||
return own(WORKER_ERROR_TEXT, code) ? WORKER_ERROR_TEXT[code] : 'Spreadsheet preview failed';
|
||||
}
|
||||
|
||||
// One notice bar item, translated on its own (the bar is one text node, which
|
||||
// the i18n layer could only match whole; the bar itself carries
|
||||
// data-i18n-skip). A number format's code is workbook text: it is appended
|
||||
// as is, never passed through the translator, which would read a `{…}` in it
|
||||
// as a placeholder. A feature word goes through its scoped
|
||||
// 'Spreadsheet feature: <word>' key and reads as the plain word when that key
|
||||
// has no translation: a bare 'charts' key would also rename a charts/ folder.
|
||||
function warningText(warning) {
|
||||
const text = String(warning);
|
||||
if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) {
|
||||
return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`;
|
||||
}
|
||||
if (own(FEATURE_LABELS, text)) {
|
||||
const label = FEATURE_LABELS[text];
|
||||
const key = `Spreadsheet feature: ${label}`;
|
||||
const translated = translate(key);
|
||||
return translated !== key ? translated : label;
|
||||
}
|
||||
return translate(text);
|
||||
}
|
||||
|
||||
function message(container, text, kind) {
|
||||
container.textContent = '';
|
||||
const state = document.createElement('div');
|
||||
@@ -209,9 +279,8 @@
|
||||
if (!notice) return;
|
||||
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
|
||||
notice.hidden = warnings.length === 0;
|
||||
const warningLabel =
|
||||
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
|
||||
const warningLabel = translate('Some workbook features are not shown');
|
||||
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.map(warningText).join(', ')}` : '';
|
||||
}
|
||||
|
||||
function pinHeadings() {
|
||||
@@ -376,6 +445,10 @@
|
||||
}
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'spreadsheet-preview-notice';
|
||||
// Written already translated, item by item (renderWarnings), and it ends
|
||||
// with workbook text (a number format's code): the observer's t() over
|
||||
// the whole line would rewrite a `{name}` or a "Codeman" in that code.
|
||||
notice.setAttribute('data-i18n-skip', '');
|
||||
notice.hidden = true;
|
||||
emptySheetState = document.createElement('div');
|
||||
emptySheetState.className = 'spreadsheet-empty-sheet';
|
||||
@@ -459,7 +532,7 @@
|
||||
clearTimer();
|
||||
renderMetadata(payload);
|
||||
} else if (payload.type === 'tile') renderTile(payload);
|
||||
else if (payload.type === 'error') fail(payload.message);
|
||||
else if (payload.type === 'error') fail(workerErrorText(payload));
|
||||
};
|
||||
worker.onerror = () => fail('Spreadsheet parser failed.');
|
||||
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
|
||||
|
||||
+37
-18
@@ -4519,7 +4519,8 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Touch screens wide enough for this view (tablets) get finger-sized chips. */
|
||||
/* Touch screens get finger-sized chips. Phones too: mobile.css's 599px block
|
||||
loads later and wins at equal specificity, so it restates the 40px. */
|
||||
@media (pointer: coarse) {
|
||||
.welcome-chip {
|
||||
height: 40px;
|
||||
@@ -19741,29 +19742,50 @@ body.tile-grid-resizing--row * {
|
||||
color: var(--accent, #4a9eff);
|
||||
}
|
||||
|
||||
/* A permission prompt or question, visible across the room. Only the border
|
||||
colour and a box-shadow animate: anything that moves layout would resize
|
||||
the tile's PTY every frame. */
|
||||
/* A permission prompt or question, visible across the room: a red border and
|
||||
a glow that pulses for as long as the prompt waits, which can be hours.
|
||||
Anything that moves layout would resize the tile's PTY every frame, and a
|
||||
box-shadow animated on the tile itself repainted the whole tile (its DOM
|
||||
rendered terminal rows included, the whole stage when zoomed) every frame.
|
||||
So the glow is a STATIC shadow on an overlay and only its opacity
|
||||
animates, which the compositor runs without repainting the tile. The
|
||||
shadow is inset because .tile is overflow: hidden and would clip an outer
|
||||
one on its own pseudo-element. Above .tile-attach (z-index 2), and never
|
||||
in the way of a click. */
|
||||
.tile.tile--needs {
|
||||
border-color: var(--red, #ef4444);
|
||||
}
|
||||
|
||||
.tile.tile--needs::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
opacity: 0;
|
||||
animation: tile-needs-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tile-needs-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--red, #ef4444) 0%, transparent);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile.tile--needs {
|
||||
animation: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--red, #ef4444) 55%, transparent);
|
||||
}
|
||||
|
||||
.tile.tile--needs::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The tab of a tiled session (the focused tile's tab is .active as usual). */
|
||||
@@ -19821,9 +19843,11 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
|
||||
/* Quiet "loading" state while the tile's capture waits in the grid's one load
|
||||
queue (or runs). */
|
||||
queue (or runs). The text is the body's data-loading-label, which tile-grid.js
|
||||
writes in the UI language (_setTileLoadingLabel): generated content is out of
|
||||
the i18n layer's reach, so no literal words here. */
|
||||
.tile.tile--loading .tile-body::after {
|
||||
content: 'Loading\2026';
|
||||
content: attr(data-loading-label);
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 10px;
|
||||
@@ -20030,13 +20054,6 @@ body.tile-grid-resizing--row * {
|
||||
animation-delay: calc(var(--tile-enter-index, 0) * 24ms);
|
||||
}
|
||||
|
||||
/* A tile that needs you enters AND pulses (one animation property). */
|
||||
.tile.tile--entering.tile--needs {
|
||||
animation:
|
||||
tile-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) calc(var(--tile-enter-index, 0) * 24ms) both,
|
||||
tile-needs-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tile-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -20121,7 +20138,7 @@ body.tile-grid-resizing--row * {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile-count-menu,
|
||||
.tile.tile--entering,
|
||||
.tile.tile--entering.tile--needs,
|
||||
.tile.tile--needs::after,
|
||||
.tile.tile--loading .tile-body::after,
|
||||
.tile-grid-ghosts .tile.tile--leaving,
|
||||
.tile-grid-ghosts.tile-grid-ghosts--release .tile.tile--leaving,
|
||||
@@ -20981,7 +20998,9 @@ html[data-session-list='sidebar'][data-sidebar='collapsed'] .session-sidebar .ta
|
||||
the routes ran through tabs, labels and box borders. Only a case wider than
|
||||
the whole strip still wraps inside its box (max-width: 100%), and the strip
|
||||
wraps around it too (_tabClustersWrapInside). The narrower screens keep
|
||||
their scrolling strip. */
|
||||
their scrolling strip: mobile.css dissolves the boxes into its chip row on
|
||||
phones (599px block) and on tablets (600 to 767px block), since a box
|
||||
allowed to shrink there wrapped its tabs out of the one-row strip. */
|
||||
@media (min-width: 768px) {
|
||||
.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster {
|
||||
flex-shrink: 0;
|
||||
|
||||
+112
-27
@@ -233,6 +233,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
onChange: (tile, state) => {
|
||||
const entry = grid.tiles.get(tile.sessionId);
|
||||
if (entry?.tile !== tile) return;
|
||||
// Rewritten on the way in, so a language switched since is picked up.
|
||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||
// The first capture has landed (or failed): the terminal fades in,
|
||||
// whole, instead of showing its replay scroll by.
|
||||
@@ -243,6 +245,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
return grid.queue;
|
||||
},
|
||||
|
||||
/**
|
||||
* The "Loading…" label of a tile body. It is CSS generated content (styles.css,
|
||||
* `content: attr(data-loading-label)`), which the i18n layer never reaches, so
|
||||
* the text is written here in the UI language.
|
||||
*/
|
||||
_setTileLoadingLabel(body) {
|
||||
if (!body) return;
|
||||
const t = window.codemanT;
|
||||
body.dataset.loadingLabel = typeof t === 'function' ? t('Loading…') : 'Loading…';
|
||||
},
|
||||
|
||||
_tileGridSection() {
|
||||
let section = document.getElementById('tileGrid');
|
||||
if (!section) {
|
||||
@@ -253,16 +266,65 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wrap = document.querySelector('.terminal-wrap');
|
||||
wrap?.parentElement?.insertBefore(section, wrap.nextSibling);
|
||||
}
|
||||
// Once per section (index.html ships it, so not only on create).
|
||||
if (this._tileFileDropSection !== section) {
|
||||
this._tileFileDropSection = section;
|
||||
this._installTileFileDrop(section);
|
||||
}
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* A file dragged over the grid. The single view's file drop (image-input.js)
|
||||
* listens on #terminalContainer, hidden while tiles are open, so nothing
|
||||
* cancelled a file drag here and the browser opened the file in place of
|
||||
* Codeman. Anywhere over the grid (a tile, an empty cell, a divider, the
|
||||
* padding) the drag is cancelled, so the page never navigates; dropped on a
|
||||
* tile, its images upload to THAT tile's session and their paths are typed
|
||||
* there, as the single view does for the active one. Bubble phase, files
|
||||
* only: a tab or tile drag carries none, and its target stops it in the
|
||||
* capture phase anyway (_acceptTabDrops).
|
||||
*/
|
||||
_installTileFileDrop(section) {
|
||||
const isFileDrag = (e) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
return !!types && Array.from(types).includes('Files');
|
||||
};
|
||||
// The open grid's tile under `target`, or null (an empty cell, a divider, the padding).
|
||||
const tileAt = (target) => {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !target) return null;
|
||||
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(target)) return id;
|
||||
return null;
|
||||
};
|
||||
section.addEventListener('dragover', (e) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer && tileAt(e.target)) e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
section.addEventListener('drop', (e) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens the grid on `ids` (unknown, detached and duplicate ids are skipped;
|
||||
* at most TILE_GRID_MAX), focusing `focusedId` or the first. Already open, it
|
||||
* adds what is missing and moves focus. `auto: false` makes the focus a human
|
||||
* selection (it acknowledges that session's idle alert). An open split
|
||||
* closes (the two are never open together); `mergeSplit` (default) makes its
|
||||
* two sessions the first tiles, false opens exactly `ids` (a stored grid).
|
||||
* two sessions the first tiles, false opens exactly `ids` (a stored grid, a
|
||||
* group, a Ctrl/Cmd+click: callers that size their own set).
|
||||
*
|
||||
* Parks the main terminal first: `_cleanupPreviousSession()` runs ONCE, while
|
||||
* its snapshot of the session it shows is still right, and closes its socket.
|
||||
@@ -275,15 +337,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const max = window.CodemanTileGrid.TILE_GRID_MAX;
|
||||
// The grid and the split are never open together. An open split becomes the
|
||||
// grid's first two tiles (Pane A focused, Pane B beside it), so "split, then
|
||||
// want more" is one step. No closing resize for Pane A: it is about to park.
|
||||
// want more" is one step.
|
||||
let requested = ids || [];
|
||||
if (this._splitPane) {
|
||||
const splitOpen = !!this._splitPane;
|
||||
if (splitOpen && mergeSplit) {
|
||||
const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean);
|
||||
this.closeSplitPane({ skipPrimaryResize: true });
|
||||
if (mergeSplit) {
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
requested = [...seed, ...requested];
|
||||
if (!requested.includes(focusedId)) focusedId = seed[0] ?? null;
|
||||
}
|
||||
const wanted = [];
|
||||
for (const id of requested) {
|
||||
@@ -292,6 +352,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
wanted.push(id);
|
||||
if (wanted.length === max) break;
|
||||
}
|
||||
// No closing resize for Pane A only when it becomes a tile (its tile sizes
|
||||
// the PTY). A Pane A left out of the set (a group, a stored grid: mergeSplit
|
||||
// false) gets its full width back now, while the main terminal still shows
|
||||
// it, or its PTY stays at the split's half width for as long as the grid
|
||||
// is open.
|
||||
if (splitOpen) this.closeSplitPane({ skipPrimaryResize: wanted.includes(this.activeSessionId) });
|
||||
if (wanted.length === 0) return grid.open;
|
||||
const focus = wanted.includes(focusedId) ? focusedId : wanted[0];
|
||||
|
||||
@@ -1015,8 +1081,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
* The tile chord `e` asks for, if it applies right now, else null. The
|
||||
* toggle applies while the grid is open, or where one could open AND the
|
||||
* per-device `showTileGridButton` setting is on (the desktop default; OFF on
|
||||
* handhelds): with it off the chord is inert and reaches the terminal like
|
||||
* any unbound key (owner decision 6 in docs/tile-grid-plan.md). An absent key
|
||||
* handhelds and touch-primary tablets): with it off the chord is inert and
|
||||
* reaches the terminal like any unbound key (owner decision 6 in
|
||||
* docs/tile-grid-plan.md). An absent key
|
||||
* resolves through the device defaults exactly as the header button does
|
||||
* (settings-ui.js), so the chord and the button can never disagree. The focus, move,
|
||||
* zoom and remove chords apply only while the grid is open, however it was
|
||||
@@ -1140,10 +1207,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// replayed fresh (forceReload drops the stale snapshot and nulls
|
||||
// activeSessionId BEFORE _cleanupPreviousSession, so nothing wrong is saved),
|
||||
// or, if that session is gone, the same fallback as closing the active tab.
|
||||
// Returns the selection's promise (it settles once the replay is written),
|
||||
// or undefined for the welcome screen.
|
||||
// A popped-out session counts as gone in both: selectSession would only
|
||||
// raise its window and return, leaving the parked terminal's pre-grid
|
||||
// content on screen under its tab (and saved as its snapshot on the next
|
||||
// switch). Returns the selection's promise (it settles once the replay is
|
||||
// written), or undefined for the welcome screen.
|
||||
_selectAfterTileGrid(sessionId) {
|
||||
if (sessionId && this.sessions.has(sessionId)) {
|
||||
const usable = (id) => this.sessions.has(id) && !this.detachedSessions?.has(id);
|
||||
if (sessionId && usable(sessionId)) {
|
||||
return this.selectSession(sessionId, { forceReload: true, auto: true });
|
||||
}
|
||||
this.activeSessionId = null;
|
||||
@@ -1152,7 +1223,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
} catch {
|
||||
/* Nothing stored. */
|
||||
}
|
||||
const next = this.sessionOrder.find((id) => this.sessions.has(id));
|
||||
const next = this.sessionOrder.find(usable);
|
||||
if (next) return this.selectSession(next, { auto: true });
|
||||
this.terminal?.clear();
|
||||
this.showWelcome();
|
||||
@@ -1213,11 +1284,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Removes one tile; the session keeps running. Its cell becomes empty where
|
||||
* it was, unless the shape changes with the count (then fitTileCells). When it held focus, `refocus`
|
||||
* moves focus to the neighbouring tile (next in grid order, else previous),
|
||||
* as the app's choice (`auto`: no idle alert is spent). The last tile
|
||||
* as the app's choice (`auto`: no idle alert is spent); `focus: false` keeps
|
||||
* DOM focus where it is (an app-driven removal: a socket the server closed),
|
||||
* so keystrokes never land in the neighbour's PTY unasked. The last tile
|
||||
* leaving closes the grid: with `refocus` the single view then shows that
|
||||
* session, without it the caller decides what comes next.
|
||||
* session (or, popped out, the next one: _selectAfterTileGrid), without it
|
||||
* the caller decides what comes next.
|
||||
*/
|
||||
removeTile(sessionId, { refocus = true } = {}) {
|
||||
removeTile(sessionId, { refocus = true, focus = true } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
const entry = grid?.open ? grid.tiles.get(sessionId) : null;
|
||||
if (!entry) return false;
|
||||
@@ -1243,7 +1317,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._applyTileLayout();
|
||||
this._scheduleTileGridRefit();
|
||||
this.renderSessionTabs?.();
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true });
|
||||
if (wasFocused && refocus && neighbor) this._selectTiledSession(neighbor, { auto: true, focus });
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1279,6 +1353,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = this._buildTileHeader(sessionId);
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tile-body';
|
||||
this._setTileLoadingLabel(body);
|
||||
el.append(header.el, body);
|
||||
// Pressing a tile is a human selection: it focuses the tile and
|
||||
// acknowledges its idle alert (the already-focused tile hits
|
||||
@@ -1331,7 +1406,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `accepts(id)` is the target's own rule (a tile takes any session but its
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left alone.
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
@@ -1525,7 +1601,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
|
||||
const base = this._tileGridOpenSet()?.ids || [];
|
||||
const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
|
||||
this.openTileGrid(ids, { focusedId: sessionId, auto: false });
|
||||
// Exactly these: an open split is already in `base` (tileGridOpenSet seeds
|
||||
// it), and merging it again went past the count (N+1) and the window.
|
||||
this.openTileGrid(ids, { focusedId: sessionId, auto: false, mergeSplit: false });
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1567,7 +1645,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
this.activeSessionId = null;
|
||||
}
|
||||
return this.openTileGrid(ids, { focusedId: focus });
|
||||
// Exactly the group: an open split closes without joining it (merged, its
|
||||
// two pushed group members out and the grid past the window's capacity).
|
||||
return this.openTileGrid(ids, { focusedId: focus, mergeSplit: false });
|
||||
},
|
||||
|
||||
/** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */
|
||||
@@ -2036,7 +2116,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* A tile's socket stopped for good. 4009 (the session exited) keeps the tile
|
||||
* with its "session ended" marker; 4003 (refused), 4004 (session gone) and
|
||||
* 4010 (another socket took over) remove it.
|
||||
* 4010 (another socket took over) remove it. Nobody here asked for that, so
|
||||
* the neighbour takes focus without the keyboard (`focus: false`): what the
|
||||
* user is typing never lands in another session's PTY.
|
||||
*/
|
||||
_onTileExit(sessionId, tile, code) {
|
||||
if (this._tileGrid?.tiles.get(sessionId)?.tile !== tile) return;
|
||||
@@ -2045,7 +2127,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._renderTileOverlay(sessionId);
|
||||
return;
|
||||
}
|
||||
this.removeTile(sessionId);
|
||||
this.removeTile(sessionId, { focus: false });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2560,8 +2642,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!grid.open) return false;
|
||||
// Only a focus that is gone moves: re-selecting the same tile would hide an
|
||||
// active web tab on every SSE blip (_selectTiledSession hides the web layer),
|
||||
// which the single view's reconnect never does.
|
||||
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true });
|
||||
// which the single view's reconnect never does. The app's choice, so DOM
|
||||
// focus stays put (an open modal or text field keeps the keyboard).
|
||||
if (!grid.has(grid.focusedId)) this._selectTiledSession(grid.ids[0], { auto: true, focus: false });
|
||||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||||
return true;
|
||||
},
|
||||
@@ -2569,7 +2652,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||
// the neighbouring tile takes it (`auto`: the app chose, so no idle alert is
|
||||
// spent). Done BEFORE the original handler, so activeSessionId no longer names
|
||||
// spent; `focus: false`: the keyboard stays put, so what the user was typing
|
||||
// never goes on into the neighbour's PTY, as the single view sends it
|
||||
// nowhere). Done BEFORE the original handler, so activeSessionId no longer names
|
||||
// the deleted id and its welcome-screen handoff stays out of it. The last tile
|
||||
// closes the grid without a reselect, and the original handler then shows the
|
||||
// welcome screen as in the single view. A close started from this tab
|
||||
@@ -2582,7 +2667,7 @@ CodemanApp.prototype._onSessionDeleted = function (data) {
|
||||
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, data.id);
|
||||
this.removeTile(data.id, { refocus: false });
|
||||
if (wasFocused && grid.open && neighbor && !this._closingSessions?.has(data.id)) {
|
||||
this._selectTiledSession(neighbor, { auto: true });
|
||||
this._selectTiledSession(neighbor, { auto: true, focus: false });
|
||||
}
|
||||
}
|
||||
return _tileGridOriginalOnSessionDeleted.call(this, data);
|
||||
|
||||
@@ -1007,15 +1007,30 @@ const VoiceInput = {
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||
// for any other session must not be typed into it.
|
||||
// for any other session must not be typed into it. It also belongs to the
|
||||
// MAIN terminal, which the tile grid parks (display: none): with tiles
|
||||
// open the text would sit in an invisible overlay the focused tile never
|
||||
// sees, so it goes straight to the session instead.
|
||||
const isActive = target === app.activeSessionId;
|
||||
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const tilesOpen = !!app._tilesOwnTerminal?.();
|
||||
if (isActive && !tilesOpen && app._localEchoEnabled && app._localEchoOverlay) {
|
||||
app._localEchoOverlay.appendText(trimmed);
|
||||
} else {
|
||||
this._sendToTarget(target, trimmed).catch(() => {});
|
||||
}
|
||||
this._showVoiceSendBtn();
|
||||
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||
setTimeout(() => {
|
||||
if (!isActive) return;
|
||||
// With the grid open the keyboard belongs to the focused tile; the
|
||||
// parked main terminal cannot take focus. Split view keeps the main
|
||||
// terminal, even if Pane B took focus meanwhile (it is not the target).
|
||||
if (app._tilesOwnTerminal?.()) {
|
||||
const pane = app._focusedPane?.();
|
||||
if (pane?.sessionId === target) pane.terminal?.focus();
|
||||
} else if (app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1045,9 +1060,10 @@ const VoiceInput = {
|
||||
if (!target) return;
|
||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||
// for another session just sends its Enter there.
|
||||
if (target !== app.activeSessionId) {
|
||||
// predictions belong to the ACTIVE session's MAIN terminal, so a
|
||||
// dictation for another session, or for a tile while the grid has the
|
||||
// main terminal parked, just sends its Enter there.
|
||||
if (target !== app.activeSessionId || app._tilesOwnTerminal?.()) {
|
||||
this._sendToTarget(target, '\r').catch(() => {});
|
||||
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const text = app._localEchoOverlay.pendingText || '';
|
||||
|
||||
@@ -116,7 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
|
||||
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { NewlineSequence } from '../../config/cli-registry/types.js';
|
||||
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
|
||||
import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js';
|
||||
import { applyLaunchDefaults } from '../launch-defaults.js';
|
||||
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
@@ -1150,8 +1150,10 @@ export function registerSessionRoutes(
|
||||
const globalNice = await ctx.getGlobalNiceConfig();
|
||||
const modelConfig = await ctx.getModelConfig();
|
||||
const mode = body.mode || 'claude';
|
||||
const launchCodexConfig =
|
||||
mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig;
|
||||
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
|
||||
// effort today) fill the CLI's own config object where the caller left it unset.
|
||||
// Local launches only: a remote attach runs whatever the remote pane already runs.
|
||||
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
|
||||
// Where a model override comes from is a capability, and the three answers are
|
||||
// genuinely different mechanisms:
|
||||
// 'flag' — the CLI takes --model, so read the value the caller sent
|
||||
@@ -1166,10 +1168,9 @@ export function registerSessionRoutes(
|
||||
const modelSource = getCli(mode)?.capabilities.model;
|
||||
const model =
|
||||
modelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>)?.[modelSource.param ?? 'model'] as string | undefined)
|
||||
? (legacyConfigForMode(mode, launchBody as unknown as Record<string, unknown>)?.[
|
||||
modelSource.param ?? 'model'
|
||||
] as string | undefined)
|
||||
: modelSource?.source === 'claude-settings-file'
|
||||
? body.model || modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
@@ -1186,12 +1187,12 @@ export function registerSessionRoutes(
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
launchCodexConfig,
|
||||
body.geminiConfig,
|
||||
body.antigravityConfig,
|
||||
body.piConfig,
|
||||
body.grokConfig,
|
||||
body.deepSeekConfig
|
||||
launchBody.codexConfig,
|
||||
launchBody.geminiConfig,
|
||||
launchBody.antigravityConfig,
|
||||
launchBody.piConfig,
|
||||
launchBody.grokConfig,
|
||||
launchBody.deepSeekConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
@@ -1204,14 +1205,14 @@ export function registerSessionRoutes(
|
||||
model,
|
||||
claudeMode: effectiveClaudeMode,
|
||||
allowedTools: claudeModeConfig.allowedTools,
|
||||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||||
openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
@@ -3875,23 +3876,31 @@ export function registerSessionRoutes(
|
||||
// Apply global Nice priority config and model config from settings
|
||||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||||
const qsModelConfig = await ctx.getModelConfig();
|
||||
const qsLaunchCodexConfig =
|
||||
mode === 'codex' && !remote && !docker
|
||||
? await resolveCodexLaunchDefaults(codexConfig, !!customModel)
|
||||
: codexConfig;
|
||||
// Synced App Settings launch defaults, as on the create path: local launches only, so
|
||||
// never a remote or Docker case, and never a custom model endpoint launch.
|
||||
const qsRequestConfigs = {
|
||||
openCodeConfig,
|
||||
codexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
};
|
||||
const qsLaunchConfigs =
|
||||
remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel);
|
||||
// ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start
|
||||
// has never taken omp's session model from ompConfig (the create path does). Kept as
|
||||
// found rather than changed in passing.
|
||||
const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs;
|
||||
// See the create path for why this is a capability rather than a mode ladder.
|
||||
const qsModelSource = getCli(mode)?.capabilities.model;
|
||||
const qsModel =
|
||||
qsModelSource?.source === 'flag'
|
||||
? (legacyConfigForMode(mode, {
|
||||
openCodeConfig,
|
||||
codexConfig: qsLaunchCodexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
|
||||
? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record<string, unknown>)?.[
|
||||
qsModelSource.param ?? 'model'
|
||||
] as string | undefined)
|
||||
: qsModelSource?.source === 'claude-settings-file'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
@@ -3907,16 +3916,16 @@ export function registerSessionRoutes(
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
qsLaunchCodexConfig,
|
||||
geminiConfig,
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig
|
||||
qsLaunchConfigs.codexConfig,
|
||||
qsLaunchConfigs.geminiConfig,
|
||||
qsLaunchConfigs.antigravityConfig,
|
||||
qsLaunchConfigs.piConfig,
|
||||
qsLaunchConfigs.grokConfig,
|
||||
qsLaunchConfigs.deepSeekConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig);
|
||||
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
|
||||
|
||||
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
|
||||
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
|
||||
@@ -4073,7 +4082,7 @@ export function registerSessionRoutes(
|
||||
claudeMode: qsEffectiveClaudeMode,
|
||||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||||
owner,
|
||||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||||
openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined,
|
||||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
|
||||
+7
-6
@@ -1406,14 +1406,14 @@ export const SettingsUpdateSchema = z
|
||||
/**
|
||||
* Tab layout, the arrangement of the tab list (Discussion #426). Display key
|
||||
* (per-device).
|
||||
* 'classic' = one flat list in tab order, as before. The default.
|
||||
* 'state' = a row per state in the header strip (needs you, waiting,
|
||||
* working, idle; option C), sections in the flat side rail and
|
||||
* the sidebar. The default.
|
||||
* the sidebar. Opt-in.
|
||||
* 'case' = one cluster per case (option A): a labelled box in the strip,
|
||||
* a section in the side rail and the sidebar.
|
||||
* a section in the side rail and the sidebar. Opt-in.
|
||||
* 'ledger' = the flat list on an aligned column grid with a status bar
|
||||
* per cell (option B). Header strip on desktop only.
|
||||
* 'classic' = one flat list in tab order, as before.
|
||||
* per cell (option B). Header strip on desktop only. Opt-in.
|
||||
*/
|
||||
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
|
||||
/**
|
||||
@@ -1457,8 +1457,9 @@ export const SettingsUpdateSchema = z
|
||||
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
|
||||
* (per-device), desktop only (the cluster is hidden below 768px).
|
||||
* 'classic' = the bars and the 5H · 7D chip, as before
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value
|
||||
* 'tiles' = label over value with a bar underneath, no icons. The default.
|
||||
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
|
||||
* The default.
|
||||
* 'tiles' = label over value with a bar underneath, no icons
|
||||
*/
|
||||
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
|
||||
showTokenCount: z.boolean().optional(),
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
|
||||
* `config/cli-registry/profiles.ts` as a named profile.
|
||||
*
|
||||
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
|
||||
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
|
||||
* approved every later copy of the same text in the same file: the codex launch defaults
|
||||
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
|
||||
* because the legacy-plumbing entry already covered that string. A new copy now fails as
|
||||
* an unapproved branch would, and a removed one fails as stale until the count drops.
|
||||
*
|
||||
* Port: none (pure static analysis).
|
||||
*/
|
||||
|
||||
@@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set(
|
||||
].map((p) => p.split('/').join(sep))
|
||||
);
|
||||
|
||||
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
|
||||
interface Allowance {
|
||||
count: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const allow = (count: number, reason: string): Allowance => ({ count, reason });
|
||||
|
||||
/**
|
||||
* Specific surviving branches, each with the reason it is not a capability.
|
||||
* Specific surviving branches, each with the reason it is not a capability and the exact
|
||||
* number of times the expression occurs in that file (see the header).
|
||||
* Keyed `<relative path>::<the matched expression>`.
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
||||
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
|
||||
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
|
||||
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
|
||||
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
|
||||
"web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
|
||||
// --- Claude's remote/docker command construction ---
|
||||
"tmux-manager.ts::mode === 'claude'":
|
||||
"tmux-manager.ts::mode === 'claude'": allow(
|
||||
2,
|
||||
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string',
|
||||
"tmux-manager.ts::mode === 'omp'":
|
||||
'`--session-id … || resume`; neither fits a static overlays.command string'
|
||||
),
|
||||
"tmux-manager.ts::mode === 'omp'": allow(
|
||||
1,
|
||||
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
|
||||
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
||||
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
||||
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
|
||||
),
|
||||
|
||||
// --- Per-CLI prose and launch handling not yet generalised ---
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'":
|
||||
'an error message explaining why THIS mode in particular will never deliver a stop signal',
|
||||
"web/routes/approval-routes.ts::mode === 'deepseek'":
|
||||
'the DeepSeek status bridge is the only non-claude source of approval items',
|
||||
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
|
||||
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
|
||||
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
|
||||
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
|
||||
"web/session-wait-registry.ts::mode === 'deepseek'": allow(
|
||||
1,
|
||||
'an error message explaining why THIS mode in particular will never deliver a stop signal'
|
||||
),
|
||||
"web/routes/approval-routes.ts::mode === 'deepseek'": allow(
|
||||
1,
|
||||
'the DeepSeek status bridge is the only non-claude source of approval items'
|
||||
),
|
||||
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
|
||||
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
|
||||
"web/routes/session-routes.ts::mode === 'claude'": allow(
|
||||
2,
|
||||
'docker case bookkeeping keyed on the claude conversation id'
|
||||
),
|
||||
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
|
||||
|
||||
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
|
||||
//
|
||||
@@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record<string, string> = {
|
||||
// there, the shared predicate silently widened both to a mode with no transcript to read.
|
||||
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
|
||||
// capability here would be actively wrong.
|
||||
"web/routes/readmymind-routes.ts::mode !== 'claude'":
|
||||
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
|
||||
"web/server.ts::mode !== 'claude'":
|
||||
"web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
|
||||
1,
|
||||
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
|
||||
),
|
||||
"web/server.ts::mode !== 'claude'": allow(
|
||||
2,
|
||||
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
|
||||
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
|
||||
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
|
||||
),
|
||||
|
||||
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
|
||||
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
|
||||
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
|
||||
// all; the resume one would need a "resumable from a claude transcript" field that nothing
|
||||
// else would read.
|
||||
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
|
||||
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
|
||||
"tui/tui-app.ts::mode !== 'claude'": allow(
|
||||
1,
|
||||
'TUI resume builds a claude --resume; claude-transcript-only by construction'
|
||||
),
|
||||
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
|
||||
|
||||
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
|
||||
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
|
||||
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
|
||||
// capability; deliberately not invented here.
|
||||
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
|
||||
'push buttons withheld where the answer route refuses keystrokes',
|
||||
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
|
||||
1,
|
||||
'push buttons withheld where the answer route refuses keystrokes'
|
||||
),
|
||||
|
||||
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
|
||||
"web/routes/session-routes.ts::mode !== 'omp'": allow(
|
||||
2,
|
||||
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
|
||||
'chain (see the opencode entry below)'
|
||||
),
|
||||
|
||||
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
|
||||
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
|
||||
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
|
||||
// change; expressing the chain as a capability would have to pick a side and would
|
||||
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
|
||||
"web/routes/session-routes.ts::mode !== 'opencode'":
|
||||
"web/routes/session-routes.ts::mode !== 'opencode'": allow(
|
||||
2,
|
||||
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
|
||||
'so any capability form would change behaviour — see PR discussion',
|
||||
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
|
||||
'so any capability form would change behaviour — see PR discussion'
|
||||
),
|
||||
"web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
@@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
|
||||
|
||||
const { findings, filesScanned } = scan();
|
||||
|
||||
interface CountMismatch {
|
||||
key: string;
|
||||
allowed: number;
|
||||
found: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
|
||||
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
|
||||
* ones with fewer (a stale approval that would let the next copy back in unseen).
|
||||
* Unallowlisted keys are not this function's business; the offenders check covers them.
|
||||
*/
|
||||
function countMismatches(
|
||||
found: Finding[],
|
||||
allowed: Record<string, Allowance>
|
||||
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
|
||||
const byKey = new Map<string, Finding[]>();
|
||||
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
|
||||
const grown: CountMismatch[] = [];
|
||||
const shrunk: CountMismatch[] = [];
|
||||
for (const [key, allowance] of Object.entries(allowed)) {
|
||||
const hits = byKey.get(key) ?? [];
|
||||
const mismatch = {
|
||||
key,
|
||||
allowed: allowance.count,
|
||||
found: hits.length,
|
||||
lines: hits.map((f) => `${f.file}:${f.line}`),
|
||||
};
|
||||
if (hits.length > allowance.count) grown.push(mismatch);
|
||||
else if (hits.length < allowance.count) shrunk.push(mismatch);
|
||||
}
|
||||
return { grown, shrunk };
|
||||
}
|
||||
|
||||
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
|
||||
|
||||
describe('no CLI-id branching outside the stock catalog', () => {
|
||||
it('scans a meaningful number of source files (sanity)', () => {
|
||||
// If this collapses toward zero the walker or the exemption list drifted and every
|
||||
@@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no new copy of an allowlisted branch', () => {
|
||||
// An approval covers the copies that were reviewed, not every later line that happens
|
||||
// to spell the same expression in the same file.
|
||||
const detail = grown
|
||||
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
|
||||
.join('\n');
|
||||
expect(
|
||||
grown,
|
||||
grown.length === 0
|
||||
? ''
|
||||
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
|
||||
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
|
||||
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no stale allowlist entries', () => {
|
||||
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
|
||||
// person to reintroduce that exact branch would sail straight through.
|
||||
const present = new Set(findings.map((f) => f.key));
|
||||
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
|
||||
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
|
||||
// person to reintroduce that exact branch would sail straight through. The same holds
|
||||
// for an approved count above what the file still has.
|
||||
const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
|
||||
expect(
|
||||
stale,
|
||||
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
|
||||
const at = (line: number): Finding => ({
|
||||
file: 'web/example.ts',
|
||||
expression: "mode === 'codex'",
|
||||
line,
|
||||
key: "web/example.ts::mode === 'codex'",
|
||||
});
|
||||
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
|
||||
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
|
||||
const extra = countMismatches([at(10), at(42)], approved);
|
||||
expect(extra.grown).toEqual([
|
||||
{
|
||||
key: "web/example.ts::mode === 'codex'",
|
||||
allowed: 1,
|
||||
found: 2,
|
||||
lines: ['web/example.ts:10', 'web/example.ts:42'],
|
||||
},
|
||||
]);
|
||||
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
|
||||
// Every live entry carries a positive whole count, or the comparison means nothing.
|
||||
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
|
||||
expect(Number.isInteger(count) && count > 0, key).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -291,6 +291,57 @@ describe('cross-field integrity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilities.launchDefaults', () => {
|
||||
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
|
||||
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
|
||||
const entry = baseEntry('codex');
|
||||
mutate(entry);
|
||||
return CliEntrySchema.safeParse(entry).success;
|
||||
}
|
||||
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
|
||||
(e.capabilities as Record<string, unknown>).launchDefaults = value;
|
||||
};
|
||||
|
||||
it('ships on codex alone, keyed by launch param', () => {
|
||||
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
|
||||
expect(declaring).toEqual(['codex']);
|
||||
expect(codexWith(() => {})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a param the entry never declared', () => {
|
||||
// Filled into the config object and then read by nothing: a silent no-op, like a
|
||||
// privilegedParams clamp naming the wrong param.
|
||||
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a settings key outside the closed list', () => {
|
||||
// An override must not be able to pour an arbitrary setting onto a command line.
|
||||
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
|
||||
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty map', () => {
|
||||
expect(codexWith(setDefaults({}))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses an entry with no legacyConfigField to fill', () => {
|
||||
// Without one the params are read off the request body itself, where `model` is
|
||||
// claude's per-session field, not this CLI's.
|
||||
expect(
|
||||
codexWith((e) => {
|
||||
delete (e.launch as Record<string, unknown>).legacyConfigField;
|
||||
})
|
||||
).toBe(false);
|
||||
// The same entry without launch defaults is fine: the refusal is about the pair.
|
||||
expect(
|
||||
codexWith((e) => {
|
||||
delete (e.launch as Record<string, unknown>).legacyConfigField;
|
||||
delete (e.capabilities as Record<string, unknown>).launchDefaults;
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the env allowlist cannot be widened by config', () => {
|
||||
it('requires a prefix to end with an underscore', () => {
|
||||
expectRejected((e) => {
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* - The parts only the new styles draw (stat rings, plan rings, meters, tile words)
|
||||
* are rendered with sane values and hidden by default in CSS, which is what
|
||||
* keeps 'classic' looking exactly as before.
|
||||
* - The connection tile's value word reads in Chinese through i18n.js's real
|
||||
* t(), from scoped keys (a bare 'retry' or 'live' key would also translate
|
||||
* other surfaces), and follows a language switch.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
@@ -195,6 +199,73 @@ describe('the parts the new styles draw', () => {
|
||||
expect(document.getElementById('connectionTileValue')!.className).toBe('connection-tile-value connected');
|
||||
});
|
||||
|
||||
it('writes the value word in the UI language, from scoped keys, and follows a language switch', () => {
|
||||
const translator = (language: string) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(read('i18n.js'), dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } })
|
||||
.CodemanI18n;
|
||||
api.configure({ language });
|
||||
return { t: api.t, close: () => dom.window.close() };
|
||||
};
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
try {
|
||||
window.codemanT = zh.t;
|
||||
window.CodemanI18n = { language: 'zh-CN' };
|
||||
const app = makeApp();
|
||||
app._computeConnectionDescriptor = () => ({
|
||||
display: 'flex',
|
||||
dotClass: 'connection-dot connected',
|
||||
text: 'WS',
|
||||
title: 'Terminal connected over WebSocket',
|
||||
});
|
||||
app._updateConnectionIndicator();
|
||||
expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('已连接');
|
||||
// A switch back to English repaints it with nothing else happening: the
|
||||
// value span is data-i18n-skip, so only applyLocalization (settings-ui.js,
|
||||
// which every language change runs) can, and the same descriptor is not
|
||||
// skipped as unchanged. No indicator update by hand here.
|
||||
window.CodemanI18n = {
|
||||
language: 'zh-CN',
|
||||
configure({ language }: { language: string }) {
|
||||
this.language = language;
|
||||
window.codemanT = language === 'zh-CN' ? zh.t : en.t;
|
||||
return { language };
|
||||
},
|
||||
};
|
||||
app.loadAppSettingsFromStorage = () => ({ language: 'en' });
|
||||
app.applyLocalization();
|
||||
expect(window.CodemanI18n.language).toBe('en');
|
||||
expect(document.getElementById('connectionTileValue')!.textContent).toBe('live');
|
||||
|
||||
// Every value word has its own Chinese, and none is a bare key.
|
||||
window.codemanT = zh.t;
|
||||
const values = [
|
||||
'connection-dot connected',
|
||||
'connection-dot fallback',
|
||||
'connection-dot offline',
|
||||
'connection-dot draining',
|
||||
'connection-dot reconnecting',
|
||||
].map((dotClass) => app._connectionTileWords({ dotClass, text: '' }).value);
|
||||
expect(values).toEqual(['live', 'fallback', 'offline', 'queued', 'retry']);
|
||||
const shown = values.map((value: string) => app._connectionTileValueText(value));
|
||||
expect(shown.filter((text: string) => /[A-Za-z]/.test(text))).toEqual([]);
|
||||
expect(values.filter((value: string) => zh.t(value) !== value)).toEqual([]);
|
||||
// The span the observer must leave alone (its text is set here, already translated).
|
||||
expect(INDEX).toMatch(/id="connectionTileValue" data-i18n-skip>/);
|
||||
} finally {
|
||||
delete window.codemanT;
|
||||
delete window.CodemanI18n;
|
||||
zh.close();
|
||||
en.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fills the CPU and MEM rings from the stats poll, clamped, red past 80%', () => {
|
||||
const app = makeApp();
|
||||
const ring = (id: string) => {
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* session name in the accessible name passed through untranslated (a name
|
||||
* that is itself a dictionary word included), and read unchanged in English.
|
||||
*
|
||||
* The toolbar's case picker rows (which replaced the translated "+" and gear
|
||||
* buttons) and the host-window and dictation toasts are read from their source,
|
||||
* so a renamed label without an entry fails here.
|
||||
*
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -183,3 +187,40 @@ describe('the Help modal and the shortcut overlay in zh-CN', () => {
|
||||
expect(overlay).not.toMatch(/<kbd>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the case picker rows and the host-window and dictation toasts in zh-CN', () => {
|
||||
const SESSION_UI = read('session-ui.js');
|
||||
const actions = SESSION_UI.slice(
|
||||
SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['),
|
||||
SESSION_UI.indexOf('];', SESSION_UI.indexOf('const CASE_PICKER_ACTIONS = ['))
|
||||
);
|
||||
const pickerLabels = [...actions.matchAll(/label: '([^']+)'/g)].map((m) => m[1]);
|
||||
const toasts = ['app.js', 'panels-ui.js', 'webview-tabs.js', 'voice-input.js'].flatMap((file) =>
|
||||
[
|
||||
...read(file).matchAll(
|
||||
/showToast\??\.?\(\s*'(Could not open a new window for this \w+|That session has closed; dictation not sent)'/g
|
||||
),
|
||||
].map((m) => m[1])
|
||||
);
|
||||
|
||||
it('finds the strings it checks (the check is not vacuous)', () => {
|
||||
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']);
|
||||
expect(SESSION_UI).toContain('<div class="case-combobox-empty">No cases match</div>');
|
||||
expect(toasts.sort()).toEqual([
|
||||
'Could not open a new window for this dashboard',
|
||||
'Could not open a new window for this preview',
|
||||
'Could not open a new window for this session',
|
||||
'That session has closed; dictation not sent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each one reads in Chinese with no English left, and unchanged in English', () => {
|
||||
const bad = [...pickerLabels, 'No cases match', ...toasts].filter((s) => {
|
||||
const text = zh.api.t(s);
|
||||
return text === s || leftover(text).length > 0 || en.api.t(s) !== s;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
// The rows reuse the wording of the buttons they replaced.
|
||||
expect(zh.api.t('Case settings\u2026')).toBe(`${zh.api.t('Case settings')}\u2026`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,6 +375,10 @@ export function resetGridHarness() {
|
||||
// The CLI catalog the server injects (labels for the harness logos); a test sets its own.
|
||||
delete windowStub.__codemanCliCatalog;
|
||||
section.children = [];
|
||||
// The grid binds its file-drop guard on the section once per app; an earlier
|
||||
// test's app must not still be listening there.
|
||||
section.listeners = {};
|
||||
section.captureFlags = {};
|
||||
main.className = 'main';
|
||||
// A split a test left open moved .terminal-wrap into its container, with
|
||||
// Pane A's header strip in it.
|
||||
|
||||
@@ -7,18 +7,26 @@ import { createRouteTestHarness, type RouteTestHarness } from './_route-test-uti
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js';
|
||||
import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
|
||||
import { buildCodexCommand } from '../../src/tmux-manager.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
import { safeRmHomeTree } from '../mocks/index.js';
|
||||
import { getDataDir } from '../../src/config/instance.js';
|
||||
import { SettingsUpdateSchema } from '../../src/web/schemas.js';
|
||||
import { getCli } from '../../src/config/cli-registry/registry.js';
|
||||
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
|
||||
import type { CodexConfig } from '../../src/types.js';
|
||||
|
||||
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
|
||||
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
|
||||
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
|
||||
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
|
||||
}
|
||||
|
||||
describe('Codex launch defaults', () => {
|
||||
let harness: RouteTestHarness;
|
||||
const workingDir = join(homedir(), 'codex-default-test');
|
||||
@@ -76,14 +84,45 @@ describe('Codex launch defaults', () => {
|
||||
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
|
||||
]) {
|
||||
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
|
||||
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
|
||||
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex');
|
||||
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
|
||||
expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps defaults out of custom endpoint launches', async () => {
|
||||
const config = { model: 'local-model', animations: false };
|
||||
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config);
|
||||
expect(await resolveCodexDefaults(config, true)).toBe(config);
|
||||
});
|
||||
|
||||
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
|
||||
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
|
||||
// what codex gets is exactly what its entry declares.
|
||||
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
|
||||
model: 'codexModel',
|
||||
reasoningEffort: 'codexReasoningEffort',
|
||||
});
|
||||
for (const entry of STOCK_CLIS) {
|
||||
if ((entry.id as string) === 'codex') continue;
|
||||
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
|
||||
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
|
||||
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
|
||||
}
|
||||
// Only the entry's own config object is filled; the rest of the bag passes through.
|
||||
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
|
||||
const filled = await applyLaunchDefaults('codex', bag);
|
||||
expect(filled).toEqual({
|
||||
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
|
||||
geminiConfig: { model: 'g' },
|
||||
name: 'n',
|
||||
});
|
||||
expect(filled.geminiConfig).toBe(bag.geminiConfig);
|
||||
expect(bag.codexConfig).toEqual({ animations: false });
|
||||
// A field the caller sent is never overwritten, one at a time.
|
||||
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
|
||||
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
|
||||
model: 'gpt-6.1',
|
||||
reasoningEffort: 'low',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not record unused defaults for a Docker quick-start', async () => {
|
||||
@@ -150,7 +189,7 @@ describe('Codex launch defaults', () => {
|
||||
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
|
||||
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
|
||||
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
|
||||
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
|
||||
expect(await resolveCodexDefaults(undefined)).toBeUndefined();
|
||||
} finally {
|
||||
await system.app.close();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import postcss, { type AtRule } from 'postcss';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadRunModeHarness() {
|
||||
@@ -638,6 +639,29 @@ describe('Codex quick start settings', () => {
|
||||
expect(html.indexOf('id="welcomeQr"')).toBeGreaterThan(tunnel);
|
||||
});
|
||||
|
||||
it('never gives a phone shorter launcher chips than a tablet', () => {
|
||||
// styles.css sizes chips for touch screens under (pointer: coarse);
|
||||
// mobile.css's phone block loads later at equal specificity and wins on
|
||||
// every phone, so a lower height there shrank the phone's chips below the
|
||||
// tablet's (36px against 40px).
|
||||
const chipHeights = (file: string, media: string) => {
|
||||
const heights: number[] = [];
|
||||
const css = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
|
||||
postcss.parse(css).walkRules('.welcome-chip', (rule) => {
|
||||
if (rule.parent?.type !== 'atrule' || (rule.parent as AtRule).params !== media) return;
|
||||
rule.walkDecls('height', (d) => {
|
||||
heights.push(parseFloat(d.value));
|
||||
});
|
||||
});
|
||||
return heights;
|
||||
};
|
||||
const touch = chipHeights('styles.css', '(pointer: coarse)');
|
||||
const phone = chipHeights('mobile.css', '(max-width: 599px)');
|
||||
expect(touch).toEqual([40]);
|
||||
expect(phone.length).toBeGreaterThan(0);
|
||||
for (const h of phone) expect(h).toBeGreaterThanOrEqual(Math.max(...touch));
|
||||
});
|
||||
|
||||
it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => {
|
||||
const catalog = CATALOG.map((cli) =>
|
||||
cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
sanitizeModelName,
|
||||
} from '../src/session-display-model.js';
|
||||
import { IDLE_SILENCE_MS } from '../src/session-activity.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
|
||||
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
|
||||
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
|
||||
@@ -223,6 +224,20 @@ describe('readScreenModel', () => {
|
||||
expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => {
|
||||
// `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model
|
||||
// picker; a hand-kept effort list in the pattern once left it out, so an ultra
|
||||
// session's header never named its model. The footer prints the lowercase level.
|
||||
const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts');
|
||||
expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra');
|
||||
for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) {
|
||||
const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts');
|
||||
expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
|
||||
const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`);
|
||||
expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
|
||||
}
|
||||
});
|
||||
|
||||
it('never takes a transcript line shaped like the footer', () => {
|
||||
// The agent printed a line exactly like each CLI's footer, and the real footer is
|
||||
// hidden (a dsh status bar switched off; a codex popup over its last row). The
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
* that shape plus the pinned versions and the dev/prod vendoring steps.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||
|
||||
@@ -94,3 +97,72 @@ describe('spreadsheet preview assets', () => {
|
||||
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The build deletes dist/web/public and only then copies the vendor bundles out of
|
||||
* node_modules. A tree whose node_modules predate exceljs/fflate (a deploy that pulled
|
||||
* but never ran `npm install`) failed there, with the live assets already gone. The
|
||||
* preflight at the top of build.mjs resolves them before anything is touched.
|
||||
*/
|
||||
describe('build preflight for the spreadsheet vendor packages', () => {
|
||||
const preflightModules = (build: string): string[] => {
|
||||
const list = /const BUILD_TIME_MODULES = \[([^\]]*)\]/.exec(build)?.[1] ?? '';
|
||||
return [...list.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
};
|
||||
|
||||
it('covers every package prepare-spreadsheet-assets.mjs resolves, and runs before tsc and the clean', () => {
|
||||
const build = read('scripts/build.mjs');
|
||||
const preflight = preflightModules(build);
|
||||
const resolvedByPrepare = [
|
||||
...read('scripts/prepare-spreadsheet-assets.mjs').matchAll(/require\.resolve\('([^']+)'\)/g),
|
||||
].map((m) => m[1]);
|
||||
// Parse sanity; the loop below is the actual coverage check.
|
||||
expect(resolvedByPrepare).toContain('exceljs/dist/exceljs.min.js');
|
||||
expect(resolvedByPrepare).toContain('fflate');
|
||||
for (const specifier of resolvedByPrepare) expect(preflight, specifier).toContain(specifier);
|
||||
|
||||
const bail = build.indexOf('[build] run `npm install` first');
|
||||
expect(bail).toBeGreaterThan(-1);
|
||||
expect(bail).toBeLessThan(build.indexOf("run('tsc', 'tsc')"));
|
||||
expect(bail).toBeLessThan(build.indexOf("'rm -rf dist/web/public'"));
|
||||
});
|
||||
|
||||
it('resolves every preflight package in this installed tree', () => {
|
||||
const requireFromBuild = createRequire(resolve(root, 'scripts/build.mjs'));
|
||||
const preflight = preflightModules(read('scripts/build.mjs'));
|
||||
expect(preflight.length).toBeGreaterThan(0);
|
||||
for (const specifier of preflight) expect(() => requireFromBuild.resolve(specifier), specifier).not.toThrow();
|
||||
});
|
||||
|
||||
it('exits with an npm install hint, and touches nothing, where the packages do not resolve', () => {
|
||||
// A copy of build.mjs outside the repo: nothing resolves from there, and its ROOT
|
||||
// (derived from its own location) is the temp dir, so a missing preflight could
|
||||
// only ever act on that throwaway tree.
|
||||
const tree = mkdtempSync(join(tmpdir(), 'codeman-build-preflight-'));
|
||||
try {
|
||||
mkdirSync(join(tree, 'scripts'));
|
||||
const copy = join(tree, 'scripts', 'build.mjs');
|
||||
copyFileSync(resolve(root, 'scripts/build.mjs'), copy);
|
||||
const requireFromCopy = createRequire(copy);
|
||||
const unresolvable = ['exceljs/dist/exceljs.min.js', 'fflate'].filter((specifier) => {
|
||||
try {
|
||||
requireFromCopy.resolve(specifier);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// Precondition: this host has no stray node_modules above the temp dir.
|
||||
expect(unresolvable.length).toBeGreaterThan(0);
|
||||
|
||||
const result = spawnSync(process.execPath, [copy], { cwd: tree, encoding: 'utf8', timeout: 20_000 });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`[build] missing build dependency: ${unresolvable.join(', ')}`);
|
||||
expect(result.stderr).toContain('run `npm install` first');
|
||||
expect(result.stdout).not.toContain('[build] tsc');
|
||||
expect(existsSync(join(tree, 'dist'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(tree, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
|
||||
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
|
||||
* land as text, never markup.
|
||||
*
|
||||
* What the user reads is the renderer's, not the worker's: each worker error
|
||||
* code is one sentence (the raw message goes to the console), feature ids are
|
||||
* words, and every one of those, the HTTP status line and the notice bar's
|
||||
* items read in Chinese through i18n.js's real t(); a number format's code is
|
||||
* workbook text and passes through as is.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -14,6 +20,12 @@ import { JSDOM } from 'jsdom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
|
||||
const coreSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
||||
const workerSource = readFileSync(
|
||||
resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview-worker.js'),
|
||||
'utf8'
|
||||
);
|
||||
const i18nSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
|
||||
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
|
||||
|
||||
// A real origin: the renderer resolves its fetch URL against `location.href`.
|
||||
@@ -392,6 +404,177 @@ describe('spreadsheet preview renderer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheet preview text the user reads', () => {
|
||||
type Translate = (s: string) => string;
|
||||
const translator = (language: string): Translate => {
|
||||
const i18nDom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(i18nSource, i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (i18nDom.window as any).CodemanI18n as { t: Translate; configure(o: object): void };
|
||||
api.configure({ language });
|
||||
return api.t;
|
||||
};
|
||||
const zh = translator('zh-CN');
|
||||
const en = translator('en');
|
||||
/** What may stay Latin in a translation: file formats, the URL acronym, digits. */
|
||||
const leftover = (text: string) => text.replace(/\b(xlsx|xls|ZIP64|URL)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
beforeEach(() => {
|
||||
WorkerMock.instances = [];
|
||||
document.body.innerHTML = '<div id="preview"></div>';
|
||||
delete (window as any).codemanT;
|
||||
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => callback(0), 0) as unknown as number;
|
||||
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
|
||||
});
|
||||
|
||||
async function openLoaded() {
|
||||
document.body.innerHTML = '<div id="preview"></div>';
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||
const worker = WorkerMock.instances.at(-1)!;
|
||||
worker.emit({ type: 'ready' });
|
||||
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||
return worker;
|
||||
}
|
||||
|
||||
const shown = () => document.querySelector('.spreadsheet-preview-message')?.textContent ?? '';
|
||||
|
||||
it('shows one sentence per worker error code, never the raw message, and each reads in Chinese', async () => {
|
||||
const codes = [
|
||||
...new Set(
|
||||
[...`${coreSource}\n${workerSource}`.matchAll(/(?:\bfail|XlsxPreviewError)\('([a-z0-9-]+)'/g)].map((m) => m[1])
|
||||
),
|
||||
];
|
||||
// Not vacuous: the refusals the core and the worker raise.
|
||||
expect(codes).toEqual(expect.arrayContaining(['encrypted', 'zip64', 'malformed', 'cell-limit', 'style-limit']));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const texts = new Map<string, string>();
|
||||
for (const code of [...codes, 'parse-failed']) {
|
||||
const worker = await openLoaded();
|
||||
worker.emit({ type: 'error', code, message: `raw ${code} detail` });
|
||||
texts.set(code, shown());
|
||||
}
|
||||
expect([...texts.values()].filter((text) => /raw .* detail/.test(text))).toEqual([]);
|
||||
// Every code the core and worker raise has its own sentence; only an
|
||||
// unlisted one (ExcelJS's own failures) shows the generic failure.
|
||||
expect(codes.filter((code) => texts.get(code) === 'Spreadsheet preview failed')).toEqual([]);
|
||||
expect(texts.get('parse-failed')).toBe('Spreadsheet preview failed');
|
||||
expect(texts.get('encrypted')).toBe(
|
||||
'This workbook is password-protected or in the old .xls format, so it cannot be previewed.'
|
||||
);
|
||||
// The developer detail is kept, in the console.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('raw encrypted detail'));
|
||||
const bad = [...new Set(texts.values())].filter((text) => {
|
||||
const t = zh(text);
|
||||
return t === text || leftover(t).length > 0 || en(text) !== text;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('words the notice bar: feature ids as words, each item translated, a format code as it is', async () => {
|
||||
const notice = async () => {
|
||||
const worker = await openLoaded();
|
||||
worker.emit({ ...metadata(), warnings: ['pivotTables', 'externalLinks', 'charts', 'drawings', 'macros'] });
|
||||
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||
worker.emit({
|
||||
type: 'tile',
|
||||
requestId: request.requestId,
|
||||
sheetId: '1',
|
||||
cells: [],
|
||||
warnings: [
|
||||
'View truncated to the first 2500 cells',
|
||||
'Formula has no cached result',
|
||||
'Unsupported cell value',
|
||||
'Unsupported number format: {name}"x"',
|
||||
'3 unsupported number formats',
|
||||
],
|
||||
});
|
||||
return document.querySelector('.spreadsheet-preview-notice')!.textContent!;
|
||||
};
|
||||
const english = await notice();
|
||||
expect(english).toBe(
|
||||
'Some workbook features are not shown: pivot tables, external links, charts, drawings, macros, ' +
|
||||
'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' +
|
||||
'Unsupported number format: {name}"x", 3 unsupported number formats'
|
||||
);
|
||||
// The real English translator reads the feature words as plain words too,
|
||||
// never as their scoped keys.
|
||||
(window as any).codemanT = en;
|
||||
expect(await notice()).toBe(english);
|
||||
(window as any).codemanT = zh;
|
||||
const chinese = await notice();
|
||||
// The format code is workbook text: kept as is ({name} is not a placeholder here).
|
||||
expect(chinese).toContain('不支持的数字格式: {name}"x"');
|
||||
expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]);
|
||||
expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true);
|
||||
// Through scoped keys only: the translator runs over the Files panel tree and
|
||||
// the case picker too, where a bare 'charts' or 'macros' key would rename a
|
||||
// Helm chart's charts/ or a dbt project's macros/ folder, or a case of that name.
|
||||
const words = ['charts', 'drawings', 'pivot tables', 'external links', 'macros'];
|
||||
expect(words.filter((word) => zh(word) !== word || zh(word.toUpperCase()) !== word.toUpperCase())).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the notice bar away from the page translator, so a format code is never rewritten', async () => {
|
||||
// The real i18n.js in the renderer's own window. configure() walks the whole
|
||||
// body with t(), which fills `{name}` and rebrands "Codeman" in every text
|
||||
// node it reaches; the bar is written already translated, item by item, and
|
||||
// ends with workbook text.
|
||||
const { confirm, alert } = window;
|
||||
const context = vm.createContext({
|
||||
window,
|
||||
document,
|
||||
Node: window.Node,
|
||||
NodeFilter: window.NodeFilter,
|
||||
MutationObserver: window.MutationObserver,
|
||||
console,
|
||||
});
|
||||
vm.runInContext(i18nSource, context, { filename: 'i18n.js' });
|
||||
try {
|
||||
const i18n = (window as any).CodemanI18n as { configure(o: object): void };
|
||||
i18n.configure({ language: 'en', displayName: 'Acme' });
|
||||
const worker = await openLoaded();
|
||||
worker.emit({ ...metadata(), warnings: ['charts'] });
|
||||
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||
worker.emit({
|
||||
type: 'tile',
|
||||
requestId: request.requestId,
|
||||
sheetId: '1',
|
||||
cells: [],
|
||||
warnings: ['Unsupported number format: {name}"Codeman"0'],
|
||||
});
|
||||
const bar = document.querySelector('.spreadsheet-preview-notice')!;
|
||||
const written = 'Some workbook features are not shown: charts, Unsupported number format: {name}"Codeman"0';
|
||||
expect(bar.textContent).toBe(written);
|
||||
// A language or display-name change re-walks the page.
|
||||
i18n.configure({ language: 'en', displayName: 'Acme' });
|
||||
expect(bar.textContent).toBe(written);
|
||||
expect(bar.hasAttribute('data-i18n-skip')).toBe(true);
|
||||
} finally {
|
||||
delete (window as any).CodemanI18n;
|
||||
delete (window as any).codemanT;
|
||||
window.confirm = confirm;
|
||||
window.alert = alert;
|
||||
}
|
||||
});
|
||||
|
||||
it('the HTTP status line reads in Chinese with its status', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) }));
|
||||
loadRenderer(fetchMock).open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||
WorkerMock.instances.at(-1)!.emit({ type: 'ready' });
|
||||
await vi.waitFor(() => expect(shown()).toBe('Spreadsheet preview failed (404)'));
|
||||
expect(zh(shown())).toBe('电子表格预览失败(404)');
|
||||
expect(en(shown())).toBe(shown());
|
||||
expect(zh('Spreadsheet preview must use a same-origin URL')).toBe('电子表格预览必须使用同源 URL');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
|
||||
* and that the worker/fetch lifecycle is torn down on close and on re-open.
|
||||
|
||||
@@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss, { type AtRule, type Rule } from 'postcss';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
@@ -350,4 +351,92 @@ describe('tab layouts by case and ledger (static)', () => {
|
||||
);
|
||||
expect(mobileCss).toMatch(/:where\(\.header\) \.tab-cluster-label \{\s*display: none;/);
|
||||
});
|
||||
|
||||
// The width range a mobile.css rule applies in: the file's own link media
|
||||
// (max-width: 1023px) narrowed by every @media around the rule. `other` marks
|
||||
// a rule that also needs a non-width feature, which therefore cannot be
|
||||
// counted on to apply.
|
||||
const mobileRuleWidths = (rule: Rule) => {
|
||||
let min = 0;
|
||||
let max = 1023;
|
||||
let other = false;
|
||||
for (let node = rule.parent; node && node.type !== 'root'; node = node.parent) {
|
||||
if (node.type !== 'atrule') continue;
|
||||
const at = node as AtRule;
|
||||
if (at.name !== 'media') {
|
||||
other = true;
|
||||
continue;
|
||||
}
|
||||
for (const part of at.params.split(/\s+and\s+/)) {
|
||||
const m = part.trim().match(/^\((min|max)-width:\s*(\d+)px\)$/);
|
||||
if (!m) other = true;
|
||||
else if (m[1] === 'min') min = Math.max(min, Number(m[2]));
|
||||
else max = Math.min(max, Number(m[2]));
|
||||
}
|
||||
}
|
||||
return { min, max, other };
|
||||
};
|
||||
const mobileClusterRules = () => {
|
||||
const rules: Array<{
|
||||
selector: string;
|
||||
decls: Record<string, string>;
|
||||
widths: ReturnType<typeof mobileRuleWidths>;
|
||||
}> = [];
|
||||
postcss.parse(mobileCss).walkRules((rule) => {
|
||||
if (!rule.selector.includes('tab-cluster') && !rule.selector.includes('tab-name-case')) return;
|
||||
const decls: Record<string, string> = {};
|
||||
rule.walkDecls((d) => {
|
||||
decls[d.prop] = d.value;
|
||||
});
|
||||
rules.push({ selector: rule.selector, decls, widths: mobileRuleWidths(rule) });
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
it('dissolves the boxes on tablet widths too, where the strip is one scrolling row (600 to 767px)', () => {
|
||||
// getDeviceType() says 'tablet' from 600 to 767px, and updateTabOverflowMode()
|
||||
// never wraps the strip there. A box allowed to shrink in that one-row strip
|
||||
// squeezed and wrapped its tabs inside itself, and every tab past a box's
|
||||
// first line was clipped under the 48px fixed header, unreachable.
|
||||
const rules = mobileClusterRules();
|
||||
const box = '.session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster';
|
||||
for (const width of [360, 599, 600, 700, 744, 767]) {
|
||||
const applies = (r: (typeof rules)[number]) => !r.widths.other && r.widths.min <= width && width <= r.widths.max;
|
||||
expect(
|
||||
rules.some((r) => applies(r) && r.selector === `:where(.header) ${box}` && r.decls.display === 'contents'),
|
||||
`box dissolved at ${width}px`
|
||||
).toBe(true);
|
||||
expect(
|
||||
rules.some(
|
||||
(r) => applies(r) && r.selector === ':where(.header) .tab-cluster-label' && r.decls.display === 'none'
|
||||
),
|
||||
`label hidden at ${width}px`
|
||||
).toBe(true);
|
||||
// With the box, its label and its case-colour border gone, the case part
|
||||
// of a generated name is the only cue left: the w<n> counter is per case,
|
||||
// so w1-alpha and w1-beta would otherwise both read "w1". It must outrank
|
||||
// styles.css's `.session-tabs.tabs-clusters .tab-name-case { display: none }`.
|
||||
expect(
|
||||
rules.some(
|
||||
(r) =>
|
||||
applies(r) &&
|
||||
r.selector === ':where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case' &&
|
||||
r.decls.display === 'inline'
|
||||
),
|
||||
`case part of the name shown at ${width}px`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the desktop strip (768px and up) to styles.css: no mobile.css cluster rule reaches it', () => {
|
||||
// mobile.css is linked up to 1023px, and from 768 getDeviceType() says
|
||||
// 'desktop': the boxes keep their width there and the strip wraps box by
|
||||
// box. The tablet block of mobile.css runs to 768 inclusive, so a cluster
|
||||
// rule put inside it would dissolve the boxes at exactly 768.
|
||||
const rules = mobileClusterRules();
|
||||
expect(rules.length).toBeGreaterThan(0);
|
||||
for (const r of rules) {
|
||||
expect(r.widths.max, `${r.selector} stops below 768px`).toBeLessThan(768);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* literal silently replaces the first).
|
||||
* - The state-row headings the strip draws (CodemanTabTriage's labels)
|
||||
* translate too.
|
||||
* - The Bottom bar group (the Git status rows, #543's max repositories and
|
||||
* git timeout among them) translates as one group: heading, every label and
|
||||
* description, and the Git panel's names that the group's key now reaches.
|
||||
*
|
||||
* The rows' `desktop` tag is a generic tag shared with rows older than #538
|
||||
* (WebGL Renderer) and is left out of the check.
|
||||
@@ -115,3 +118,67 @@ describe('the Tab Layout settings rows in zh-CN', () => {
|
||||
expect(labels.filter((s) => api.t(s) === s || leftover(api.t(s)).length)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The Bottom bar group: its heading, then each row's label (without its tag) and description. */
|
||||
function bottomBarStrings(doc: Document): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const group = doc.getElementById('appSettingsShowGitStatus')!.closest('.set-group')!;
|
||||
out.set('heading', group.querySelector('h4')!.textContent!.trim());
|
||||
for (const row of group.querySelectorAll('.set-row')) {
|
||||
const id = row.querySelector('input, select')!.id;
|
||||
const label = row.querySelector('.set-row-label')!.cloneNode(true) as Element;
|
||||
label.querySelector('.set-scope')?.remove();
|
||||
out.set(`${id} label`, label.textContent!.trim());
|
||||
out.set(`${id} description`, row.querySelector('.set-row-desc')!.textContent!.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('the Bottom bar settings group in zh-CN', () => {
|
||||
const englishGroup = bottomBarStrings(source.window.document);
|
||||
const chineseGroup = bottomBarStrings(dom.window.document);
|
||||
/** What may stay Latin here: the git and Docker names, and the product name. */
|
||||
const left = (text: string) => text.replace(/\b(Git|git|Docker|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it("finds the heading and the four Git status rows, #543's two included (the check is not vacuous)", () => {
|
||||
expect(englishGroup.size).toBe(9);
|
||||
expect(englishGroup.get('heading')).toBe('Bottom bar');
|
||||
expect(englishGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git status: max repositories');
|
||||
expect(englishGroup.get('appSettingsGitStatusTimeout label')).toBe('Git status: git timeout');
|
||||
});
|
||||
|
||||
it('translates the heading, every label and every description, with no English left', () => {
|
||||
const bad: string[] = [];
|
||||
for (const [where, text] of chineseGroup) {
|
||||
if (text === englishGroup.get(where) || left(text).length) bad.push(`${where}: "${text}"`);
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
expect(chineseGroup.get('appSettingsGitStatusMaxRepos label')).toBe('Git 状态:最多仓库数');
|
||||
});
|
||||
|
||||
it('reads exactly as before in English, and adds each key once', () => {
|
||||
const en = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
vm.runInContext(I18N, en.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const t = (en.window as unknown as { CodemanI18n: Api }).CodemanI18n;
|
||||
t.configure({ language: 'en' });
|
||||
expect([...englishGroup.values()].filter((s) => t.t(s) !== s)).toEqual([]);
|
||||
en.window.close();
|
||||
const repeated = [...englishGroup.values()].filter((s) => {
|
||||
const key = s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return (I18N.match(new RegExp(`^\\s*(?:'${key}'|"${key}"|${key}):`, 'gm')) ?? []).length !== 1;
|
||||
});
|
||||
expect(repeated).toEqual([]);
|
||||
});
|
||||
|
||||
it("the Git panel's names, which the group's 'Git status' key reaches too, read naturally", () => {
|
||||
const doc = dom.window.document;
|
||||
expect(doc.getElementById('gitStatusPanel')!.getAttribute('aria-label')).toBe('Git 状态');
|
||||
const labels = [...doc.querySelectorAll('#gitStatusPanel .git-status-actions button')].map((b) =>
|
||||
b.getAttribute('aria-label')
|
||||
);
|
||||
expect(labels).toEqual(['刷新 Git 状态', '关闭 Git 状态']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeTile, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
|
||||
import { FakeTile, flushFrames, makeGridApp, resetGridHarness, type GridApp } from './mocks/tile-grid-vm.js';
|
||||
|
||||
const IDS = ['s-a', 's-b', 's-c'];
|
||||
|
||||
@@ -148,3 +148,57 @@ describe('a tiled session deleted elsewhere', () => {
|
||||
expect(app.showWelcome).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// The handoffs above are the APP's choice: focus moves (activeSessionId, the
|
||||
// focus paint) but the keyboard does not. Moving DOM focus into the
|
||||
// neighbour's xterm sent whatever the user was still typing, Enter included,
|
||||
// into another session's agent; the single view sends it nowhere. Only a
|
||||
// removal the user made (the x button, Remove Focused Tile) carries the
|
||||
// keyboard along.
|
||||
describe('an app-driven refocus never moves the keyboard into another session', () => {
|
||||
const live = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed)!;
|
||||
const focusCalls = () => FakeTile.all.reduce((n, t) => n + t.terminal.focus.mock.calls.length, 0);
|
||||
/** A grid on IDS focused on s-b, every terminal built, no focus recorded yet. */
|
||||
function built() {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||
flushFrames();
|
||||
for (const t of FakeTile.all) t.terminal.focus.mockClear();
|
||||
return app;
|
||||
}
|
||||
|
||||
it('a remote delete of the focused tile hands focus to the neighbour, DOM focus untouched', () => {
|
||||
const app = built();
|
||||
app._onSessionDeleted({ id: 's-b' });
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
expect(app._tileGrid.focusedId).toBe('s-c');
|
||||
expect(focusCalls()).toBe(0);
|
||||
// No deferred focus left to land later either.
|
||||
expect(app._tileGrid.focusOnConnect ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it.each([4003, 4004, 4010])('a socket the server closed (%i) removes the tile, DOM focus untouched', (code) => {
|
||||
const app = built();
|
||||
app._onTileExit('s-b', live('s-b'), code);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
expect(focusCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it('a reconcile that finds the focused session gone moves focus, DOM focus untouched', () => {
|
||||
const app = built();
|
||||
app.sessions.delete('s-b');
|
||||
app._reconcileTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
expect(focusCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it('a removal the user made still carries the keyboard to the neighbour', () => {
|
||||
const app = built();
|
||||
app.removeFocusedTile();
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
expect(live('s-c').terminal.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @fileoverview A file dragged onto the tile grid.
|
||||
*
|
||||
* The single view's file drop (image-input.js) listens on #terminalContainer,
|
||||
* which is hidden while tiles are open. Nothing else cancelled a file drag,
|
||||
* so dropping a screenshot on a tile made the browser open the file in place
|
||||
* of Codeman. Now the grid section itself takes every file drag (bubble
|
||||
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
|
||||
* dragover and drop are cancelled, so the page never navigates, and a drop on
|
||||
* a tile uploads its images to THAT tile's session, the same upload and the
|
||||
* same "Only image files" toast as the single view. Tab and tile drags are
|
||||
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeEl, makeGridApp, resetGridHarness, section, tileEl, type GridApp } from './mocks/tile-grid-vm.js';
|
||||
|
||||
const IDS = ['s-a', 's-b', 's-c'];
|
||||
|
||||
function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>, types = ['Files']) {
|
||||
return {
|
||||
target,
|
||||
dataTransfer: { types, files, dropEffect: 'none' },
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A grid on `ids`, s-a focused, uploads stubbed. Five tiles make a 3x2 with one empty cell. */
|
||||
function gridApp(ids = IDS): GridApp {
|
||||
const app = makeGridApp(ids);
|
||||
app._uploadAndInsertImages = vi.fn();
|
||||
app.openTileGrid(ids, { focusedId: 's-a' });
|
||||
return app;
|
||||
}
|
||||
|
||||
const slot = () => section.children.find((el) => el.classList.contains('tile-slot'))!;
|
||||
const PNG = { type: 'image/png', name: 'shot.png' };
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
});
|
||||
|
||||
describe('a file dropped on a tile', () => {
|
||||
it("uploads its images to that tile's session (not the focused one), the page staying put", () => {
|
||||
const app = gridApp();
|
||||
// Deep inside the tile, as on xterm's rows or its helper textarea.
|
||||
const inner = tileEl('s-b').querySelector('.tile-body')!;
|
||||
const over = fileEvent(inner, [PNG]);
|
||||
section.dispatch('dragover', over);
|
||||
expect(over.preventDefault).toHaveBeenCalled();
|
||||
expect(over.dataTransfer.dropEffect).toBe('copy');
|
||||
|
||||
const drop = fileEvent(inner, [PNG, { type: 'image/jpeg' }]);
|
||||
section.dispatch('drop', drop);
|
||||
expect(drop.preventDefault).toHaveBeenCalled();
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1);
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, { type: 'image/jpeg' }], { sessionId: 's-b' });
|
||||
// A drop is not a selection: focus stays where it was.
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
});
|
||||
|
||||
it('only the images of a mixed drop are uploaded', () => {
|
||||
const app = gridApp();
|
||||
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
|
||||
});
|
||||
|
||||
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
|
||||
const app = gridApp();
|
||||
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
|
||||
section.dispatch('drop', drop);
|
||||
expect(drop.preventDefault).toHaveBeenCalled();
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a file dragged anywhere else over the grid', () => {
|
||||
it('an empty cell, a divider or the padding: cancelled (no navigation), nothing uploaded', () => {
|
||||
const app = gridApp([...IDS, 's-d', 's-e']);
|
||||
for (const target of [slot(), section]) {
|
||||
expect(target).toBeTruthy();
|
||||
const over = fileEvent(target, [PNG]);
|
||||
section.dispatch('dragover', over);
|
||||
expect(over.preventDefault).toHaveBeenCalled();
|
||||
const drop = fileEvent(target, [PNG]);
|
||||
section.dispatch('drop', drop);
|
||||
expect(drop.preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab and tile drags are left to their targets', () => {
|
||||
it('a drag that carries no files is not touched by the grid section', () => {
|
||||
const app = gridApp();
|
||||
app.draggedTabId = 's-other';
|
||||
const over = fileEvent(section, [], ['text/plain']);
|
||||
section.dispatch('dragover', over);
|
||||
section.dispatch('drop', over);
|
||||
expect(over.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a tab dropped on a tile still replaces it through the tile's own handler", () => {
|
||||
const app = gridApp();
|
||||
app.draggedTabId = 's-other';
|
||||
const drop = fileEvent(tileEl('s-b'), [], ['text/plain']);
|
||||
tileEl('s-b').dispatch('drop', drop);
|
||||
expect(app._tileGrid.ids).toContain('s-other');
|
||||
expect(app._tileGrid.ids).not.toContain('s-b');
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the guard is installed once', () => {
|
||||
it('opening and closing the grid again never stacks listeners', () => {
|
||||
const app = gridApp();
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.openTileGrid(IDS);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.openTileGrid(IDS);
|
||||
expect(section.listeners.dragover).toHaveLength(1);
|
||||
expect(section.listeners.drop).toHaveLength(1);
|
||||
// Bubble phase: the tab and tile drop targets stop their own drags in capture.
|
||||
expect(section.captureFlags.drop).toEqual([false]);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,15 @@
|
||||
* writes text: the count menu (cap and window wording), the Attach
|
||||
* overlay (not attached, attaching, exited, ended), zoom, the header
|
||||
* tooltip, the dividers, the empty slot, every toast, the crash-restart
|
||||
* confirm, the Tiles and Split button titles. Each must translate to text
|
||||
* with no Latin word left beyond key names and durations, and read
|
||||
* unchanged in English.
|
||||
* confirm, the Tiles and Split button titles, the loading label (a body
|
||||
* attribute, shown as CSS generated content) and the rename field's name.
|
||||
* Each must translate to text with no Latin word left beyond key names and
|
||||
* durations, and read unchanged in English.
|
||||
* - Static strings: the shortcut registry's tile entries (overlay and App
|
||||
* Settings list), and index.html run through the real translator in JSDOM
|
||||
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
|
||||
* - No words in the tile CSS: generated content (`content: '...'`) is out of
|
||||
* the translator's reach, so a tile rule may carry glyphs, never text.
|
||||
* - User text stays as typed: session names (tile header) and group names
|
||||
* carry data-i18n-skip, and a session name inside the
|
||||
* confirm passes through the pattern untranslated.
|
||||
@@ -24,6 +27,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss from 'postcss';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
@@ -33,6 +37,7 @@ import {
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
section,
|
||||
tileEl,
|
||||
windowStub,
|
||||
type GridApp,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
@@ -102,6 +107,8 @@ function harvest(root: FakeEl | null | undefined, where: string) {
|
||||
add(el.title, 'title');
|
||||
add(el.attrs['aria-label'], 'aria-label');
|
||||
add(el.attrs.title, 'title attribute');
|
||||
// The loading label: CSS shows it (content: attr(data-loading-label)).
|
||||
add(el.dataset.loadingLabel, 'loading label');
|
||||
for (const child of el.children) walk(child, skip);
|
||||
};
|
||||
if (root) walk(root, false);
|
||||
@@ -215,6 +222,19 @@ async function exercise() {
|
||||
harvestAll(app, 'zoomed');
|
||||
app.zoomTile('s-1');
|
||||
|
||||
// Renaming a tile: the input in the name's place, then Escape.
|
||||
app.startTileRename('s-1');
|
||||
harvestAll(app, 'renaming');
|
||||
tileEl('s-1').querySelector('.tile-rename-input')!.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||||
|
||||
// A file that is not an image dropped on a tile.
|
||||
section.dispatch('drop', {
|
||||
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
|
||||
dataTransfer: { types: ['Files'], files: [{ type: 'application/pdf' }] },
|
||||
preventDefault: vi.fn(),
|
||||
});
|
||||
harvestAll(app, 'file drop');
|
||||
|
||||
// The toasts of a full grid, by the cap and by the window.
|
||||
app._joinTileGridFromRun('s-7');
|
||||
app.addSessionToTiles('s-7');
|
||||
@@ -314,10 +334,13 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'Could not attach the session',
|
||||
'This group has no session to show as tiles',
|
||||
'No sessions to show as tiles',
|
||||
'Only image files are supported',
|
||||
'Split: unavailable while tiles are open',
|
||||
'Tiles: show several sessions side by side (right-click for how many)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)',
|
||||
'6 tiles was stopped after crashing repeatedly. Restart it?',
|
||||
'Loading…',
|
||||
'Session name',
|
||||
];
|
||||
const missing = expected.filter((s) => !seen.has(s));
|
||||
expect(missing).toEqual([]);
|
||||
@@ -462,3 +485,55 @@ describe('user text in the tile code', () => {
|
||||
expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the loading label: CSS generated content, written in the UI language', () => {
|
||||
it('goes through the translator when the tile is built, and again each time the tile starts loading', () => {
|
||||
setUp();
|
||||
const Queue = windowStub.TileLoadQueue;
|
||||
const queue: { onChange?: (tile: unknown, state: string) => void } = {};
|
||||
windowStub.TileLoadQueue = class {
|
||||
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
|
||||
queue.onChange = opts.onChange;
|
||||
}
|
||||
schedule() {}
|
||||
drop() {}
|
||||
};
|
||||
try {
|
||||
windowStub.codemanT = zh.api.t;
|
||||
const app = makeGridApp(EIGHT);
|
||||
app.openTileGrid(EIGHT.slice(0, 2));
|
||||
const entry = app._tileGrid.tiles.get('s-1');
|
||||
expect(entry.body.dataset.loadingLabel).toBe('加载中…');
|
||||
// A language switched since is picked up the next time the tile loads.
|
||||
windowStub.codemanT = en.api.t;
|
||||
app._tileLoadQueue();
|
||||
queue.onChange!(entry.tile, 'queued');
|
||||
expect(entry.el.classList.contains('tile--loading')).toBe(true);
|
||||
expect(entry.body.dataset.loadingLabel).toBe('Loading…');
|
||||
queue.onChange!(entry.tile, 'idle');
|
||||
expect(entry.el.classList.contains('tile--loading')).toBe(false);
|
||||
} finally {
|
||||
windowStub.TileLoadQueue = Queue;
|
||||
delete windowStub.codemanT;
|
||||
}
|
||||
});
|
||||
|
||||
it('no tile rule carries words in generated content (the translator cannot reach it)', () => {
|
||||
const bad: string[] = [];
|
||||
const contents = new Map<string, string>();
|
||||
postcss.parse(read('styles.css')).walkRules((rule) => {
|
||||
if (!/\.tile\b/.test(rule.selector)) return;
|
||||
rule.walkDecls('content', (decl) => {
|
||||
contents.set(rule.selector, decl.value);
|
||||
// CSS escapes are glyphs (\2026, \00B7), not words.
|
||||
const literals = [...decl.value.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) =>
|
||||
(m[1] ?? m[2]).replace(/\\[0-9a-fA-F]{1,6}\s?/g, '')
|
||||
);
|
||||
if (literals.some((text) => /[A-Za-z]/.test(text))) bad.push(`${rule.selector} { content: ${decl.value} }`);
|
||||
});
|
||||
});
|
||||
// Not vacuous: the loading label is one of the rules read.
|
||||
expect(contents.get('.tile.tile--loading .tile-body::after')).toBe('attr(data-loading-label)');
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import postcss, { type AtRule, type Rule } from 'postcss';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
@@ -279,7 +280,15 @@ describe('the CSS', () => {
|
||||
};
|
||||
|
||||
it('every new keyframe animates opacity and transform only', () => {
|
||||
for (const name of ['tile-enter', 'tile-ghost-dim', 'tile-leave', 'tile-leave-now', 'tile-loading-breathe', 'tile-count-menu-in']) {
|
||||
for (const name of [
|
||||
'tile-enter',
|
||||
'tile-ghost-dim',
|
||||
'tile-leave',
|
||||
'tile-leave-now',
|
||||
'tile-loading-breathe',
|
||||
'tile-count-menu-in',
|
||||
'tile-needs-pulse',
|
||||
]) {
|
||||
const body = keyframes(name);
|
||||
expect(body, name).not.toBe('');
|
||||
const props = [...body.matchAll(/^\s*([a-z-]+):/gm)].map((m) => m[1]);
|
||||
@@ -300,11 +309,62 @@ describe('the CSS', () => {
|
||||
it('nothing moves under prefers-reduced-motion, and a web tab hides the copy', () => {
|
||||
const block = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce) {\n .tile-count-menu,'));
|
||||
const reduced = block.slice(0, block.indexOf('\n}\n'));
|
||||
for (const sel of ['.tile-count-menu', '.tile.tile--entering', '.tile.tile--loading .tile-body::after', '.tile-grid-ghosts .tile.tile--leaving']) {
|
||||
for (const sel of [
|
||||
'.tile-count-menu',
|
||||
'.tile.tile--entering',
|
||||
'.tile.tile--needs::after',
|
||||
'.tile.tile--loading .tile-body::after',
|
||||
'.tile-grid-ghosts .tile.tile--leaving',
|
||||
]) {
|
||||
expect(reduced).toContain(sel);
|
||||
}
|
||||
expect(reduced).toMatch(/animation: none;/);
|
||||
expect(reduced).toMatch(/\.tile-body \.xterm \{\s*transition: none;/);
|
||||
expect(css).toMatch(/\.main\.webview-active \.tile-grid-ghosts \{\s*display: none;/);
|
||||
});
|
||||
|
||||
it('the needs-you pulse fades a static glow overlay in and out, never repainting the tile itself', () => {
|
||||
// A needs tile pulses for as long as its prompt waits, hours at a time. A
|
||||
// box-shadow animated on the tile repainted the whole tile (its DOM-rendered
|
||||
// terminal rows with it, the whole stage when zoomed) every frame.
|
||||
const rules: Array<{ selector: string; media: string; decls: Record<string, string> }> = [];
|
||||
postcss.parse(css).walkRules((rule: Rule) => {
|
||||
if (!rule.selector.includes('tile--needs')) return;
|
||||
const decls: Record<string, string> = {};
|
||||
rule.walkDecls((d) => {
|
||||
decls[d.prop] = d.value;
|
||||
});
|
||||
const media = rule.parent?.type === 'atrule' ? (rule.parent as AtRule).params : '';
|
||||
for (const selector of rule.selectors) rules.push({ selector, media, decls });
|
||||
});
|
||||
const find = (selector: string, media = '') => rules.filter((r) => r.selector === selector && r.media === media);
|
||||
|
||||
// The tile keeps its red border and animates nothing.
|
||||
const [tile] = find('.tile.tile--needs');
|
||||
expect(tile.decls['border-color']).toContain('var(--red');
|
||||
expect(tile.decls.animation).toBeUndefined();
|
||||
expect(tile.decls['box-shadow']).toBeUndefined();
|
||||
|
||||
// The glow is an overlay: a STATIC inset shadow (.tile is overflow: hidden
|
||||
// and clips an outer one), above the Attach overlay, out of the pointer's way.
|
||||
const [glow] = find('.tile.tile--needs::after');
|
||||
expect(glow.decls.content).toBe("''");
|
||||
expect(glow.decls.position).toBe('absolute');
|
||||
expect(glow.decls.inset).toBe('0');
|
||||
expect(glow.decls['pointer-events']).toBe('none');
|
||||
expect(Number(glow.decls['z-index'])).toBeGreaterThan(2);
|
||||
expect(glow.decls['box-shadow']).toMatch(/^inset /);
|
||||
expect(glow.decls.animation).toMatch(/^tile-needs-pulse /);
|
||||
|
||||
// Only that overlay runs the pulse: no rule restates it on the tile (the old
|
||||
// entering-and-needs shorthand would now blink the whole tile's opacity).
|
||||
const pulsing = rules.filter((r) => /tile-needs-pulse/.test(r.decls.animation ?? ''));
|
||||
expect(pulsing.map((r) => r.selector)).toEqual(['.tile.tile--needs::after']);
|
||||
expect(rules.some((r) => r.selector.includes('tile--entering') && r.selector.includes('tile--needs'))).toBe(false);
|
||||
|
||||
// Reduced motion: no pulse, and a static ring on the tile instead.
|
||||
const reduce = '(prefers-reduced-motion: reduce)';
|
||||
expect(find('.tile.tile--needs::after', reduce).some((r) => r.decls.display === 'none')).toBe(true);
|
||||
expect(find('.tile.tile--needs', reduce).some((r) => /^0 0 0 2px /.test(r.decls['box-shadow'] ?? ''))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Port: none (pure static analysis, runs in CI, no browser/server).
|
||||
//
|
||||
// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is
|
||||
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds) with the same three-way rule as
|
||||
// a PER-DEVICE setting (default ON on desktop, OFF on handhelds and touch-primary
|
||||
// tablets) with the same three-way rule as
|
||||
// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the
|
||||
// history): in settings-ui.js's displayKeys merge policy, stripped out of the
|
||||
// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending
|
||||
@@ -14,7 +15,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { makeGridApp } from './mocks/tile-grid-vm.js';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
@@ -59,6 +62,150 @@ describe('showTileGridButton stays per-device: display key, stripped from the PU
|
||||
});
|
||||
});
|
||||
|
||||
// The real getDefaultSettings() (settings-ui.js) on each kind of device. A
|
||||
// touch-primary tablet (iPad, an Android tablet: primary pointer coarse) is
|
||||
// not a handheld, so it used to take the desktop defaults and get the Tiles
|
||||
// button ON, although a tile has none of the main terminal's touch, IME and
|
||||
// soft-keyboard handling. It defaults OFF there (opt-in, like Split); a
|
||||
// touchscreen laptop (primary pointer fine) keeps the desktop default.
|
||||
describe('the device default: ON on desktop, OFF on handhelds and touch-primary tablets', () => {
|
||||
const SOURCE = readFileSync(join(PUBLIC, 'settings-ui.js'), 'utf8');
|
||||
type Device = { handheld: boolean; touch: boolean; coarse: boolean };
|
||||
function defaultsOn({ handheld, touch, coarse }: Device) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
VoiceInput: {},
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
MobileDetection: {
|
||||
isHandheldDevice: () => handheld,
|
||||
isTouchDevice: () => touch,
|
||||
getDeviceType: () => (handheld ? 'mobile' : 'desktop'),
|
||||
},
|
||||
window: {
|
||||
matchMedia: (q: string) => ({
|
||||
matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse,
|
||||
}),
|
||||
},
|
||||
});
|
||||
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
|
||||
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
|
||||
return app.getDefaultSettings();
|
||||
}
|
||||
/** What every reader resolves an absent key to: the button, the App Settings chip and the chord. */
|
||||
const resolved = (d: Device) => (defaultsOn(d).showTileGridButton ?? true) === true;
|
||||
|
||||
const DESKTOP = { handheld: false, touch: false, coarse: false };
|
||||
const TOUCH_LAPTOP = { handheld: false, touch: true, coarse: false };
|
||||
const TABLET = { handheld: false, touch: true, coarse: true };
|
||||
const PHONE = { handheld: true, touch: true, coarse: true };
|
||||
|
||||
it('a desktop and a touchscreen laptop (fine primary pointer): ON', () => {
|
||||
expect(defaultsOn(DESKTOP).showTileGridButton).toBeUndefined();
|
||||
expect(resolved(DESKTOP)).toBe(true);
|
||||
expect(defaultsOn(TOUCH_LAPTOP).showTileGridButton).toBeUndefined();
|
||||
expect(resolved(TOUCH_LAPTOP)).toBe(true);
|
||||
});
|
||||
|
||||
it('a touch-primary tablet (coarse primary pointer, not a handheld): OFF, and only that key changes', () => {
|
||||
expect(defaultsOn(TABLET)).toEqual({ showTileGridButton: false });
|
||||
expect(resolved(TABLET)).toBe(false);
|
||||
});
|
||||
|
||||
it('a handheld: OFF, as before', () => {
|
||||
expect(defaultsOn(PHONE).showTileGridButton).toBe(false);
|
||||
});
|
||||
|
||||
it('never stores the posture default: a 2-in-1 first opened as a tablet gets the button once it is docked', () => {
|
||||
// A fresh device caches loadAppSettingsFromStorage()'s fallback, and the
|
||||
// server-settings merge saves that object (saveAppSettingsToStorage(merged)),
|
||||
// so a default taken from the instantaneous primary pointer must not be in it.
|
||||
let coarse = true;
|
||||
const stored = new Map<string, string>();
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
VoiceInput: {},
|
||||
localStorage: {
|
||||
getItem: (k: string) => stored.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => stored.set(k, String(v)),
|
||||
},
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
MobileDetection: {
|
||||
isHandheldDevice: () => false,
|
||||
isTouchDevice: () => true,
|
||||
getDeviceType: () => 'desktop',
|
||||
},
|
||||
window: {
|
||||
matchMedia: (q: string) => ({
|
||||
matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse,
|
||||
}),
|
||||
},
|
||||
});
|
||||
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
|
||||
type SettingsApp = {
|
||||
getDefaultSettings(): Record<string, unknown>;
|
||||
loadAppSettingsFromStorage(): Record<string, unknown>;
|
||||
saveAppSettingsToStorage(s: Record<string, unknown>): void;
|
||||
};
|
||||
const app = Object.create(CodemanApp.prototype) as SettingsApp;
|
||||
// What every reader resolves (header button, App Settings chip, chord).
|
||||
const resolvedNow = () =>
|
||||
(app.loadAppSettingsFromStorage().showTileGridButton ?? app.getDefaultSettings().showTileGridButton ?? true) ===
|
||||
true;
|
||||
|
||||
// First load in tablet posture: OFF, and nothing about it is cached or saved.
|
||||
expect(resolvedNow()).toBe(false);
|
||||
expect(app.loadAppSettingsFromStorage()).not.toHaveProperty('showTileGridButton');
|
||||
app.saveAppSettingsToStorage({ ...app.loadAppSettingsFromStorage() });
|
||||
expect([...stored.values()].join('')).not.toContain('showTileGridButton');
|
||||
|
||||
// Docked (keyboard and trackpad: fine primary pointer): the desktop default.
|
||||
coarse = false;
|
||||
expect(resolvedNow()).toBe(true);
|
||||
});
|
||||
|
||||
it('a context with no window at all still answers (the desktop default)', () => {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
VoiceInput: {},
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: () => null },
|
||||
console,
|
||||
MobileDetection: { isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' });
|
||||
const app = Object.create(CodemanApp.prototype) as { getDefaultSettings(): Record<string, unknown> };
|
||||
expect(app.getDefaultSettings()).toEqual({});
|
||||
});
|
||||
|
||||
it('the Ctrl+Shift+G chord follows the same default: inert on a tablet with nothing stored, live on a touchscreen laptop', () => {
|
||||
const TOGGLE = {
|
||||
type: 'keydown',
|
||||
key: 'G',
|
||||
code: 'KeyG',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
target: { closest: () => null },
|
||||
};
|
||||
for (const [device, applies] of [
|
||||
[TABLET, false],
|
||||
[TOUCH_LAPTOP, true],
|
||||
] as const) {
|
||||
const app = makeGridApp(['s-a', 's-b']);
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.getDefaultSettings = () => defaultsOn(device);
|
||||
expect(app.tileShortcutFor(TOGGLE)).toBe(applies ? 'toggle-tile-grid' : null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('App Settings search finds Split and Tiles', () => {
|
||||
// The real filter (settings-ui.js) over the real markup: it matches each chip
|
||||
// by its own data-search and its text, so the chips carry their own keywords.
|
||||
|
||||
@@ -201,6 +201,35 @@ describe('moving focus between tiles', () => {
|
||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The last tile popped out closes the grid. The single view must never try
|
||||
// to show that session: selectSession only raises its window, and the parked
|
||||
// terminal's stale pre-grid content would stay up under its tab (and be
|
||||
// saved as its snapshot on the next switch).
|
||||
it('popping out the LAST tile falls through to the next session, never the popped-out one', () => {
|
||||
const app = makeGridApp(['s-a']);
|
||||
app.openTileGrid(['s-a']);
|
||||
app.$ = () => null;
|
||||
app.selectSession = vi.fn();
|
||||
app._markDetached('s-a', true);
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(app.selectSession.mock.calls).toEqual([['s-other', { auto: true }]]);
|
||||
// Nulled before the selection, so the parked buffer is not snapshotted as s-a's.
|
||||
expect(app.activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it('popping out the last tile with every other session popped out too: the welcome screen, the parked terminal cleared', () => {
|
||||
const app = makeGridApp(['s-a']);
|
||||
app.detachedSessions.add('s-other');
|
||||
app.openTileGrid(['s-a']);
|
||||
app.$ = () => null;
|
||||
app.selectSession = vi.fn();
|
||||
app._markDetached('s-a', true);
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.activeSessionId).toBeNull();
|
||||
expect(app.terminal.clear).toHaveBeenCalled();
|
||||
expect(app.showWelcome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('going Home leaves the grid, remembered', () => {
|
||||
const app = openGrid();
|
||||
app.goHome();
|
||||
|
||||
@@ -83,6 +83,7 @@ describe('opening the grid over an open split', () => {
|
||||
app.activeSessionId = 's-a';
|
||||
localStore.set('codeman:tile-count', '2');
|
||||
const paneB = openSplit(app);
|
||||
app.sendResize.mockClear();
|
||||
|
||||
app.toggleTileGrid();
|
||||
|
||||
@@ -91,6 +92,8 @@ describe('opening the grid over an open split', () => {
|
||||
// Filled from tab order (s-other first), not seeded with the split's two.
|
||||
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
// Pane A (s-a) is not a tile: the split's closing resize gave it its full width back.
|
||||
expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]);
|
||||
});
|
||||
|
||||
it('an explicit open over a split keeps both split sessions first', () => {
|
||||
@@ -100,6 +103,70 @@ describe('opening the grid over an open split', () => {
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
});
|
||||
|
||||
// The entry points that size their own set never get the split's two
|
||||
// prepended on top of it (that went past the group, the count and the
|
||||
// window's capacity, and auto-zoomed with a "too small" toast).
|
||||
it("'Open group as tiles' over a split opens exactly the group, the split closed", () => {
|
||||
const app = makeGridApp([...IDS, 's-d']);
|
||||
app.tabLayout = {
|
||||
groups: [
|
||||
{
|
||||
id: 'g',
|
||||
name: 'G',
|
||||
refs: [
|
||||
{ kind: 'session', id: 's-c' },
|
||||
{ kind: 'session', id: 's-d' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const paneB = openSplit(app);
|
||||
|
||||
app.openGroupAsTiles('g');
|
||||
|
||||
expect(paneB.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(app._tileGrid.ids).toEqual(['s-c', 's-d']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
// Pane A (s-a) is not in the group, so no tile will ever size its PTY: the
|
||||
// split's closing resize gives it its full width back before the main
|
||||
// terminal parks, or it stays at the split's half width for as long as
|
||||
// the grid is open. The split's opening resize, then the closing one.
|
||||
expect(app.sendResize).toHaveBeenCalledTimes(2);
|
||||
expect(app.sendResize).toHaveBeenLastCalledWith('s-a', { force: true });
|
||||
});
|
||||
|
||||
it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
localStore.set('codeman:tile-count', '2');
|
||||
openSplit(app);
|
||||
|
||||
app.addSessionToTiles('s-c');
|
||||
|
||||
expect(app._splitPane).toBeNull();
|
||||
// The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3.
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
// Pane A is a tile, which sizes its PTY: no closing resize for it.
|
||||
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
|
||||
});
|
||||
|
||||
it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.selectSession = vi.fn();
|
||||
app.openTileGrid(['s-other', 's-c']);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.activeSessionId = 's-a';
|
||||
localStore.set('codeman:tile-count', '2');
|
||||
openSplit(app);
|
||||
|
||||
app.addSessionToTiles('s-b');
|
||||
|
||||
expect(app._splitPane).toBeNull();
|
||||
expect(app._tileGrid.ids).toEqual(['s-other', 's-b']);
|
||||
expect(app.activeSessionId).toBe('s-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('while the grid is open', () => {
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
* session. The target is now captured in `start()` (through `_focusedPane()`,
|
||||
* so a second terminal pane can claim it later) and every send path uses it.
|
||||
*
|
||||
* With the tile grid open the main terminal (and with it the local-echo
|
||||
* overlay) is parked with `display: none`, so direct-mode dictation used to be
|
||||
* typed into an invisible overlay and never reached the focused tile. While
|
||||
* `_tilesOwnTerminal()` is true the overlay is skipped and the keyboard goes
|
||||
* back to the focused tile, not the parked main terminal.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed `app` (no jsdom).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -27,10 +33,13 @@ type Voice = {
|
||||
_targetSessionId: string | null;
|
||||
};
|
||||
|
||||
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
|
||||
type Pane = { sessionId: string; isPrimary?: boolean; terminal?: { focus: () => void } };
|
||||
|
||||
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string; tiles?: boolean; pane?: Pane } = {}) {
|
||||
const sendInput = vi.fn(async () => {});
|
||||
const sendInputAsync = vi.fn();
|
||||
const appendText = vi.fn();
|
||||
const overlayClear = vi.fn();
|
||||
const showToast = vi.fn();
|
||||
const gear = {
|
||||
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
|
||||
@@ -53,9 +62,19 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
|
||||
showToast,
|
||||
terminal: { focus: vi.fn() },
|
||||
_localEchoEnabled: !!opts.localEcho,
|
||||
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
|
||||
_localEchoOverlay: opts.localEcho
|
||||
? { appendText, pendingText: '', clear: overlayClear, suppressBufferDetection: vi.fn() }
|
||||
: null,
|
||||
_predictiveEcho: { clearPredictions: vi.fn() },
|
||||
};
|
||||
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
|
||||
// A full pane record, as terminal-ui.js _focusedPane() returns it. Reassign
|
||||
// app._focusedPane in a test to move focus between panes.
|
||||
if (opts.pane) {
|
||||
const pane = opts.pane;
|
||||
app._focusedPane = () => pane;
|
||||
}
|
||||
if (opts.tiles) app._tilesOwnTerminal = () => true;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
setTimeout: (fn: () => void) => fn(),
|
||||
@@ -81,7 +100,12 @@ function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string
|
||||
// Recording itself is out of scope: start() only has to pick the target.
|
||||
voice._resolveProvider = () => 'webspeech';
|
||||
voice._startWebSpeech = vi.fn();
|
||||
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
|
||||
return { voice, app, sendInput, sendInputAsync, appendText, overlayClear, showToast, gear };
|
||||
}
|
||||
|
||||
function clickGreenSend(gear: { addEventListener: { mock: { calls: unknown[][] } } }) {
|
||||
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
|
||||
handler();
|
||||
}
|
||||
|
||||
describe('dictation target', () => {
|
||||
@@ -163,6 +187,63 @@ describe('dictation target', () => {
|
||||
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
|
||||
});
|
||||
|
||||
it('with the tile grid open, dictation goes to the focused tile, not the parked overlay', () => {
|
||||
const tileTerminal = { focus: vi.fn() };
|
||||
const { voice, app, appendText, sendInput, sendInputAsync } = load({
|
||||
localEcho: true,
|
||||
tiles: true,
|
||||
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
|
||||
});
|
||||
voice.start();
|
||||
|
||||
voice._insertText('into the tile');
|
||||
|
||||
expect(appendText).not.toHaveBeenCalled();
|
||||
expect(sendInput).toHaveBeenCalledWith('into the tile');
|
||||
expect(sendInputAsync).not.toHaveBeenCalled();
|
||||
// The keyboard goes back to the tile; the main terminal is display: none.
|
||||
expect(tileTerminal.focus).toHaveBeenCalled();
|
||||
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with the tile grid open, the green send button sends only Enter to the tile session', () => {
|
||||
const tileTerminal = { focus: vi.fn() };
|
||||
const { voice, app, gear, sendInput, overlayClear } = load({
|
||||
localEcho: true,
|
||||
tiles: true,
|
||||
pane: { sessionId: 'session-a', isPrimary: false, terminal: tileTerminal },
|
||||
});
|
||||
voice.start();
|
||||
voice._insertText('ship it');
|
||||
// Something stale in the parked main overlay must not ride along.
|
||||
(app._localEchoOverlay as { pendingText: string }).pendingText = 'stale main-terminal text';
|
||||
|
||||
clickGreenSend(gear);
|
||||
|
||||
expect(overlayClear).not.toHaveBeenCalled();
|
||||
expect(sendInput).not.toHaveBeenCalledWith('stale main-terminal text');
|
||||
expect(sendInput).toHaveBeenLastCalledWith('\r');
|
||||
// The parked main terminal's predictions are not this pane's.
|
||||
expect(
|
||||
(app._predictiveEcho as { clearPredictions: ReturnType<typeof vi.fn> }).clearPredictions
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('split view is unchanged: a Pane A dictation keeps the overlay and refocuses the main terminal', () => {
|
||||
const paneB = { focus: vi.fn() };
|
||||
const { voice, app, appendText, sendInput } = load({ localEcho: true, focused: 'session-a' });
|
||||
voice.start();
|
||||
// The user clicked into Pane B while speaking.
|
||||
app._focusedPane = () => ({ sessionId: 'session-b', isPrimary: false, terminal: paneB });
|
||||
|
||||
voice._insertText('for pane a');
|
||||
|
||||
expect(appendText).toHaveBeenCalledWith('for pane a');
|
||||
expect(sendInput).not.toHaveBeenCalled();
|
||||
expect((app.terminal as { focus: ReturnType<typeof vi.fn> }).focus).toHaveBeenCalled();
|
||||
expect(paneB.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to start with no session at all', () => {
|
||||
const { voice, app, showToast } = load();
|
||||
app.activeSessionId = null;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview Static guard: the wiki Home page's "Everything in the manual" index
|
||||
* links every page the sidebar lists.
|
||||
*
|
||||
* docs/wiki/ is mirrored to the GitHub wiki, where _Sidebar.md shows on every page and
|
||||
* Home.md calls itself the whole manual. A new page added to the sidebar alone (Tile
|
||||
* Grid, Custom Model Endpoints) silently dropped out of that index. Compared by link
|
||||
* target, since the two files label some pages differently.
|
||||
*
|
||||
* Port: N/A (pure static analysis).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const wiki = resolve(import.meta.dirname, '..', 'docs', 'wiki');
|
||||
const read = (name: string) => readFileSync(resolve(wiki, name), 'utf8');
|
||||
|
||||
/** Internal wiki page targets of `[label](Target)` links, external URLs excluded. */
|
||||
const pageTargets = (markdown: string): Set<string> =>
|
||||
new Set(
|
||||
[...markdown.matchAll(/\]\(([^)\s#]+)(?:#[^)]*)?\)/g)]
|
||||
.map((m) => m[1])
|
||||
.filter((target) => !/^[a-z]+:/i.test(target) && target !== 'Home')
|
||||
);
|
||||
|
||||
describe('wiki Home index', () => {
|
||||
it('links every page the sidebar lists', () => {
|
||||
const sidebar = pageTargets(read('_Sidebar.md'));
|
||||
expect(sidebar.size).toBeGreaterThan(20);
|
||||
const index = read('Home.md').split('## Everything in the manual')[1] ?? '';
|
||||
expect(index, 'Home.md has an "Everything in the manual" section').not.toBe('');
|
||||
const indexed = pageTargets(index);
|
||||
const missing = [...sidebar].filter((target) => !indexed.has(target));
|
||||
expect(missing, 'add these to the "Everything in the manual" tables in docs/wiki/Home.md').toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user