mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
chore: version packages
Release 1.9.8 (aicodeman) and 0.1.8 (xterm-zerolag-input). Fixes macOS session start (`posix_spawnp failed.`, issues #6 and #204): node-pty ships its macOS spawn-helper as mode 0644 and macOS launches every PTY through it. `scripts/fix-node-pty.mjs` (npm run fix:node-pty) chmods every helper, prebuilds/ included, then verifies by really opening a PTY; the blind Node-22+ rebuild is gone. `spawnPtyWithHelperRepair()` self-heals an already broken install on the first failed spawn. Adds the phone home screen (session overview under 430px, per-device `mobileOverviewEnabled`, default ON) and a guided Tailscale path in install.sh, plus `install.sh tailscale` to retrofit it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
-32
@@ -66,6 +66,8 @@ import {
|
||||
MAX_SESSION_TOKENS,
|
||||
execPattern,
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -1284,15 +1286,17 @@ export class Session extends EventEmitter {
|
||||
const attachCommand = IS_TEST_MODE ? process.execPath : mux.getAttachCommand();
|
||||
const attachArgs = IS_TEST_MODE ? ['-e', TEST_PTY_SCRIPT] : mux.getAttachArgs(this._muxSession!.muxName);
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(attachCommand, attachArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(attachCommand, attachArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: ptyCols,
|
||||
rows: ptyRows,
|
||||
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
|
||||
// COD-75: codex/gemini/antigravity get COLORTERM=truecolor — mirrors buildEnvExports()
|
||||
// in tmux-manager.ts so the attach client and the tmux session agree.
|
||||
env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'),
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
||||
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
||||
@@ -1619,14 +1623,16 @@ export class Session extends EventEmitter {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -1949,13 +1955,15 @@ export class Session extends EventEmitter {
|
||||
// Fallback to direct PTY if mux is not used
|
||||
if (!this.ptyProcess) {
|
||||
try {
|
||||
this.ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildShellEnv(this.id),
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: buildShellEnv(this.id),
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||
this._status = 'stopped';
|
||||
@@ -2055,14 +2063,16 @@ export class Session extends EventEmitter {
|
||||
const args = buildPromptArgs(prompt, model, this._claudeMode, this._allowedTools);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
});
|
||||
this.ptyProcess = spawnPtyWithHelperRepair(() =>
|
||||
pty.spawn(getClaudeBinaryPath(), args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
||||
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
||||
})
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
this.emit(
|
||||
|
||||
@@ -59,6 +59,21 @@ export function findClaudeDir(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an absolute path to the `claude` binary, falling back to the bare
|
||||
* name `'claude'` when it cannot be located (so PATH resolution still gets a
|
||||
* chance).
|
||||
*
|
||||
* Preferred over passing `'claude'` to `pty.spawn()`: a PTY child resolves the
|
||||
* command against the environment it is handed, and an install that lives in
|
||||
* `~/.local/bin` or `~/.claude/local` is frequently absent from the PATH the
|
||||
* server process inherited (issue #6).
|
||||
*/
|
||||
export function getClaudeBinaryPath(): string {
|
||||
const dir = findClaudeDir();
|
||||
return dir ? join(dir, 'claude') : 'claude';
|
||||
}
|
||||
|
||||
/** Cached augmented PATH string */
|
||||
let _augmentedPath: string | null = null;
|
||||
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-cli-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @fileoverview Runtime self-heal for node-pty's macOS `spawn-helper`.
|
||||
*
|
||||
* node-pty@1.1.0 ships its macOS prebuilt helper as
|
||||
* `prebuilds/darwin-<arch>/spawn-helper` with mode 0644 (no execute bit). On
|
||||
* macOS every PTY is launched through that helper via posix_spawnp, so a
|
||||
* non-executable helper turns every session start into
|
||||
* `Error: posix_spawnp failed.` (issues #6 and #204). The bug is macOS-only:
|
||||
* `spawn-helper` is an `OS=="mac"` gyp target and pty.cc only spawns it under
|
||||
* `#if defined(__APPLE__)`, and node-pty ships no Linux prebuild, so Linux always
|
||||
* compiles a correctly-permissioned helper from source.
|
||||
*
|
||||
* `scripts/fix-node-pty.mjs` fixes this at install time. This module is the
|
||||
* safety net for installs that are already broken: the first PTY spawn that
|
||||
* fails this way is repaired and retried in-process, so the user never sees a
|
||||
* dead session. If the retry still fails, the thrown error carries the manual
|
||||
* repair command instead of a bare "posix_spawnp failed".
|
||||
*
|
||||
* @module utils/node-pty-repair
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/** The one-line fix appended to errors we could not repair automatically. */
|
||||
export const SPAWN_HELPER_FIX_HINT =
|
||||
'node-pty cannot execute its spawn-helper. Repair it with: npm run fix:node-pty ' +
|
||||
'(or: chmod +x node_modules/node-pty/prebuilds/*/spawn-helper)';
|
||||
|
||||
/** Set once a repair has been attempted, so a genuinely broken install cannot chmod-storm. */
|
||||
let repairAttempted = false;
|
||||
|
||||
/**
|
||||
* True when an error is node-pty failing to launch its spawn-helper.
|
||||
*
|
||||
* The native throw site is `throw Napi::Error::New(napiEnv, "posix_spawnp failed.")`
|
||||
* in pty.cc, reached only on Apple platforms.
|
||||
*/
|
||||
export function isSpawnHelperFailure(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? '');
|
||||
return /posix_spawnp|spawn-helper/i.test(message);
|
||||
}
|
||||
|
||||
/** Locates the installed node-pty package root, or null when it can't be resolved. */
|
||||
export function findNodePtyDir(): string | null {
|
||||
// node-pty declares no "exports" map, so the package.json subpath resolves and
|
||||
// lands on the package root. require.resolve('node-pty') would return
|
||||
// <pkg>/lib/index.js, one level deeper than callers need.
|
||||
try {
|
||||
return dirname(require.resolve('node-pty/package.json'));
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
return join(dirname(require.resolve('node-pty')), '..');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every `spawn-helper` present in a node-pty install.
|
||||
*
|
||||
* node-pty's loader checks `build/Release`, `build/Debug`, then
|
||||
* `prebuilds/<platform>-<arch>`, and takes the helper from whichever directory
|
||||
* the native module loaded out of, so every copy has to be executable, not just
|
||||
* the one this machine happens to use.
|
||||
*/
|
||||
export function listSpawnHelpers(ptyDir: string): string[] {
|
||||
const dirs = [join(ptyDir, 'build', 'Release'), join(ptyDir, 'build', 'Debug')];
|
||||
|
||||
const prebuilds = join(ptyDir, 'prebuilds');
|
||||
if (existsSync(prebuilds)) {
|
||||
try {
|
||||
for (const entry of readdirSync(prebuilds, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) dirs.push(join(prebuilds, entry.name));
|
||||
}
|
||||
} catch {
|
||||
/* unreadable prebuilds dir: nothing to repair there */
|
||||
}
|
||||
}
|
||||
|
||||
return dirs.map((d) => join(d, 'spawn-helper')).filter((p) => existsSync(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the execute bit to every `spawn-helper` missing it.
|
||||
*
|
||||
* @param ptyDir - node-pty package root; resolved automatically when omitted.
|
||||
* @returns Paths actually changed (empty when nothing needed repair, or node-pty
|
||||
* is missing, or the files are not writable).
|
||||
*/
|
||||
export function repairSpawnHelperPermissions(ptyDir?: string): string[] {
|
||||
const dir = ptyDir ?? findNodePtyDir();
|
||||
if (!dir) return [];
|
||||
|
||||
const repaired: string[] = [];
|
||||
for (const helper of listSpawnHelpers(dir)) {
|
||||
try {
|
||||
const mode = statSync(helper).mode & 0o777;
|
||||
if ((mode & 0o111) === 0o111) continue;
|
||||
chmodSync(helper, mode | 0o755);
|
||||
repaired.push(helper);
|
||||
} catch {
|
||||
// Read-only install (or not ours to chmod): fall through to the hint.
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
/** Wraps an error so the message carries the actionable repair command. */
|
||||
function withFixHint(err: unknown): Error {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Error(`${message}. ${SPAWN_HELPER_FIX_HINT}`, { cause: err });
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a `pty.spawn()` call, repairing a non-executable spawn-helper and
|
||||
* retrying once if that is why it failed.
|
||||
*
|
||||
* Any unrelated spawn error is rethrown untouched, so this stays invisible on
|
||||
* every platform but a broken macOS install.
|
||||
*
|
||||
* @param spawn - The `pty.spawn(...)` call to run.
|
||||
* @param ptyDir - node-pty package root; resolved automatically when omitted.
|
||||
*/
|
||||
export function spawnPtyWithHelperRepair<T>(spawn: () => T, ptyDir?: string): T {
|
||||
try {
|
||||
return spawn();
|
||||
} catch (err) {
|
||||
if (!isSpawnHelperFailure(err)) throw err;
|
||||
if (repairAttempted) throw withFixHint(err);
|
||||
|
||||
repairAttempted = true;
|
||||
const repaired = repairSpawnHelperPermissions(ptyDir);
|
||||
if (repaired.length === 0) throw withFixHint(err);
|
||||
|
||||
console.warn(`[node-pty] spawn-helper was not executable, repaired ${repaired.join(', ')} and retrying`);
|
||||
try {
|
||||
return spawn();
|
||||
} catch (retryErr) {
|
||||
throw withFixHint(retryErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget that a repair was already attempted in this process. */
|
||||
export function resetSpawnHelperRepairState(): void {
|
||||
repairAttempted = false;
|
||||
}
|
||||
@@ -3456,6 +3456,10 @@ class CodemanApp {
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
this._applyTabEntrances?.();
|
||||
// Phone overview rides on this one call: every state change it cares about
|
||||
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
|
||||
// already funnels through here. No-ops unless that surface is showing.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
|
||||
@@ -350,6 +350,26 @@
|
||||
'Show Shortcuts': '显示快捷键',
|
||||
'Full shortcut reference': '完整快捷键参考',
|
||||
|
||||
// Mobile overview (phone home screen)
|
||||
'Needs you': '需要你',
|
||||
'Current sessions': '当前会话',
|
||||
'Past sessions': '历史会话',
|
||||
'Show all past sessions': '显示全部历史会话',
|
||||
'Show fewer': '收起',
|
||||
'Choose what to run': '选择运行方式',
|
||||
'Web / URL': '网页 / 链接',
|
||||
'Add URL…': '添加链接…',
|
||||
'Nothing running. Hit Run to start something.': '当前没有运行中的会话。点击“运行”开始。',
|
||||
'No past conversations yet': '尚无历史对话',
|
||||
'Loading…': '加载中…',
|
||||
// Status pills are deliberately NOT listed: they are single generic words
|
||||
// ("idle", "done", "error") that also appear as state strings elsewhere, so
|
||||
// they carry data-i18n-skip in the DOM instead of a translation entry here.
|
||||
'Overview Home Screen': '概览主页',
|
||||
'On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen':
|
||||
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
|
||||
Phone: '手机',
|
||||
|
||||
// Session/case dialogs
|
||||
'Session Options': '会话选项',
|
||||
'Session Name': '会话名称',
|
||||
|
||||
@@ -379,6 +379,12 @@
|
||||
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Overview (phone home screen, shown in place of the welcome
|
||||
overlay when the "C" logo is tapped). Ships `hidden`: only
|
||||
mobile-overview.js removes it, and only when the phone gate passes, so
|
||||
desktop (which never loads mobile.css) can never render it. -->
|
||||
<div class="mobile-overview" id="mobileOverview" hidden></div>
|
||||
</main>
|
||||
|
||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||
@@ -1442,6 +1448,16 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Phone Section (only meaningful under 430px, hidden elsewhere by settings-ui.js) -->
|
||||
<div class="settings-section-header" id="appSettingsPhoneSection">Phone</div>
|
||||
<div class="settings-item" id="appSettingsMobileOverviewItem" title="On phones, the C logo opens a session overview (needs you / spaces / idle) instead of the welcome screen">
|
||||
<span class="settings-item-label">Overview Home Screen</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsMobileOverview" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
<div class="settings-item" title="Show folder path below tab name and allow tab bar to wrap into multiple rows">
|
||||
@@ -2644,6 +2660,7 @@
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="mobile-overview.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* @fileoverview Phone home screen: a scrolling overview of what every session is
|
||||
* doing, shown instead of the welcome overlay when the "C" logo is tapped.
|
||||
*
|
||||
* The welcome screen answers "how do I start something"; on a phone the more
|
||||
* urgent question is "which of my sessions is blocked on me". This surface
|
||||
* answers that first: NEEDS YOU (pending permission/question/idle hooks and
|
||||
* errored sessions), then SPACES (cases, expandable to their sessions), then
|
||||
* WORKING and IDLE / DONE.
|
||||
*
|
||||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 430px, not a
|
||||
* popped-out solo window, per-device setting on). Tablet and desktop keep the
|
||||
* welcome overlay untouched. The container ships with the `hidden` attribute and
|
||||
* only this module removes it, so desktop (which never loads mobile.css) cannot
|
||||
* render an unstyled overview even if a class rule leaked.
|
||||
*
|
||||
* Everything renders from state the page already holds (`this.sessions`,
|
||||
* `this.cases`, `this.pendingHooks`) — no endpoint, no SSE event, no schema.
|
||||
* `buildMobileOverviewModel()` is pure and unit-tested (test/mobile-overview.test.ts).
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||||
* @dependency mobile-handlers.js (MobileDetection)
|
||||
* @dependency session-ui.js (selectQuickStartCase for "New session here")
|
||||
* @loadorder 12.55 of 16, after webview-tabs.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** Viewport width that counts as a phone. Matches the mobile.css phone block. */
|
||||
const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 430px)';
|
||||
|
||||
/** Sort rank per state: the most demanding thing sorts first inside a section. */
|
||||
const MOBILE_OVERVIEW_STATE_RANK = {
|
||||
needs: 0,
|
||||
error: 1,
|
||||
waiting: 2,
|
||||
working: 3,
|
||||
idle: 4,
|
||||
done: 5,
|
||||
};
|
||||
|
||||
/** How many past conversations show before the "Show all" toggle. */
|
||||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
|
||||
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
|
||||
*/
|
||||
const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
|
||||
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
|
||||
{ mode: 'codex', label: 'Codex', short: 'Codex' },
|
||||
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
|
||||
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
error: 'error',
|
||||
waiting: 'waiting',
|
||||
working: 'working',
|
||||
idle: 'idle',
|
||||
done: 'done',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Model (pure)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Classify one session.
|
||||
* Order matters: an action hook outranks everything (it is literally blocking
|
||||
* the agent), and a pending idle_prompt outranks a stale 'busy' status because
|
||||
* the hook is the newer signal.
|
||||
* @param {object} session session state from this.sessions
|
||||
* @param {Set<string>|undefined} hooks pending hook types for that session
|
||||
* @returns {'needs'|'error'|'waiting'|'working'|'idle'|'done'}
|
||||
*/
|
||||
_mobileOverviewState(session, hooks) {
|
||||
if (hooks && (hooks.has('permission_prompt') || hooks.has('elicitation_dialog'))) return 'needs';
|
||||
if (session.status === 'error') return 'error';
|
||||
if (hooks && hooks.has('idle_prompt')) return 'waiting';
|
||||
if (session.status === 'busy') return 'working';
|
||||
if (session.status === 'stopped') return 'done';
|
||||
return 'idle';
|
||||
},
|
||||
|
||||
/**
|
||||
* Longest-prefix match of a workingDir against the case list, so a session
|
||||
* started in a subdirectory still belongs to its case. Mirrors the matching in
|
||||
* `_resolveCaseLabel()` (terminal-ui.js) but returns the case itself.
|
||||
* @returns {object|null} the matching case, or null when the dir is outside every case
|
||||
*/
|
||||
_mobileOverviewCaseFor(workingDir, cases) {
|
||||
if (!workingDir) return null;
|
||||
let best = null;
|
||||
for (const c of cases || []) {
|
||||
if (!c || !c.path) continue;
|
||||
if (workingDir === c.path) return c;
|
||||
if (workingDir.startsWith(c.path + '/') && (!best || c.path.length > best.path.length)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the whole overview model. PURE: reads only its argument, touches no DOM
|
||||
* and no `this` state, so it can be unit-tested against plain objects.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {Map<string, object>|Array} input.sessions live sessions (this.sessions)
|
||||
* @param {Array} input.cases case list (this.cases)
|
||||
* @param {Array<string>} [input.sessionOrder] the user's tab order, used as the tiebreak
|
||||
* @param {Map<string, Set<string>>} [input.pendingHooks] this.pendingHooks
|
||||
* @param {Array} [input.history] unified session items (GET /api/sessions/unified)
|
||||
* @returns {{needsYou: Array, current: Array, past: Array, sessionCount: number}}
|
||||
*/
|
||||
buildMobileOverviewModel(input) {
|
||||
const cases = Array.isArray(input && input.cases) ? input.cases : [];
|
||||
const order = Array.isArray(input && input.sessionOrder) ? input.sessionOrder : [];
|
||||
const pendingHooks = (input && input.pendingHooks) || new Map();
|
||||
const raw = (input && input.sessions) || [];
|
||||
const sessions = typeof raw.values === 'function' ? Array.from(raw.values()) : Array.from(raw);
|
||||
|
||||
const rows = sessions.map((session) => {
|
||||
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
|
||||
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
|
||||
const orderIndex = order.indexOf(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
name: this.getSessionName ? this.getSessionName(session) : session.name || session.id.slice(0, 8),
|
||||
mode: session.mode || 'claude',
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const bySeverityThenOrder = (a, b) => {
|
||||
const rank = MOBILE_OVERVIEW_STATE_RANK[a.state] - MOBILE_OVERVIEW_STATE_RANK[b.state];
|
||||
return rank !== 0 ? rank : a.orderIndex - b.orderIndex;
|
||||
};
|
||||
|
||||
const inSection = (states) => rows.filter((r) => states.includes(r.state)).sort(bySeverityThenOrder);
|
||||
|
||||
// Past = conversations from the unified list that are not currently live.
|
||||
// The endpoint already folds a transcript into its owning session (via the
|
||||
// claudeSessionId alias map), so a plain id check is enough to avoid listing
|
||||
// a running session twice.
|
||||
const liveIds = new Set(rows.map((r) => r.id));
|
||||
const past = (Array.isArray(input && input.history) ? input.history : [])
|
||||
.filter((item) => item && item.sessionId && !liveIds.has(item.sessionId))
|
||||
.map((item) => {
|
||||
const matched = this._mobileOverviewCaseFor(item.workingDir, cases);
|
||||
const dir = item.workingDir || '';
|
||||
// The transcript reader emits the literal "(no content)" for a
|
||||
// conversation it could not pull a prompt from; that is not a title.
|
||||
const prompt = (item.firstPrompt || '').trim();
|
||||
const title = prompt && prompt !== '(no content)' ? prompt : '';
|
||||
return {
|
||||
id: item.sessionId,
|
||||
claudeSessionId: item.claudeSessionId || '',
|
||||
workingDir: dir,
|
||||
name: item.name || '',
|
||||
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
|
||||
mode: item.mode || 'claude',
|
||||
caseName: matched ? matched.name : '',
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
|
||||
at: item.lastActivityAt || item.createdAt || 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.at - a.at);
|
||||
|
||||
return {
|
||||
needsYou: inSection(['needs', 'error', 'waiting']),
|
||||
current: inSection(['working', 'idle', 'done']),
|
||||
past,
|
||||
sessionCount: rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Gate + visibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Phone-only gate. Width-driven (not `isHandheldDevice()`): this is a LAYOUT
|
||||
* decision, and an unfolded foldable with a tablet-width viewport should get
|
||||
* the tablet welcome screen. Per-device settings identity is a separate
|
||||
* question and deliberately stays handheld-based.
|
||||
*/
|
||||
shouldUseMobileOverview() {
|
||||
if (this.isSoloWindow) return false;
|
||||
const settings = this.loadAppSettingsFromStorage ? this.loadAppSettingsFromStorage() : {};
|
||||
if (settings.mobileOverviewEnabled === false) return false;
|
||||
if (typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType) {
|
||||
return MobileDetection.getDeviceType() === 'mobile';
|
||||
}
|
||||
return !!(window.matchMedia && window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY).matches);
|
||||
},
|
||||
|
||||
/** True while the overview is the visible home surface. */
|
||||
isMobileOverviewVisible() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
return !!el && el.classList.contains('visible');
|
||||
},
|
||||
|
||||
showMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
el.hidden = false;
|
||||
el.classList.add('visible');
|
||||
this._wireMobileOverview(el);
|
||||
this.renderMobileOverview();
|
||||
void this.loadMobileOverviewHistory();
|
||||
},
|
||||
|
||||
hideMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
this._closeMobileOverviewRunMenu();
|
||||
el.classList.remove('visible');
|
||||
el.hidden = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Past conversations, fetched once per home-screen visit. The unified list is
|
||||
* the same source the welcome screen resumes from, so a row resumed here and a
|
||||
* row resumed there behave identically. Failures leave the section out rather
|
||||
* than showing an error: the live sessions above it are the important part.
|
||||
*/
|
||||
async loadMobileOverviewHistory() {
|
||||
if (this._mobileOverviewHistoryLoading) return;
|
||||
this._mobileOverviewHistoryLoading = true;
|
||||
try {
|
||||
this._mobileOverviewHistory = await this._fetchUnifiedSessions(60);
|
||||
} catch (err) {
|
||||
console.warn('[mobile-overview] history load failed:', err);
|
||||
this._mobileOverviewHistory = this._mobileOverviewHistory || [];
|
||||
} finally {
|
||||
this._mobileOverviewHistoryLoading = false;
|
||||
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
|
||||
}
|
||||
},
|
||||
|
||||
/** Re-render only when the surface is actually showing (called from the tab renderer). */
|
||||
_refreshMobileOverviewIfVisible() {
|
||||
if (!this.isMobileOverviewVisible()) return;
|
||||
this._debouncedCall('mobileOverview', () => this.renderMobileOverview(), 150);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a breakpoint listener so
|
||||
* rotating or unfolding while on the home screen swaps to the right surface
|
||||
* instead of stranding a phone layout on a tablet-width viewport.
|
||||
*/
|
||||
_wireMobileOverview(el) {
|
||||
if (this._mobileOverviewWired) return;
|
||||
this._mobileOverviewWired = true;
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
|
||||
if (!target) return;
|
||||
const action = target.dataset.moAction;
|
||||
if (action === 'session') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.selectSession(target.dataset.moSession);
|
||||
} else if (action === 'resume') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.resumeMobileOverviewSession(target.dataset.moSession);
|
||||
} else if (action === 'more-past') {
|
||||
this._mobileOverviewShowAllPast = !this._mobileOverviewShowAllPast;
|
||||
this.renderMobileOverview();
|
||||
} else if (action === 'run') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.run();
|
||||
} else if (action === 'run-menu') {
|
||||
this._toggleMobileOverviewRunMenu();
|
||||
} else if (action === 'run-mode') {
|
||||
// Picking a backend both selects it (so the Run button keeps meaning what
|
||||
// you last chose, exactly like the toolbar) and launches it: on a phone
|
||||
// the pick IS the intent to start.
|
||||
this._closeMobileOverviewRunMenu();
|
||||
this.setRunMode(target.dataset.moMode);
|
||||
void this.run();
|
||||
} else if (action === 'run-webview') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
void this.openWebviewFromMenu(target.dataset.moWebview);
|
||||
} else if (action === 'run-add-url') {
|
||||
this._closeMobileOverviewRunMenu();
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
this._mobileOverviewRunMenuOpen = !this._mobileOverviewRunMenuOpen;
|
||||
this.renderMobileOverview();
|
||||
},
|
||||
|
||||
_closeMobileOverviewRunMenu() {
|
||||
if (!this._mobileOverviewRunMenuOpen) return;
|
||||
this._mobileOverviewRunMenuOpen = false;
|
||||
if (this.isMobileOverviewVisible()) this.renderMobileOverview();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resume a past conversation. Delegates to the same resumeHistorySession() the
|
||||
* welcome screen's Resume list uses, so name synthesis, envOverrides and the
|
||||
* resumeSessionId wiring stay in one place.
|
||||
*/
|
||||
async resumeMobileOverviewSession(sessionId) {
|
||||
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
|
||||
if (!row || !row.workingDir) return;
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Render
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
renderMobileOverview() {
|
||||
const el = document.getElementById('mobileOverview');
|
||||
if (!el) return;
|
||||
|
||||
const model = this.buildMobileOverviewModel({
|
||||
sessions: this.sessions,
|
||||
cases: this.cases,
|
||||
sessionOrder: this.sessionOrder,
|
||||
pendingHooks: this.pendingHooks,
|
||||
history: this._mobileOverviewHistory,
|
||||
});
|
||||
// Resume needs the workingDir/claudeSessionId off the row the user tapped.
|
||||
this._mobileOverviewPastRows = model.past;
|
||||
|
||||
el.replaceChildren();
|
||||
el.appendChild(this._buildMobileOverviewTop());
|
||||
|
||||
if (model.needsYou.length) {
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Needs you',
|
||||
model.needsYou.length,
|
||||
model.needsYou.map((r) => this._buildMobileOverviewRow(r))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Current sessions',
|
||||
model.current.length,
|
||||
model.current.map((r) => this._buildMobileOverviewRow(r)),
|
||||
'Nothing running. Hit Run to start something.'
|
||||
)
|
||||
);
|
||||
|
||||
el.appendChild(
|
||||
this._buildMobileOverviewSection(
|
||||
'Past sessions',
|
||||
model.past.length,
|
||||
this._buildMobileOverviewPast(model),
|
||||
this._mobileOverviewHistory ? 'No past conversations yet' : 'Loading…'
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Past conversations, newest first and capped: the unified list can run to
|
||||
* dozens, and this section sits below the live ones on purpose.
|
||||
*/
|
||||
_buildMobileOverviewPast(model) {
|
||||
const showAll = !!this._mobileOverviewShowAllPast;
|
||||
const visible = showAll ? model.past : model.past.slice(0, MOBILE_OVERVIEW_PAST_LIMIT);
|
||||
const children = visible.map((r) => this._buildMobileOverviewPastRow(r));
|
||||
const hiddenCount = model.past.length - visible.length;
|
||||
if (hiddenCount > 0 || showAll) {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'mobile-overview-more';
|
||||
toggle.dataset.moAction = 'more-past';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = showAll ? 'Show fewer' : 'Show all past sessions';
|
||||
toggle.appendChild(label);
|
||||
if (!showAll) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'mobile-overview-more-count';
|
||||
count.setAttribute('data-i18n-skip', '');
|
||||
count.textContent = String(hiddenCount);
|
||||
toggle.appendChild(count);
|
||||
}
|
||||
children.push(toggle);
|
||||
}
|
||||
return children;
|
||||
},
|
||||
|
||||
_buildMobileOverviewTop() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'mobile-overview-header';
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'mobile-overview-top';
|
||||
|
||||
const brand = document.createElement('span');
|
||||
brand.className = 'mobile-overview-brand';
|
||||
brand.textContent = (window.CodemanI18n && window.CodemanI18n.displayName) || 'Codeman';
|
||||
brand.setAttribute('data-i18n-skip', '');
|
||||
top.appendChild(brand);
|
||||
|
||||
// Split button carrying the TOOLBAR's own classes (`btn-toolbar btn-run
|
||||
// mode-<mode>` / `btn-run-gear`), so the per-backend gradient, border and
|
||||
// text color come from the same rules as the Run button in the toolbar and
|
||||
// stay in sync with it for free. mobile.css only sizes it.
|
||||
const group = document.createElement('div');
|
||||
group.className = 'mobile-overview-run-group';
|
||||
|
||||
const mode = this.runMode || 'claude';
|
||||
const run = document.createElement('button');
|
||||
run.className = `btn-toolbar btn-run mode-${mode} mobile-overview-run`;
|
||||
run.type = 'button';
|
||||
run.dataset.moAction = 'run';
|
||||
const runLabel = document.createElement('span');
|
||||
runLabel.textContent = 'Run';
|
||||
run.appendChild(runLabel);
|
||||
const runMode = document.createElement('span');
|
||||
runMode.className = 'mobile-overview-run-mode';
|
||||
runMode.setAttribute('data-i18n-skip', '');
|
||||
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
|
||||
run.appendChild(runMode);
|
||||
group.appendChild(run);
|
||||
|
||||
const caret = document.createElement('button');
|
||||
caret.className = `btn-toolbar btn-run-gear mode-${mode} mobile-overview-run-caret`;
|
||||
caret.type = 'button';
|
||||
caret.dataset.moAction = 'run-menu';
|
||||
caret.setAttribute('aria-label', 'Choose what to run');
|
||||
caret.setAttribute('aria-expanded', String(!!this._mobileOverviewRunMenuOpen));
|
||||
// An SVG chevron, not a "⌄" glyph: the character carries its own baseline
|
||||
// offset, so it sits visibly low in a flex-centered box no matter what the
|
||||
// line-height says. A path is centered by geometry. Same shape the toolbar's
|
||||
// run-mode gear uses.
|
||||
caret.appendChild(this._buildMobileOverviewChevron());
|
||||
group.appendChild(caret);
|
||||
|
||||
top.appendChild(group);
|
||||
wrap.appendChild(top);
|
||||
|
||||
if (this._mobileOverviewRunMenuOpen) wrap.appendChild(this._buildMobileOverviewRunMenu());
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/** Down chevron as SVG (see the note at its call site). */
|
||||
_buildMobileOverviewChevron() {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2.5');
|
||||
svg.setAttribute('stroke-linecap', 'round');
|
||||
svg.setAttribute('stroke-linejoin', 'round');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M6 9l6 6 6-6');
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
},
|
||||
|
||||
/**
|
||||
* The Run picker: the same backends as the toolbar's run-mode menu, plus saved
|
||||
* web tabs. Deliberately no "Recent Sessions" block, unlike the toolbar menu:
|
||||
* past conversations have their own section further down this screen.
|
||||
*/
|
||||
_buildMobileOverviewRunMenu() {
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'mobile-overview-run-menu';
|
||||
const current = this.runMode || 'claude';
|
||||
|
||||
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option' + (entry.mode === current ? ' selected' : '');
|
||||
option.dataset.moAction = 'run-mode';
|
||||
option.dataset.moMode = entry.mode;
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'run-mode-dot ' + entry.mode;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
const label = document.createElement('span');
|
||||
label.textContent = entry.label;
|
||||
option.appendChild(label);
|
||||
menu.appendChild(option);
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mobile-overview-run-header';
|
||||
header.textContent = 'Web / URL';
|
||||
menu.appendChild(header);
|
||||
|
||||
for (const webview of this.webviews ? this.webviews.values() : []) {
|
||||
const option = document.createElement('button');
|
||||
option.type = 'button';
|
||||
option.className = 'mobile-overview-run-option';
|
||||
option.dataset.moAction = 'run-webview';
|
||||
option.dataset.moWebview = webview.id;
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'run-mode-dot web';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
option.appendChild(dot);
|
||||
const label = document.createElement('span');
|
||||
// A dashboard name is user content.
|
||||
label.className = 'case-name';
|
||||
label.textContent = webview.name;
|
||||
option.appendChild(label);
|
||||
menu.appendChild(option);
|
||||
}
|
||||
|
||||
const add = document.createElement('button');
|
||||
add.type = 'button';
|
||||
add.className = 'mobile-overview-run-option mobile-overview-run-option--add';
|
||||
add.dataset.moAction = 'run-add-url';
|
||||
const addDot = document.createElement('span');
|
||||
addDot.className = 'run-mode-dot web';
|
||||
addDot.setAttribute('aria-hidden', 'true');
|
||||
add.appendChild(addDot);
|
||||
const addLabel = document.createElement('span');
|
||||
addLabel.textContent = 'Add URL…';
|
||||
add.appendChild(addLabel);
|
||||
menu.appendChild(add);
|
||||
|
||||
return menu;
|
||||
},
|
||||
|
||||
_buildMobileOverviewSection(title, count, children, emptyText) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'mobile-overview-section';
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.className = 'mobile-overview-heading';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = title;
|
||||
heading.appendChild(label);
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'mobile-overview-heading-count';
|
||||
badge.textContent = String(count);
|
||||
badge.setAttribute('data-i18n-skip', '');
|
||||
heading.appendChild(badge);
|
||||
section.appendChild(heading);
|
||||
|
||||
if (!children.length && emptyText) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'mobile-overview-empty';
|
||||
empty.textContent = emptyText;
|
||||
section.appendChild(empty);
|
||||
return section;
|
||||
}
|
||||
for (const child of children) section.appendChild(child);
|
||||
return section;
|
||||
},
|
||||
|
||||
/**
|
||||
* A session row. The state class drives the same visual language as the
|
||||
* session tabs: green dot when it is fine (pulsing while working), a yellow
|
||||
* blinking row when it wants input, a red blinking row when it asked a
|
||||
* question. Anything else here would mean two different meanings for the same
|
||||
* colors on one screen.
|
||||
*/
|
||||
_buildMobileOverviewRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--' + row.state;
|
||||
item.dataset.moAction = 'session';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state;
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'mobile-overview-row-body';
|
||||
|
||||
const line1 = document.createElement('span');
|
||||
line1.className = 'mobile-overview-row-title';
|
||||
const name = document.createElement('span');
|
||||
// .session-name is in the i18n skip list: a session name is user content.
|
||||
name.className = 'session-name';
|
||||
name.textContent = row.name;
|
||||
line1.appendChild(name);
|
||||
if (row.caseName) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'mobile-overview-row-case case-name';
|
||||
meta.textContent = ' · ' + row.caseName;
|
||||
line1.appendChild(meta);
|
||||
}
|
||||
body.appendChild(line1);
|
||||
|
||||
const line2 = document.createElement('span');
|
||||
line2.className = 'mobile-overview-row-sub';
|
||||
line2.setAttribute('data-i18n-skip', '');
|
||||
line2.textContent = row.mode + (row.dir ? ' · ' + row.dir : '');
|
||||
body.appendChild(line2);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state;
|
||||
// Skipped by i18n on purpose: the labels are generic single words ("idle",
|
||||
// "done", "error") that collide with state strings on other surfaces.
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = '›';
|
||||
item.appendChild(chevron);
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
/** A past conversation. Tapping it resumes, which creates a fresh session. */
|
||||
_buildMobileOverviewPastRow(row) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'mobile-overview-row mobile-overview-row--past';
|
||||
item.dataset.moAction = 'resume';
|
||||
item.dataset.moSession = row.id;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mobile-overview-dot mobile-overview-dot--past';
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(dot);
|
||||
|
||||
const body = document.createElement('span');
|
||||
body.className = 'mobile-overview-row-body';
|
||||
|
||||
const title = document.createElement('span');
|
||||
// A first prompt is user content, never app copy.
|
||||
title.className = 'mobile-overview-row-title session-name';
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'mobile-overview-row-sub';
|
||||
sub.setAttribute('data-i18n-skip', '');
|
||||
const when = row.at && this._formatTimeAgo ? this._formatTimeAgo(row.at) : '';
|
||||
sub.textContent = [row.caseName || row.dir, when].filter(Boolean).join(' · ');
|
||||
body.appendChild(sub);
|
||||
|
||||
item.appendChild(body);
|
||||
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'mobile-overview-pill mobile-overview-pill--past';
|
||||
pill.textContent = 'resume';
|
||||
pill.setAttribute('data-i18n-skip', '');
|
||||
item.appendChild(pill);
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
chevron.textContent = '›';
|
||||
item.appendChild(chevron);
|
||||
|
||||
return item;
|
||||
},
|
||||
});
|
||||
@@ -2259,6 +2259,433 @@ html.mobile-init .file-browser-panel {
|
||||
border-radius: 8px;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ---- Mobile Overview (phone home screen) ----
|
||||
Shown in place of the welcome overlay when the C logo is tapped. Styled only
|
||||
with :root tokens so every skin, including the four light ones, works with
|
||||
no override block. Never give .mobile-overview itself a display value: the
|
||||
element ships with [hidden] and only .visible may turn it on. */
|
||||
|
||||
.mobile-overview.visible {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.6rem calc(1rem + var(--safe-area-bottom));
|
||||
background: var(--bg-dark);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* No side padding: the wordmark's left edge lines up with the session cards
|
||||
below it, and the Run button's right edge with theirs. */
|
||||
.mobile-overview-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Same accent as the "C" in the header (`.logo` uses --accent-hover), and the
|
||||
same 48px box as the Run button opposite it, so the two are centered on one
|
||||
line by construction rather than by eye. */
|
||||
.mobile-overview-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--accent-hover);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Split Run button. Colors come from the toolbar's own
|
||||
`.btn-toolbar.btn-run.mode-<backend>` rules in styles.css (the element
|
||||
carries those classes), so this button and the one in the toolbar are the
|
||||
same control in two places. Only size/shape is set here, and NOTHING that
|
||||
would override the mode gradient. */
|
||||
.mobile-overview-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-overview-run-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Sized well past the 44px touch minimum: this is the primary action on the
|
||||
home screen, and the caret is a separate target right next to it.
|
||||
⚠️ The !important is required, not decorative: the phone block clamps every
|
||||
.btn-toolbar to `height/min-height/max-height: 26px !important`, and this
|
||||
button deliberately carries .btn-toolbar to inherit the run-mode gradient.
|
||||
Without matching !important (max-height included) it renders 26px tall. */
|
||||
.mobile-overview-run-group .mobile-overview-run {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 48px !important;
|
||||
height: 48px !important;
|
||||
max-height: 48px !important;
|
||||
padding: 0 1.15rem !important;
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
border-radius: 10px 0 0 10px !important;
|
||||
}
|
||||
|
||||
.mobile-overview-run-mode {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.mobile-overview-run-group .mobile-overview-run-caret {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px !important;
|
||||
height: 48px !important;
|
||||
max-height: 48px !important;
|
||||
min-width: 48px !important;
|
||||
width: 48px !important;
|
||||
padding: 0 !important;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
border-radius: 0 10px 10px 0 !important;
|
||||
}
|
||||
|
||||
/* The phone block shrinks every run-gear glyph to 10px; this one is a 48px
|
||||
tap target, so it gets a proportionate chevron. `display:block` drops the
|
||||
inline-baseline gap that would otherwise push it a pixel low. */
|
||||
.mobile-overview-run-group .mobile-overview-run-caret svg {
|
||||
display: block;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-overview-run-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--floating-bg);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
padding: 0 0.6rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-overview-run-option.selected {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.mobile-overview-run-option--add {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-overview-run-header {
|
||||
padding: 0.4rem 0.6rem 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Flush left with the wordmark and the cards: one left edge down the screen. */
|
||||
.mobile-overview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-heading-count {
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-overview-empty {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Row: 56px tap target, dot + two-line body + pill + chevron */
|
||||
.mobile-overview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-overview-row:active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Attention states mirror the session tabs exactly: red blink when the agent
|
||||
asked something (permission / question), yellow blink when it is waiting for
|
||||
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
|
||||
styles.css; the keyframes are re-declared here only because a tab's resting
|
||||
background is transparent while a row's is the card color. */
|
||||
.mobile-overview-row--needs {
|
||||
border-color: var(--red);
|
||||
animation: mobile-overview-blink-red 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mobile-overview-row--waiting {
|
||||
border-color: var(--yellow);
|
||||
animation: mobile-overview-blink-yellow 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mobile-overview-row--error {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
@keyframes mobile-overview-blink-red {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border-color: var(--red);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-overview-blink-yellow {
|
||||
0%,
|
||||
100% {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
50% {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-overview-row-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-overview-row-title {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-overview-row-case {
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-overview-row-sub {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-overview-dot {
|
||||
flex-shrink: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--needs,
|
||||
.mobile-overview-dot--error {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--waiting {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
/* Same as .session-tab .tab-status: green when the session is fine, and the
|
||||
shared `pulse` keyframes while it is working. */
|
||||
.mobile-overview-dot--working {
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.mobile-overview-dot--idle {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--done {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mobile-overview-pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-overview-pill--needs,
|
||||
.mobile-overview-pill--error {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--waiting {
|
||||
border-color: var(--yellow);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--idle,
|
||||
.mobile-overview-pill--working {
|
||||
border-color: var(--green);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.mobile-overview-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Past conversations: quieter than a live row, since tapping one starts work */
|
||||
.mobile-overview-row--past {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.mobile-overview-row--past .mobile-overview-row-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--past {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.mobile-overview-pill--past {
|
||||
border-color: var(--border-light);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mobile-overview-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 40px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--control-bg);
|
||||
color: var(--text-dim);
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mobile-overview-more-count {
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* The overview's attention blink is an alert, so it stays visible without
|
||||
motion: hold the alert color instead of animating to it. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-overview-row--needs,
|
||||
.mobile-overview-row--waiting {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.mobile-overview-row--needs {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.mobile-overview-row--waiting {
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
}
|
||||
|
||||
.mobile-overview-dot--working {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||
|
||||
@@ -316,6 +316,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
select.dataset.listenerAdded = 'true';
|
||||
}
|
||||
this.setupQuickStartCasePicker();
|
||||
// The phone overview labels rows with their case name, and a case rename or
|
||||
// link does not go through the session-tab renderer.
|
||||
this._refreshMobileOverviewIfVisible?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to load cases:', err);
|
||||
}
|
||||
|
||||
@@ -331,6 +331,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings);
|
||||
document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false;
|
||||
// Phone overview home screen: only meaningful under 430px, so the row is
|
||||
// hidden elsewhere rather than offering a toggle that changes nothing.
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
|
||||
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
|
||||
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
|
||||
const phoneSection = document.getElementById('appSettingsPhoneSection');
|
||||
if (phoneSection) phoneSection.style.display = phoneOnly;
|
||||
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
|
||||
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
|
||||
// in the template, so an unchecked box and a hidden button stay consistent).
|
||||
@@ -1453,6 +1461,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
@@ -1616,6 +1625,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply CJK input visibility immediately
|
||||
this._updateCjkInputState();
|
||||
|
||||
// The phone home surface (overview vs welcome) may have just been toggled.
|
||||
// Only re-decide while a home screen is actually up.
|
||||
if (!this.activeSessionId) this.showWelcome();
|
||||
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
@@ -1646,6 +1659,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: _ssb,
|
||||
showAwayDigestButton: _adb,
|
||||
showCronButton: _crb,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
@@ -1814,6 +1829,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
showSessionButton: false,
|
||||
showAwayDigestButton: false,
|
||||
showCronButton: false,
|
||||
// Phone home screen: the C logo opens the session overview instead of the
|
||||
// welcome screen. ON by default here, and the escape hatch if it ever
|
||||
// misbehaves on a device (the gate treats only an explicit false as off).
|
||||
mobileOverviewEnabled: true,
|
||||
// Remote auto-reconnect (COD-108) — on by default
|
||||
remoteAutoReconnect: true,
|
||||
// Input
|
||||
@@ -2269,6 +2288,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'language',
|
||||
'terminalWheelLocalScrollback',
|
||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||
'mobileOverviewEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. It
|
||||
|
||||
@@ -1178,6 +1178,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
showWelcome() {
|
||||
// Phones get the session overview instead of the welcome screen: on a small
|
||||
// screen "which session is blocked on me" beats "how do I start one". The
|
||||
// gate lives in mobile-overview.js; every other device falls through
|
||||
// unchanged. Both surfaces are toggled here so a breakpoint change (rotate,
|
||||
// unfold) swaps cleanly instead of showing both.
|
||||
if (this.shouldUseMobileOverview?.()) {
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) overlay.classList.remove('visible');
|
||||
this.showMobileOverview();
|
||||
this._updateCjkInputState?.();
|
||||
return;
|
||||
}
|
||||
this.hideMobileOverview?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('visible');
|
||||
@@ -1191,6 +1204,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
Reference in New Issue
Block a user