@firstpick/pi-package-webui 0.3.5 → 0.3.6
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/README.md +4 -1
- package/bin/pi-webui.mjs +242 -0
- package/package.json +1 -1
- package/public/app.js +186 -2
- package/public/index.html +17 -3
- package/public/styles.css +143 -35
- package/tests/mobile-static.test.mjs +19 -3
package/README.md
CHANGED
|
@@ -125,11 +125,12 @@ Environment variables:
|
|
|
125
125
|
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
|
|
126
126
|
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
127
127
|
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
128
|
-
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
|
|
128
|
+
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
|
|
129
129
|
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
130
130
|
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
131
131
|
- Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
|
|
132
132
|
- Browser support for Pi extension UI prompts, widgets, status updates, browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
|
|
133
|
+
- Localhost-only Pi update checks with a top-right update notification and a confirmed **Update & restart** action that runs `pi update`, then restarts the Web UI server.
|
|
133
134
|
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
|
|
134
135
|
- Mobile-friendly layout and PWA install support where the browser allows it.
|
|
135
136
|
|
|
@@ -138,6 +139,7 @@ Useful browser endpoints exposed by the local server include:
|
|
|
138
139
|
- `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
|
|
139
140
|
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
140
141
|
- `POST /api/optional-feature-install` for installing known optional companion packages from the side panel.
|
|
142
|
+
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` followed by a Web UI server restart.
|
|
141
143
|
|
|
142
144
|
For local development, run the checkout helper directly, for example:
|
|
143
145
|
|
|
@@ -191,6 +193,7 @@ This requires `/git-staged-msg` and `/pr` from `@firstpick/pi-prompts-git-pr`; b
|
|
|
191
193
|
- The side-panel **Open to network** button rebinds the server to `0.0.0.0`, shows LAN URLs when available, and toggles to "Close for network".
|
|
192
194
|
- `--host 0.0.0.0` also exposes the Web UI to the local network.
|
|
193
195
|
- Any connected browser client can control Pi and run Web UI bash actions as the Web UI process user.
|
|
196
|
+
- The Web UI update endpoint is restricted to localhost, because it runs `pi update` and restarts the server.
|
|
194
197
|
- Treat Pi Web UI as a local companion, not a hardened multi-user web service.
|
|
195
198
|
|
|
196
199
|
## Troubleshooting
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -19,6 +19,13 @@ const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
|
|
|
19
19
|
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
|
|
20
20
|
const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
|
|
21
21
|
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
22
|
+
let piPackageJson = {};
|
|
23
|
+
try {
|
|
24
|
+
const piPackageJsonPath = require.resolve("@earendil-works/pi-coding-agent/package.json", { paths: [packageRoot] });
|
|
25
|
+
piPackageJson = JSON.parse(await readFile(piPackageJsonPath, "utf8"));
|
|
26
|
+
} catch {
|
|
27
|
+
piPackageJson = {};
|
|
28
|
+
}
|
|
22
29
|
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
23
30
|
const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
|
|
24
31
|
|
|
@@ -28,6 +35,14 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
28
35
|
const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
|
|
29
36
|
const WEBUI_HELPER_COMMAND = "webui-helper";
|
|
30
37
|
const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
|
|
38
|
+
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
39
|
+
const WEBUI_PACKAGE = packageJson.name || "@firstpick/pi-package-webui";
|
|
40
|
+
const PI_LATEST_VERSION_URL = process.env.PI_WEBUI_PI_LATEST_VERSION_URL || "https://pi.dev/api/latest-version";
|
|
41
|
+
const NPM_REGISTRY_URL = (process.env.PI_WEBUI_NPM_REGISTRY_URL || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
42
|
+
const UPDATE_STATUS_CACHE_MS = 10 * 60 * 1000;
|
|
43
|
+
const UPDATE_STATUS_TIMEOUT_MS = 10 * 1000;
|
|
44
|
+
const PI_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
45
|
+
const PI_UPDATE_OUTPUT_MAX_CHARS = 120_000;
|
|
31
46
|
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
32
47
|
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
33
48
|
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
@@ -363,6 +378,51 @@ function delay(ms) {
|
|
|
363
378
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
364
379
|
}
|
|
365
380
|
|
|
381
|
+
function truncateLongText(value, maxLength = 8000) {
|
|
382
|
+
const text = String(value || "");
|
|
383
|
+
if (text.length <= maxLength) return text;
|
|
384
|
+
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function parsePackageVersion(version) {
|
|
388
|
+
const match = String(version || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
|
|
389
|
+
if (!match) return undefined;
|
|
390
|
+
return {
|
|
391
|
+
major: Number.parseInt(match[1], 10),
|
|
392
|
+
minor: Number.parseInt(match[2], 10),
|
|
393
|
+
patch: Number.parseInt(match[3], 10),
|
|
394
|
+
prerelease: match[4],
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function comparePackageVersions(leftVersion, rightVersion) {
|
|
399
|
+
const left = parsePackageVersion(leftVersion);
|
|
400
|
+
const right = parsePackageVersion(rightVersion);
|
|
401
|
+
if (!left || !right) return undefined;
|
|
402
|
+
if (left.major !== right.major) return left.major - right.major;
|
|
403
|
+
if (left.minor !== right.minor) return left.minor - right.minor;
|
|
404
|
+
if (left.patch !== right.patch) return left.patch - right.patch;
|
|
405
|
+
if (left.prerelease === right.prerelease) return 0;
|
|
406
|
+
if (!left.prerelease) return 1;
|
|
407
|
+
if (!right.prerelease) return -1;
|
|
408
|
+
return left.prerelease.localeCompare(right.prerelease);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isNewerPackageVersion(candidateVersion, currentVersion) {
|
|
412
|
+
const comparison = comparePackageVersions(candidateVersion, currentVersion);
|
|
413
|
+
if (comparison !== undefined) return comparison > 0;
|
|
414
|
+
return String(candidateVersion || "").trim() !== String(currentVersion || "").trim();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function fetchJsonWithTimeout(url, { timeoutMs = UPDATE_STATUS_TIMEOUT_MS, headers = {} } = {}) {
|
|
418
|
+
const response = await fetch(url, {
|
|
419
|
+
headers,
|
|
420
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
421
|
+
});
|
|
422
|
+
if (!response.ok) throw new Error(`${response.status}${response.statusText ? ` ${response.statusText}` : ""}`);
|
|
423
|
+
return response.json();
|
|
424
|
+
}
|
|
425
|
+
|
|
366
426
|
class PiRpcProcess {
|
|
367
427
|
constructor({ command, args, displayCommand, cwd }) {
|
|
368
428
|
this.command = command;
|
|
@@ -3912,6 +3972,173 @@ function spawnRestartServer(restorableTabs) {
|
|
|
3912
3972
|
return child;
|
|
3913
3973
|
}
|
|
3914
3974
|
|
|
3975
|
+
let updateStatusCache = null;
|
|
3976
|
+
let updateStatusCacheAt = 0;
|
|
3977
|
+
let piUpdateInProgress = false;
|
|
3978
|
+
|
|
3979
|
+
function updateChecksSkippedReason() {
|
|
3980
|
+
if (process.env.PI_OFFLINE) return "PI_OFFLINE is set";
|
|
3981
|
+
if (process.env.PI_SKIP_VERSION_CHECK) return "PI_SKIP_VERSION_CHECK is set";
|
|
3982
|
+
return "";
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function basePackageUpdateStatus(packageName, currentVersion) {
|
|
3986
|
+
return {
|
|
3987
|
+
packageName,
|
|
3988
|
+
currentVersion: String(currentVersion || ""),
|
|
3989
|
+
latestVersion: null,
|
|
3990
|
+
updateAvailable: false,
|
|
3991
|
+
checked: false,
|
|
3992
|
+
skipped: false,
|
|
3993
|
+
skippedReason: "",
|
|
3994
|
+
error: "",
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
async function checkLatestPiReleaseStatus() {
|
|
3999
|
+
const status = basePackageUpdateStatus(PI_CODING_AGENT_PACKAGE, piPackageJson.version);
|
|
4000
|
+
const skippedReason = updateChecksSkippedReason();
|
|
4001
|
+
if (skippedReason) {
|
|
4002
|
+
status.skipped = true;
|
|
4003
|
+
status.skippedReason = skippedReason;
|
|
4004
|
+
return status;
|
|
4005
|
+
}
|
|
4006
|
+
try {
|
|
4007
|
+
const data = await fetchJsonWithTimeout(PI_LATEST_VERSION_URL, {
|
|
4008
|
+
headers: {
|
|
4009
|
+
"User-Agent": `pi-webui/${packageJson.version} pi/${piPackageJson.version || "unknown"}`,
|
|
4010
|
+
accept: "application/json",
|
|
4011
|
+
},
|
|
4012
|
+
});
|
|
4013
|
+
const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
|
|
4014
|
+
if (!latestVersion) throw new Error("latest-version response did not include a version");
|
|
4015
|
+
status.latestVersion = latestVersion;
|
|
4016
|
+
status.packageName = typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : PI_CODING_AGENT_PACKAGE;
|
|
4017
|
+
status.note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : "";
|
|
4018
|
+
status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
|
|
4019
|
+
status.checked = true;
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
status.error = sanitizeError(error);
|
|
4022
|
+
}
|
|
4023
|
+
return status;
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
function npmLatestPackageUrl(packageName) {
|
|
4027
|
+
return `${NPM_REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
async function checkLatestNpmPackageStatus(packageName, currentVersion) {
|
|
4031
|
+
const status = basePackageUpdateStatus(packageName, currentVersion);
|
|
4032
|
+
const skippedReason = updateChecksSkippedReason();
|
|
4033
|
+
if (skippedReason) {
|
|
4034
|
+
status.skipped = true;
|
|
4035
|
+
status.skippedReason = skippedReason;
|
|
4036
|
+
return status;
|
|
4037
|
+
}
|
|
4038
|
+
try {
|
|
4039
|
+
const data = await fetchJsonWithTimeout(npmLatestPackageUrl(packageName), {
|
|
4040
|
+
headers: {
|
|
4041
|
+
"User-Agent": `pi-webui/${packageJson.version}`,
|
|
4042
|
+
accept: "application/json",
|
|
4043
|
+
},
|
|
4044
|
+
});
|
|
4045
|
+
const latestVersion = typeof data.version === "string" ? data.version.trim() : "";
|
|
4046
|
+
if (!latestVersion) throw new Error(`${packageName} latest metadata did not include a version`);
|
|
4047
|
+
status.latestVersion = latestVersion;
|
|
4048
|
+
status.updateAvailable = status.currentVersion ? isNewerPackageVersion(latestVersion, status.currentVersion) : false;
|
|
4049
|
+
status.checked = true;
|
|
4050
|
+
} catch (error) {
|
|
4051
|
+
status.error = sanitizeError(error);
|
|
4052
|
+
}
|
|
4053
|
+
return status;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
function updateStatusForRequest(status, req) {
|
|
4057
|
+
return {
|
|
4058
|
+
...status,
|
|
4059
|
+
canRunUpdate: isLocalAddress(req?.socket?.remoteAddress),
|
|
4060
|
+
updateInProgress: piUpdateInProgress,
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
async function getUpdateStatus({ force = false } = {}) {
|
|
4065
|
+
const now = Date.now();
|
|
4066
|
+
if (!force && updateStatusCache && now - updateStatusCacheAt < UPDATE_STATUS_CACHE_MS) return updateStatusCache;
|
|
4067
|
+
const [piStatus, webuiStatus] = await Promise.all([
|
|
4068
|
+
checkLatestPiReleaseStatus(),
|
|
4069
|
+
checkLatestNpmPackageStatus(WEBUI_PACKAGE, packageJson.version),
|
|
4070
|
+
]);
|
|
4071
|
+
const updateAvailable = !!(piStatus.updateAvailable || webuiStatus.updateAvailable);
|
|
4072
|
+
updateStatusCache = {
|
|
4073
|
+
checkedAt: new Date(now).toISOString(),
|
|
4074
|
+
updateAvailable,
|
|
4075
|
+
restartRequired: true,
|
|
4076
|
+
command: "pi update",
|
|
4077
|
+
webuiDev: webuiDevServer,
|
|
4078
|
+
pi: piStatus,
|
|
4079
|
+
webui: webuiStatus,
|
|
4080
|
+
packages: {
|
|
4081
|
+
checked: false,
|
|
4082
|
+
note: "pi update will also update configured unpinned Pi packages.",
|
|
4083
|
+
},
|
|
4084
|
+
};
|
|
4085
|
+
updateStatusCacheAt = now;
|
|
4086
|
+
return updateStatusCache;
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
async function resolvePiUpdateCommand() {
|
|
4090
|
+
if (options.piBinExplicit) {
|
|
4091
|
+
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
4095
|
+
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
4096
|
+
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
return resolvePiCommand(["update"]);
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
async function runPiUpdateAndPrepareRestart() {
|
|
4103
|
+
if (piUpdateInProgress) throw makeHttpError(409, "A Pi update is already running.");
|
|
4104
|
+
piUpdateInProgress = true;
|
|
4105
|
+
let restartPrepared = false;
|
|
4106
|
+
try {
|
|
4107
|
+
const restorableTabs = await restorableTabsForRestart();
|
|
4108
|
+
const piCommand = await resolvePiUpdateCommand();
|
|
4109
|
+
const command = piCommand.displayCommand || formatCommandForDisplay(piCommand.command, piCommand.args || []);
|
|
4110
|
+
recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
|
|
4111
|
+
const result = await runCommand(piCommand.command, piCommand.args || [], {
|
|
4112
|
+
cwd: process.cwd(),
|
|
4113
|
+
timeoutMs: PI_UPDATE_TIMEOUT_MS,
|
|
4114
|
+
maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
|
|
4115
|
+
});
|
|
4116
|
+
const ok = result.exitCode === 0 && !result.timedOut && !result.error;
|
|
4117
|
+
if (!ok) {
|
|
4118
|
+
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
4119
|
+
recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
|
|
4120
|
+
throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
updateStatusCache = null;
|
|
4124
|
+
updateStatusCacheAt = 0;
|
|
4125
|
+
const child = spawnRestartServer(restorableTabs);
|
|
4126
|
+
restartPrepared = true;
|
|
4127
|
+
recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
4128
|
+
return {
|
|
4129
|
+
message: "Pi update completed. Pi Web UI is restarting.",
|
|
4130
|
+
command,
|
|
4131
|
+
stdout: result.stdout,
|
|
4132
|
+
stderr: result.stderr,
|
|
4133
|
+
webuiPid: process.pid,
|
|
4134
|
+
nextWebuiPid: child.pid,
|
|
4135
|
+
restorableTabCount: restorableTabs.length,
|
|
4136
|
+
};
|
|
4137
|
+
} finally {
|
|
4138
|
+
if (!restartPrepared) piUpdateInProgress = false;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
|
|
3915
4142
|
function rememberClosedRestorableTab(tab, state = null) {
|
|
3916
4143
|
const descriptor = restorableTabDescriptor(tab, state);
|
|
3917
4144
|
if (!descriptor) return;
|
|
@@ -5517,6 +5744,13 @@ const server = createServer(async (req, res) => {
|
|
|
5517
5744
|
return;
|
|
5518
5745
|
}
|
|
5519
5746
|
|
|
5747
|
+
if (url.pathname === "/api/update-status" && req.method === "GET") {
|
|
5748
|
+
const force = ["1", "true", "yes", "refresh"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
|
|
5749
|
+
const status = await getUpdateStatus({ force });
|
|
5750
|
+
sendJson(res, 200, { ok: true, data: updateStatusForRequest(status, req) });
|
|
5751
|
+
return;
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5520
5754
|
if (url.pathname === "/api/native-parity" && req.method === "GET") {
|
|
5521
5755
|
sendJson(res, 200, { ok: true, data: nativeParityMatrix });
|
|
5522
5756
|
return;
|
|
@@ -5577,6 +5811,14 @@ const server = createServer(async (req, res) => {
|
|
|
5577
5811
|
return;
|
|
5578
5812
|
}
|
|
5579
5813
|
|
|
5814
|
+
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
5815
|
+
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Updating Pi from the Web UI is only allowed from localhost");
|
|
5816
|
+
const data = await runPiUpdateAndPrepareRestart();
|
|
5817
|
+
sendJson(res, 200, { ok: true, data });
|
|
5818
|
+
setTimeout(() => shutdown("api update"), 20).unref();
|
|
5819
|
+
return;
|
|
5820
|
+
}
|
|
5821
|
+
|
|
5580
5822
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
5581
5823
|
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
|
|
5582
5824
|
sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|
package/public/app.js
CHANGED
|
@@ -15,6 +15,12 @@ const elements = {
|
|
|
15
15
|
serverOfflinePanel: $("#serverOfflinePanel"),
|
|
16
16
|
serverRestartPanel: $("#serverRestartPanel"),
|
|
17
17
|
serverRestartMessage: $("#serverRestartMessage"),
|
|
18
|
+
updateNotification: $("#updateNotification"),
|
|
19
|
+
updateNotificationTitle: $("#updateNotificationTitle"),
|
|
20
|
+
updateNotificationMessage: $("#updateNotificationMessage"),
|
|
21
|
+
updateNotificationDetail: $("#updateNotificationDetail"),
|
|
22
|
+
updateNotificationUpdateButton: $("#updateNotificationUpdateButton"),
|
|
23
|
+
updateNotificationDismissButton: $("#updateNotificationDismissButton"),
|
|
18
24
|
serverOfflineCommand: $("#serverOfflineCommand"),
|
|
19
25
|
serverOfflineSlashCommand: $("#serverOfflineSlashCommand"),
|
|
20
26
|
copyServerCommandButton: $("#copyServerCommandButton"),
|
|
@@ -251,6 +257,10 @@ let refreshCodexUsageTimer = null;
|
|
|
251
257
|
let codexUsageRenderTimer = null;
|
|
252
258
|
let backendOffline = false;
|
|
253
259
|
let serverRestartInProgress = false;
|
|
260
|
+
let updateRequestInProgress = false;
|
|
261
|
+
let latestUpdateStatus = null;
|
|
262
|
+
let updateStatusRefreshTimer = null;
|
|
263
|
+
let updateNotificationHideTimer = null;
|
|
254
264
|
let backendOfflineNoticeShown = false;
|
|
255
265
|
let latestMessages = [];
|
|
256
266
|
let promptHistoryByTab = new Map();
|
|
@@ -311,6 +321,7 @@ const SIDE_PANEL_SECTION_STORAGE_KEY = "pi-webui-side-panel-sections-collapsed";
|
|
|
311
321
|
const TAB_STORAGE_KEY = "pi-webui-active-tab";
|
|
312
322
|
const PATH_FAST_PICKS_STORAGE_KEY = "pi-webui-path-fast-picks";
|
|
313
323
|
const AGENT_DONE_NOTIFICATIONS_STORAGE_KEY = "pi-webui-agent-done-notifications";
|
|
324
|
+
const UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY = "pi-webui-update-notification-dismissed";
|
|
314
325
|
const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible";
|
|
315
326
|
const BUSY_PROMPT_BEHAVIOR_STORAGE_KEY = "pi-webui-busy-prompt-behavior";
|
|
316
327
|
const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1";
|
|
@@ -360,6 +371,8 @@ const CHAT_PROGRAMMATIC_SCROLL_GRACE_MS = 500;
|
|
|
360
371
|
const CHAT_USER_SCROLL_INTENT_MS = 700;
|
|
361
372
|
const CODEX_USAGE_REFRESH_MS = 5 * 60 * 1000;
|
|
362
373
|
const CODEX_USAGE_RENDER_TICK_MS = 30 * 1000;
|
|
374
|
+
const UPDATE_STATUS_REFRESH_MS = 6 * 60 * 60 * 1000;
|
|
375
|
+
const UPDATE_STATUS_INITIAL_DELAY_MS = 1800;
|
|
363
376
|
const RUN_INDICATOR_TICK_MS = 1000;
|
|
364
377
|
const RUN_INDICATOR_START_GRACE_MS = 2500;
|
|
365
378
|
const RUN_INDICATOR_STATE_RECHECK_MS = 5000;
|
|
@@ -1698,6 +1711,7 @@ function setServerRestartOverlay(active, message = "Waiting for the server to co
|
|
|
1698
1711
|
document.body.classList.toggle("server-restarting", serverRestartInProgress);
|
|
1699
1712
|
if (elements.serverRestartPanel) elements.serverRestartPanel.hidden = !serverRestartInProgress;
|
|
1700
1713
|
if (elements.serverRestartMessage) elements.serverRestartMessage.textContent = message;
|
|
1714
|
+
if (serverRestartInProgress) hideUpdateNotification();
|
|
1701
1715
|
if (serverRestartInProgress && elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = true;
|
|
1702
1716
|
}
|
|
1703
1717
|
|
|
@@ -1708,6 +1722,7 @@ function setBackendOffline(offline, error) {
|
|
|
1708
1722
|
if (elements.serverOfflinePanel) elements.serverOfflinePanel.hidden = !showOfflinePanel;
|
|
1709
1723
|
renderServerOfflinePanel();
|
|
1710
1724
|
if (backendOffline) {
|
|
1725
|
+
hideUpdateNotification();
|
|
1711
1726
|
if (!serverRestartInProgress && !backendOfflineNoticeShown) {
|
|
1712
1727
|
backendOfflineNoticeShown = true;
|
|
1713
1728
|
addEvent(`Pi Web UI server is offline${error?.message ? `: ${error.message}` : ""}`, "warn");
|
|
@@ -1950,6 +1965,169 @@ async function refreshWebuiVersion() {
|
|
|
1950
1965
|
setWebuiDevServer(isWebuiDevMetadata(health));
|
|
1951
1966
|
}
|
|
1952
1967
|
|
|
1968
|
+
function packageUpdateText(label, status = {}) {
|
|
1969
|
+
const current = formatWebuiVersion(status.currentVersion || "");
|
|
1970
|
+
const latest = formatWebuiVersion(status.latestVersion || "");
|
|
1971
|
+
if (current && latest) return `${label} ${current} → ${latest}`;
|
|
1972
|
+
if (latest) return `${label} ${latest}`;
|
|
1973
|
+
return label;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function updateNotificationItems(status = latestUpdateStatus) {
|
|
1977
|
+
const items = [];
|
|
1978
|
+
if (status?.pi?.updateAvailable) items.push(packageUpdateText("Pi", status.pi));
|
|
1979
|
+
if (status?.webui?.updateAvailable) items.push(packageUpdateText("Web UI", status.webui));
|
|
1980
|
+
return items;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function updateNotificationDismissKey(status = latestUpdateStatus) {
|
|
1984
|
+
const parts = [status?.pi?.latestVersion, status?.webui?.latestVersion]
|
|
1985
|
+
.map((value) => String(value || "").trim())
|
|
1986
|
+
.filter(Boolean);
|
|
1987
|
+
return parts.length ? parts.join("|") : "";
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function storedDismissedUpdateKey() {
|
|
1991
|
+
try {
|
|
1992
|
+
return localStorage.getItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY) || "";
|
|
1993
|
+
} catch {
|
|
1994
|
+
return "";
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
function rememberDismissedUpdateKey(key) {
|
|
1999
|
+
if (!key) return;
|
|
2000
|
+
try {
|
|
2001
|
+
localStorage.setItem(UPDATE_NOTIFICATION_DISMISS_STORAGE_KEY, key);
|
|
2002
|
+
} catch {
|
|
2003
|
+
// Ignore private-mode storage failures.
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function hideUpdateNotification({ remember = false } = {}) {
|
|
2008
|
+
const panel = elements.updateNotification;
|
|
2009
|
+
if (!panel) return;
|
|
2010
|
+
clearTimeout(updateNotificationHideTimer);
|
|
2011
|
+
if (remember) rememberDismissedUpdateKey(updateNotificationDismissKey());
|
|
2012
|
+
panel.classList.remove("show");
|
|
2013
|
+
updateNotificationHideTimer = setTimeout(() => {
|
|
2014
|
+
panel.hidden = true;
|
|
2015
|
+
}, 360);
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function renderUpdateNotification(status = latestUpdateStatus, { force = false } = {}) {
|
|
2019
|
+
const panel = elements.updateNotification;
|
|
2020
|
+
if (!panel) return;
|
|
2021
|
+
latestUpdateStatus = status || latestUpdateStatus;
|
|
2022
|
+
const items = updateNotificationItems(latestUpdateStatus);
|
|
2023
|
+
const dismissKey = updateNotificationDismissKey(latestUpdateStatus);
|
|
2024
|
+
const shouldShow = !!latestUpdateStatus?.updateAvailable && items.length > 0 && !updateRequestInProgress;
|
|
2025
|
+
if (!shouldShow || (!force && dismissKey && storedDismissedUpdateKey() === dismissKey)) {
|
|
2026
|
+
hideUpdateNotification();
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
const canRunUpdate = latestUpdateStatus.canRunUpdate !== false;
|
|
2031
|
+
if (elements.updateNotificationTitle) elements.updateNotificationTitle.textContent = items.length === 1 ? `${items[0]} available` : "Pi updates available";
|
|
2032
|
+
if (elements.updateNotificationMessage) {
|
|
2033
|
+
elements.updateNotificationMessage.textContent = canRunUpdate
|
|
2034
|
+
? "Run pi update now, then restart this Web UI server automatically."
|
|
2035
|
+
: "Updates are available. Direct Web UI updates are only enabled from localhost on the host machine.";
|
|
2036
|
+
}
|
|
2037
|
+
const details = [
|
|
2038
|
+
items.join(" · "),
|
|
2039
|
+
latestUpdateStatus.webuiDev && latestUpdateStatus.webui?.updateAvailable ? "The current Web UI is a dev checkout; pi update updates installed Pi packages, not this checkout." : "",
|
|
2040
|
+
latestUpdateStatus.packages?.note || "",
|
|
2041
|
+
].filter(Boolean).join(" ");
|
|
2042
|
+
if (elements.updateNotificationDetail) elements.updateNotificationDetail.textContent = details;
|
|
2043
|
+
if (elements.updateNotificationUpdateButton) {
|
|
2044
|
+
elements.updateNotificationUpdateButton.hidden = !canRunUpdate;
|
|
2045
|
+
elements.updateNotificationUpdateButton.disabled = updateRequestInProgress || latestUpdateStatus.updateInProgress;
|
|
2046
|
+
elements.updateNotificationUpdateButton.textContent = latestUpdateStatus.updateInProgress ? "Updating…" : "Update & restart";
|
|
2047
|
+
}
|
|
2048
|
+
clearTimeout(updateNotificationHideTimer);
|
|
2049
|
+
panel.hidden = false;
|
|
2050
|
+
requestAnimationFrame(() => panel.classList.add("show"));
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
async function refreshUpdateStatus({ force = false, notify = true } = {}) {
|
|
2054
|
+
const path = force ? "/api/update-status?refresh=1" : "/api/update-status";
|
|
2055
|
+
const response = await api(path, { scoped: false });
|
|
2056
|
+
latestUpdateStatus = response.data || null;
|
|
2057
|
+
if (notify) renderUpdateNotification(latestUpdateStatus);
|
|
2058
|
+
return latestUpdateStatus;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function scheduleUpdateStatusRefresh() {
|
|
2062
|
+
clearTimeout(updateStatusRefreshTimer);
|
|
2063
|
+
updateStatusRefreshTimer = setTimeout(() => {
|
|
2064
|
+
updateStatusRefreshTimer = null;
|
|
2065
|
+
refreshUpdateStatus({ force: true }).catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
|
|
2066
|
+
scheduleUpdateStatusRefresh();
|
|
2067
|
+
}, UPDATE_STATUS_REFRESH_MS);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function initializeUpdateNotifications() {
|
|
2071
|
+
setTimeout(() => {
|
|
2072
|
+
refreshUpdateStatus().catch((error) => addEvent(`Pi update check failed: ${error.message || String(error)}`, "warn"));
|
|
2073
|
+
scheduleUpdateStatusRefresh();
|
|
2074
|
+
}, UPDATE_STATUS_INITIAL_DELAY_MS);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function piUpdateConfirmationText() {
|
|
2078
|
+
const items = updateNotificationItems();
|
|
2079
|
+
const workingWarning = hasWorkingTab() ? "\n\nOne or more Pi tabs look busy or blocked. Finish or abort in-flight work before updating if you need to preserve it." : "";
|
|
2080
|
+
const versionText = items.length ? `\n\nDetected update: ${items.join(" · ")}.` : "";
|
|
2081
|
+
return `Run pi update now?${versionText}\n\nThis will run \"pi update\" on the Web UI host. After it finishes, Pi Web UI will restart itself. Browser clients will briefly disconnect, and managed Pi tabs/RPC processes will be restarted from saved session state when possible.${workingWarning}`;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
async function runPiUpdateAndRestart() {
|
|
2085
|
+
if (updateRequestInProgress) return;
|
|
2086
|
+
if (latestUpdateStatus?.canRunUpdate === false) {
|
|
2087
|
+
addEvent("Pi update can only be started from localhost on the Web UI host", "warn");
|
|
2088
|
+
renderUpdateNotification(latestUpdateStatus, { force: true });
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (!confirm(piUpdateConfirmationText())) return;
|
|
2092
|
+
|
|
2093
|
+
updateRequestInProgress = true;
|
|
2094
|
+
hideUpdateNotification();
|
|
2095
|
+
setServerActionBusy("Updating…");
|
|
2096
|
+
setServerActionStatus("Running pi update. The server will restart after the update completes…", "warn");
|
|
2097
|
+
setServerRestartOverlay(true, "Running pi update. The server will restart after the update completes…");
|
|
2098
|
+
try {
|
|
2099
|
+
await api("/api/update", { method: "POST", scoped: false });
|
|
2100
|
+
addEvent("Pi update completed; Pi Web UI server restart requested", "warn");
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
if (!error?.backendOffline) {
|
|
2103
|
+
updateRequestInProgress = false;
|
|
2104
|
+
setServerRestartOverlay(false);
|
|
2105
|
+
resetServerActionControls();
|
|
2106
|
+
const message = error.message || String(error);
|
|
2107
|
+
setServerActionStatus(message, "error");
|
|
2108
|
+
addEvent(message, "error");
|
|
2109
|
+
renderUpdateNotification(latestUpdateStatus, { force: true });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
addEvent("Pi Web UI server connection dropped during update restart request", "warn");
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
setBackendOffline(true, new Error("update requested from side panel"));
|
|
2116
|
+
const restarted = await waitForServerRestart();
|
|
2117
|
+
updateRequestInProgress = false;
|
|
2118
|
+
resetServerActionControls();
|
|
2119
|
+
if (restarted) {
|
|
2120
|
+
hideUpdateNotification({ remember: true });
|
|
2121
|
+
setServerActionStatus("Updated, restarted, and reconnected.", "success");
|
|
2122
|
+
refreshUpdateStatus({ force: true, notify: false }).catch(() => {});
|
|
2123
|
+
} else {
|
|
2124
|
+
setServerRestartOverlay(false);
|
|
2125
|
+
setBackendOffline(true, new Error("update restart reconnect timed out"));
|
|
2126
|
+
setServerActionStatus("Update completed, but the server did not reconnect automatically.", "error");
|
|
2127
|
+
addEvent("Pi Web UI server did not come back online after update request", "error");
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1953
2131
|
function formatBytes(bytes) {
|
|
1954
2132
|
const value = Number(bytes) || 0;
|
|
1955
2133
|
if (value < 1024) return `${value} B`;
|
|
@@ -12084,9 +12262,11 @@ function updateServerActionButton() {
|
|
|
12084
12262
|
const button = elements.runServerActionButton;
|
|
12085
12263
|
if (!button) return;
|
|
12086
12264
|
button.disabled = !action;
|
|
12087
|
-
button.textContent = action === "restart" ? "Restart" : action === "stop" ? "Stop" : "Run";
|
|
12265
|
+
button.textContent = action === "restart" ? "Restart" : action === "update" ? "Update" : action === "stop" ? "Stop" : "Run";
|
|
12088
12266
|
button.classList.toggle("danger", action === "stop");
|
|
12089
|
-
if (action
|
|
12267
|
+
if (action === "restart") setServerActionStatus("Ready to restart the Web UI server.", "info");
|
|
12268
|
+
else if (action === "update") setServerActionStatus("Ready to run pi update, then restart the Web UI server.", "info");
|
|
12269
|
+
else if (action === "stop") setServerActionStatus("Ready to stop the Web UI server.", "info");
|
|
12090
12270
|
else setServerActionStatus();
|
|
12091
12271
|
}
|
|
12092
12272
|
|
|
@@ -12192,6 +12372,7 @@ async function stopServer() {
|
|
|
12192
12372
|
async function runSelectedServerAction() {
|
|
12193
12373
|
const action = elements.serverActionSelect?.value || "";
|
|
12194
12374
|
if (action === "restart") await restartServer();
|
|
12375
|
+
else if (action === "update") await runPiUpdateAndRestart();
|
|
12195
12376
|
else if (action === "stop") await stopServer();
|
|
12196
12377
|
}
|
|
12197
12378
|
|
|
@@ -13405,6 +13586,8 @@ if (elements.backgroundClearButton) {
|
|
|
13405
13586
|
elements.openNetworkButton.addEventListener("click", openToNetwork);
|
|
13406
13587
|
elements.serverActionSelect.addEventListener("change", updateServerActionButton);
|
|
13407
13588
|
elements.runServerActionButton.addEventListener("click", () => runSelectedServerAction().catch((error) => addEvent(error.message || String(error), "error")));
|
|
13589
|
+
elements.updateNotificationUpdateButton?.addEventListener("click", () => runPiUpdateAndRestart().catch((error) => addEvent(error.message || String(error), "error")));
|
|
13590
|
+
elements.updateNotificationDismissButton?.addEventListener("click", () => hideUpdateNotification({ remember: true }));
|
|
13408
13591
|
updateServerActionButton();
|
|
13409
13592
|
elements.agentDoneNotificationsToggle.addEventListener("change", () => {
|
|
13410
13593
|
setAgentDoneNotificationsEnabled(elements.agentDoneNotificationsToggle.checked, {
|
|
@@ -13752,6 +13935,7 @@ restoreSidePanelSectionState();
|
|
|
13752
13935
|
bindSidePanelSectionToggles();
|
|
13753
13936
|
restoreSidePanelState();
|
|
13754
13937
|
initializeCodexUsage();
|
|
13938
|
+
initializeUpdateNotifications();
|
|
13755
13939
|
bindMobileViewChanges();
|
|
13756
13940
|
bindSidePanelOverlayViewChanges();
|
|
13757
13941
|
registerPwaServiceWorker();
|
package/public/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
13
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
14
14
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
15
|
+
<link rel="stylesheet" href="/styles.css?v=39" />
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
|
|
@@ -45,6 +45,19 @@
|
|
|
45
45
|
</div>
|
|
46
46
|
</section>
|
|
47
47
|
|
|
48
|
+
<section id="updateNotification" class="update-notification" aria-live="polite" hidden>
|
|
49
|
+
<div class="update-notification-card" role="status">
|
|
50
|
+
<span class="update-notification-kicker">Update available</span>
|
|
51
|
+
<h2 id="updateNotificationTitle">Pi update available</h2>
|
|
52
|
+
<p id="updateNotificationMessage">A newer Pi version is available.</p>
|
|
53
|
+
<p id="updateNotificationDetail" class="update-notification-detail muted"></p>
|
|
54
|
+
<div class="update-notification-actions">
|
|
55
|
+
<button id="updateNotificationUpdateButton" class="primary" type="button">Update & restart</button>
|
|
56
|
+
<button id="updateNotificationDismissButton" type="button">Later</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
48
61
|
<main class="layout">
|
|
49
62
|
<section class="chat-panel">
|
|
50
63
|
<header class="terminal-tabs-shell">
|
|
@@ -84,6 +97,7 @@
|
|
|
84
97
|
<div id="gitWorkflowActions" class="git-workflow-actions"></div>
|
|
85
98
|
</section>
|
|
86
99
|
<form id="composer" class="composer">
|
|
100
|
+
<div id="commandSuggest" class="command-suggest" role="listbox" aria-label="Command and path suggestions" hidden></div>
|
|
87
101
|
<div class="composer-input-row">
|
|
88
102
|
<div class="composer-context-tags">
|
|
89
103
|
<button
|
|
@@ -118,7 +132,6 @@
|
|
|
118
132
|
><svg class="composer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.82-2.82l8.48-8.49" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
|
119
133
|
<input id="attachmentInput" class="attachment-input" type="file" multiple accept="image/*,video/*,audio/*,text/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/json,application/xml,.md,.markdown,.csv,.ts,.tsx,.js,.jsx,.mjs,.cjs,.py,.rs,.go,.java,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.fish,.yaml,.yml,.toml,.ini,.log" />
|
|
120
134
|
</div>
|
|
121
|
-
<div id="commandSuggest" class="command-suggest" role="listbox" aria-label="Command and path suggestions" hidden></div>
|
|
122
135
|
<div id="attachmentTray" class="attachment-tray" aria-live="polite" hidden></div>
|
|
123
136
|
<div class="composer-row">
|
|
124
137
|
<button id="composerActionsButton" class="composer-actions-button" type="button" aria-controls="composerActionsPanel" aria-expanded="false" title="Open session, workflow, and follow-up actions">Actions</button>
|
|
@@ -348,6 +361,7 @@
|
|
|
348
361
|
<select id="serverActionSelect" title="Server action" aria-label="Server action">
|
|
349
362
|
<option value="" selected>Choose action…</option>
|
|
350
363
|
<option value="restart">Restart Server</option>
|
|
364
|
+
<option value="update">Update Pi & Restart</option>
|
|
351
365
|
<option value="stop">Stop Server</option>
|
|
352
366
|
</select>
|
|
353
367
|
<button id="runServerActionButton" type="button" disabled>Run</button>
|
|
@@ -584,6 +598,6 @@
|
|
|
584
598
|
</form>
|
|
585
599
|
</dialog>
|
|
586
600
|
|
|
587
|
-
<script type="module" src="/app.js?v=
|
|
601
|
+
<script type="module" src="/app.js?v=38"></script>
|
|
588
602
|
</body>
|
|
589
603
|
</html>
|
package/public/styles.css
CHANGED
|
@@ -434,6 +434,79 @@ body.server-restarting .layout {
|
|
|
434
434
|
@keyframes server-restart-spin {
|
|
435
435
|
to { transform: rotate(360deg); }
|
|
436
436
|
}
|
|
437
|
+
.update-notification[hidden] {
|
|
438
|
+
display: none !important;
|
|
439
|
+
}
|
|
440
|
+
.update-notification {
|
|
441
|
+
position: fixed;
|
|
442
|
+
top: calc(env(safe-area-inset-top, 0px) + 1rem);
|
|
443
|
+
right: calc(env(safe-area-inset-right, 0px) + 1rem);
|
|
444
|
+
z-index: 64;
|
|
445
|
+
width: min(28rem, calc(100vw - 2rem));
|
|
446
|
+
opacity: 0;
|
|
447
|
+
transform: translateY(-1.6rem);
|
|
448
|
+
pointer-events: none;
|
|
449
|
+
transition: opacity 320ms ease, transform 1050ms cubic-bezier(0.18, 0.92, 0.24, 1);
|
|
450
|
+
}
|
|
451
|
+
.update-notification.show {
|
|
452
|
+
opacity: 1;
|
|
453
|
+
transform: translateY(0);
|
|
454
|
+
pointer-events: auto;
|
|
455
|
+
animation: update-notification-slide-down 1100ms cubic-bezier(0.18, 0.92, 0.24, 1);
|
|
456
|
+
}
|
|
457
|
+
.update-notification-card {
|
|
458
|
+
position: relative;
|
|
459
|
+
overflow: hidden;
|
|
460
|
+
padding: 1rem;
|
|
461
|
+
border: 1px solid rgba(166, 227, 161, 0.42);
|
|
462
|
+
border-radius: 1rem;
|
|
463
|
+
background:
|
|
464
|
+
radial-gradient(circle at 100% 0, rgba(166, 227, 161, 0.18), transparent 13rem),
|
|
465
|
+
linear-gradient(145deg, rgba(var(--ctp-base-rgb), 0.96), rgba(var(--ctp-crust-rgb), 0.98));
|
|
466
|
+
box-shadow: 0 1.1rem 3.2rem rgba(var(--ctp-crust-rgb), 0.72), 0 0 1.8rem rgba(166, 227, 161, 0.16), inset 0 1px 0 rgba(255,255,255,0.08);
|
|
467
|
+
backdrop-filter: blur(18px) saturate(145%);
|
|
468
|
+
}
|
|
469
|
+
.update-notification-card::before {
|
|
470
|
+
content: "";
|
|
471
|
+
position: absolute;
|
|
472
|
+
inset: 0 0 auto;
|
|
473
|
+
height: 2px;
|
|
474
|
+
background: linear-gradient(90deg, var(--ctp-green), var(--ctp-teal), var(--ctp-blue));
|
|
475
|
+
box-shadow: 0 0 1rem rgba(166, 227, 161, 0.42);
|
|
476
|
+
}
|
|
477
|
+
.update-notification-kicker {
|
|
478
|
+
display: inline-block;
|
|
479
|
+
margin-bottom: 0.45rem;
|
|
480
|
+
color: var(--ok);
|
|
481
|
+
font-size: 0.72rem;
|
|
482
|
+
font-weight: 900;
|
|
483
|
+
letter-spacing: 0.12em;
|
|
484
|
+
text-transform: uppercase;
|
|
485
|
+
}
|
|
486
|
+
.update-notification h2 {
|
|
487
|
+
margin: 0 0 0.45rem;
|
|
488
|
+
font-size: 1.1rem;
|
|
489
|
+
}
|
|
490
|
+
.update-notification p {
|
|
491
|
+
margin: 0 0 0.65rem;
|
|
492
|
+
color: rgba(var(--ctp-text-rgb), 0.88);
|
|
493
|
+
line-height: 1.45;
|
|
494
|
+
}
|
|
495
|
+
.update-notification-detail {
|
|
496
|
+
font-size: 0.86rem;
|
|
497
|
+
}
|
|
498
|
+
.update-notification-actions {
|
|
499
|
+
display: flex;
|
|
500
|
+
flex-wrap: wrap;
|
|
501
|
+
gap: 0.55rem;
|
|
502
|
+
justify-content: flex-end;
|
|
503
|
+
margin-top: 0.85rem;
|
|
504
|
+
}
|
|
505
|
+
@keyframes update-notification-slide-down {
|
|
506
|
+
0% { opacity: 0; transform: translateY(-2.4rem); }
|
|
507
|
+
70% { opacity: 1; transform: translateY(0.18rem); }
|
|
508
|
+
100% { opacity: 1; transform: translateY(0); }
|
|
509
|
+
}
|
|
437
510
|
.side-panel-expand-button {
|
|
438
511
|
position: fixed;
|
|
439
512
|
top: 1rem;
|
|
@@ -1362,39 +1435,39 @@ body.side-panel-collapsed .terminal-tabs-shell {
|
|
|
1362
1435
|
background: rgba(var(--ctp-subtext-rgb), 0.38);
|
|
1363
1436
|
box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 0 0.45rem rgba(var(--ctp-subtext-rgb), 0.10);
|
|
1364
1437
|
}
|
|
1365
|
-
.terminal-tab.activity-working .terminal-tab-activity-indicator {
|
|
1438
|
+
.terminal-tab.activity-working > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1366
1439
|
background: var(--ctp-yellow);
|
|
1367
1440
|
box-shadow: 0 0 0 1px rgba(249, 226, 175, 0.34), 0 0 0.8rem rgba(249, 226, 175, 0.52);
|
|
1368
1441
|
animation: terminal-tab-working-pulse 1.15s ease-in-out infinite;
|
|
1369
1442
|
}
|
|
1370
|
-
.terminal-tab.activity-blocked .terminal-tab-activity-indicator {
|
|
1443
|
+
.terminal-tab.activity-blocked > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1371
1444
|
background: var(--ctp-peach);
|
|
1372
1445
|
box-shadow: 0 0 0 1px rgba(250, 179, 135, 0.38), 0 0 0.85rem rgba(250, 179, 135, 0.58);
|
|
1373
1446
|
animation: terminal-tab-working-pulse 1.35s ease-in-out infinite;
|
|
1374
1447
|
}
|
|
1375
|
-
.terminal-tab.activity-done .terminal-tab-activity-indicator {
|
|
1448
|
+
.terminal-tab.activity-done > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1376
1449
|
background: var(--ctp-green);
|
|
1377
1450
|
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.34), 0 0 0.8rem rgba(166, 227, 161, 0.48);
|
|
1378
1451
|
}
|
|
1379
|
-
.terminal-tab.stopped .terminal-tab-activity-indicator {
|
|
1452
|
+
.terminal-tab.stopped > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1380
1453
|
background: var(--ctp-red);
|
|
1381
1454
|
box-shadow: 0 0 0 1px rgba(243, 139, 168, 0.28), 0 0 0.6rem rgba(243, 139, 168, 0.24);
|
|
1382
1455
|
}
|
|
1383
|
-
.terminal-tab-group-item.activity-working .terminal-tab-activity-indicator {
|
|
1456
|
+
.terminal-tab-group-item.activity-working > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1384
1457
|
background: var(--ctp-yellow);
|
|
1385
1458
|
box-shadow: 0 0 0 1px rgba(249, 226, 175, 0.34), 0 0 0.8rem rgba(249, 226, 175, 0.52);
|
|
1386
1459
|
animation: terminal-tab-working-pulse 1.15s ease-in-out infinite;
|
|
1387
1460
|
}
|
|
1388
|
-
.terminal-tab-group-item.activity-blocked .terminal-tab-activity-indicator {
|
|
1461
|
+
.terminal-tab-group-item.activity-blocked > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1389
1462
|
background: var(--ctp-peach);
|
|
1390
1463
|
box-shadow: 0 0 0 1px rgba(250, 179, 135, 0.38), 0 0 0.85rem rgba(250, 179, 135, 0.58);
|
|
1391
1464
|
animation: terminal-tab-working-pulse 1.35s ease-in-out infinite;
|
|
1392
1465
|
}
|
|
1393
|
-
.terminal-tab-group-item.activity-done .terminal-tab-activity-indicator {
|
|
1466
|
+
.terminal-tab-group-item.activity-done > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1394
1467
|
background: var(--ctp-green);
|
|
1395
1468
|
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.34), 0 0 0.8rem rgba(166, 227, 161, 0.48);
|
|
1396
1469
|
}
|
|
1397
|
-
.terminal-tab-group-item.stopped .terminal-tab-activity-indicator {
|
|
1470
|
+
.terminal-tab-group-item.stopped > .terminal-tab-button .terminal-tab-activity-indicator {
|
|
1398
1471
|
background: var(--ctp-red);
|
|
1399
1472
|
box-shadow: 0 0 0 1px rgba(243, 139, 168, 0.28), 0 0 0.6rem rgba(243, 139, 168, 0.24);
|
|
1400
1473
|
}
|
|
@@ -1426,17 +1499,17 @@ body.side-panel-collapsed .terminal-tabs-shell {
|
|
|
1426
1499
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
1427
1500
|
font-size: 0.68rem;
|
|
1428
1501
|
}
|
|
1429
|
-
.terminal-tab.active .terminal-tab-title,
|
|
1430
|
-
.terminal-tab-group-item.active .terminal-tab-title {
|
|
1502
|
+
.terminal-tab.active > .terminal-tab-button .terminal-tab-title,
|
|
1503
|
+
.terminal-tab-group-item.active > .terminal-tab-button .terminal-tab-title {
|
|
1431
1504
|
color: var(--ctp-teal);
|
|
1432
1505
|
text-shadow: 0 0 0.8rem var(--glow-teal);
|
|
1433
1506
|
}
|
|
1434
|
-
.terminal-tab.activity-working .terminal-tab-meta,
|
|
1435
|
-
.terminal-tab-group-item.activity-working .terminal-tab-meta { color: rgba(249, 226, 175, 0.82); }
|
|
1436
|
-
.terminal-tab.activity-blocked .terminal-tab-meta,
|
|
1437
|
-
.terminal-tab-group-item.activity-blocked .terminal-tab-meta { color: rgba(250, 179, 135, 0.88); }
|
|
1438
|
-
.terminal-tab.activity-done .terminal-tab-meta,
|
|
1439
|
-
.terminal-tab-group-item.activity-done .terminal-tab-meta { color: rgba(166, 227, 161, 0.82); }
|
|
1507
|
+
.terminal-tab.activity-working > .terminal-tab-button .terminal-tab-meta,
|
|
1508
|
+
.terminal-tab-group-item.activity-working > .terminal-tab-button .terminal-tab-meta { color: rgba(249, 226, 175, 0.82); }
|
|
1509
|
+
.terminal-tab.activity-blocked > .terminal-tab-button .terminal-tab-meta,
|
|
1510
|
+
.terminal-tab-group-item.activity-blocked > .terminal-tab-button .terminal-tab-meta { color: rgba(250, 179, 135, 0.88); }
|
|
1511
|
+
.terminal-tab.activity-done > .terminal-tab-button .terminal-tab-meta,
|
|
1512
|
+
.terminal-tab-group-item.activity-done > .terminal-tab-button .terminal-tab-meta { color: rgba(166, 227, 161, 0.82); }
|
|
1440
1513
|
.terminal-tab-close {
|
|
1441
1514
|
flex: 0 0 2.1rem;
|
|
1442
1515
|
color: rgba(var(--ctp-subtext-rgb), 0.72);
|
|
@@ -3599,7 +3672,7 @@ button.composer-skill-tag:focus-visible {
|
|
|
3599
3672
|
background: linear-gradient(180deg, rgba(137, 180, 250, 0.14), rgba(var(--ctp-crust-rgb), 0.86));
|
|
3600
3673
|
}
|
|
3601
3674
|
.command-suggest {
|
|
3602
|
-
margin
|
|
3675
|
+
margin: 0 0 0.5rem;
|
|
3603
3676
|
max-height: 15rem;
|
|
3604
3677
|
overflow: auto;
|
|
3605
3678
|
background: rgba(var(--ctp-crust-rgb), 0.94);
|
|
@@ -5491,14 +5564,21 @@ button.composer-skill-tag:focus-visible {
|
|
|
5491
5564
|
}
|
|
5492
5565
|
.terminal-tab { min-width: min(11rem, 100%); max-width: 100%; flex: 1 1 9rem; }
|
|
5493
5566
|
.terminal-tab-group {
|
|
5494
|
-
|
|
5567
|
+
display: grid;
|
|
5568
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
5569
|
+
align-items: stretch;
|
|
5495
5570
|
}
|
|
5496
5571
|
.terminal-tab-group:hover,
|
|
5497
5572
|
.terminal-tab-group:focus-within,
|
|
5498
5573
|
.terminal-tab-group.menu-open {
|
|
5499
5574
|
flex-basis: 100%;
|
|
5500
5575
|
}
|
|
5576
|
+
.terminal-tab-group-button {
|
|
5577
|
+
width: auto;
|
|
5578
|
+
min-width: 0;
|
|
5579
|
+
}
|
|
5501
5580
|
.terminal-tab-group-menu {
|
|
5581
|
+
grid-column: 1 / -1;
|
|
5502
5582
|
position: static;
|
|
5503
5583
|
flex-direction: column;
|
|
5504
5584
|
flex-wrap: nowrap;
|
|
@@ -5506,7 +5586,7 @@ button.composer-skill-tag:focus-visible {
|
|
|
5506
5586
|
max-width: none;
|
|
5507
5587
|
max-height: none;
|
|
5508
5588
|
min-width: 0;
|
|
5509
|
-
margin: 0.34rem;
|
|
5589
|
+
margin: 0.34rem 0 0;
|
|
5510
5590
|
padding-top: 0;
|
|
5511
5591
|
overflow: visible;
|
|
5512
5592
|
}
|
|
@@ -5780,6 +5860,7 @@ button.composer-skill-tag:focus-visible {
|
|
|
5780
5860
|
.composer {
|
|
5781
5861
|
position: sticky;
|
|
5782
5862
|
bottom: 0;
|
|
5863
|
+
z-index: 50;
|
|
5783
5864
|
padding: 0.55rem 0.55rem calc(0.55rem + env(safe-area-inset-bottom));
|
|
5784
5865
|
background: linear-gradient(180deg, rgba(var(--ctp-crust-rgb), 0.92), rgba(var(--ctp-crust-rgb), 0.98));
|
|
5785
5866
|
box-shadow: 0 -0.85rem 1.8rem rgba(var(--ctp-crust-rgb), 0.56), inset 0 1px 0 rgba(255,255,255,0.04);
|
|
@@ -5843,12 +5924,12 @@ button.composer-skill-tag:focus-visible {
|
|
|
5843
5924
|
left: 0.55rem;
|
|
5844
5925
|
right: 0.55rem;
|
|
5845
5926
|
bottom: calc(100% + 0.42rem);
|
|
5846
|
-
z-index:
|
|
5927
|
+
z-index: 55;
|
|
5847
5928
|
display: none;
|
|
5848
5929
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
5849
5930
|
gap: 0.45rem;
|
|
5850
5931
|
max-height: min(42dvh, 22rem);
|
|
5851
|
-
overflow:
|
|
5932
|
+
overflow: visible;
|
|
5852
5933
|
padding: 0.68rem;
|
|
5853
5934
|
border: 1px solid rgba(249, 226, 175, 0.24);
|
|
5854
5935
|
border-radius: 0.95rem;
|
|
@@ -5881,8 +5962,17 @@ button.composer-skill-tag:focus-visible {
|
|
|
5881
5962
|
font-size: 0.92rem;
|
|
5882
5963
|
}
|
|
5883
5964
|
body:not(.pi-run-active):not(.mobile-keyboard-open) .composer-row button.primary { grid-column: span 4; }
|
|
5884
|
-
body.pi-run-active:not(.mobile-keyboard-open) .composer-abort-button:not([hidden]) {
|
|
5885
|
-
|
|
5965
|
+
body.pi-run-active:not(.mobile-keyboard-open) .composer-abort-button:not([hidden]) {
|
|
5966
|
+
order: 1;
|
|
5967
|
+
grid-column: span 2;
|
|
5968
|
+
}
|
|
5969
|
+
body.pi-run-active:not(.mobile-keyboard-open) .composer-row > #steerButton { order: 2; }
|
|
5970
|
+
body.pi-run-active:not(.mobile-keyboard-open) .composer-row > #followUpButton { order: 3; }
|
|
5971
|
+
body.pi-run-active:not(.mobile-keyboard-open) .composer-actions-button { order: 4; }
|
|
5972
|
+
body.pi-run-active:not(.mobile-keyboard-open) .composer-row button.primary {
|
|
5973
|
+
order: 5;
|
|
5974
|
+
grid-column: span 4;
|
|
5975
|
+
}
|
|
5886
5976
|
.composer-row > #followUpButton,
|
|
5887
5977
|
.composer-row > #steerButton,
|
|
5888
5978
|
.composer-actions-button { grid-column: span 2; }
|
|
@@ -5896,31 +5986,49 @@ button.composer-skill-tag:focus-visible {
|
|
|
5896
5986
|
width: 100%;
|
|
5897
5987
|
margin-right: 0;
|
|
5898
5988
|
}
|
|
5989
|
+
.composer-actions-panel > .composer-publish-menu::after {
|
|
5990
|
+
content: "";
|
|
5991
|
+
position: absolute;
|
|
5992
|
+
left: -0.32rem;
|
|
5993
|
+
right: -0.32rem;
|
|
5994
|
+
bottom: 100%;
|
|
5995
|
+
display: none;
|
|
5996
|
+
height: 0.8rem;
|
|
5997
|
+
pointer-events: auto;
|
|
5998
|
+
}
|
|
5999
|
+
.composer-actions-panel > .composer-publish-menu:hover::after,
|
|
6000
|
+
.composer-actions-panel > .composer-publish-menu:focus-within::after,
|
|
6001
|
+
.composer-actions-panel > .composer-publish-menu.open::after {
|
|
6002
|
+
display: block;
|
|
6003
|
+
}
|
|
5899
6004
|
.composer-actions-panel > .composer-publish-menu.open {
|
|
5900
|
-
|
|
5901
|
-
grid-column: 1 / -1;
|
|
5902
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
5903
|
-
gap: 0.42rem;
|
|
6005
|
+
z-index: 120;
|
|
5904
6006
|
}
|
|
5905
|
-
.composer-actions-panel > .composer-publish-menu.open .composer-publish-button
|
|
5906
|
-
|
|
5907
|
-
grid-column: 1 / -1;
|
|
6007
|
+
.composer-actions-panel > .composer-publish-menu.open .composer-publish-button {
|
|
6008
|
+
min-width: 0;
|
|
5908
6009
|
}
|
|
5909
6010
|
.composer-actions-panel > .composer-publish-menu .composer-publish-menu-panel {
|
|
5910
|
-
position:
|
|
6011
|
+
position: absolute;
|
|
6012
|
+
inset: auto auto calc(100% + 0.38rem) 0;
|
|
5911
6013
|
flex-direction: column;
|
|
5912
6014
|
width: 100%;
|
|
5913
6015
|
min-width: 0;
|
|
5914
6016
|
max-width: 100%;
|
|
5915
|
-
max-height:
|
|
5916
|
-
margin
|
|
6017
|
+
max-height: min(34dvh, 18rem);
|
|
6018
|
+
margin: 0;
|
|
5917
6019
|
padding-bottom: 0;
|
|
5918
|
-
overflow:
|
|
6020
|
+
overflow: auto;
|
|
6021
|
+
}
|
|
6022
|
+
.composer-actions-panel > .composer-options-menu .composer-publish-menu-panel {
|
|
6023
|
+
inset-inline: auto 0;
|
|
6024
|
+
max-height: min(76dvh, 34rem);
|
|
5919
6025
|
}
|
|
5920
6026
|
.composer-actions-panel > .composer-publish-menu .composer-publish-menu-item {
|
|
5921
6027
|
width: 100%;
|
|
6028
|
+
min-width: 0;
|
|
5922
6029
|
max-width: 100%;
|
|
5923
|
-
flex: 1 1
|
|
6030
|
+
flex: 1 1 auto;
|
|
6031
|
+
white-space: normal;
|
|
5924
6032
|
}
|
|
5925
6033
|
.command-suggest {
|
|
5926
6034
|
max-height: 35dvh;
|
|
@@ -106,6 +106,7 @@ assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed la
|
|
|
106
106
|
assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
|
|
107
107
|
assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submittable after the agent finishes");
|
|
108
108
|
assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
|
|
109
|
+
assert.ok(html.includes('id="commandSuggest"') && html.indexOf('id="commandSuggest"') < html.indexOf('id="promptInput"'), "slash-command and @ path suggestions should render above the prompt input");
|
|
109
110
|
assert.match(html, /id="busyPromptBehaviorTag"[\s\S]*class="composer-busy-mode-tag"[\s\S]*aria-controls="busyPromptBehaviorMenu"/, "composer should expose a clickable busy prompt behavior tag on the input frame");
|
|
110
111
|
assert.doesNotMatch(html, /Busy send:/i, "busy prompt behavior tag should show only the current mode label");
|
|
111
112
|
assert.match(html, /id="sessionSkillTags" class="composer-skill-tags"[\s\S]*hidden/, "composer should expose a hidden-until-used skill tag strip beside the busy mode tag");
|
|
@@ -175,7 +176,8 @@ assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base control
|
|
|
175
176
|
assert.match(css, /\.composer-row button[\s\S]*?min-height:\s*44px/, "mobile composer buttons should keep 44px touch targets");
|
|
176
177
|
assert.match(css, /\.composer-abort-button,\n\.composer-row button\.primary \{[\s\S]*?min-width:/, "Abort and Send should share stable bottom-row sizing");
|
|
177
178
|
assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animation:\s*abort-long-press-fill 700ms linear forwards/, "Abort should expose a visible long-press progress affordance");
|
|
178
|
-
assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{
|
|
179
|
+
assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{\n\s+order:\s*1;\n\s+grid-column:\s*span 2;/, "active mobile runs should move Abort to the top row");
|
|
180
|
+
assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-actions-button \{ order:\s*4; \}[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{\n\s+order:\s*5;\n\s+grid-column:\s*span 4;/, "active mobile runs should keep Actions beside Send on the bottom row");
|
|
179
181
|
assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
|
|
180
182
|
assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
|
|
181
183
|
assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
|
|
@@ -243,6 +245,7 @@ assert.match(css, /\.command-item \{[\s\S]*?width:\s*100%/, "side-panel commands
|
|
|
243
245
|
assert.match(css, /\.toggle-control \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\)/, "side-panel notification toggle should align checkbox and label text");
|
|
244
246
|
assert.match(css, /\.toggle-control:has\(input:checked\)/, "side-panel notification toggle should style the enabled state");
|
|
245
247
|
assert.match(css, /\.command-item:hover,[\s\S]*?\.command-item:focus-visible/, "side-panel commands should have hover and keyboard focus affordances");
|
|
248
|
+
assert.match(css, /\.command-suggest \{\n\s+margin:\s*0 0 0\.5rem;[\s\S]*?max-height:\s*15rem/, "slash-command and @ path suggestions should reserve spacing below themselves above the prompt input");
|
|
246
249
|
assert.match(css, /\.command-suggest-item:hover \{\n\s+box-shadow: none;\n\s+transform: none;\n\}\n\.command-suggest-item\.active \{/, "autocomplete hover should not render as the selected suggestion unless JS marks it active");
|
|
247
250
|
assert.doesNotMatch(css, /\.command-suggest-item:hover,\n\.command-suggest-item\.active/, "autocomplete hover and active selection styles should stay separate");
|
|
248
251
|
assert.match(css, /\.feedback-tray\[hidden\] \{ display: none; \}/, "queued action-feedback tray should hide when empty");
|
|
@@ -286,9 +289,11 @@ assert.match(css, /\.terminal-tab-group\.active \{[\s\S]*?background:[\s\S]*?var
|
|
|
286
289
|
assert.match(css, /\.terminal-tab-group\.stopped \{[\s\S]*?opacity:\s*1/, "stopped terminal tab groups should not become transparent");
|
|
287
290
|
assert.match(css, /\.terminal-tabs:has\(\.terminal-tab-group\.menu-open\)/, "open terminal tab groups should keep the tab strip usable across rerenders");
|
|
288
291
|
assert.match(css, /\.terminal-tab-group\.menu-open \.terminal-tab-group-menu \{[\s\S]*?display:\s*flex/, "open terminal tab group menus should remain visible without hover");
|
|
289
|
-
assert.match(css, /\.terminal-tab\.activity-working[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
|
|
292
|
+
assert.match(css, /\.terminal-tab\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
|
|
293
|
+
assert.match(css, /\.terminal-tab-group-item\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working indicators should still animate on grouped tab menu items themselves");
|
|
290
294
|
assert.match(css, /\.terminal-tab\.activity-blocked[\s\S]*?rgba\(250, 179, 135/, "blocked tab indicators should use orange styling");
|
|
291
|
-
assert.match(css, /\.terminal-tab\.activity-blocked \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
|
|
295
|
+
assert.match(css, /\.terminal-tab\.activity-blocked > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
|
|
296
|
+
assert.doesNotMatch(css, /\.terminal-tab\.activity-(?:working|blocked|done)\s+\.terminal-tab-activity-indicator/, "group status styling should not cascade into child tabs in the group menu");
|
|
292
297
|
assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should have a distinct tab style");
|
|
293
298
|
assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
|
|
294
299
|
assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body\.mobile-keyboard-open \.widget-area,[\s\S]*?body\.mobile-keyboard-open \.statusbar/, "mobile keyboard mode should hide header, widgets, and footer");
|
|
@@ -795,7 +800,18 @@ assert.match(app, /function syncLastUserPromptFromMessages\(messages = latestMes
|
|
|
795
800
|
assert.match(app, /dataset\.compacted/, "sticky prompt should expose a compacted fallback state when its source message was summarized away");
|
|
796
801
|
assert.match(app, /stickyUserPromptButton\?\.addEventListener\("click", jumpToStickyUserPrompt\)/, "last user prompt header should be clickable without breaking stale cached HTML");
|
|
797
802
|
assert.match(app, /function setComposerActionsOpen\(/, "mobile composer actions panel should be JS-toggleable");
|
|
803
|
+
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.composer \{[\s\S]*?z-index:\s*50;/, "mobile composer should stack above transcript reaction controls while Actions are open");
|
|
804
|
+
assert.match(css, /\.composer-actions-panel \{[\s\S]*?z-index:\s*55;[\s\S]*?overflow:\s*visible;/, "mobile Actions panel should stay above message reactions and allow submenu overlays instead of clipping them into the panel layout");
|
|
805
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu\.open \{\n\s+z-index:\s*120;\n\s+\}/, "opened mobile Actions dropdowns should overlay neighboring controls without taking grid space");
|
|
806
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu::after \{[\s\S]*?bottom:\s*100%;[\s\S]*?height:\s*0\.8rem;[\s\S]*?pointer-events:\s*auto;/, "mobile Actions dropdowns should keep a hover bridge above the trigger and below the floating submenu");
|
|
807
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu:hover::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu:focus-within::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu\.open::after \{\n\s+display:\s*block;/, "mobile Actions dropdown hover bridge should activate while hovered, focused, or opened");
|
|
808
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?position:\s*absolute;[\s\S]*?inset:\s*auto auto calc\(100% \+ 0\.38rem\) 0;[\s\S]*?max-height:\s*min\(34dvh, 18rem\);[\s\S]*?overflow:\s*auto;/, "opened mobile Actions dropdown panels should float upward over the Actions controls with their own scrollbar");
|
|
809
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?max-width:\s*100%;/, "mobile Actions dropdown panels should align to the width of their trigger buttons");
|
|
810
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-item \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*normal;/, "mobile Actions dropdown option buttons should not keep desktop min-widths that misalign with triggers");
|
|
811
|
+
assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel \{[\s\S]*?inset-inline:\s*auto 0;[\s\S]*?max-height:\s*min\(76dvh, 34rem\);/, "mobile Options dropdown should be tall enough to avoid scrolling for the standard option list");
|
|
798
812
|
assert.match(app, /function setMobileTabsExpanded\(/, "mobile tab strip should be JS-toggleable");
|
|
813
|
+
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group \{\n\s+display:\s*grid;\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;/, "mobile terminal tab groups should use a stable grid row for the tab and close button when expanded");
|
|
814
|
+
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group-menu \{[\s\S]*?grid-column:\s*1 \/ -1;[\s\S]*?margin:\s*0\.34rem 0 0;/, "mobile terminal tab group menus should not add horizontal margins that overflow and distort the tab card");
|
|
799
815
|
assert.match(app, /let openTerminalTabGroupKey = null/, "frontend should track the open terminal tab group across tab bar rerenders");
|
|
800
816
|
assert.match(app, /function updateTerminalTabGroupOpenState\(\)/, "frontend should be able to reapply open terminal tab group state after rerenders");
|
|
801
817
|
assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\)/, "frontend should enable dense tab layout before tab names become unreadable");
|