@baique/pi-web-sky 0.1.35 → 0.1.36
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +16 -16
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +1 -1
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/skills/route.js +2 -31
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/update/route.js +1 -1
- package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +16 -16
- package/.next/server/chunks/2404.js +1 -1
- package/.next/server/chunks/3500.js +30 -0
- package/.next/server/chunks/4226.js +2 -2
- package/.next/server/chunks/542.js +10 -5
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/6429.js +1 -1
- package/.next/server/chunks/898.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
- package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
- package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
- package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
- package/.next/static/css/422c7dc0faa04d89.css +1 -0
- package/.next/trace +8 -9
- package/.next/trace-build +1 -1
- package/bin/pi-web-sky.js +15 -20
- package/lib/agent-client.test.mjs +64 -0
- package/lib/agent-client.ts +53 -0
- package/lib/agent-event-connection.test.mjs +193 -0
- package/lib/agent-event-connection.ts +202 -0
- package/lib/agent-event-stream.test.mjs +155 -0
- package/lib/agent-event-stream.ts +132 -0
- package/lib/agent-event-wire.test.mjs +278 -0
- package/lib/agent-event-wire.ts +107 -0
- package/lib/agent-phase.ts +24 -0
- package/lib/allowed-roots.ts +31 -0
- package/lib/ansi.test.mjs +52 -0
- package/lib/ansi.ts +211 -0
- package/lib/api-types.ts +139 -0
- package/lib/app-update.test.mjs +27 -0
- package/lib/app-update.ts +28 -0
- package/lib/atomic-file.test.mjs +37 -0
- package/lib/atomic-file.ts +34 -0
- package/lib/audit-session.ts +235 -0
- package/lib/bash-output.test.mjs +63 -0
- package/lib/bash-output.ts +53 -0
- package/lib/bg-image.ts +129 -0
- package/lib/board-events.ts +83 -0
- package/lib/board-purge.test.mjs +66 -0
- package/lib/board-purge.ts +65 -0
- package/lib/board-search.test.mjs +110 -0
- package/lib/board-search.ts +63 -0
- package/lib/board-store.test.mjs +542 -0
- package/lib/board-store.ts +794 -0
- package/lib/board-types.ts +96 -0
- package/lib/board-utils.ts +17 -0
- package/lib/bounded-form-data.test.mjs +54 -0
- package/lib/bounded-form-data.ts +49 -0
- package/lib/browser-notifications.test.mjs +167 -0
- package/lib/browser-notifications.ts +104 -0
- package/lib/chat-lazy-load.test.mjs +140 -0
- package/lib/chat-lazy-load.ts +64 -0
- package/lib/clipboard-paths.test.mjs +140 -0
- package/lib/clipboard-paths.ts +192 -0
- package/lib/clipboard.ts +18 -0
- package/lib/compaction-summary.test.mjs +44 -0
- package/lib/compaction-summary.ts +34 -0
- package/lib/custom-ui-terminal.test.mjs +32 -0
- package/lib/custom-ui-terminal.ts +27 -0
- package/lib/deepseek-pricing.test.mjs +120 -0
- package/lib/deepseek-pricing.ts +71 -0
- package/lib/directory-browser.test.mjs +60 -0
- package/lib/directory-browser.ts +83 -0
- package/lib/draft-stash.test.mjs +91 -0
- package/lib/draft-stash.ts +102 -0
- package/lib/draft-store.ts +105 -0
- package/lib/dropdown-direction.ts +27 -0
- package/lib/extension-command.test.mjs +63 -0
- package/lib/extension-command.ts +81 -0
- package/lib/file-access.test.mjs +31 -0
- package/lib/file-access.ts +59 -0
- package/lib/file-dirent.test.mjs +56 -0
- package/lib/file-dirent.ts +17 -0
- package/lib/file-explorer-state.test.mjs +63 -0
- package/lib/file-explorer-state.ts +36 -0
- package/lib/file-fuzzy.test.mjs +29 -0
- package/lib/file-fuzzy.ts +189 -0
- package/lib/file-links.test.mjs +92 -0
- package/lib/file-links.ts +148 -0
- package/lib/file-paths.ts +43 -0
- package/lib/file-types.test.mjs +34 -0
- package/lib/file-types.ts +73 -0
- package/lib/file-upload.test.mjs +70 -0
- package/lib/file-upload.ts +59 -0
- package/lib/file-viewer-state.test.mjs +20 -0
- package/lib/file-viewer-state.ts +15 -0
- package/lib/frontmatter.test.mjs +55 -0
- package/lib/frontmatter.ts +87 -0
- package/lib/git-changes.test.mjs +52 -0
- package/lib/git-changes.ts +232 -0
- package/lib/git-status.ts +47 -0
- package/lib/git-types.ts +29 -0
- package/lib/http-dispatcher.test.mjs +83 -0
- package/lib/http-dispatcher.ts +86 -0
- package/lib/i18n/format.test.mjs +19 -0
- package/lib/i18n/format.ts +60 -0
- package/lib/i18n/messages/en.ts +662 -0
- package/lib/i18n/messages/zh-CN.ts +661 -0
- package/lib/i18n/registry.test.mjs +32 -0
- package/lib/i18n/registry.ts +43 -0
- package/lib/i18n/types.ts +15 -0
- package/lib/image-attachments.test.mjs +27 -0
- package/lib/image-attachments.ts +56 -0
- package/lib/initial-navigation.test.mjs +65 -0
- package/lib/initial-navigation.ts +15 -0
- package/lib/markdown.test.mjs +104 -0
- package/lib/markdown.ts +347 -0
- package/lib/message-display.test.mjs +130 -0
- package/lib/message-display.ts +47 -0
- package/lib/model-catalog.test.mjs +327 -0
- package/lib/model-catalog.ts +414 -0
- package/lib/model-discovery-auth.ts +58 -0
- package/lib/model-discovery.test.mjs +50 -0
- package/lib/model-discovery.ts +75 -0
- package/lib/model-id-preference.ts +46 -0
- package/lib/model-scope.test.mjs +193 -0
- package/lib/model-scope.ts +198 -0
- package/lib/models-cache.test.mjs +122 -0
- package/lib/models-cache.ts +90 -0
- package/lib/models-config-store.test.mjs +200 -0
- package/lib/models-config-store.ts +84 -0
- package/lib/next-config-esm.test.mjs +14 -0
- package/lib/next-config.test.mjs +13 -0
- package/lib/node-version.test.mjs +33 -0
- package/lib/normalize.test.mjs +54 -0
- package/lib/normalize.ts +63 -0
- package/lib/npx.ts +62 -0
- package/lib/panel-layout.test.mjs +56 -0
- package/lib/panel-layout.ts +52 -0
- package/lib/patch.test.mjs +110 -0
- package/lib/patch.ts +134 -0
- package/lib/path-security.ts +42 -0
- package/lib/paths.test.mjs +58 -0
- package/lib/paths.ts +71 -0
- package/lib/pi-types.ts +183 -0
- package/lib/pi-web-options.test.mjs +63 -0
- package/lib/process-lifecycle.test.mjs +83 -0
- package/lib/project-command-env.test.mjs +298 -0
- package/lib/project-command-env.ts +129 -0
- package/lib/project-groups.test.mjs +60 -0
- package/lib/project-groups.ts +53 -0
- package/lib/project-identity.test.mjs +37 -0
- package/lib/project-identity.ts +26 -0
- package/lib/project-tree.test.mjs +115 -0
- package/lib/project-tree.ts +176 -0
- package/lib/project-trust.test.mjs +117 -0
- package/lib/project-trust.ts +48 -0
- package/lib/prompt-recovery.test.mjs +33 -0
- package/lib/prompt-recovery.ts +47 -0
- package/lib/provider-api-key-route.test.mjs +12 -0
- package/lib/provider-credential-store.test.mjs +126 -0
- package/lib/provider-credential-store.ts +114 -0
- package/lib/provider-listing-runtime.ts +37 -0
- package/lib/provider-listing.test.mjs +114 -0
- package/lib/provider-listing.ts +118 -0
- package/lib/provider-services.ts +64 -0
- package/lib/request-security.test.mjs +256 -0
- package/lib/request-security.ts +136 -0
- package/lib/rpc-manager-shutdown.test.mjs +372 -0
- package/lib/rpc-manager-widgets.test.mjs +342 -0
- package/lib/rpc-manager.test.mjs +139 -0
- package/lib/rpc-manager.ts +1703 -0
- package/lib/rpc-session-info.test.mjs +93 -0
- package/lib/session-delete.test.mjs +53 -0
- package/lib/session-delete.ts +86 -0
- package/lib/session-file-references-core.ts +82 -0
- package/lib/session-file-references.test.mjs +91 -0
- package/lib/session-file-references.ts +29 -0
- package/lib/session-path.test.mjs +24 -0
- package/lib/session-path.ts +11 -0
- package/lib/session-reader.pagination.test.mjs +115 -0
- package/lib/session-reader.test.mjs +446 -0
- package/lib/session-reader.ts +548 -0
- package/lib/session-resolve.test.mjs +65 -0
- package/lib/session-row-context-menu.test.mjs +46 -0
- package/lib/session-row-context-menu.ts +29 -0
- package/lib/session-scanner.test.mjs +151 -0
- package/lib/session-scanner.ts +238 -0
- package/lib/session-search.test.mjs +173 -0
- package/lib/session-search.ts +284 -0
- package/lib/session-stats.ts +26 -0
- package/lib/session-timing.test.mjs +107 -0
- package/lib/session-timing.ts +45 -0
- package/lib/session-title.test.mjs +284 -0
- package/lib/session-title.ts +255 -0
- package/lib/skill-frontmatter.test.mjs +75 -0
- package/lib/skill-frontmatter.ts +40 -0
- package/lib/skill-lock.test.mjs +182 -0
- package/lib/skill-lock.ts +146 -0
- package/lib/skill-updates.test.mjs +182 -0
- package/lib/skill-updates.ts +263 -0
- package/lib/skills-service.ts +16 -0
- package/lib/slash-display.test.mjs +122 -0
- package/lib/slash-display.ts +30 -0
- package/lib/sqlite-db.test.mjs +140 -0
- package/lib/sqlite-db.ts +205 -0
- package/lib/startup-preferences.test.mjs +130 -0
- package/lib/startup-preferences.ts +65 -0
- package/lib/streaming-message.test.mjs +212 -0
- package/lib/streaming-message.ts +151 -0
- package/lib/subagent-widget.test.mjs +87 -0
- package/lib/subagent-widget.ts +109 -0
- package/lib/sync-room-server.mjs +137 -0
- package/lib/task-card-store.test.mjs +309 -0
- package/lib/task-card-store.ts +559 -0
- package/lib/task-scheduler.ts +498 -0
- package/lib/task-store.test.mjs +216 -0
- package/lib/task-store.ts +271 -0
- package/lib/terminal-input.test.mjs +46 -0
- package/lib/terminal-input.ts +63 -0
- package/lib/terminal-manager.test.mjs +71 -0
- package/lib/terminal-manager.ts +245 -0
- package/lib/tool-execution-progress.test.mjs +32 -0
- package/lib/tool-execution-progress.ts +29 -0
- package/lib/tool-names.ts +25 -0
- package/lib/tool-preset-preference.test.mjs +72 -0
- package/lib/tool-preset-preference.ts +41 -0
- package/lib/tool-presets.test.mjs +56 -0
- package/lib/tool-presets.ts +42 -0
- package/lib/turn-written-files.test.mjs +137 -0
- package/lib/turn-written-files.ts +60 -0
- package/lib/types.ts +349 -0
- package/lib/wallpaper-settings.ts +191 -0
- package/lib/web-auth.test.mjs +50 -0
- package/lib/web-auth.ts +45 -0
- package/lib/workspace-memory.test.mjs +129 -0
- package/lib/workspace-memory.ts +98 -0
- package/lib/worktree.test.mjs +50 -0
- package/lib/worktree.ts +250 -0
- package/package.json +3 -1
- package/server.mjs +34 -0
- package/.next/server/chunks/7513.js +0 -30
- package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
- package/.next/static/css/cf25164cb7c04d95.css +0 -1
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createJiti } from "jiti";
|
|
7
|
+
|
|
8
|
+
const jiti = createJiti(import.meta.url);
|
|
9
|
+
const {
|
|
10
|
+
normalizeModelsConfigCosts,
|
|
11
|
+
readModelsConfig,
|
|
12
|
+
writeModelsConfig,
|
|
13
|
+
} = await jiti.import("./models-config-store.ts");
|
|
14
|
+
const { invalidateModelsCache, loadModelsWithCache } = await jiti.import("./models-cache.ts");
|
|
15
|
+
const { buildSessionContext, getSessionEntries } = await jiti.import("./session-reader.ts");
|
|
16
|
+
|
|
17
|
+
function createTempRoot(t) {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "pi-web-models-config-"));
|
|
19
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function modelsData(id) {
|
|
24
|
+
return {
|
|
25
|
+
models: { [`provider:${id}`]: id },
|
|
26
|
+
modelList: [{ id, name: id, provider: "provider" }],
|
|
27
|
+
defaultModel: null,
|
|
28
|
+
thinkingLevels: {},
|
|
29
|
+
thinkingLevelMaps: {},
|
|
30
|
+
thinkingLevelPins: {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("saving models.json atomically invalidates the model-list cache", async (t) => {
|
|
35
|
+
const root = createTempRoot(t);
|
|
36
|
+
const modelsPath = join(root, "agent", "models.json");
|
|
37
|
+
const config = {
|
|
38
|
+
providers: {
|
|
39
|
+
acme: {
|
|
40
|
+
baseUrl: "https://models.example.test/v1",
|
|
41
|
+
api: "openai-completions",
|
|
42
|
+
models: [{ id: "acme-2" }],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
let loads = 0;
|
|
47
|
+
|
|
48
|
+
invalidateModelsCache();
|
|
49
|
+
await loadModelsWithCache(root, async () => modelsData(`load-${++loads}`));
|
|
50
|
+
writeModelsConfig(config, modelsPath);
|
|
51
|
+
const reloaded = await loadModelsWithCache(root, async () => modelsData(`load-${++loads}`));
|
|
52
|
+
|
|
53
|
+
assert.equal(loads, 2);
|
|
54
|
+
assert.equal(reloaded.modelList[0].id, "load-2");
|
|
55
|
+
assert.deepEqual(readModelsConfig(modelsPath), config);
|
|
56
|
+
assert.deepEqual(readdirSync(join(root, "agent")), ["models.json"]);
|
|
57
|
+
if (process.platform !== "win32") {
|
|
58
|
+
assert.equal(statSync(modelsPath).mode & 0o777, 0o600);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("models.json writes fill partial cost groups with zero and remove empty groups", (t) => {
|
|
63
|
+
const root = createTempRoot(t);
|
|
64
|
+
const modelsPath = join(root, "agent", "models.json");
|
|
65
|
+
const config = {
|
|
66
|
+
providers: {
|
|
67
|
+
acme: {
|
|
68
|
+
models: [
|
|
69
|
+
{ id: "empty-cost", cost: {} },
|
|
70
|
+
{ id: "partial-cost", cost: { input: 1, output: 2, cacheRead: 0.1 } },
|
|
71
|
+
{ id: "complete-cost", cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1.1 } },
|
|
72
|
+
],
|
|
73
|
+
modelOverrides: {
|
|
74
|
+
inherited: { cost: { input: 3 } },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const normalized = normalizeModelsConfigCosts(config);
|
|
81
|
+
assert.deepEqual(normalized.providers.acme.models, [
|
|
82
|
+
{ id: "empty-cost" },
|
|
83
|
+
{ id: "partial-cost", cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } },
|
|
84
|
+
{ id: "complete-cost", cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1.1 } },
|
|
85
|
+
]);
|
|
86
|
+
assert.deepEqual(normalized.providers.acme.modelOverrides, {
|
|
87
|
+
inherited: { cost: { input: 3 } },
|
|
88
|
+
});
|
|
89
|
+
assert.deepEqual(config.providers.acme.models[0], { id: "empty-cost", cost: {} });
|
|
90
|
+
|
|
91
|
+
writeModelsConfig(config, modelsPath);
|
|
92
|
+
assert.deepEqual(readModelsConfig(modelsPath), normalized);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("saving models.json drops blank model rows without hiding other schema errors", (t) => {
|
|
96
|
+
const root = createTempRoot(t);
|
|
97
|
+
const modelsPath = join(root, "agent", "models.json");
|
|
98
|
+
|
|
99
|
+
writeModelsConfig({
|
|
100
|
+
providers: {
|
|
101
|
+
acme: {
|
|
102
|
+
baseUrl: "https://models.example.test/v1",
|
|
103
|
+
api: "openai-completions",
|
|
104
|
+
models: [
|
|
105
|
+
{ id: "working-model", cost: { input: 1 } },
|
|
106
|
+
{ id: "" },
|
|
107
|
+
{ id: " " },
|
|
108
|
+
{ id: 42 },
|
|
109
|
+
{ name: "Missing identifier" },
|
|
110
|
+
null,
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
}, modelsPath);
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(readModelsConfig(modelsPath), {
|
|
117
|
+
providers: {
|
|
118
|
+
acme: {
|
|
119
|
+
baseUrl: "https://models.example.test/v1",
|
|
120
|
+
api: "openai-completions",
|
|
121
|
+
models: [
|
|
122
|
+
{ id: "working-model", cost: { input: 1, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
123
|
+
{ id: 42 },
|
|
124
|
+
{ name: "Missing identifier" },
|
|
125
|
+
null,
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("an existing session opens after its historical model is removed from config", (t) => {
|
|
133
|
+
const root = createTempRoot(t);
|
|
134
|
+
const sessionPath = join(root, "session.jsonl");
|
|
135
|
+
const modelsPath = join(root, "models.json");
|
|
136
|
+
const records = [
|
|
137
|
+
{
|
|
138
|
+
type: "session",
|
|
139
|
+
version: 3,
|
|
140
|
+
id: "existing-session",
|
|
141
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
142
|
+
cwd: root,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "model_change",
|
|
146
|
+
id: "model-old",
|
|
147
|
+
parentId: null,
|
|
148
|
+
provider: "retired-provider",
|
|
149
|
+
modelId: "retired-model",
|
|
150
|
+
timestamp: "2026-01-01T00:00:01.000Z",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "message",
|
|
154
|
+
id: "user-1",
|
|
155
|
+
parentId: "model-old",
|
|
156
|
+
timestamp: "2026-01-01T00:00:02.000Z",
|
|
157
|
+
message: { role: "user", content: "keep this conversation" },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
type: "message",
|
|
161
|
+
id: "assistant-1",
|
|
162
|
+
parentId: "user-1",
|
|
163
|
+
timestamp: "2026-01-01T00:00:03.000Z",
|
|
164
|
+
message: {
|
|
165
|
+
role: "assistant",
|
|
166
|
+
provider: "retired-provider",
|
|
167
|
+
model: "retired-model",
|
|
168
|
+
content: [{ type: "text", text: "still readable" }],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
writeFileSync(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
173
|
+
|
|
174
|
+
writeModelsConfig({
|
|
175
|
+
providers: {
|
|
176
|
+
"retired-provider": {
|
|
177
|
+
baseUrl: "https://retired.example.test/v1",
|
|
178
|
+
api: "openai-completions",
|
|
179
|
+
models: [{ id: "retired-model" }],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}, modelsPath);
|
|
183
|
+
const beforeChange = buildSessionContext(getSessionEntries(sessionPath));
|
|
184
|
+
assert.equal(beforeChange.messages[1].content[0].text, "still readable");
|
|
185
|
+
|
|
186
|
+
writeModelsConfig({
|
|
187
|
+
providers: {
|
|
188
|
+
replacement: {
|
|
189
|
+
baseUrl: "https://replacement.example.test/v1",
|
|
190
|
+
api: "openai-completions",
|
|
191
|
+
models: [{ id: "replacement-model" }],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}, modelsPath);
|
|
195
|
+
|
|
196
|
+
const afterChange = buildSessionContext(getSessionEntries(sessionPath));
|
|
197
|
+
assert.deepEqual(afterChange.entryIds, ["user-1", "assistant-1"]);
|
|
198
|
+
assert.equal(afterChange.messages[0].content, "keep this conversation");
|
|
199
|
+
assert.equal(afterChange.messages[1].content[0].text, "still readable");
|
|
200
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { writePrivateFileAtomicSync } from "./atomic-file";
|
|
5
|
+
import { invalidateModelsCache } from "./models-cache";
|
|
6
|
+
|
|
7
|
+
const MODEL_COST_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const;
|
|
8
|
+
|
|
9
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeModelCost(value: unknown): Record<string, unknown> | undefined {
|
|
14
|
+
if (!isRecord(value)) return undefined;
|
|
15
|
+
const providedKeys = MODEL_COST_KEYS.filter((key) => value[key] !== undefined);
|
|
16
|
+
if (providedKeys.length === 0) return undefined;
|
|
17
|
+
if (providedKeys.some((key) => (
|
|
18
|
+
typeof value[key] !== "number" || !Number.isFinite(value[key])
|
|
19
|
+
))) return undefined;
|
|
20
|
+
|
|
21
|
+
return Object.fromEntries([
|
|
22
|
+
...Object.entries(value),
|
|
23
|
+
...MODEL_COST_KEYS.map((key) => [key, value[key] ?? 0]),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Complete partial cost groups with zero; omit a cost group only when it is empty. */
|
|
28
|
+
export function normalizeModelsConfigCosts(
|
|
29
|
+
data: Record<string, unknown>,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
const normalized = structuredClone(data);
|
|
32
|
+
if (!isRecord(normalized.providers)) return normalized;
|
|
33
|
+
|
|
34
|
+
for (const provider of Object.values(normalized.providers)) {
|
|
35
|
+
if (!isRecord(provider) || !Array.isArray(provider.models)) continue;
|
|
36
|
+
for (const model of provider.models) {
|
|
37
|
+
if (!isRecord(model) || !("cost" in model)) continue;
|
|
38
|
+
const cost = normalizeModelCost(model.cost);
|
|
39
|
+
if (cost) model.cost = cost;
|
|
40
|
+
else delete model.cost;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeModelsConfig(data: Record<string, unknown>): Record<string, unknown> {
|
|
47
|
+
if (!isRecord(data.providers)) return data;
|
|
48
|
+
|
|
49
|
+
const providers = Object.fromEntries(Object.entries(data.providers).map(([providerId, provider]) => {
|
|
50
|
+
if (!isRecord(provider) || !Array.isArray(provider.models)) return [providerId, provider];
|
|
51
|
+
const models = provider.models.filter((model) => (
|
|
52
|
+
!isRecord(model) || typeof model.id !== "string" || model.id.trim().length > 0
|
|
53
|
+
));
|
|
54
|
+
return [providerId, { ...provider, models }];
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
return { ...data, providers };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getModelsConfigPath(): string {
|
|
61
|
+
return join(getAgentDir(), "models.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readModelsConfig(
|
|
65
|
+
modelsPath = getModelsConfigPath(),
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
if (!existsSync(modelsPath)) return { providers: {} };
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(modelsPath, "utf8")) as Record<string, unknown>;
|
|
70
|
+
} catch {
|
|
71
|
+
return { providers: {} };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function writeModelsConfig(
|
|
76
|
+
data: Record<string, unknown>,
|
|
77
|
+
modelsPath = getModelsConfigPath(),
|
|
78
|
+
): void {
|
|
79
|
+
const dir = dirname(modelsPath);
|
|
80
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
81
|
+
const normalized = normalizeModelsConfigCosts(sanitizeModelsConfig(data));
|
|
82
|
+
writePrivateFileAtomicSync(modelsPath, JSON.stringify(normalized, null, 2));
|
|
83
|
+
invalidateModelsCache();
|
|
84
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const configUrl = new URL("../next.config.ts", import.meta.url);
|
|
6
|
+
const source = await readFile(configUrl, "utf8");
|
|
7
|
+
|
|
8
|
+
test("resolves config paths without CommonJS globals", async () => {
|
|
9
|
+
assert.doesNotMatch(source, /\b__dirname\b/);
|
|
10
|
+
assert.match(source, /dirname\(fileURLToPath\(import\.meta\.url\)\)/);
|
|
11
|
+
|
|
12
|
+
const config = await import(`${configUrl.href}?esm-test`);
|
|
13
|
+
assert.equal(typeof config.default.env.NEXT_PUBLIC_APP_VERSION, "string");
|
|
14
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
|
|
9
|
+
test("scopes Next.js output file tracing to the pi-web package", async () => {
|
|
10
|
+
const config = await createJiti(import.meta.url).import("../next.config.ts", { default: true });
|
|
11
|
+
|
|
12
|
+
assert.equal(config.outputFileTracingRoot, projectRoot);
|
|
13
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const packageJson = require("../package.json");
|
|
7
|
+
const {
|
|
8
|
+
MIN_NODE_VERSION,
|
|
9
|
+
getUnsupportedNodeVersionMessage,
|
|
10
|
+
isNodeVersionSupported,
|
|
11
|
+
} = require("../bin/node-version.js");
|
|
12
|
+
|
|
13
|
+
test("accepts the minimum supported Node.js version and newer versions", () => {
|
|
14
|
+
for (const version of ["22.19.0", "v22.19.0", "22.19.1", "23.0.0"]) {
|
|
15
|
+
assert.equal(isNodeVersionSupported(version), true, version);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects older and invalid Node.js versions", () => {
|
|
20
|
+
for (const version of ["20.19.5", "22.18.99", "invalid"]) {
|
|
21
|
+
assert.equal(isNodeVersionSupported(version), false, version);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("keeps the package engine aligned with the startup check", () => {
|
|
26
|
+
assert.equal(packageJson.engines.node, `>=${MIN_NODE_VERSION}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("reports both the required and current Node.js versions", () => {
|
|
30
|
+
const message = getUnsupportedNodeVersionMessage("20.19.5");
|
|
31
|
+
assert.match(message, /requires Node\.js 22\.19\.0 or newer/);
|
|
32
|
+
assert.match(message, /Current Node\.js version: 20\.19\.5/);
|
|
33
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const jiti = createJiti(import.meta.url);
|
|
6
|
+
const {
|
|
7
|
+
normalizeStreamingToolCalls,
|
|
8
|
+
normalizeToolCalls,
|
|
9
|
+
} = await jiti.import("./normalize.ts");
|
|
10
|
+
|
|
11
|
+
function assistant(block) {
|
|
12
|
+
return {
|
|
13
|
+
role: "assistant",
|
|
14
|
+
provider: "anthropic",
|
|
15
|
+
model: "claude-test",
|
|
16
|
+
content: [block],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("keeps SDK scratch input only for an in-flight snapshot", () => {
|
|
21
|
+
const message = assistant({
|
|
22
|
+
type: "toolCall",
|
|
23
|
+
id: "call-1",
|
|
24
|
+
name: "write",
|
|
25
|
+
arguments: { path: "/tmp/file" },
|
|
26
|
+
partialJson: '{"path":"/tmp/file","content":"hel',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
assert.deepEqual(normalizeStreamingToolCalls(message).content[0], {
|
|
30
|
+
type: "toolCall",
|
|
31
|
+
toolCallId: "call-1",
|
|
32
|
+
toolName: "write",
|
|
33
|
+
input: { path: "/tmp/file" },
|
|
34
|
+
rawInput: '{"path":"/tmp/file","content":"hel',
|
|
35
|
+
});
|
|
36
|
+
assert.deepEqual(normalizeToolCalls(message).content[0], {
|
|
37
|
+
type: "toolCall",
|
|
38
|
+
toolCallId: "call-1",
|
|
39
|
+
toolName: "write",
|
|
40
|
+
input: { path: "/tmp/file" },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("removes a client raw buffer when normalizing a completed message", () => {
|
|
45
|
+
const message = assistant({
|
|
46
|
+
type: "toolCall",
|
|
47
|
+
toolCallId: "call-2",
|
|
48
|
+
toolName: "write",
|
|
49
|
+
input: { path: "/tmp/file", content: "complete" },
|
|
50
|
+
rawInput: "temporary",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(Object.hasOwn(normalizeToolCalls(message).content[0], "rawInput"), false);
|
|
54
|
+
});
|
package/lib/normalize.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentMessage, AssistantMessage, ToolCallContent } from "./types";
|
|
2
|
+
|
|
3
|
+
function isObject(val: unknown): val is Record<string, unknown> {
|
|
4
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function streamingRawInput(block: Record<string, unknown>): string | undefined {
|
|
8
|
+
if (typeof block.rawInput === "string") return block.rawInput;
|
|
9
|
+
if (typeof block.partialJson === "string") return block.partialJson;
|
|
10
|
+
if (typeof block.partialArgs === "string") return block.partialArgs;
|
|
11
|
+
|
|
12
|
+
const customInput = isObject(block.customInput) ? block.customInput : null;
|
|
13
|
+
const property = customInput && typeof customInput.property === "string"
|
|
14
|
+
? customInput.property
|
|
15
|
+
: null;
|
|
16
|
+
const args = isObject(block.arguments) ? block.arguments : null;
|
|
17
|
+
return property && args && typeof args[property] === "string"
|
|
18
|
+
? args[property]
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeToolCallBlock(
|
|
23
|
+
block: unknown,
|
|
24
|
+
options: { includeStreamingRawInput?: boolean } = {},
|
|
25
|
+
): ToolCallContent | null {
|
|
26
|
+
if (!isObject(block) || block.type !== "toolCall") return null;
|
|
27
|
+
const normalized: ToolCallContent = {
|
|
28
|
+
type: "toolCall",
|
|
29
|
+
toolCallId: typeof block.toolCallId === "string" ? block.toolCallId : (typeof block.id === "string" ? block.id : ""),
|
|
30
|
+
toolName: typeof block.toolName === "string" ? block.toolName : (typeof block.name === "string" ? block.name : ""),
|
|
31
|
+
input: typeof block.input === "object" && block.input !== null && !Array.isArray(block.input)
|
|
32
|
+
? block.input as Record<string, unknown>
|
|
33
|
+
: (typeof block.arguments === "object" && block.arguments !== null && !Array.isArray(block.arguments)
|
|
34
|
+
? block.arguments as Record<string, unknown>
|
|
35
|
+
: {}),
|
|
36
|
+
};
|
|
37
|
+
const rawInput = options.includeStreamingRawInput ? streamingRawInput(block) : undefined;
|
|
38
|
+
return rawInput === undefined ? normalized : { ...normalized, rawInput };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeAssistantToolCalls(
|
|
42
|
+
msg: AgentMessage,
|
|
43
|
+
options: { includeStreamingRawInput?: boolean } = {},
|
|
44
|
+
): AgentMessage {
|
|
45
|
+
// Non-assistant roles (user, toolResult, bashExecution, custom) are returned
|
|
46
|
+
// unchanged — only assistant messages go through tool-call field normalization.
|
|
47
|
+
if (msg.role !== "assistant") return msg;
|
|
48
|
+
const content = (msg as AssistantMessage).content;
|
|
49
|
+
if (!Array.isArray(content)) return msg;
|
|
50
|
+
const normalized = content.map((block) => {
|
|
51
|
+
const result = normalizeToolCallBlock(block, options);
|
|
52
|
+
return result ?? block;
|
|
53
|
+
});
|
|
54
|
+
return { ...msg, content: normalized } as AgentMessage;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeToolCalls(msg: AgentMessage): AgentMessage {
|
|
58
|
+
return normalizeAssistantToolCalls(msg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeStreamingToolCalls(msg: AgentMessage): AgentMessage {
|
|
62
|
+
return normalizeAssistantToolCalls(msg, { includeStreamingRawInput: true });
|
|
63
|
+
}
|
package/lib/npx.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { execPath } from "process";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Locate `npx-cli.js` shipped with the running Node.js installation.
|
|
11
|
+
*
|
|
12
|
+
* On Windows the `npx` on PATH is actually `npx.cmd`, which Node.js (since
|
|
13
|
+
* 20.12 due to CVE-2024-27980) refuses to spawn from `execFile`/`spawn`
|
|
14
|
+
* without `shell: true`. Going through a shell reintroduces quoting bugs for
|
|
15
|
+
* user-supplied args. Instead we find the real `npx-cli.js` and invoke it
|
|
16
|
+
* directly via the current `node` binary, which works identically on every
|
|
17
|
+
* platform and needs no shell.
|
|
18
|
+
*/
|
|
19
|
+
function findNpxCli(): string | null {
|
|
20
|
+
const nodeDir = dirname(execPath);
|
|
21
|
+
const candidates = [
|
|
22
|
+
// Windows MSI installer layout: node.exe and node_modules share a dir
|
|
23
|
+
join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js"),
|
|
24
|
+
// Unix layout: .../bin/node + .../lib/node_modules/npm/bin/npx-cli.js
|
|
25
|
+
join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npx-cli.js"),
|
|
26
|
+
];
|
|
27
|
+
for (const p of candidates) {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(p)) return p;
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RunNpxOptions {
|
|
38
|
+
timeout?: number;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RunNpxResult {
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cross-platform wrapper for invoking `npx <args>` without ever using a
|
|
50
|
+
* shell, so user-controlled arguments are never interpreted as shell syntax.
|
|
51
|
+
*/
|
|
52
|
+
export async function runNpx(args: string[], opts: RunNpxOptions = {}): Promise<RunNpxResult> {
|
|
53
|
+
const npxCli = findNpxCli();
|
|
54
|
+
const { command, commandArgs } = npxCli
|
|
55
|
+
? { command: execPath, commandArgs: [npxCli, ...args] }
|
|
56
|
+
: { command: "npx", commandArgs: args };
|
|
57
|
+
return execFileAsync(command, commandArgs, {
|
|
58
|
+
timeout: opts.timeout,
|
|
59
|
+
cwd: opts.cwd,
|
|
60
|
+
env: opts.env,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const jiti = createJiti(import.meta.url);
|
|
6
|
+
const {
|
|
7
|
+
clampPanelWidth,
|
|
8
|
+
getDefaultRightPanelWidth,
|
|
9
|
+
getRightPanelMaxWidth,
|
|
10
|
+
getSidebarMaxWidth,
|
|
11
|
+
} = await jiti.import("./panel-layout.ts");
|
|
12
|
+
|
|
13
|
+
test("clamps panel widths to finite bounds", () => {
|
|
14
|
+
assert.equal(clampPanelWidth(420.4, 180, 480), 420);
|
|
15
|
+
assert.equal(clampPanelWidth(120, 180, 480), 180);
|
|
16
|
+
assert.equal(clampPanelWidth(600, 180, 480), 480);
|
|
17
|
+
assert.equal(clampPanelWidth(Number.NaN, 180, 480), 180);
|
|
18
|
+
assert.equal(clampPanelWidth(200, 300, 250), 300);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("keeps the responsive right panel default within useful limits", () => {
|
|
22
|
+
assert.equal(getDefaultRightPanelWidth(700), 360);
|
|
23
|
+
assert.equal(getDefaultRightPanelWidth(1366), 574);
|
|
24
|
+
assert.equal(getDefaultRightPanelWidth(1920), 640);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("reserves chat space while split panels are visible", () => {
|
|
28
|
+
assert.equal(getSidebarMaxWidth({
|
|
29
|
+
viewportWidth: 700,
|
|
30
|
+
rightPanelOpen: true,
|
|
31
|
+
rightPanelWidth: 560,
|
|
32
|
+
}), 380);
|
|
33
|
+
assert.equal(getSidebarMaxWidth({
|
|
34
|
+
viewportWidth: 1366,
|
|
35
|
+
rightPanelOpen: true,
|
|
36
|
+
rightPanelWidth: 686,
|
|
37
|
+
}), 260);
|
|
38
|
+
assert.equal(getRightPanelMaxWidth({
|
|
39
|
+
viewportWidth: 1024,
|
|
40
|
+
sidebarOpen: true,
|
|
41
|
+
sidebarWidth: 260,
|
|
42
|
+
}), 344);
|
|
43
|
+
assert.equal(getRightPanelMaxWidth({
|
|
44
|
+
viewportWidth: 1366,
|
|
45
|
+
sidebarOpen: true,
|
|
46
|
+
sidebarWidth: 260,
|
|
47
|
+
}), 686);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("does not rewrite desktop widths while the file panel is in overlay mode", () => {
|
|
51
|
+
assert.equal(getRightPanelMaxWidth({
|
|
52
|
+
viewportWidth: 900,
|
|
53
|
+
sidebarOpen: true,
|
|
54
|
+
sidebarWidth: 480,
|
|
55
|
+
}), 1200);
|
|
56
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const MOBILE_MAX_WIDTH = 640;
|
|
2
|
+
export const SPLIT_PANEL_MIN_WIDTH = 960;
|
|
3
|
+
|
|
4
|
+
export const SIDEBAR_DEFAULT_WIDTH = 260;
|
|
5
|
+
export const SIDEBAR_MIN_WIDTH = 180;
|
|
6
|
+
export const SIDEBAR_MAX_WIDTH = 480;
|
|
7
|
+
|
|
8
|
+
export const RIGHT_PANEL_FALLBACK_WIDTH = 560;
|
|
9
|
+
export const RIGHT_PANEL_MIN_WIDTH = 300;
|
|
10
|
+
export const RIGHT_PANEL_MAX_WIDTH = 1200;
|
|
11
|
+
|
|
12
|
+
const COMPACT_CHAT_MIN_WIDTH = 320;
|
|
13
|
+
const DESKTOP_CHAT_MIN_WIDTH = 420;
|
|
14
|
+
|
|
15
|
+
export function clampPanelWidth(width: number, minWidth: number, maxWidth: number): number {
|
|
16
|
+
const finiteWidth = Number.isFinite(width) ? width : minWidth;
|
|
17
|
+
const effectiveMax = Math.max(minWidth, maxWidth);
|
|
18
|
+
return Math.round(Math.max(minWidth, Math.min(effectiveMax, finiteWidth)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getDefaultRightPanelWidth(viewportWidth: number): number {
|
|
22
|
+
return clampPanelWidth(viewportWidth * 0.42, 360, 640);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getSidebarMaxWidth(options: {
|
|
26
|
+
viewportWidth: number;
|
|
27
|
+
rightPanelOpen: boolean;
|
|
28
|
+
rightPanelWidth: number;
|
|
29
|
+
}): number {
|
|
30
|
+
const { viewportWidth, rightPanelOpen, rightPanelWidth } = options;
|
|
31
|
+
if (viewportWidth <= MOBILE_MAX_WIDTH) return SIDEBAR_MAX_WIDTH;
|
|
32
|
+
|
|
33
|
+
const compact = viewportWidth < SPLIT_PANEL_MIN_WIDTH;
|
|
34
|
+
const chatWidth = compact ? COMPACT_CHAT_MIN_WIDTH : DESKTOP_CHAT_MIN_WIDTH;
|
|
35
|
+
const visibleRightPanelWidth = !compact && rightPanelOpen ? rightPanelWidth : 0;
|
|
36
|
+
return Math.min(SIDEBAR_MAX_WIDTH, viewportWidth - chatWidth - visibleRightPanelWidth);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getRightPanelMaxWidth(options: {
|
|
40
|
+
viewportWidth: number;
|
|
41
|
+
sidebarOpen: boolean;
|
|
42
|
+
sidebarWidth: number;
|
|
43
|
+
}): number {
|
|
44
|
+
const { viewportWidth, sidebarOpen, sidebarWidth } = options;
|
|
45
|
+
if (viewportWidth < SPLIT_PANEL_MIN_WIDTH) return RIGHT_PANEL_MAX_WIDTH;
|
|
46
|
+
|
|
47
|
+
const visibleSidebarWidth = sidebarOpen ? sidebarWidth : 0;
|
|
48
|
+
return Math.min(
|
|
49
|
+
RIGHT_PANEL_MAX_WIDTH,
|
|
50
|
+
viewportWidth - DESKTOP_CHAT_MIN_WIDTH - visibleSidebarWidth,
|
|
51
|
+
);
|
|
52
|
+
}
|