@oar-sh/oar 0.9.12 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Pure pieces of the task console and the task rows: the pass/fail chip, the
|
|
2
|
-
// stall hint, the ✓/✘ of an ended task, the console's line rows
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// stall hint, the ✓/✘ of an ended task, the console's line rows, the search
|
|
3
|
+
// for the first failure, the "output stops here" marker of a command piped
|
|
4
|
+
// into tail, and the texts for a task without output (the relay's answer
|
|
5
|
+
// uses them too). DOM nodes are built with createElement and textContent
|
|
6
|
+
// only: every string here comes from a worker's output.
|
|
5
7
|
|
|
6
8
|
import { ansiToSegments, stallMinutes } from './task-output-text.mjs';
|
|
7
9
|
|
|
@@ -58,19 +60,26 @@ export function stallHint(lastOutputAt, thresholdMinutes, now = Date.now()) {
|
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* ✓, ✘, ■ or ? for an ended task, from its status and exit code. `unknown`
|
|
65
|
+
* (a task whose worker did not come back after a relay restart) is neither a
|
|
66
|
+
* success nor a failure: a neutral ?, not a green ✓.
|
|
67
|
+
*/
|
|
62
68
|
export function endedOutcome(task) {
|
|
63
69
|
const status = String(task?.status || '').trim().toLowerCase();
|
|
64
70
|
const exitCode = task?.exitCode;
|
|
65
71
|
const hasExit = exitCode !== null && exitCode !== undefined && exitCode !== '' && Number.isFinite(Number(exitCode));
|
|
66
72
|
const failed = status === 'failed' || (hasExit && Number(exitCode) !== 0);
|
|
67
73
|
const stopped = status === 'stopped' || status === 'killed' || status === 'cancelled';
|
|
74
|
+
const unknown = !failed && !stopped && !hasExit && status === 'unknown';
|
|
68
75
|
const label = failed
|
|
69
76
|
? (hasExit ? `failed, exit code ${Number(exitCode)}` : 'failed')
|
|
70
|
-
: stopped ? 'stopped'
|
|
77
|
+
: stopped ? 'stopped'
|
|
78
|
+
: unknown ? 'ended, result unknown'
|
|
79
|
+
: status === 'completed' || (hasExit && Number(exitCode) === 0) ? 'completed' : 'ended';
|
|
71
80
|
return {
|
|
72
|
-
icon: failed ? '✘' : stopped ? '■' : '✓',
|
|
73
|
-
ok: !failed && !stopped,
|
|
81
|
+
icon: failed ? '✘' : stopped ? '■' : unknown ? '?' : '✓',
|
|
82
|
+
ok: !failed && !stopped && !unknown,
|
|
74
83
|
failed,
|
|
75
84
|
label,
|
|
76
85
|
};
|
|
@@ -98,12 +107,165 @@ export function taskOutputUnavailable(task) {
|
|
|
98
107
|
return String(task?.outputKind || '').trim() === 'none';
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Why a task has no output to show: the console's `none` answer (the relay's
|
|
112
|
+
* output route imports these) and the row's hover title, in one wording.
|
|
113
|
+
*/
|
|
114
|
+
export const TASK_OUTPUT_NONE_MESSAGES = Object.freeze({
|
|
115
|
+
provider: 'Output is not available for this provider.',
|
|
116
|
+
subagent: 'Sub-agents have no shell output; open the sub-agent view.',
|
|
117
|
+
noOutputFile: 'This session did not report an output file for this task. Sessions started before the task console need a restart to show output.',
|
|
118
|
+
noPath: 'Output is not available for this task.',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Commands piped into tail ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
// A command line as its pipeline stages: `{ words, piped }` per simple
|
|
124
|
+
// command, `piped` when a `|` (or `|&`) feeds it. Quotes and backslashes are
|
|
125
|
+
// honoured, `||`, `&&`, `;`, `&` and newlines end a stage without a pipe;
|
|
126
|
+
// `2>&1` and `&>` are redirections, not separators, and a `#` that starts a
|
|
127
|
+
// word comments out the rest of its line. Not a shell: enough to tell where
|
|
128
|
+
// a `| tail` sits.
|
|
129
|
+
function pipelineStages(command) {
|
|
130
|
+
const text = String(command ?? '');
|
|
131
|
+
const stages = [];
|
|
132
|
+
let words = [];
|
|
133
|
+
let word = '';
|
|
134
|
+
let inWord = false;
|
|
135
|
+
let quote = '';
|
|
136
|
+
let piped = false;
|
|
137
|
+
const endWord = () => {
|
|
138
|
+
if (inWord) words.push(word);
|
|
139
|
+
word = '';
|
|
140
|
+
inWord = false;
|
|
141
|
+
};
|
|
142
|
+
const endStage = (nextPiped) => {
|
|
143
|
+
endWord();
|
|
144
|
+
if (words.length) stages.push({ words, piped });
|
|
145
|
+
words = [];
|
|
146
|
+
piped = nextPiped;
|
|
147
|
+
};
|
|
148
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
149
|
+
const char = text[index];
|
|
150
|
+
if (quote) {
|
|
151
|
+
if (char === quote) quote = '';
|
|
152
|
+
else if (char === '\\' && quote === '"' && index + 1 < text.length) word += text[++index];
|
|
153
|
+
else word += char;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (char === '\\') {
|
|
157
|
+
word += text[index + 1] ?? '';
|
|
158
|
+
inWord = true;
|
|
159
|
+
index += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (char === '#' && !inWord) {
|
|
163
|
+
while (index + 1 < text.length && text[index + 1] !== '\n') index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (char === "'" || char === '"') {
|
|
167
|
+
quote = char;
|
|
168
|
+
inWord = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (char === '|') {
|
|
172
|
+
if (text[index + 1] === '|') {
|
|
173
|
+
index += 1;
|
|
174
|
+
endStage(false);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (text[index + 1] === '&') index += 1;
|
|
178
|
+
endStage(true);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (char === '&') {
|
|
182
|
+
const redirect = text[index - 1] === '>' || text[index - 1] === '<' || text[index + 1] === '>';
|
|
183
|
+
if (!redirect) {
|
|
184
|
+
if (text[index + 1] === '&') index += 1;
|
|
185
|
+
endStage(false);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (char === ';' || char === '\n' || char === '(' || char === ')') {
|
|
190
|
+
endStage(false);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (/\s/.test(char)) {
|
|
194
|
+
endWord();
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
word += char;
|
|
198
|
+
inWord = true;
|
|
199
|
+
}
|
|
200
|
+
endStage(false);
|
|
201
|
+
return stages;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// tail -f / -F / --follow streams; so does tail from a line (`-n +2`, `+2`).
|
|
205
|
+
function tailStreams(args) {
|
|
206
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
207
|
+
const arg = args[index];
|
|
208
|
+
if (/^--follow\b/.test(arg) || /^-[A-Za-z0-9]*[fF]/.test(arg)) return true;
|
|
209
|
+
if (/^\+\d/.test(arg) || /^-[nc]\+\d/.test(arg) || /^--(?:lines|bytes)=\+/.test(arg)) return true;
|
|
210
|
+
if ((arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') && /^\+\d/.test(args[index + 1] || '')) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 'tail' when the command line pipes into a tail that holds the output back
|
|
217
|
+
* until the command ends (`npm test 2>&1 | tail -40`), else ''. `tail -f`
|
|
218
|
+
* and `tail -n +N` stream and do not count; neither does a `|` inside quotes
|
|
219
|
+
* or a comment. `head` passes its lines on as they come (and ends the
|
|
220
|
+
* command early), so it holds nothing back.
|
|
221
|
+
*/
|
|
222
|
+
export function pipeHoldProgram(command) {
|
|
223
|
+
for (const { words, piped } of pipelineStages(command)) {
|
|
224
|
+
if (!piped) continue;
|
|
225
|
+
let at = 0;
|
|
226
|
+
while (at < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at])) at += 1;
|
|
227
|
+
const program = String(words[at] || '').split('/').pop();
|
|
228
|
+
if (program === 'tail' && !tailStreams(words.slice(at + 1))) return 'tail';
|
|
229
|
+
}
|
|
230
|
+
return '';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 'tail' when a task's command line (its `command`, else its description) pipes into a holding tail. */
|
|
234
|
+
export function taskPipeHold(task) {
|
|
235
|
+
return pipeHoldProgram(task?.command) || pipeHoldProgram(task?.description);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What the console and the quiet row say about a command piped into `program`. */
|
|
239
|
+
export function pipeHoldNotice(program) {
|
|
240
|
+
if (!program) return null;
|
|
241
|
+
const hint = 'Logging to a file instead (> run.log 2>&1) and following that file shows it live.';
|
|
242
|
+
return {
|
|
243
|
+
text: `Output stops here: this command pipes into ${program}, so the rest arrives when it ends.`,
|
|
244
|
+
hint,
|
|
245
|
+
title: `This command pipes into ${program}, so its output arrives when it ends. ${hint}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether the console shows the "output stops here" marker: a live task
|
|
251
|
+
* whose command pipes into tail, as long as nothing came in since
|
|
252
|
+
* the console opened (`openedAt` the end offset of the first read).
|
|
253
|
+
*/
|
|
254
|
+
export function showPipeHoldMarker({ program, ended, kind, openedAt, nextOffset }) {
|
|
255
|
+
if (!program || ended || kind === 'none' || !kind) return false;
|
|
256
|
+
return !Number.isFinite(Number(openedAt)) || Number(nextOffset) <= Number(openedAt);
|
|
257
|
+
}
|
|
258
|
+
|
|
101
259
|
// ─── Line rows ───────────────────────────────────────────────────────────────
|
|
102
260
|
|
|
261
|
+
// Inverse text arrives with its colours swapped (task-output-text.mjs); a
|
|
262
|
+
// side left unset takes the console's own colour of the other side.
|
|
103
263
|
function segmentClass(segment) {
|
|
104
264
|
const classes = [];
|
|
105
265
|
if (Number.isInteger(segment.fg)) classes.push(`tc-fg-${segment.fg}`);
|
|
266
|
+
else if (segment.inverse) classes.push('tc-fg-inverse');
|
|
106
267
|
if (Number.isInteger(segment.bg)) classes.push(`tc-bg-${segment.bg}`);
|
|
268
|
+
else if (segment.inverse) classes.push('tc-bg-inverse');
|
|
107
269
|
if (segment.bold) classes.push('tc-bold');
|
|
108
270
|
if (segment.dim) classes.push('tc-dim');
|
|
109
271
|
if (segment.italic) classes.push('tc-italic');
|
|
@@ -188,6 +350,8 @@ export function consoleMetaParts({ task, elapsed, ended, status, exitCode, kind
|
|
|
188
350
|
parts.push(type === 'monitor' ? 'Monitor' : type === 'local_bash' ? 'Shell' : (type || 'Task'));
|
|
189
351
|
if (elapsed) parts.push(elapsed);
|
|
190
352
|
if (ended) parts.push(endedOutcome({ status, exitCode }).label);
|
|
353
|
+
// Held since a relay restart until its worker reports.
|
|
354
|
+
else if (status === 'held') parts.push('waiting for its worker after the relay restart');
|
|
191
355
|
else parts.push('running');
|
|
192
356
|
if (kind === 'snapshot') parts.push('last 8 KB, read again every 3 s');
|
|
193
357
|
if (kind === 'spool' && task?.mirrored) parts.push("output mirrored from the runtime's recent window");
|
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// as `task_output_chunk`, which the relay sends only to pages watching the
|
|
5
5
|
// task (task-console-watch.mjs). A chunk that does not continue where the
|
|
6
6
|
// view stands (a dropped chunk, a rewritten file) reads the tail again.
|
|
7
|
-
// "Load earlier" pages back up to 5 MB
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// "Load earlier" pages back up to 5 MB (from the top of the output, or the
|
|
8
|
+
// header while following the end). A provider that can only give a snapshot
|
|
9
|
+
// is read again every 3 s instead. A command piped into tail gets a
|
|
10
|
+
// marker where its output stops until it ends. On wider screens the console
|
|
11
|
+
// ends above the task's row in the composer panel, so the row's chip stays
|
|
12
|
+
// in view; full-screen on phones. Escape, ×, a click outside and the system
|
|
13
|
+
// back close it.
|
|
10
14
|
|
|
11
15
|
import { apiFetchOrThrow } from './api-client.js';
|
|
12
16
|
import { currentConvId } from './store.js';
|
|
@@ -19,22 +23,32 @@ import {
|
|
|
19
23
|
getBackgroundTaskHealth,
|
|
20
24
|
getTaskStallMinutes,
|
|
21
25
|
isBackgroundTaskStopping,
|
|
26
|
+
onBackgroundTasksPanelRendered,
|
|
22
27
|
stopBackgroundTask,
|
|
23
28
|
subscribeBackgroundTasks,
|
|
24
29
|
} from './background-tasks-view.mjs';
|
|
25
30
|
import {
|
|
31
|
+
TASK_OUTPUT_NONE_MESSAGES,
|
|
26
32
|
buildLineNode,
|
|
27
33
|
chunkAction,
|
|
28
34
|
chunkEndOffset,
|
|
29
35
|
consoleMetaParts,
|
|
30
36
|
findFirstFailureIndex,
|
|
31
37
|
loadEarlierState,
|
|
38
|
+
pipeHoldNotice,
|
|
32
39
|
runnerChip,
|
|
40
|
+
showPipeHoldMarker,
|
|
33
41
|
stallHint,
|
|
42
|
+
taskPipeHold,
|
|
34
43
|
} from './task-console-render.mjs';
|
|
35
44
|
|
|
36
45
|
const TAIL_LINES = 2000;
|
|
37
46
|
const SNAPSHOT_POLL_MS = 3000;
|
|
47
|
+
// Wider than a phone, the console stops above the task's row when the row's
|
|
48
|
+
// top is at least this far down the window (the console keeps about 320 px
|
|
49
|
+
// of it); otherwise it covers the page as before.
|
|
50
|
+
const PHONE_MAX_WIDTH = 680;
|
|
51
|
+
const ABOVE_ROW_MIN_TOP = 360;
|
|
38
52
|
|
|
39
53
|
const tc = {
|
|
40
54
|
open: false,
|
|
@@ -56,6 +70,8 @@ const tc = {
|
|
|
56
70
|
partialNode: null,
|
|
57
71
|
startOffset: 0,
|
|
58
72
|
nextOffset: 0,
|
|
73
|
+
// Where the output stood at the first read: past it, output came in.
|
|
74
|
+
openedAt: null,
|
|
59
75
|
size: 0,
|
|
60
76
|
hasMoreBefore: false,
|
|
61
77
|
loadedBytes: 0,
|
|
@@ -86,7 +102,9 @@ function elements() {
|
|
|
86
102
|
stop: document.getElementById('task-console-stop'),
|
|
87
103
|
scroll: document.getElementById('task-console-scroll'),
|
|
88
104
|
earlier: document.getElementById('task-console-earlier'),
|
|
105
|
+
earlierTop: document.getElementById('task-console-earlier-top'),
|
|
89
106
|
body: document.getElementById('task-console-body'),
|
|
107
|
+
hold: document.getElementById('task-console-hold'),
|
|
90
108
|
status: document.getElementById('task-console-status'),
|
|
91
109
|
jump: document.getElementById('task-console-jump'),
|
|
92
110
|
};
|
|
@@ -139,11 +157,13 @@ function renderHeader() {
|
|
|
139
157
|
}).join(' · ');
|
|
140
158
|
}
|
|
141
159
|
const health = getBackgroundTaskHealth(tc.conversationId, tc.taskId) || tc.health;
|
|
160
|
+
const holdNotice = pipeHoldNotice(taskPipeHold(task));
|
|
142
161
|
if (stall) {
|
|
143
162
|
const hint = ended ? null : stallHint(health?.lastOutputAt, getTaskStallMinutes(tc.conversationId));
|
|
144
163
|
stall.hidden = !hint;
|
|
145
164
|
stall.textContent = hint ? hint.text : '';
|
|
146
165
|
stall.className = hint?.warn ? 'tc-stall tc-stall-warn' : 'tc-stall';
|
|
166
|
+
stall.title = holdNotice ? holdNotice.title : '';
|
|
147
167
|
}
|
|
148
168
|
const chipInfo = runnerChip(health);
|
|
149
169
|
if (chip) {
|
|
@@ -160,6 +180,56 @@ function renderHeader() {
|
|
|
160
180
|
stop.disabled = stopping;
|
|
161
181
|
stop.textContent = stopping ? 'Stopping…' : 'Stop';
|
|
162
182
|
}
|
|
183
|
+
renderHold();
|
|
184
|
+
syncLayout();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The "output stops here" marker under the last line, while nothing new came. */
|
|
188
|
+
function renderHold() {
|
|
189
|
+
const { hold } = elements();
|
|
190
|
+
if (!hold) return;
|
|
191
|
+
const found = currentTask();
|
|
192
|
+
const program = taskPipeHold(found?.task || tc.answerTask);
|
|
193
|
+
const notice = pipeHoldNotice(program);
|
|
194
|
+
const show = !!notice && !tc.loading && showPipeHoldMarker({
|
|
195
|
+
program,
|
|
196
|
+
ended: tc.ended || !!found?.ended,
|
|
197
|
+
kind: tc.kind,
|
|
198
|
+
openedAt: tc.openedAt,
|
|
199
|
+
nextOffset: tc.nextOffset,
|
|
200
|
+
});
|
|
201
|
+
hold.hidden = !show;
|
|
202
|
+
if (!show) return;
|
|
203
|
+
hold.querySelector('.tc-hold-text').textContent = notice.text;
|
|
204
|
+
hold.querySelector('.tc-hold-hint').textContent = notice.hint;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Wider than a phone, the modal ends above the task's row in the composer
|
|
209
|
+
* panel, so the row (its ✓/✘ chip, clock and Stop) stays in view and live.
|
|
210
|
+
* Without the row on screen, or with too little room above it, the console
|
|
211
|
+
* covers the page as before.
|
|
212
|
+
*/
|
|
213
|
+
function syncLayout() {
|
|
214
|
+
const { modal } = elements();
|
|
215
|
+
if (!modal) return;
|
|
216
|
+
let bottom = 0;
|
|
217
|
+
if (tc.open && window.innerWidth > PHONE_MAX_WIDTH) {
|
|
218
|
+
const panel = document.getElementById('background-tasks-panel');
|
|
219
|
+
const row = panel && !panel.hidden && panel.hasAttribute('open')
|
|
220
|
+
? panel.querySelector(`.bg-task-row[data-task-id="${CSS.escape(tc.taskId)}"]`)
|
|
221
|
+
: null;
|
|
222
|
+
const rect = row?.getBoundingClientRect?.();
|
|
223
|
+
if (rect && rect.height > 0 && rect.top >= ABOVE_ROW_MIN_TOP && rect.top < window.innerHeight) {
|
|
224
|
+
bottom = Math.ceil(window.innerHeight - rect.top + 6);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
modal.classList.toggle('tc-above-panel', bottom > 0);
|
|
228
|
+
modal.style.bottom = bottom > 0 ? `${bottom}px` : '';
|
|
229
|
+
// Above the panel the page below stays usable: the console is no modal then.
|
|
230
|
+
const dialog = modal.querySelector('[role="dialog"]');
|
|
231
|
+
if (bottom > 0) dialog?.removeAttribute('aria-modal');
|
|
232
|
+
else dialog?.setAttribute('aria-modal', 'true');
|
|
163
233
|
}
|
|
164
234
|
|
|
165
235
|
function renderStatus() {
|
|
@@ -168,7 +238,7 @@ function renderStatus() {
|
|
|
168
238
|
const hasLines = tc.earlierLines.length || tc.folded.lines.length || tc.folded.partial;
|
|
169
239
|
let text = '';
|
|
170
240
|
if (tc.error) text = tc.error;
|
|
171
|
-
else if (tc.kind === 'none') text = tc.message ||
|
|
241
|
+
else if (tc.kind === 'none') text = tc.message || TASK_OUTPUT_NONE_MESSAGES.provider;
|
|
172
242
|
else if (tc.loading && !hasLines) text = 'Loading…';
|
|
173
243
|
else if (!hasLines && !tc.note) text = tc.ended ? 'This task wrote no output.' : 'No output yet. Lines appear as the task writes them.';
|
|
174
244
|
if (tc.note) text = text ? `${text} ${tc.note}` : tc.note;
|
|
@@ -176,13 +246,17 @@ function renderStatus() {
|
|
|
176
246
|
status.hidden = !text;
|
|
177
247
|
}
|
|
178
248
|
|
|
249
|
+
// Both "Load earlier" buttons: the one above the first line, and the
|
|
250
|
+
// header's, which is in reach while following the end.
|
|
179
251
|
function renderEarlierButton() {
|
|
180
|
-
const { earlier } = elements();
|
|
181
|
-
if (!earlier) return;
|
|
252
|
+
const { earlier, earlierTop } = elements();
|
|
182
253
|
const state = loadEarlierState({ hasMoreBefore: tc.hasMoreBefore, loadedBytes: tc.loadedBytes, loading: tc.loadingEarlier });
|
|
183
|
-
earlier
|
|
184
|
-
|
|
185
|
-
|
|
254
|
+
for (const button of [earlier, earlierTop]) {
|
|
255
|
+
if (!button) continue;
|
|
256
|
+
button.hidden = state.hidden;
|
|
257
|
+
button.disabled = state.disabled;
|
|
258
|
+
button.textContent = state.text;
|
|
259
|
+
}
|
|
186
260
|
}
|
|
187
261
|
|
|
188
262
|
// ─── Lines ───────────────────────────────────────────────────────────────────
|
|
@@ -202,6 +276,7 @@ function clearLines() {
|
|
|
202
276
|
function afterContentChange(appended) {
|
|
203
277
|
const { scroll, jump } = elements();
|
|
204
278
|
renderStatus();
|
|
279
|
+
renderHold();
|
|
205
280
|
if (!scroll) return;
|
|
206
281
|
if (tc.following) {
|
|
207
282
|
scroll.scrollTop = scroll.scrollHeight;
|
|
@@ -340,6 +415,7 @@ async function fetchTail({ before = null } = {}) {
|
|
|
340
415
|
tc.nextOffset = Number.isFinite(Number(data.endOffset)) ? Number(data.endOffset) : Number(data.size) || 0;
|
|
341
416
|
tc.size = Number(data.size) || tc.nextOffset;
|
|
342
417
|
tc.hasMoreBefore = data.hasMoreBefore === true;
|
|
418
|
+
if (tc.openedAt === null) tc.openedAt = tc.nextOffset;
|
|
343
419
|
if (typeof data.text === 'string') {
|
|
344
420
|
feed(data.text);
|
|
345
421
|
tc.loadedBytes = Math.max(0, tc.nextOffset - tc.startOffset) || data.text.length;
|
|
@@ -357,7 +433,11 @@ async function fetchTail({ before = null } = {}) {
|
|
|
357
433
|
for (const chunk of pending) applyChunk(chunk);
|
|
358
434
|
}
|
|
359
435
|
|
|
360
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Pages back. `reveal` (the header's button): the view goes to the lines
|
|
438
|
+
* just loaded, at the top, instead of staying where the reader was.
|
|
439
|
+
*/
|
|
440
|
+
async function loadEarlier({ reveal = false } = {}) {
|
|
361
441
|
if (!tc.open || tc.loadingEarlier || !tc.hasMoreBefore) return;
|
|
362
442
|
if (loadEarlierState({ hasMoreBefore: true, loadedBytes: tc.loadedBytes }).disabled) return;
|
|
363
443
|
const token = tc.fetchToken;
|
|
@@ -372,6 +452,11 @@ async function loadEarlier() {
|
|
|
372
452
|
if (Number.isFinite(start)) tc.startOffset = start;
|
|
373
453
|
tc.hasMoreBefore = data.hasMoreBefore === true;
|
|
374
454
|
prependEarlier(lines);
|
|
455
|
+
const { scroll } = elements();
|
|
456
|
+
if (reveal && scroll && lines.length) {
|
|
457
|
+
tc.following = false;
|
|
458
|
+
scroll.scrollTop = 0;
|
|
459
|
+
}
|
|
375
460
|
} catch (error) {
|
|
376
461
|
if (token === tc.fetchToken) {
|
|
377
462
|
tc.note = errorText(error);
|
|
@@ -452,6 +537,7 @@ function resetState() {
|
|
|
452
537
|
tc.loadingEarlier = false;
|
|
453
538
|
tc.startOffset = 0;
|
|
454
539
|
tc.nextOffset = 0;
|
|
540
|
+
tc.openedAt = null;
|
|
455
541
|
tc.size = 0;
|
|
456
542
|
tc.hasMoreBefore = false;
|
|
457
543
|
tc.loadedBytes = 0;
|
|
@@ -505,6 +591,8 @@ export function closeTaskConsole({ fromHistory = false } = {}) {
|
|
|
505
591
|
consoleWatch.unwatch();
|
|
506
592
|
clearLines();
|
|
507
593
|
resetState();
|
|
594
|
+
syncLayout();
|
|
595
|
+
renderHold();
|
|
508
596
|
syncSnapshotPoll();
|
|
509
597
|
syncTicker();
|
|
510
598
|
if (!fromHistory) consoleHistory?.closed();
|
|
@@ -604,13 +692,17 @@ export function initTaskConsole() {
|
|
|
604
692
|
renderHeader();
|
|
605
693
|
});
|
|
606
694
|
|
|
607
|
-
const { modal, stop, scroll, jump, earlier, failure, chip } = elements();
|
|
695
|
+
const { modal, stop, scroll, jump, earlier, earlierTop, failure, chip } = elements();
|
|
608
696
|
modal?.addEventListener('click', (event) => {
|
|
609
697
|
if (event.target === modal) closeTaskConsole();
|
|
610
698
|
});
|
|
611
699
|
modal?.querySelector('.task-console-close')?.addEventListener('click', () => closeTaskConsole());
|
|
612
700
|
stop?.addEventListener('click', () => void stopTask());
|
|
613
701
|
earlier?.addEventListener('click', () => void loadEarlier());
|
|
702
|
+
earlierTop?.addEventListener('click', () => void loadEarlier({ reveal: true }));
|
|
703
|
+
window.addEventListener('resize', () => {
|
|
704
|
+
if (tc.open) syncLayout();
|
|
705
|
+
});
|
|
614
706
|
failure?.addEventListener('click', () => jumpToFirstFailure());
|
|
615
707
|
chip?.addEventListener('click', () => jumpToFirstFailure());
|
|
616
708
|
jump?.addEventListener('click', () => {
|
|
@@ -624,7 +716,15 @@ export function initTaskConsole() {
|
|
|
624
716
|
}, { passive: true });
|
|
625
717
|
document.addEventListener('keydown', (event) => {
|
|
626
718
|
if (event.key !== 'Escape' || !tc.open) return;
|
|
719
|
+
// Above the panel, Escape in the composer (or any field outside the
|
|
720
|
+
// console) is that field's own.
|
|
721
|
+
if (modal?.classList.contains('tc-above-panel') && !modal.contains(event.target)
|
|
722
|
+
&& event.target?.closest?.('input, textarea, select, [contenteditable="true"], [contenteditable=""]')) return;
|
|
627
723
|
event.preventDefault();
|
|
628
724
|
closeTaskConsole();
|
|
629
725
|
});
|
|
726
|
+
// The panel drew its rows again (a fold, a task set): the row may have moved.
|
|
727
|
+
onBackgroundTasksPanelRendered(() => {
|
|
728
|
+
if (tc.open) syncLayout();
|
|
729
|
+
});
|
|
630
730
|
}
|
|
@@ -163,7 +163,7 @@ function clampByte(value) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
function plainStyle() {
|
|
166
|
-
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
|
|
166
|
+
return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
function applySgr(state, params) {
|
|
@@ -175,11 +175,13 @@ function applySgr(state, params) {
|
|
|
175
175
|
else if (code === 2) state.dim = true;
|
|
176
176
|
else if (code === 3) state.italic = true;
|
|
177
177
|
else if (code === 4) state.underline = true;
|
|
178
|
+
else if (code === 7) state.inverse = true;
|
|
178
179
|
else if (code === 22) {
|
|
179
180
|
state.bold = false;
|
|
180
181
|
state.dim = false;
|
|
181
182
|
} else if (code === 23) state.italic = false;
|
|
182
183
|
else if (code === 24) state.underline = false;
|
|
184
|
+
else if (code === 27) state.inverse = false;
|
|
183
185
|
else if (code >= 30 && code <= 37) state.fg = code - 30;
|
|
184
186
|
else if (code === 39) state.fg = null;
|
|
185
187
|
else if (code >= 40 && code <= 47) state.bg = code - 40;
|
|
@@ -204,8 +206,11 @@ function applySgr(state, params) {
|
|
|
204
206
|
/**
|
|
205
207
|
* One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
|
|
206
208
|
* colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
+
* Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
|
|
210
|
+
* and `inverse: true`: a null `fg` then means the default background as the
|
|
211
|
+
* text colour, a null `bg` the default text colour as the background (the
|
|
212
|
+
* filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
|
|
213
|
+
* style. Any escape that is not SGR is dropped from the text.
|
|
209
214
|
*/
|
|
210
215
|
export function ansiToSegments(lineText) {
|
|
211
216
|
const text = String(lineText ?? '');
|
|
@@ -214,13 +219,17 @@ export function ansiToSegments(lineText) {
|
|
|
214
219
|
const pushText = (value) => {
|
|
215
220
|
const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
|
|
216
221
|
if (!clean) return;
|
|
222
|
+
const fg = state.inverse ? state.bg : state.fg;
|
|
223
|
+
const bg = state.inverse ? state.fg : state.bg;
|
|
217
224
|
const last = segments[segments.length - 1];
|
|
218
|
-
if (last && last.fg ===
|
|
219
|
-
&& last.
|
|
225
|
+
if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
|
|
226
|
+
&& last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
|
|
220
227
|
last.text += clean;
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
223
|
-
|
|
230
|
+
const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
|
|
231
|
+
if (state.inverse) segment.inverse = true;
|
|
232
|
+
segments.push(segment);
|
|
224
233
|
};
|
|
225
234
|
let at = 0;
|
|
226
235
|
const pattern = new RegExp(SGR_PATTERN.source, 'g');
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
shouldHoldFocusOnJump,
|
|
30
30
|
} from './composer-keyboard.mjs';
|
|
31
31
|
import {
|
|
32
|
+
baselineBeforeUpdate,
|
|
32
33
|
isScrollOnSendEnabled,
|
|
33
34
|
followsEndThroughResize,
|
|
34
35
|
isNearBottom,
|
|
@@ -197,13 +198,16 @@ export function followsTranscriptEnd() {
|
|
|
197
198
|
if (!isConversationWindowAtTail()) return false;
|
|
198
199
|
const el = scroller();
|
|
199
200
|
const height = jump ? null : observedClientHeight;
|
|
200
|
-
|
|
201
|
+
const follows = followsEndThroughResize({
|
|
201
202
|
nearBottomNow: isMessagesNearBottom(),
|
|
202
203
|
clientHeight: el?.clientHeight,
|
|
203
204
|
observedClientHeight: height,
|
|
204
205
|
distanceAtObservedHeight: el && height !== null ? Math.max(0, el.scrollHeight - height - el.scrollTop) : null,
|
|
205
206
|
nearBottomPxAtObservedHeight: height !== null ? resolveNearBottomThresholdPx(height) : 0,
|
|
206
207
|
});
|
|
208
|
+
// What the update adds is counted against the rows on the page before it.
|
|
209
|
+
if (!follows && el && !jump && syncStateConversation()) state = baselineBeforeUpdate(state, rowKeys(el));
|
|
210
|
+
return follows;
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
/**
|
|
@@ -103,6 +103,26 @@ export function nextJumpButtonState(previous, {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The state before a live update that does not follow the end changes the
|
|
108
|
+
* transcript: the baseline is taken now, from the rows before it, if there
|
|
109
|
+
* is none yet. Otherwise it is taken by the next frame's nextJumpButtonState,
|
|
110
|
+
* by then with the update's row in it, and a row that lands between the
|
|
111
|
+
* reader's scroll away and that frame (one frame, or a tenth of a second on
|
|
112
|
+
* a busy page) is not counted.
|
|
113
|
+
*/
|
|
114
|
+
export function baselineBeforeUpdate(previous, rowKeys = []) {
|
|
115
|
+
if (Array.isArray(previous?.baseline)) return previous;
|
|
116
|
+
return {
|
|
117
|
+
visible: previous?.visible === true,
|
|
118
|
+
baseline: rowKeys.slice(),
|
|
119
|
+
count: 0,
|
|
120
|
+
unseen: previous?.unseen === true,
|
|
121
|
+
question: previous?.question === true,
|
|
122
|
+
waiting: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
export const JUMP_BUTTON_LABEL = 'Jump to the latest message';
|
|
107
127
|
export const JUMP_BUTTON_QUESTION_LABEL = 'A question is waiting for your answer. Jump to it';
|
|
108
128
|
export const JUMP_QUESTION_BADGE = '?';
|