mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
Merge origin/master into feat/remote-host-wake
Resolves CLAUDE.md count tables (route counts recounted on the merged tree: 235 handlers, sessions 37) and keeps both the host-wake and the reboot-restore banner in index.html. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
+108
-6
@@ -958,6 +958,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);
|
||||
@@ -1910,6 +1914,53 @@ class CodemanApp {
|
||||
this._onSessionClearTerminal(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a buffer load that just fetched `payload` must end.
|
||||
*
|
||||
* A tmux pane capture is a point-in-time frame, so nothing that reached the
|
||||
* browser after the response headers can already be in it. Such a load
|
||||
* replays exactly that tail; discarding it drops the CLI's output for the
|
||||
* rest of the load window, and its next partial redraw then lands on a frame
|
||||
* the terminal never received.
|
||||
*
|
||||
* A `history` payload is the server's byte buffer alone: the direct-PTY
|
||||
* fallback, or a mux pane whose capture came back empty. The route reads
|
||||
* that buffer in the same synchronous tick it takes the capture, so it is
|
||||
* current up to the route's own read and no further, which is the same
|
||||
* exposure. It deliberately keeps the pre-existing discard all the same:
|
||||
* both cases are rare, neither has been measured, and a duplicated Ink
|
||||
* redraw is more visible than a few milliseconds of missing output.
|
||||
* `capturedFromMux` below is the one line to widen if either turns out to
|
||||
* matter.
|
||||
*
|
||||
* `headersReceivedAt` is the caller's own `performance.now()` reading from
|
||||
* the moment the response arrived, compared only against other client-side
|
||||
* readings, so there is no clock skew to worry about.
|
||||
*
|
||||
* What this cutoff does NOT cover, and there are two contributors. The
|
||||
* server appends output to the byte buffer and emits it in the same tick,
|
||||
* but BROADCASTS on a batch timer (8ms over WebSocket, 16 to 50ms over SSE),
|
||||
* and the terminal route runs synchronously from `capture-pane` to its
|
||||
* return, so a batch already pending when the capture ran leaves the server
|
||||
* after the reply, arrives after `headersReceivedAt`, and is replayed
|
||||
* although the capture holds it. Separately, `captureActivePaneBuffer` is
|
||||
* `execSync`, which blocks the event loop for the whole capture: anything
|
||||
* tmux had already painted into the pane that the server had not yet read
|
||||
* from the attach PTY is in the capture too, is broadcast only after the
|
||||
* reply, and replays the same way. The duplicate is one batch interval plus
|
||||
* one capture wide, against a recovery window that spans the whole chunked
|
||||
* write. Closing it belongs on the server: flush that session's pending
|
||||
* batch before taking the capture.
|
||||
*
|
||||
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
|
||||
* @param {number} headersReceivedAt - When that response reached this client.
|
||||
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
|
||||
*/
|
||||
_bufferLoadFinishOpts(payload, headersReceivedAt) {
|
||||
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
|
||||
return { flushQueued: capturedFromMux, since: headersReceivedAt };
|
||||
}
|
||||
|
||||
_onSessionTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
|
||||
@@ -1919,7 +1970,7 @@ class CodemanApp {
|
||||
// jump over the cap. Dropped data is recovered from the canonical buffer.
|
||||
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this.flickerFilterBuffer?.length || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
|
||||
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
|
||||
+ (this._terminalWriteInFlightBytes || 0);
|
||||
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
|
||||
// Schedule a self-recovery once the
|
||||
@@ -2502,9 +2553,11 @@ class CodemanApp {
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
@@ -2520,7 +2573,12 @@ class CodemanApp {
|
||||
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
|
||||
this.terminal.clear();
|
||||
this.terminal.reset();
|
||||
await this.chunkedTerminalWrite(data.terminalBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(data, headersReceivedAt)
|
||||
);
|
||||
// A tail fetch can be partial, and the banner would otherwise keep
|
||||
// describing the pre-refresh buffer (#258).
|
||||
this._setHistoryTruncation(sessionId, data);
|
||||
@@ -2530,6 +2588,10 @@ class CodemanApp {
|
||||
});
|
||||
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
|
||||
else this.terminal.scrollToLine(target);
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above.
|
||||
this._syncStickyScrollBaseline();
|
||||
// Re-position local echo overlay at new prompt location
|
||||
this._localEchoOverlay?.rerender();
|
||||
// Resize PTY to match actual browser dimensions (critical for OpenCode
|
||||
@@ -2556,6 +2618,7 @@ class CodemanApp {
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
|
||||
this.terminal.clear();
|
||||
@@ -2565,7 +2628,12 @@ class CodemanApp {
|
||||
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
|
||||
const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, '');
|
||||
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
|
||||
await this.chunkedTerminalWrite(cleanBuffer);
|
||||
await this.chunkedTerminalWrite(
|
||||
cleanBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
undefined,
|
||||
this._bufferLoadFinishOpts(termData, headersReceivedAt)
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget resize — don't block on it
|
||||
@@ -3763,6 +3831,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');
|
||||
@@ -5856,7 +5930,12 @@ class CodemanApp {
|
||||
parsedAt,
|
||||
bufferLength: parsedBufferLength,
|
||||
completed,
|
||||
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
|
||||
} = await this.chunkedTerminalWrite(
|
||||
buffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
sessionId,
|
||||
this._bufferLoadFinishOpts(payload, headersReceivedAt)
|
||||
);
|
||||
timing.resetAndParseMs = parsedAt - replayStartedAt;
|
||||
if (!completed || this.activeSessionId !== sessionId) return;
|
||||
// Keep shell tab restores bounded too. A user-triggered full-history pull
|
||||
@@ -5874,6 +5953,12 @@ class CodemanApp {
|
||||
const delta = parsedBufferLength - rowsBefore;
|
||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||
else this.terminal.scrollToTop();
|
||||
// The load's own replay sampled the sticky-scroll baseline while the
|
||||
// terminal sat at the bottom of a just-rewritten buffer, so the next
|
||||
// flush would scroll back down and undo the restore above. This path is
|
||||
// reached only from a scroll-up gesture, so being dragged down is the
|
||||
// exact opposite of what the user asked for.
|
||||
this._syncStickyScrollBaseline();
|
||||
timing.totalMs = performance.now() - requestStartedAt;
|
||||
this._recordTerminalLoadTiming(timing);
|
||||
} catch {
|
||||
@@ -6318,6 +6403,15 @@ class CodemanApp {
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
// point-in-time frame, so nothing that reached the browser after the
|
||||
// response headers can already be in it. Replay exactly that tail;
|
||||
// discarding it drops the CLI's output for the rest of the load window,
|
||||
// and its next partial redraw then lands on a frame the terminal never
|
||||
// received. `since` keeps the pre-capture events dropped, because the
|
||||
// capture does hold those and replaying them would duplicate output.
|
||||
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
|
||||
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
|
||||
|
||||
let freshResetAndParseMs = 0;
|
||||
@@ -6344,7 +6438,8 @@ class CodemanApp {
|
||||
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
|
||||
data.terminalBuffer,
|
||||
TERMINAL_CHUNK_SIZE,
|
||||
bufferLoadOwner
|
||||
bufferLoadOwner,
|
||||
finishOpts
|
||||
);
|
||||
freshResetAndParseMs = freshParsedAt - replayStartedAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
@@ -6394,7 +6489,14 @@ class CodemanApp {
|
||||
// COD-144: when the load painted nothing, FLUSH the queued events instead of
|
||||
// discarding — a new session's prompt arrives only as a queued SSE event.
|
||||
if (this._isLoadingBuffer) {
|
||||
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
|
||||
// Only reached when the write was skipped. COD-144 lives here: a new
|
||||
// session's first prompt exists only as a queued event that predates the
|
||||
// response, so an empty paint replays its queue WHOLE rather than from
|
||||
// the header timestamp.
|
||||
this._finishBufferLoad(
|
||||
bufferLoadOwner,
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
'Ultracode Agents': 'Ultracode 智能体',
|
||||
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
|
||||
'Approvals Inbox': '审批收件箱',
|
||||
'Auto-name Sessions': '自动命名会话',
|
||||
Approvals: '审批',
|
||||
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
|
||||
'No pending approvals': '没有待处理的审批',
|
||||
|
||||
@@ -225,6 +225,24 @@
|
||||
<button class="offline-banner-retry" id="hostWakeBannerAction" onclick="app.hostWakeAction()">Wake</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">
|
||||
@@ -2059,6 +2077,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsAutoNameSessionsItem" data-search="auto name session title first prompt tab rename">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Auto-name Sessions <span class="set-tag">synced</span></span>
|
||||
<span class="set-row-desc">Title a new tab after its first prompt, keeping the case prefix. Renamed tabs are never touched.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
|
||||
@@ -3580,6 +3605,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="host-wake-ui.js"></script>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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', {});
|
||||
},
|
||||
});
|
||||
@@ -408,6 +408,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// header), so the row is hidden elsewhere rather than offering a toggle that
|
||||
// changes nothing. Default ON — only an explicit false turns it off.
|
||||
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
|
||||
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
|
||||
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
|
||||
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
|
||||
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
|
||||
@@ -2111,6 +2113,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked,
|
||||
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
|
||||
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
|
||||
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
|
||||
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
|
||||
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
|
||||
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15243,6 +15247,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;
|
||||
}
|
||||
|
||||
|
||||
+135
-29
@@ -3159,6 +3159,38 @@ Object.assign(CodemanApp.prototype, {
|
||||
return buffer.viewportY >= buffer.baseY - 2;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-take the sticky-scroll baseline from where the viewport now sits.
|
||||
*
|
||||
* `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before it queues
|
||||
* data, and `flushPendingWrites` scrolls to the bottom off that sample. A
|
||||
* buffer load that replays its queue samples at the worst possible moment:
|
||||
* `_finishBufferLoad` runs inside `chunkedTerminalWrite`, before its promise
|
||||
* resolves, with the terminal freshly reset and rewritten, so the sample is
|
||||
* always true. A caller that then restores the reader's position would have
|
||||
* that restore undone by the next flush.
|
||||
*
|
||||
* `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory` restore a position
|
||||
* and both call this, so their baseline describes the position they chose.
|
||||
*
|
||||
* The other two load paths do not call it, for different reasons.
|
||||
* `_onSessionClearTerminal` resets and rewrites with no scroll afterwards,
|
||||
* so the sampled true is already the truth there. `selectSession` does NOT
|
||||
* end at the bottom, whatever its `scrollToBottom()` after the write
|
||||
* suggests: it ends at `scrollToLastNonEmptyLine()`, which targets
|
||||
* `lastNonEmptyLine - rows + 2` and therefore parks ABOVE `baseY` whenever
|
||||
* the replayed frame keeps trailing blank rows, which a full capture does on
|
||||
* purpose. Its baseline is a stale true. What decides whether that matters
|
||||
* is the sticky snap in `flushPendingWrites`, and since de864e7d that snap
|
||||
* fires only when the flush found the viewport already at the bottom
|
||||
* (`preserveViewportY === null`), which a parked selectSession viewport is
|
||||
* not. Do not read the absent call here as a claim that selectSession lands
|
||||
* at the bottom.
|
||||
*/
|
||||
_syncStickyScrollBaseline() {
|
||||
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
|
||||
},
|
||||
|
||||
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
|
||||
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
|
||||
// lands back at the bottom clears the suppression immediately.
|
||||
@@ -3295,7 +3327,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to prevent interleaving historical buffer data with live SSE data.
|
||||
// This is critical: interleaving causes cursor position chaos with Ink redraws.
|
||||
if (this._isLoadingBuffer) {
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
|
||||
// Each entry records when it arrived. A flush of a tmux-capture load
|
||||
// replays only what arrived after the capture; without the timestamp it
|
||||
// would have to replay the whole queue, duplicating the events the
|
||||
// capture already contains. See _finishBufferLoad's `since`.
|
||||
if (this._loadBufferQueue) this._loadBufferQueue.push({ at: performance.now(), data });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3543,6 +3579,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 +3637,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 +3653,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 +3670,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 +3681,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();
|
||||
}
|
||||
|
||||
@@ -3775,9 +3845,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
|
||||
* @param {string} buffer - The full terminal buffer to write
|
||||
* @param {number} chunkSize - Size of each chunk (default 32KB)
|
||||
* @param {string} [loadOwner] - Load token to finish under
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [finishOpts] - Passed to
|
||||
* `_finishBufferLoad`. This method ends the load for every non-empty buffer,
|
||||
* so a caller that wants the queue replayed has to say so HERE; the call in
|
||||
* `selectSession` only runs when the write was skipped entirely.
|
||||
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
|
||||
*/
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
|
||||
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, finishOpts) {
|
||||
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
|
||||
// older writes abort instead of continuing to push stale data into the terminal.
|
||||
const writeGen = ++this._chunkedWriteGen;
|
||||
@@ -3790,7 +3865,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
completed,
|
||||
});
|
||||
if (!buffer || buffer.length === 0) {
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
resolve(parseSnapshot());
|
||||
return;
|
||||
}
|
||||
@@ -3804,7 +3879,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
|
||||
// The write is now ordered in xterm's queue. Release live output before
|
||||
// parsing completes; subsequent writes stay behind it without being lost.
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3835,7 +3910,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
resolve(result);
|
||||
});
|
||||
this._finishBufferLoad(bufferLoadOwner);
|
||||
this._finishBufferLoad(bufferLoadOwner, finishOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3849,15 +3924,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Open a buffer load: live terminal events are queued from here until
|
||||
* `_finishBufferLoad` decides what to do with them. Returns the load token the
|
||||
* finish call must present; a stale token makes that call a no-op.
|
||||
*
|
||||
* @param {string} [owner] Reuse an existing token to re-enter the same load
|
||||
* (see below); omit it to start a new one.
|
||||
* @returns {string} The load token.
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
|
||||
// opens it again under the SAME owner when it starts writing. Resetting the
|
||||
// queue on that second call would throw away everything that arrived during
|
||||
// the fetch, which on the capture path is output no buffer holds. Re-entering
|
||||
// one load keeps its queue; a genuinely new load still starts empty.
|
||||
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
if (!reentering) this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
/**
|
||||
* Complete a buffer load: unblock live SSE writes.
|
||||
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
|
||||
*
|
||||
* By default queued SSE events are DISCARDED, not flushed. For an established
|
||||
* session the loaded buffer from the API is the source of truth up to the
|
||||
* response timestamp; SSE events queued during the fetch+write overlap already
|
||||
* appear in that buffer, so flushing them writes duplicate data (especially Ink
|
||||
* cursor-up redraws), corrupting the terminal display.
|
||||
* session whose buffer came from the server's accumulated byte history, that
|
||||
* history is the source of truth up to the response timestamp; SSE events
|
||||
* queued during the fetch+write overlap already appear in it, so flushing
|
||||
* them writes duplicate data (especially Ink cursor-up redraws), corrupting
|
||||
* the terminal display.
|
||||
*
|
||||
* A tmux PANE CAPTURE is the exception, and the reason `since` exists. A
|
||||
* capture is a point-in-time frame taken part-way through the fetch, so it is
|
||||
* the source of truth only up to CAPTURE time — not up to the response. Every
|
||||
* event that arrives between the capture and the end of the chunked write is
|
||||
* queued and, under a plain discard, lost outright: nothing re-fetches, and
|
||||
* the CLI's next partial redraw lands on a frame the terminal never received.
|
||||
* The caller passes the response's own arrival time as `since` so exactly
|
||||
* that tail is replayed and the pre-capture events stay dropped.
|
||||
*
|
||||
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
|
||||
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
|
||||
@@ -3871,17 +3980,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* After unblocking, new SSE/WS events deliver subsequent output normally.
|
||||
*
|
||||
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
|
||||
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
|
||||
* @param {{ flushQueued?: boolean, since?: number }} [opts] When `flushQueued`
|
||||
* is true, replay queued events whose arrival timestamp is at or after
|
||||
* `since` (default 0, meaning the whole queue).
|
||||
*/
|
||||
_beginBufferLoad(owner) {
|
||||
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
|
||||
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
|
||||
this._bufferLoadOwner = loadOwner;
|
||||
this._isLoadingBuffer = true;
|
||||
this._loadBufferQueue = [];
|
||||
return loadOwner;
|
||||
},
|
||||
|
||||
_finishBufferLoad(owner, opts) {
|
||||
if (owner !== undefined && this._bufferLoadOwner !== owner) {
|
||||
return false;
|
||||
@@ -3892,9 +3994,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._bufferLoadOwner = null;
|
||||
// COD-144: replay (rather than discard) queued live events when the load
|
||||
// painted nothing — the queued prompt is the only content a new session has.
|
||||
// A tmux-capture load replays too, but only the tail: `since` cuts the queue
|
||||
// at the moment the capture stopped being able to contain what arrived.
|
||||
if (opts?.flushQueued && queued && queued.length) {
|
||||
for (const data of queued) {
|
||||
this.batchTerminalWrite(data);
|
||||
const since = typeof opts.since === 'number' ? opts.since : 0;
|
||||
for (const entry of queued) {
|
||||
if (entry.at < since) continue;
|
||||
this.batchTerminalWrite(entry.data);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user