mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis
Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI
Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).
Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.
Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).
Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.
Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.
Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.
27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else
window.__codemanCliAvailable — the flag isCliAvailable() reads client-side
to gate the welcome-screen buttons, the Run-menu dropdown and the mobile
overview — was built purely from each CLI's own installed-on-PATH resolver
(isClaudeAvailable() etc.), with no reference to the registry's `enabled`
flag at all. So disabling a CLI via the new Settings UI (or a hand-edited
clis.json) updated the settings row and nothing else: every launch surface
kept offering it, both live and after a full page reload, since even a
fresh render never consulted the registry.
Fixed in two places:
- server.ts: after building `available`, intersect the nine real
SessionMode ids against `enabledClis()`. git/cloudflared (utility
binaries, not CLI registry entries) and deepseekBinary (a secondary
installed-only flag for the "add a profile" affordance) are deliberately
left alone.
- settings-ui.js: `toggleCliEnabled()` now patches
`window.__codemanCliAvailable` in place and refreshes the welcome screen,
the mobile overview and an already-open Run menu, mirroring the existing
`installDeepSeekProfile()` pattern for the same "injected once, needs an
explicit patch" reason — without this half, the server-side fix alone
still left every surface stale until the next reload.
New test in test/render-index-html.test.ts: an installed-but-disabled CLI
(codex, forced via clis.json + reloadCliRegistry()) reads as unavailable,
while an installed-and-enabled one (claude) is unaffected by the override.
Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has
none and WebServer's constructor hard-requires it): typecheck clean, the
new test passes (17/17 in render-index-html.test.ts), the CLI-registry
suites pass (86/86), and the full CI gate is green (415 test files, 7855
tests, 0 failures).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap
Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:
- A new "Status as of 2026-09-22" section up top: what's actually
implemented (verified by grepping the routes/schema/UI, not just trusting
the commit messages), the availability-flag staleness bug found and fixed
in this session (commit 0c77dd0a) with its devbox verification record, and
one real outstanding gap.
- The outstanding gap: a custom CLI created via Phase 5's write API has no
way to actually be launched. The Run menu is static per-mode markup with
no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
custom entry, confirm it can be launched" verify step was never actually
exercised against this. Documented with two candidate fixes, neither
started.
- Each phase's checklist flipped to [x] where confirmed present in the tree,
Status lines updated from TODO to DONE, and the two originally-open
questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
marked resolved against what actually shipped.
No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* docs: add the CLI-registry deployment plan and the parked Copilot plan
Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:
- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
name in docs/cli-enable-disable-plan.md's own header as a sibling plan
tracked separately — kept for continuity, not active on this branch.
The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
* fix(cli-registry): render enabled CLIs in launch surfaces
* test(cli-registry): update frontend branch guard
* fix(test): isolate suite from deployment environment
* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent
shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.
Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): hide shell's toggle entirely instead of greying it out
A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders no switch at
all - a plain "Always available" label - so there is nothing to click
that could look like it should work but doesn't. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical
renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* style: prettier fixes from the master merge
* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels
Four gaps found verifying #476 against the #343 review trail:
- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
(the nine per-CLI resolvers and the generic registry one) caches in its own
closure, with a negative-cache backoff of up to 5 minutes, and nothing
cleared them. invalidateCliExecutableResolvers(binaries) now drops those
caches per binary; install (success or failure), create, edit and delete
call it plus invalidateCliResolverCache(id). Before this, a CLI installed
from Settings could fail to launch for minutes, and an edited custom entry
kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
ignoring the entry's searchDirs and the login-shell lookup that spawn and
the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
sit behind an explicit confirm; the confirm now names the exact command,
which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
of the word ("Claude", "Codex"). It uses the registry label again, which is
identical to the old static table for every stock CLI (now pinned).
14 new tests; 9 of them fail against the previous head and pass here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs
Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
does not parse or has group/world permission bits instead of overwriting it
(isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
existence/duplicate checks inside the serialized step, plus a unique tmp
name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md
Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
"Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* test(cli-registry): pin the #343/#347 findings #476 makes reachable
A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
990 lines
42 KiB
JavaScript
990 lines
42 KiB
JavaScript
/**
|
||
* @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.
|
||
*
|
||
* Rows inside a section are ordered by `CodemanSessionOrder` (constants.js),
|
||
* the SAME comparator the desktop rail uses: blocked longest-first, then
|
||
* running longest-first, then quiet most-recently-quiet first.
|
||
*
|
||
* PHONE ONLY. The gate is `shouldUseMobileOverview()` (viewport < 600px, 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.
|
||
*
|
||
* Each live row also carries when the session FIRST started and how long it has
|
||
* been in the state it is in ("started 3d ago · idle 12m"). Both go stale on
|
||
* their own (a sitting session emits no event), so a slow clock rewrites them
|
||
* IN PLACE from the epoch ms parked on the elements, never by re-rendering,
|
||
* which would restart every row's blink and pulse.
|
||
*
|
||
* 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 constants.js (CodemanSessionOrder, the shared row comparator)
|
||
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession, run)
|
||
* @dependency ralph-panel.js (formatRelativeTime, the app's one relative-time formatter)
|
||
* @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: 599px)';
|
||
|
||
/** How many past conversations show before the "Show all" toggle. */
|
||
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
|
||
const SHELL_KIND = 'shell';
|
||
|
||
/**
|
||
* 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 Code' },
|
||
{ 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: 'pi', label: 'Pi', short: 'Pi' },
|
||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||
];
|
||
|
||
/**
|
||
* The one word every surface puts on the watching badge, and the tooltip that says
|
||
* what the pane actually reported. Both live here so the phone overview, the desktop
|
||
* home rail and the rich sidebar rows cannot word the same badge three ways.
|
||
*/
|
||
const WATCHING_BADGE_TEXT = 'watching';
|
||
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
|
||
|
||
function mobileOverviewRunModes() {
|
||
const catalog =
|
||
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
|
||
if (catalog.length === 0) return MOBILE_OVERVIEW_RUN_MODES;
|
||
return catalog
|
||
.filter((entry) => entry.enabled)
|
||
.map((entry) => ({
|
||
mode: entry.id,
|
||
label: entry.kind === SHELL_KIND ? 'Terminal / Shell' : entry.label,
|
||
// The registry `label`, not `shortBadge`: the Run button has always shown a word
|
||
// ("Claude", "Codex", "Shell"), and every stock label IS that word, so this stays
|
||
// identical to MOBILE_OVERVIEW_RUN_MODES above. `shortBadge` is the two-letter tab
|
||
// code ("CC", "CX"), which read as a regression on the button.
|
||
short: entry.label,
|
||
}));
|
||
}
|
||
|
||
/** 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',
|
||
};
|
||
|
||
/**
|
||
* Label for the "how long has it been like this" stamp, per state. The pill
|
||
* already names the state, so this word is there to say what the duration next
|
||
* to it is measuring.
|
||
*/
|
||
const MOBILE_OVERVIEW_SINCE_LABEL = {
|
||
needs: 'waiting',
|
||
waiting: 'waiting',
|
||
error: 'failed',
|
||
working: 'working',
|
||
idle: 'idle',
|
||
done: 'ended',
|
||
};
|
||
|
||
/** How often the age stamps are rewritten in place while the home screen is up. */
|
||
const MOBILE_OVERVIEW_CLOCK_MS = 20000;
|
||
|
||
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';
|
||
},
|
||
|
||
/**
|
||
* Anchor + label for the row's second stamp: how long the session has been in
|
||
* the state it is in.
|
||
*
|
||
* For everything that is NOT working that anchor is `lastActivityAt`, the last
|
||
* byte the pane printed: a Claude pane sitting at its composer prints nothing,
|
||
* so the end of the last turn is exactly when the session went quiet.
|
||
*
|
||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
||
* last-activity stamp is always "now" and would report every running turn as
|
||
* 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is
|
||
* persisted server-side and therefore survives a Codeman restart. A working
|
||
* session with NO submit stamp falls back to `lastActivityAt`, because that is
|
||
* exactly what `sessionActivityAnchor` (constants.js) sorts it by: a row must
|
||
* never be ranked by a number it does not show.
|
||
*
|
||
* @returns {{key: string, at: number}|null}
|
||
*/
|
||
_mobileOverviewSince(state, session) {
|
||
const activeAt = Number(session.lastActivityAt) || 0;
|
||
const at = state === 'working' ? Number(session.lastSubmitAt) || activeAt : activeAt;
|
||
if (!at) return null;
|
||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||
},
|
||
|
||
/**
|
||
* 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,
|
||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||
// "2 shells"), straight off the session payload. A row that has one is quiet
|
||
// because the agent is waiting for that, not because it is waiting for you.
|
||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||
// Epoch ms, straight off the session payload; formatting happens at
|
||
// render time so the clock can redo it without a re-render.
|
||
createdAt: Number(session.createdAt) || 0,
|
||
// Raw stamps for the shared order comparator; `since` above is the same
|
||
// pair resolved for DISPLAY, and the two must not drift apart.
|
||
lastActivityAt: Number(session.lastActivityAt) || 0,
|
||
lastSubmitAt: Number(session.lastSubmitAt) || 0,
|
||
since: this._mobileOverviewSince(state, session),
|
||
orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
|
||
};
|
||
});
|
||
|
||
// Order is `CodemanSessionOrder` (constants.js), shared with the desktop
|
||
// rail: blocked first (longest-blocked at the top), then running
|
||
// longest-first, then quiet most-recent-first.
|
||
// Guarded: a stale cached constants.js (iOS Safari after a deploy) must
|
||
// degrade to tab order, not TypeError the overview away.
|
||
const inSection = (states) => {
|
||
const filtered = rows.filter((r) => states.includes(r.state));
|
||
return window.CodemanSessionOrder ? window.CodemanSessionOrder.sort(filtered) : filtered;
|
||
};
|
||
|
||
// 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',
|
||
// Only the scanner sets this, and only for a codex rollout. Dropping it here
|
||
// is not cosmetic: resumeMobileOverviewSession() passes row.resumeId on to
|
||
// resumeHistorySession(), so without it a tapped Codex row starts a FRESH
|
||
// session on a thread that is already on disk.
|
||
resumeId: item.resumeId || undefined,
|
||
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();
|
||
this._stopMobileOverviewClock();
|
||
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,
|
||
row.mode,
|
||
row.resumeId
|
||
);
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 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…'
|
||
)
|
||
);
|
||
|
||
this._startMobileOverviewClock();
|
||
},
|
||
|
||
/**
|
||
* 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 = mobileOverviewRunModes().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.
|
||
*
|
||
* Gated the same way as the toolbar's #runModeMenu (isCliAvailable(), shell
|
||
* exempt) — this list is a separate, hardcoded duplicate of the toolbar's menu
|
||
* rather than a shared render, so it never picked up #201's gating and offered
|
||
* every backend regardless of what's actually installed.
|
||
*/
|
||
_buildMobileOverviewRunMenu() {
|
||
const menu = document.createElement('div');
|
||
menu.className = 'mobile-overview-run-menu';
|
||
const current = this.runMode || 'claude';
|
||
|
||
for (const entry of mobileOverviewRunModes()) {
|
||
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
|
||
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() : []) {
|
||
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
|
||
// writes one), not saved dashboards: same filter as the desktop run menu,
|
||
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
|
||
// next server restart with no affordance here to restart it.
|
||
if (webview.managed) continue;
|
||
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);
|
||
|
||
body.appendChild(this._buildMobileOverviewMeta(row));
|
||
|
||
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);
|
||
|
||
if (row.watching) item.appendChild(this._buildWatchingBadge(row.watching, 'mobile-overview-pill'));
|
||
|
||
const chevron = document.createElement('span');
|
||
chevron.className = 'mobile-overview-chevron';
|
||
chevron.setAttribute('aria-hidden', 'true');
|
||
chevron.textContent = '›';
|
||
item.appendChild(chevron);
|
||
|
||
// Approvals Inbox: a pending dialog for this session gets an answer strip
|
||
// BELOW the row (the row itself is a <button>, so actions cannot nest
|
||
// inside it). Tapping the row still opens the session, unchanged.
|
||
const approval = this._pendingApprovalForSession(row.id);
|
||
if (approval) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'mobile-overview-row-wrap';
|
||
wrap.appendChild(item);
|
||
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
|
||
return wrap;
|
||
}
|
||
|
||
return item;
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Watching badge
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* The badge a session wears while work it started in the background is still
|
||
* running: a monitor, a backgrounded shell, a cloud session.
|
||
*
|
||
* It says one word, and the label the pane itself printed ("1 monitor") rides in
|
||
* the tooltip, because the badge shares a row with the state pill on the narrowest
|
||
* screen this app renders on. It does NOT replace that pill: an agent can arm a
|
||
* monitor and ask the user a question in the same breath, so the row still says
|
||
* "needs you" and this says what else is going on.
|
||
*
|
||
* Shared with the desktop home rail (home-sessions.js), for the same reason
|
||
* `_mobileOverviewState` is: one badge, one wording, one place to change it. The
|
||
* caller names its own pill class, because each surface styles its pills itself and
|
||
* the phone's live inside a media query the desktop never enters.
|
||
*/
|
||
_buildWatchingBadge(label, baseClass) {
|
||
const badge = document.createElement('span');
|
||
const base = baseClass || 'mobile-overview-pill';
|
||
badge.className = base + ' ' + base + '--watching';
|
||
badge.setAttribute('data-i18n-skip', '');
|
||
badge.textContent = WATCHING_BADGE_TEXT;
|
||
// The label rides in BOTH, because a tooltip is desktop-only: a phone has no hover
|
||
// target, and a screen reader gets the one word either way. This is the surface the
|
||
// badge was built for first, so "watching" with no way to learn what would be the
|
||
// wrong place to save a line.
|
||
badge.title = watchingBadgeTitle(label);
|
||
badge.setAttribute('aria-label', watchingBadgeTitle(label));
|
||
return badge;
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Age stamps: started / how long in this state
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* The "started 3d ago · idle 12m" line under a session row. Both stamps keep
|
||
* their raw epoch ms on the element (`data-mo-ts`) so `_tickMobileOverviewTimes()`
|
||
* can rewrite the text without rebuilding the row (a re-render would restart
|
||
* the blink on every waiting row and the pulse on every working one).
|
||
*/
|
||
_buildMobileOverviewMeta(row) {
|
||
const meta = document.createElement('span');
|
||
meta.className = 'mobile-overview-row-meta';
|
||
// Relative times are generated text, and "started"/"idle" here are the same
|
||
// generic words that mean something else on other surfaces.
|
||
meta.setAttribute('data-i18n-skip', '');
|
||
|
||
meta.appendChild(this._buildMobileOverviewStamp('started', row.createdAt, 'ago', 'mobile-overview-meta-started'));
|
||
|
||
if (row.since) {
|
||
const sep = document.createElement('span');
|
||
sep.className = 'mobile-overview-meta-sep';
|
||
sep.setAttribute('aria-hidden', 'true');
|
||
sep.textContent = '·';
|
||
meta.appendChild(sep);
|
||
meta.appendChild(
|
||
this._buildMobileOverviewStamp(row.since.key, row.since.at, 'for', 'mobile-overview-meta-since')
|
||
);
|
||
}
|
||
|
||
return meta;
|
||
},
|
||
|
||
/** One labelled stamp: a dim key, the value, the full date in the title. */
|
||
_buildMobileOverviewStamp(key, timestamp, format, className) {
|
||
const wrap = document.createElement('span');
|
||
wrap.className = 'mobile-overview-meta-item ' + className;
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'mobile-overview-meta-key';
|
||
label.textContent = key;
|
||
wrap.appendChild(label);
|
||
|
||
const value = document.createElement('span');
|
||
value.dataset.moTs = String(timestamp || 0);
|
||
value.dataset.moFmt = format;
|
||
value.textContent = this._mobileOverviewStampText(timestamp, format);
|
||
wrap.appendChild(value);
|
||
|
||
if (timestamp) wrap.title = `${key}: ${new Date(timestamp).toLocaleString()}`;
|
||
return wrap;
|
||
},
|
||
|
||
/**
|
||
* 'ago' points at a moment ("3d ago", the app's one relative formatter);
|
||
* 'for' measures a span from it to now ("12m"), which is what a duration
|
||
* beside a state word wants to read as.
|
||
*/
|
||
_mobileOverviewStampText(timestamp, format) {
|
||
if (!timestamp) return '—';
|
||
if (format === 'ago') {
|
||
return (this.formatRelativeTime && this.formatRelativeTime(timestamp)) || '—';
|
||
}
|
||
const ms = Date.now() - timestamp;
|
||
if (ms < 60000) return '<1m';
|
||
const mins = Math.floor(ms / 60000);
|
||
if (mins < 60) return `${mins}m`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return mins % 60 ? `${hours}h ${mins % 60}m` : `${hours}h`;
|
||
const days = Math.floor(hours / 24);
|
||
return hours % 24 ? `${days}d ${hours % 24}h` : `${days}d`;
|
||
},
|
||
|
||
/**
|
||
* Rewrites the stamps in place every `MOBILE_OVERVIEW_CLOCK_MS`. A sitting
|
||
* session emits nothing, so without this its "idle 2m" would still read 2m an
|
||
* hour later, the one number on the screen that has to move on its own.
|
||
*/
|
||
_startMobileOverviewClock() {
|
||
if (this._mobileOverviewClock) return;
|
||
this._mobileOverviewClock = setInterval(() => {
|
||
if (!this.isMobileOverviewVisible()) {
|
||
this._stopMobileOverviewClock();
|
||
return;
|
||
}
|
||
this._tickMobileOverviewTimes();
|
||
}, MOBILE_OVERVIEW_CLOCK_MS);
|
||
},
|
||
|
||
_stopMobileOverviewClock() {
|
||
if (!this._mobileOverviewClock) return;
|
||
clearInterval(this._mobileOverviewClock);
|
||
this._mobileOverviewClock = null;
|
||
},
|
||
|
||
_tickMobileOverviewTimes() {
|
||
const el = document.getElementById('mobileOverview');
|
||
if (!el) return;
|
||
for (const node of el.querySelectorAll('[data-mo-ts]')) {
|
||
const text = this._mobileOverviewStampText(Number(node.dataset.moTs) || 0, node.dataset.moFmt);
|
||
if (node.textContent !== text) node.textContent = text;
|
||
}
|
||
},
|
||
|
||
/** The session's pending approval, when the strip should render (dialogs only). */
|
||
_pendingApprovalForSession(sessionId) {
|
||
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
|
||
for (const item of this.approvals.values()) {
|
||
if (item.sessionId === sessionId && item.kind !== 'idle') return item;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
|
||
_buildMobileOverviewApprovalStrip(approval) {
|
||
const strip = document.createElement('div');
|
||
strip.className = 'mobile-overview-approval-strip';
|
||
strip.setAttribute('data-i18n-skip', '');
|
||
const addBtn = (label, cls, onTap) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'mobile-overview-approval-btn' + (cls ? ' ' + cls : '');
|
||
btn.textContent = label;
|
||
btn.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
onTap();
|
||
});
|
||
strip.appendChild(btn);
|
||
};
|
||
if (approval.options && approval.options.length) {
|
||
for (const o of approval.options) {
|
||
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
|
||
addBtn(`${o.n}. ${label}`, o.n === 1 ? 'primary' : '', () => this.answerApproval(approval.id, 'option', o.n));
|
||
}
|
||
} else {
|
||
addBtn('Approve', 'primary', () => this.answerApproval(approval.id, 'approve'));
|
||
addBtn('Deny', 'danger', () => this.answerApproval(approval.id, 'deny'));
|
||
}
|
||
return strip;
|
||
},
|
||
|
||
/** 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;
|
||
},
|
||
});
|