Merge remote-tracking branch 'upstream/master' into feature/run-menu-custom-model-picker

This commit is contained in:
Devvyn
2026-09-19 03:25:26 +08:00
36 changed files with 3184 additions and 144 deletions
+10
View File
@@ -957,6 +957,10 @@ class CodemanApp {
this.registerServiceWorker();
// Fetch tunnel status for header indicator (desktop only)
this.loadTunnelStatus();
// Ask whether a host reboot left sessions worth rebuilding (banner, never
// automatic). handleInit() re-reads it on every SSE init; this covers the
// path where that event never arrives.
this.initRebootRestoreBanner?.();
// Share a single settings fetch between both consumers
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
@@ -3778,6 +3782,12 @@ class CodemanApp {
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
// A board left open across a host reboot reconnects HERE, to a server that came
// back with an empty session list. The reboot-restore offer is built at boot,
// before any client could be listening, so re-read it on every init rather than
// only on the page-load path.
this.refreshRebootRestoreBanner?.();
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
+19
View File
@@ -213,6 +213,24 @@
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
</div>
<!-- Reboot-restore offer: shown when the server found sessions a host reboot
killed and is asking whether to rebuild them. Populated by
reboot-restore-ui.js; nothing is created until the user clicks. -->
<div class="reboot-restore-banner" id="rebootRestoreBanner" role="status" hidden>
<span class="reboot-restore-banner-icon" aria-hidden="true">↺</span>
<span class="reboot-restore-banner-text" id="rebootRestoreBannerText"></span>
<span class="reboot-restore-banner-detail" id="rebootRestoreBannerDetail"></span>
<span class="reboot-restore-banner-note">Conversations return; terminal history does not.</span>
<button
class="reboot-restore-banner-accept"
id="rebootRestoreBannerAccept"
onclick="app.restoreRebootSessions()"
>
Restore
</button>
<button class="reboot-restore-banner-dismiss" onclick="app.dismissRebootRestore()">Dismiss</button>
</div>
<!-- Timer Banner (shown when timed run is active) -->
<div class="timer-banner" id="timerBanner" style="display: none;">
<div class="timer-content">
@@ -3658,6 +3676,7 @@
<script defer src="readmymind-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="approvals-ui.js"></script>
<script defer src="reboot-restore-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
+37
View File
@@ -3241,6 +3241,43 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
other banners. The overlay is fixed and handles its own insets.
============================================================================ */
@media (max-width: 599px) {
/* Reboot-restore banner: the same treatment as the offline banner below. Its
text and note are nowrap and the two buttons cannot shrink, so without this
the actions are pushed off a phone-width viewport and become unreachable. */
.reboot-restore-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
gap: 0.4rem;
}
/* The session names and the scrollback note are the first things to go. The
count plus the two buttons carry the message on their own, and the note
survives as the accept button's title. */
.reboot-restore-banner-detail,
.reboot-restore-banner-note {
display: none;
}
/* A flex item will not shrink below its content width at the default
`min-width: auto`, so without this the nowrap text pushes the buttons off a
360px viewport and the ellipsis never engages. */
.reboot-restore-banner-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.reboot-restore-banner-accept,
.reboot-restore-banner-dismiss {
padding: 0.25rem 0.5rem;
}
.reboot-restore-banner-accept {
margin-left: auto;
}
.offline-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
+142
View File
@@ -0,0 +1,142 @@
/**
* @fileoverview Reboot-restore banner: offer back the sessions a host reboot destroyed.
*
* A host reboot takes the tmux server down with it, so every session's pane dies
* and the board comes up empty. The server works out what was running from the
* records it still holds at boot, and this banner asks the user whether to
* rebuild them. Nothing is created until they click, because the server's
* reboot guess is a heuristic and a wrong automatic restore would spawn CLI
* processes nobody asked for.
*
* Seeded from `GET /api/reboot-restore` on init and again on every SSE reconnect,
* because the tab most likely to want this is one that was open across the reboot
* and reconnects to a server that came back up with an empty board. Restore posts to
* `POST /api/reboot-restore/restore` and Dismiss posts to
* `POST /api/reboot-restore/dismiss`. Dismiss always clears the banner; Restore
* re-reads the plan afterwards, because the server puts back anything it could
* not build for a reason that may pass, such as a session limit or an agent that
* would not start. The restored sessions arrive as ordinary `session:created`
* events, so no extra rendering is needed here.
*
* The banner says that terminal history did not survive, because a restored
* session is a new pane: the conversation continues and the scrollback does not.
* Saying so is what keeps an empty pane from reading as a broken restore.
* Backend: src/web/reboot-restore-registry.ts, src/web/routes/reboot-restore-routes.ts.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, showToast)
* @dependency api-client.js at runtime (this._api / this._apiJson)
* @loadorder 11.65, after approvals-ui.js and before admin-ui.js (11.7)
*/
/** Plain-language wording for one skip reason, for the toast after a restore. */
function rebootSkipReason(reason) {
switch (reason) {
case 'workspace-missing':
return 'workspace is gone';
case 'workspace-forbidden':
return 'workspace is outside your space';
case 'already-live':
return 'already open';
case 'capacity-reached':
return 'session limit reached';
case 'rebuild-failed':
return 'the agent would not start';
default:
return reason;
}
}
Object.assign(CodemanApp.prototype, {
/** Ask the server whether a reboot left anything on offer, and show the banner if so. */
async initRebootRestoreBanner() {
const data = await this._apiJson('/api/reboot-restore');
const sessions = data?.sessions ?? [];
if (sessions.length === 0) return;
this._rebootRestoreSessions = sessions;
this.renderRebootRestoreBanner();
},
renderRebootRestoreBanner() {
const banner = this.$('rebootRestoreBanner');
if (!banner) return;
const sessions = this._rebootRestoreSessions ?? [];
if (sessions.length === 0) {
banner.hidden = true;
return;
}
const count = sessions.length;
const text = this.$('rebootRestoreBannerText');
if (text) {
const noun = count === 1 ? 'session' : 'sessions';
text.textContent = `Restore ${count} ${noun} from before the reboot`;
}
const detail = this.$('rebootRestoreBannerDetail');
if (detail) {
// Names, so the user can tell what they are about to relaunch.
const names = sessions
.map((s) => s.name || s.workingDir?.split('/').pop() || s.id.slice(0, 8))
.slice(0, 4)
.join(', ');
detail.textContent = count > 4 ? `${names}, …` : names;
detail.title = sessions.map((s) => `${s.name || s.id}\n${s.workingDir}`).join('\n\n');
}
const accept = this.$('rebootRestoreBannerAccept');
// The note is hidden at phone width, so the warning travels on the button too.
if (accept) accept.title = 'Conversations return; terminal history does not.';
banner.hidden = false;
},
/** Rebuild everything on offer. The panes are new, so scrollback does not come back. */
async restoreRebootSessions() {
const button = this.$('rebootRestoreBannerAccept');
if (button) button.disabled = true;
const res = await this._api('/api/reboot-restore/restore', { method: 'POST', body: {} });
if (res && res.status === 409) {
if (button) button.disabled = false;
this.showToast?.('A restore is already running', 'info');
return;
}
// The uniform envelope wraps every /api payload; reading the outer object
// would report every count as zero.
const body = res && res.ok ? (await res.json().catch(() => null))?.data : null;
if (!body) {
if (button) button.disabled = false;
this.showToast?.('Could not restore the sessions', 'error');
return;
}
const restored = body.restored?.length ?? 0;
const skipped = body.skipped?.length ?? 0;
// Re-read rather than clearing: the server puts back anything it could not
// build for a reason that may pass, such as a session limit or an agent that
// would not start, and blanking the banner here would put those entries out
// of reach until a reload.
await this.refreshRebootRestoreBanner();
if (button) button.disabled = false;
if (restored > 0) {
const noun = restored === 1 ? 'conversation' : 'conversations';
this.showToast?.(`Restored ${restored} ${noun}. Terminal history did not survive the reboot.`, 'success');
}
if (skipped > 0) {
// Each reason means a different next step for the user, so they are not
// collapsed into one message: capacity clears by closing something, a
// failed start usually means the CLI is not on the server's PATH.
const reasons = new Set((body.skipped ?? []).map((s) => s.reason));
this.showToast?.(`${skipped} not restored: ${[...reasons].map(rebootSkipReason).join('; ')}`, 'warning');
}
},
/** Re-read the offer after a reconnect, for a tab that was open across the reboot. */
async refreshRebootRestoreBanner() {
const data = await this._apiJson('/api/reboot-restore');
this._rebootRestoreSessions = data?.sessions ?? [];
this.renderRebootRestoreBanner();
},
/** Drop the offer. The Resume list still reaches every one of these conversations. */
async dismissRebootRestore() {
this._rebootRestoreSessions = [];
this.renderRebootRestoreBanner();
await this._apiPost('/api/reboot-restore/dismiss', {});
},
});
+83
View File
@@ -2548,6 +2548,10 @@ body.solo-mode .header-tokens,
body.solo-mode .btn-notifications,
body.solo-mode .btn-multimonitor,
body.solo-mode .header-plan-usage,
/* A solo window shows ONE session and has no tab strip to put restored ones in,
so offering to rebuild a list of them there is an offer it cannot show the
result of. The dashboard that spawned this window carries the banner. */
body.solo-mode .reboot-restore-banner,
body.solo-mode .btn-lifecycle-log {
display: none !important;
}
@@ -15457,6 +15461,85 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
skin, including the light ones. Visibility is driven by the `hidden`
attribute, so the display rules need !important to lose to it. */
/* Reboot-restore offer. Amber rather than red: nothing is wrong, the board is
asking a question, and the user can ignore it. See reboot-restore-ui.js. */
.reboot-restore-banner {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 1rem;
background: linear-gradient(90deg, #b45309, #92400e);
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
flex-shrink: 0;
z-index: 1250;
}
.reboot-restore-banner[hidden] {
display: none !important;
}
.reboot-restore-banner-icon {
flex-shrink: 0;
font-size: 0.95rem;
line-height: 1;
}
.reboot-restore-banner-text {
white-space: nowrap;
}
.reboot-restore-banner-detail {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
/* A flex item will not shrink below its content width at the default
`min-width: auto`, so without this the session names push the buttons out of
the line between the phone breakpoint and full width. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reboot-restore-banner-note {
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
white-space: nowrap;
margin-left: auto;
}
.reboot-restore-banner-accept,
.reboot-restore-banner-dismiss {
flex-shrink: 0;
padding: 0.2rem 0.6rem;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.reboot-restore-banner-accept:hover,
.reboot-restore-banner-dismiss:hover {
background: rgba(255, 255, 255, 0.24);
}
.reboot-restore-banner-accept:disabled {
opacity: 0.6;
cursor: default;
}
.reboot-restore-banner-dismiss {
border-color: rgba(255, 255, 255, 0.3);
background: transparent;
font-weight: 500;
}
.offline-banner {
display: flex;
align-items: center;
@@ -66,6 +66,38 @@
let composing = false;
const pending = [];
/**
* Resolve every candidate still pending, right now, instead of waiting for
* its zero-delay timer.
*
* Android soft keyboards commit the last character and send the Enter key
* in ONE InputConnection transaction: the `input` event and the Enter
* keydown are both processed before any timer runs. Left on its timer the
* candidate lost BOTH ways — xterm emits '\r' synchronously from the Enter
* keydown (so the local-echo composer submitted the prompt without the
* character), and that '\r' bumps `canonicalCount`, so the candidate then
* read "xterm spoke for this keystroke" and stood down, dropping the
* character outright. That is the "every message loses its last character"
* report from phones.
*
* Draining at the next keydown is correct on both counts: the counter still
* holds the value it had while this candidate's keystroke was current, and
* the byte reaches the composer ahead of whatever the new key emits.
*/
function flushPending() {
for (const candidate of pending.splice(0)) {
if (candidate.timer !== null) {
try {
clearTimer(candidate.timer);
} catch {
// A broken timer host must not break input handling.
}
candidate.timer = null;
}
resolveCandidate(candidate);
}
}
function cancelPending() {
for (const candidate of pending.splice(0)) {
candidate.active = false;
@@ -111,6 +143,11 @@
*/
function handleKeyEvent(event) {
if (destroyed || event?.type !== 'keydown') return;
// Settle the PREVIOUS keystroke before this one can move the counter or
// reach the PTY — see flushPending(). This runs from xterm's custom key
// handler, i.e. before xterm processes the key, so a recovered character
// is always ordered ahead of the bytes this keydown produces.
flushPending();
keydownSnapshot = canonicalCount;
}
+42 -8
View File
@@ -3543,6 +3543,29 @@ Object.assign(CodemanApp.prototype, {
this._sendInputAsync(this.activeSessionId, text);
},
/**
* Re-assert a history anchor captured before a terminal write (#358).
*
* Called from xterm's write callback, never synchronously after write():
* xterm parses on its own schedule, so the buffer only carries the redraw's
* effect once that callback fires. A null anchor means the user was following
* live output and nothing needs restoring.
*/
_restoreTerminalViewport(preserveViewportY, sessionId) {
if (preserveViewportY === null || preserveViewportY === undefined) return;
// The anchor is a row index into the buffer it was captured from. Now that
// this runs a parse later instead of synchronously, a session switch can land
// in between: selectSession() resets the terminal and chunk-loads the new
// session's scrollback, and scrolling THAT buffer to a row that meant
// something in the previous one is not a restore, it is a jump to an
// arbitrary place. Both checks cover one half of that window.
if (sessionId !== undefined && sessionId !== this.activeSessionId) return;
if (this._isLoadingBuffer) return;
if (typeof this.terminal?.scrollToLine !== 'function') return;
if (this.terminal.buffer?.active?.viewportY === preserveViewportY) return;
this.terminal.scrollToLine(preserveViewportY);
},
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
@@ -3578,6 +3601,8 @@ Object.assign(CodemanApp.prototype, {
// scroll-to-bottom below, where it protects against a mid-flush race.
const preserveViewportY =
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
// Which buffer the anchor belongs to, checked again when the write parses.
const flushSessionId = this.activeSessionId;
const writeChunk = joined.slice(0, MAX_FRAME_BYTES);
if (_joinedLen > MAX_FRAME_BYTES) {
@@ -3592,6 +3617,16 @@ Object.assign(CodemanApp.prototype, {
this.terminal.write(writeChunk, () => {
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
// Restore INSIDE the callback (#358). xterm parses asynchronously, so
// the moment write() returns the buffer has not moved yet: the old
// restore ran here, found viewportY still equal to the anchor, and did
// nothing at all — then the parse landed and a cursor-addressed Codex
// redraw dragged the viewport to the live bottom with nothing left to
// pull it back. The callback is xterm's own "this chunk is parsed"
// signal, which is the earliest point the anchor can actually be
// reasserted. (The synchronous version passed its regression test only
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
});
} catch (err) {
@@ -3599,13 +3634,6 @@ Object.assign(CodemanApp.prototype, {
this._terminalWriteInFlightBytes = 0;
throw err;
}
if (
preserveViewportY !== null &&
this.terminal.buffer?.active?.viewportY !== preserveViewportY &&
typeof this.terminal.scrollToLine === 'function'
) {
this.terminal.scrollToLine(preserveViewportY);
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred)
@@ -3617,7 +3645,13 @@ Object.assign(CodemanApp.prototype, {
// Give manual scroll-up gestures a short grace window so high-frequency
// Codex status ticks do not snap the viewport back while the user is
// trying to inspect earlier output.
if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
//
// A live anchor wins outright. The two flags are captured at different
// moments (_wasAtBottomBeforeWrite at the frame's first batchTerminalWrite,
// the anchor at flush time), so a scroll-up in between leaves both set; now
// that the anchor is reasserted after the parse, running both would jump to
// the bottom and then back one frame later instead of simply staying put.
if (preserveViewportY === null && this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) {
this.terminal.scrollToBottom();
}