@hienlh/ppm 0.19.7 → 0.19.10
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/CHANGELOG.md +15 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +2 -1
- package/dist/web/assets/{accounts-settings-section-D65EHybd.js → accounts-settings-section-B54qbh-_.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-DmOGQ_1E.js → ai-resource-editor-BXuRvCii.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Dlv_XYy3.js → ai-settings-section-ByXxu5Jp.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DP53HaV9.js → ai-settings-section-D8-z9POX.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-WuJwZW7v.js → appearance-settings-section-OZIxYrXu.js} +1 -1
- package/dist/web/assets/{chat-tab-B3tKoaUT.js → chat-tab-CYhKZEQb.js} +7 -7
- package/dist/web/assets/{code-editor-8BjOSWul.js → code-editor-CtMXFx5E.js} +2 -2
- package/dist/web/assets/{conflict-editor-D2JPf7dR.js → conflict-editor-DCTjpFtC.js} +1 -1
- package/dist/web/assets/{csv-preview-DfHSVE7o.js → csv-preview-oAb_uhuk.js} +1 -1
- package/dist/web/assets/{database-viewer-BwSf-V1L.js → database-viewer-C-qFE_XB.js} +1 -1
- package/dist/web/assets/{diff-viewer-CQU6j7uQ.js → diff-viewer-DPc5AiW_.js} +1 -1
- package/dist/web/assets/{explorer-body-B2FmdPAx.js → explorer-body-CFvpR1ZN.js} +1 -1
- package/dist/web/assets/{explorer-window-content-CtSMepys.js → explorer-window-content-DplJ_PMM.js} +1 -1
- package/dist/web/assets/{extension-manager-section-CBRXxG8W.js → extension-manager-section-Da827Nnv.js} +1 -1
- package/dist/web/assets/{extension-webview-yfMof39w.js → extension-webview-BxGlHycs.js} +1 -1
- package/dist/web/assets/{files-settings-section-Dxn0H4jj.js → files-settings-section-B0ED_J-Q.js} +1 -1
- package/dist/web/assets/{general-settings-section-C3ccyyOe.js → general-settings-section-nuoOnutx.js} +1 -1
- package/dist/web/assets/{git-log-panel-d2XBsNFP.js → git-log-panel-DK1MWTgZ.js} +1 -1
- package/dist/web/assets/{glide-data-grid-5Un6IKEP.js → glide-data-grid-DbE9VFqa.js} +1 -1
- package/dist/web/assets/{group-chat-tab-COcbheaZ.js → group-chat-tab-c6DU8xYR.js} +1 -1
- package/dist/web/assets/{index-DB-bD6Cx.js → index-DgJJy_D0.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-BmDvZZ76.js → jira-watcher-section-CAjH1D-v.js} +1 -1
- package/dist/web/assets/{keybindings-store-DAMW7K3e.js → keybindings-store-Hupd6B3g.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BsZKKzf2.js → keyboard-shortcuts-section-BZNwSm4a.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Dy4Vz3VW.js → markdown-renderer-CXuSaI_B.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BtrHvth9.js → markdown-renderer-DOTEwBPU.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-BL1t1Ptr.js → mobile-explorer-sheet-CKuKGmoo.js} +1 -1
- package/dist/web/assets/{notification-store-DIwuBHMP.js → notification-store-CaP8GdjN.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CpnG8Ep4.js → notifications-settings-section-BXOwi-as.js} +1 -1
- package/dist/web/assets/{pdf-preview-9Olujr_E.js → pdf-preview-DUCPeqdU.js} +1 -1
- package/dist/web/assets/{postgres-viewer-qQtI_Qan.js → postgres-viewer-Dw5GcVrG.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-C_yNqYo1.js → ppmbot-settings-section-BDhcXJn2.js} +1 -1
- package/dist/web/assets/{process-table-DH95ASxx.js → process-table-Dr-usEYu.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-7OpR2hAD.js → proxy-settings-section-DYuVzg21.js} +1 -1
- package/dist/web/assets/{query-audit-section-CHncxP-Z.js → query-audit-section-fYtxmDrt.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-Db21Gh-Y.js → remote-desktop-mobile-sheet-BcJZbFnp.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-CiKtuPVS.js → remote-desktop-mobile-view-BV0WkCO0.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BSBIPU4d.js → remote-desktop-readiness-gate-BejKUMGH.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CJOxXllZ.js → remote-desktop-window-content-6F1Oaygj.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-Q5X3kNJ8.js → schedules-settings-section-DVv3TujZ.js} +1 -1
- package/dist/web/assets/{settings-body-5804QGDH.js → settings-body-BGsYkBkl.js} +2 -2
- package/dist/web/assets/{settings-tab-DgJP4K6b.js → settings-tab-BUoekCNf.js} +1 -1
- package/dist/web/assets/{settings-window-content-lyNwahx3.js → settings-window-content-CgBwhXex.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DH3H3gvx.js → sql-query-editor-DNp7d1Fp.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DkmaTXae.js → sqlite-viewer-CxAYuvMa.js} +1 -1
- package/dist/web/assets/streaming-store-9_9j70zt.js +1 -0
- package/dist/web/assets/{system-monitor-body-KuahlPOb.js → system-monitor-body-CeQHDXUr.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-BK-nYh25.js → system-monitor-tab-Ba3dB3lY.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-C5TkD158.js → system-monitor-window-content-D1u2-cq_.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-BwDnxvbx.js → tab-host-window-content-gCdL-dfg.js} +1 -1
- package/dist/web/assets/{team-member-sheet-Cg82OxYB.js → team-member-sheet-D6HBfQBL.js} +2 -2
- package/dist/web/assets/{team-member-window-content-FxBRzgMW.js → team-member-window-content-CSB85Rx7.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bro7kJBK.js → terminal-tab-BoYE74U4.js} +1 -1
- package/dist/web/assets/{tool-cards-BDmz33Y9.js → tool-cards-D5Bfnd4O.js} +2 -2
- package/dist/web/assets/{use-accounts-data-CvfdFWm3.js → use-accounts-data-DF_fHEHm.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-D3Owwbo5.js → use-monaco-theme-qPgB4FLP.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DOg4xZ-8.js → use-remote-desktop-display-choice-CdLY-Uvx.js} +1 -1
- package/dist/web/assets/{use-websocket-4y-ICdd7.js → use-websocket-C0qf6WqW.js} +1 -1
- package/dist/web/assets/{video-preview-C70-MurL.js → video-preview-Ccps7Y-_.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/codex-app-server/codex-provider.ts +4 -0
- package/src/providers/registry.ts +12 -1
- package/src/server/index.ts +2 -2
- package/src/server/routes/chat-global.ts +53 -0
- package/src/server/routes/chat.ts +3 -0
- package/src/server/ws/chat.ts +15 -3
- package/src/services/db.service.ts +33 -2
- package/src/types/chat.ts +2 -0
- package/src/types/config.ts +14 -0
- package/src/web/components/chat/message-input.tsx +3 -4
- package/src/web/hooks/use-global-events.ts +12 -2
- package/src/web/lib/slash-items-cache.ts +5 -4
- package/src/web/lib/sync-running-sessions.ts +17 -15
- package/src/web/stores/streaming-store.ts +27 -14
- package/dist/web/assets/streaming-store-DDst3zA7.js +0 -1
- package/src/server/routes/chat-unread.ts +0 -36
|
@@ -660,6 +660,10 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
660
660
|
* skill, which should show up without a restart, but a picker keystroke must
|
|
661
661
|
* not spawn an app-server.
|
|
662
662
|
*/
|
|
663
|
+
invalidateSkillsCache(): void {
|
|
664
|
+
this.skillsCache.clear();
|
|
665
|
+
}
|
|
666
|
+
|
|
663
667
|
async listSkills(sessionId?: string): Promise<CodexSkill[]> {
|
|
664
668
|
const cwd = (sessionId ? getSessionProjectPath(sessionId) : null) || process.cwd();
|
|
665
669
|
// Reuse the account already bound to the session; resolving afresh here
|
|
@@ -2,6 +2,7 @@ import type { AIProvider } from "./provider.interface.ts";
|
|
|
2
2
|
import { MockProvider } from "./mock-provider.ts";
|
|
3
3
|
import { ClaudeAgentSdkProvider } from "./claude-agent-sdk.ts";
|
|
4
4
|
import { configService } from "../services/config.service.ts";
|
|
5
|
+
import { CODEX_DEFAULT_MODEL } from "../types/config.ts";
|
|
5
6
|
|
|
6
7
|
export interface ProviderInfo {
|
|
7
8
|
id: string;
|
|
@@ -92,12 +93,22 @@ export async function bootstrapProviders(): Promise<void> {
|
|
|
92
93
|
if (await codex.isAvailable()) {
|
|
93
94
|
providerRegistry.register(codex);
|
|
94
95
|
const ai = configService.get("ai");
|
|
96
|
+
// Only when codex has never been configured. An absent `model` is a real
|
|
97
|
+
// choice here — the settings picker writes it for "Auto (default)", which
|
|
98
|
+
// hands model selection back to codex — so filling one in on every startup
|
|
99
|
+
// would undo that choice each restart. Existing installs are given the
|
|
100
|
+
// default once, by migration.
|
|
95
101
|
if (!ai.providers["codex"]) {
|
|
96
102
|
configService.set("ai", {
|
|
97
103
|
...ai,
|
|
98
104
|
providers: {
|
|
99
105
|
...ai.providers,
|
|
100
|
-
codex: {
|
|
106
|
+
codex: {
|
|
107
|
+
type: "cli",
|
|
108
|
+
cli_command: "codex",
|
|
109
|
+
permission_mode: "bypassPermissions",
|
|
110
|
+
model: CODEX_DEFAULT_MODEL,
|
|
111
|
+
},
|
|
101
112
|
},
|
|
102
113
|
});
|
|
103
114
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { tunnelRoutes } from "./routes/tunnel.ts";
|
|
|
13
13
|
import { tunnelService } from "../services/tunnel.service.ts";
|
|
14
14
|
import { staticRoutes } from "./routes/static.ts";
|
|
15
15
|
import { projectScopedRouter } from "./routes/project-scoped.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { chatGlobalRoutes } from "./routes/chat-global.ts";
|
|
17
17
|
import { postgresRoutes } from "./routes/postgres.ts";
|
|
18
18
|
import { databaseRoutes } from "./routes/database.ts";
|
|
19
19
|
import { fsBrowseRoutes } from "./routes/fs-browse.ts";
|
|
@@ -193,7 +193,7 @@ app.route("/api/tunnel", tunnelRoutes);
|
|
|
193
193
|
import { namedTunnelRoutes } from "./routes/named-tunnel.ts";
|
|
194
194
|
app.route("/api/tunnel/named", namedTunnelRoutes);
|
|
195
195
|
app.route("/api/projects", projectRoutes);
|
|
196
|
-
app.route("/api/chat",
|
|
196
|
+
app.route("/api/chat", chatGlobalRoutes);
|
|
197
197
|
app.route("/api/project/:projectName", projectScopedRouter);
|
|
198
198
|
app.route("/api/postgres", postgresRoutes);
|
|
199
199
|
app.route("/api/db", databaseRoutes);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { clearSessionUnreadMany } from "../../services/db.service.ts";
|
|
3
|
+
import { ok, err } from "../../types/api.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Chat routes that are deliberately *not* project-scoped.
|
|
7
|
+
*
|
|
8
|
+
* Their state is keyed by session, not by project, so a project segment adds nothing and
|
|
9
|
+
* actively gets in the way: mounted under `/api/project/:projectName/chat` the project
|
|
10
|
+
* middleware 404s first, which makes the entries most in need of attention the ones that
|
|
11
|
+
* cannot be reached — a session whose project was renamed or deleted, or one PPM never
|
|
12
|
+
* recorded a project for.
|
|
13
|
+
*/
|
|
14
|
+
export const chatGlobalRoutes = new Hono();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GET /chat/sessions/running — every session with a turn in flight, across all projects.
|
|
18
|
+
*
|
|
19
|
+
* The project-scoped twin under `/api/project/:projectName/chat` can only ever reconcile the
|
|
20
|
+
* project the user is looking at, which leaves a stale entry from any other project with
|
|
21
|
+
* nothing that can clear it. Consumers that ask "is anything running at all" — the screen
|
|
22
|
+
* wake lock — need the whole picture, so they get it from here.
|
|
23
|
+
*/
|
|
24
|
+
chatGlobalRoutes.get("/sessions/running", async (c) => {
|
|
25
|
+
try {
|
|
26
|
+
const { listRunningSessions } = await import("../ws/chat.ts");
|
|
27
|
+
return c.json(ok(listRunningSessions()));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return c.json(err((e as Error).message), 500);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** POST /chat/sessions/read — mark many sessions as read, in one request */
|
|
34
|
+
chatGlobalRoutes.post("/sessions/read", async (c) => {
|
|
35
|
+
try {
|
|
36
|
+
const body = await c.req.json<{ sessionIds?: unknown }>();
|
|
37
|
+
const ids = Array.isArray(body.sessionIds)
|
|
38
|
+
? body.sessionIds.filter((v): v is string => typeof v === "string" && v.length > 0)
|
|
39
|
+
: null;
|
|
40
|
+
if (!ids) return c.json(err("sessionIds must be an array of session ids"), 400);
|
|
41
|
+
|
|
42
|
+
clearSessionUnreadMany(ids);
|
|
43
|
+
// One broadcast per session, but one request and one DB transaction: "Clear all" used
|
|
44
|
+
// to be N requests fanned out to every connected device.
|
|
45
|
+
const { broadcastGlobalEvent } = await import("../ws/chat.ts");
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
broadcastGlobalEvent({ type: "session:unread_changed", sessionId: id, unreadCount: 0, unreadType: null, projectName: "" });
|
|
48
|
+
}
|
|
49
|
+
return c.json(ok({ cleared: ids.length }));
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return c.json(err((e as Error).message), 500);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -94,6 +94,9 @@ chatRoutes.delete("/slash-items/cache", (c) => {
|
|
|
94
94
|
try {
|
|
95
95
|
invalidateCache(c.get("projectPath"));
|
|
96
96
|
invalidateSdkCommands(c.get("projectPath"));
|
|
97
|
+
for (const { id } of providerRegistry.listAll()) {
|
|
98
|
+
providerRegistry.get(id)?.invalidateSkillsCache?.();
|
|
99
|
+
}
|
|
97
100
|
return c.json(ok({ invalidated: true }));
|
|
98
101
|
} catch (e) {
|
|
99
102
|
return c.json(err((e as Error).message), 500);
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -259,12 +259,14 @@ export function hasActiveClient(): boolean {
|
|
|
259
259
|
* background turn would otherwise show no spinner in the tab strip and no
|
|
260
260
|
* indicator in the document title. Reads the in-memory registry only — no DB.
|
|
261
261
|
*/
|
|
262
|
-
export function listRunningSessions(
|
|
263
|
-
|
|
262
|
+
export function listRunningSessions(
|
|
263
|
+
projectName?: string,
|
|
264
|
+
): { sessionId: string; phase: SessionPhase; projectName: string }[] {
|
|
265
|
+
const running: { sessionId: string; phase: SessionPhase; projectName: string }[] = [];
|
|
264
266
|
for (const [sessionId, entry] of activeSessions) {
|
|
265
267
|
if (entry.phase === "idle") continue;
|
|
266
268
|
if (projectName && entry.projectName !== projectName) continue;
|
|
267
|
-
running.push({ sessionId, phase: entry.phase });
|
|
269
|
+
running.push({ sessionId, phase: entry.phase, projectName: entry.projectName ?? "" });
|
|
268
270
|
}
|
|
269
271
|
return running;
|
|
270
272
|
}
|
|
@@ -854,6 +856,16 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
854
856
|
try { (client as any).data.sessionId = newId; } catch { /* ignore */ }
|
|
855
857
|
}
|
|
856
858
|
}
|
|
859
|
+
// Announce the rename app-wide too. Every later phase change goes out under the
|
|
860
|
+
// new id, so a client that only hears the global bus — which is every client whose
|
|
861
|
+
// chat tab is not mounted — would keep the old id marked running for good: its
|
|
862
|
+
// `idle` is never coming, because nothing is keyed to it any more.
|
|
863
|
+
broadcastGlobalEvent({
|
|
864
|
+
type: "session:migrated",
|
|
865
|
+
oldSessionId: sessionId,
|
|
866
|
+
newSessionId: newId,
|
|
867
|
+
projectName: oldEntry?.projectName ?? "",
|
|
868
|
+
});
|
|
857
869
|
// The consumer must target the new id for every subsequent broadcast —
|
|
858
870
|
// including this session_migrated event — since the entry moved. Without
|
|
859
871
|
// this, a provider that always migrates (e.g. codex: threadId ≠ ppm id)
|
|
@@ -5,7 +5,8 @@ import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
6
|
import { assertProdDbAccessAllowed } from "./prod-db-guard.ts";
|
|
7
7
|
import { backupDbSync } from "./db-backup/db-backup-sync.ts";
|
|
8
|
-
|
|
8
|
+
import { CODEX_DEFAULT_MODEL } from "../types/config.ts";
|
|
9
|
+
export const CURRENT_SCHEMA_VERSION = 45;
|
|
9
10
|
|
|
10
11
|
let db: Database | null = null;
|
|
11
12
|
let dbProfile: string | null = null;
|
|
@@ -89,7 +90,8 @@ export function setDb(instance: Database): void {
|
|
|
89
90
|
// Schema migrations
|
|
90
91
|
// ---------------------------------------------------------------------------
|
|
91
92
|
|
|
92
|
-
|
|
93
|
+
/** Exported so a test can drive an upgrade from a specific older version. */
|
|
94
|
+
export function runMigrations(database: Database): void {
|
|
93
95
|
const row = database.query("PRAGMA user_version").get() as { user_version: number };
|
|
94
96
|
const current = row.user_version;
|
|
95
97
|
|
|
@@ -1047,6 +1049,35 @@ function runMigrations(database: Database): void {
|
|
|
1047
1049
|
PRAGMA user_version = 44;
|
|
1048
1050
|
`);
|
|
1049
1051
|
}
|
|
1052
|
+
|
|
1053
|
+
if (current < 45) {
|
|
1054
|
+
// Codex was registered without a model, and PPM sends no model when none is
|
|
1055
|
+
// configured, so codex fell back to its own default — its most capable and
|
|
1056
|
+
// most expensive model — for every chat.
|
|
1057
|
+
//
|
|
1058
|
+
// Applied once, here, rather than on each startup: the settings picker
|
|
1059
|
+
// stores "Auto (default)" as an absent model, so a startup check could not
|
|
1060
|
+
// tell that choice apart from never having chosen and would reimpose this
|
|
1061
|
+
// value at every restart.
|
|
1062
|
+
try {
|
|
1063
|
+
const row = database.query("SELECT value FROM config WHERE key = 'ai'").get() as { value: string } | null;
|
|
1064
|
+
if (row?.value) {
|
|
1065
|
+
const ai = JSON.parse(row.value) as {
|
|
1066
|
+
providers?: Record<string, Record<string, unknown> | undefined>;
|
|
1067
|
+
};
|
|
1068
|
+
const codex = ai.providers?.["codex"];
|
|
1069
|
+
if (codex && codex.model == null) {
|
|
1070
|
+
codex.model = CODEX_DEFAULT_MODEL;
|
|
1071
|
+
database.query("UPDATE config SET value = ? WHERE key = 'ai'").run(JSON.stringify(ai));
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
} catch {
|
|
1075
|
+
// A config that cannot be read or parsed is not worth failing the upgrade
|
|
1076
|
+
// over; codex keeps choosing for itself, exactly as before.
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
database.exec(`PRAGMA user_version = 45;`);
|
|
1080
|
+
}
|
|
1050
1081
|
}
|
|
1051
1082
|
|
|
1052
1083
|
// ---------------------------------------------------------------------------
|
package/src/types/chat.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface AIProvider {
|
|
|
57
57
|
* codex; absent for Claude, whose skills come from the shared disk discovery.
|
|
58
58
|
*/
|
|
59
59
|
listSkills?(sessionId?: string): Promise<import("../providers/codex-app-server/codex-protocol").CodexSkill[]>;
|
|
60
|
+
/** Drop runtime skill discovery results after a user requests a refresh. */
|
|
61
|
+
invalidateSkillsCache?(): void;
|
|
60
62
|
/** Provider-specific usage/quota (rate limits). Used by GET /chat/usage. */
|
|
61
63
|
getUsage?(sessionId?: string): Promise<UsageInfo>;
|
|
62
64
|
/** True when a live streaming subprocess exists for this session */
|
package/src/types/config.ts
CHANGED
|
@@ -111,6 +111,20 @@ export interface AIProviderConfig {
|
|
|
111
111
|
cli_command?: string;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Model a new codex session uses unless the picker says otherwise.
|
|
116
|
+
*
|
|
117
|
+
* Codex is registered at runtime rather than shipped in DEFAULT_CONFIG — it only
|
|
118
|
+
* exists once `@openai/codex` is installed — so its default lives here, beside
|
|
119
|
+
* Claude's, instead of being invisible inside the registration code.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately not codex's own default, which is its most capable model and
|
|
122
|
+
* bills accordingly; codex describes this one as its balanced agentic coding
|
|
123
|
+
* model for everyday work, which is what a chat in an IDE mostly does. Changing
|
|
124
|
+
* it in Settings, or per session in the chat's model picker, overrides this.
|
|
125
|
+
*/
|
|
126
|
+
export const CODEX_DEFAULT_MODEL = "gpt-5.6-terra";
|
|
127
|
+
|
|
114
128
|
export const DEFAULT_CONFIG: PpmConfig = {
|
|
115
129
|
device_name: "",
|
|
116
130
|
port: 8080,
|
|
@@ -323,9 +323,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
323
323
|
setTimeout(() => { getVisibleTextarea()?.focus(); }, 100);
|
|
324
324
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
325
325
|
|
|
326
|
-
//
|
|
327
|
-
// every chat tab, and the picker renders nothing until it resolves — fetching it
|
|
328
|
-
// per tab mount put a 23 KB round trip in front of the first `/` in every tab.
|
|
326
|
+
// Cache per project/provider/session, with a TTL for externally installed skills.
|
|
329
327
|
const loadSlashItems = useCallback(() => {
|
|
330
328
|
if (!projectName) {
|
|
331
329
|
slashItemsRef.current = [];
|
|
@@ -708,6 +706,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
708
706
|
if (hasSlash) {
|
|
709
707
|
const slashMatch = textBefore.match(/(?:^|\s)\/(\S*)$/);
|
|
710
708
|
if (slashMatch && slashItemsRef.current.length > 0) {
|
|
709
|
+
if (!slashPickerOpenRef.current) loadSlashItems();
|
|
711
710
|
const filter = slashMatch[1] ?? "";
|
|
712
711
|
onSlashStateChange?.(true, filter);
|
|
713
712
|
slashPickerOpenRef.current = true;
|
|
@@ -731,7 +730,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
731
730
|
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
732
731
|
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
733
732
|
},
|
|
734
|
-
[onSlashStateChange, onFileStateChange],
|
|
733
|
+
[onSlashStateChange, onFileStateChange, loadSlashItems],
|
|
735
734
|
);
|
|
736
735
|
|
|
737
736
|
/** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
|
|
@@ -59,7 +59,7 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
59
59
|
if (projectRef.current) {
|
|
60
60
|
client.send(JSON.stringify({ type: "watch", projectName: projectRef.current }));
|
|
61
61
|
}
|
|
62
|
-
void syncRunningSessions(
|
|
62
|
+
void syncRunningSessions();
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -85,6 +85,16 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
if (type === "session:migrated") {
|
|
89
|
+
// The server re-keyed the session, so every phase change from here on carries the new
|
|
90
|
+
// id and the old one's `idle` will never arrive. Drop it now rather than waiting for
|
|
91
|
+
// the next reconnect to reconcile it away — a socket that stays up for hours would
|
|
92
|
+
// hold the screen awake for just as long.
|
|
93
|
+
const d = data as unknown as { oldSessionId: string; newSessionId: string };
|
|
94
|
+
useStreamingStore.getState().dropSession(d.oldSessionId);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
if (type.startsWith("jira:") || type.startsWith("tunnel:")) {
|
|
89
99
|
window.dispatchEvent(new CustomEvent(type, { detail: data }));
|
|
90
100
|
}
|
|
@@ -104,6 +114,6 @@ export function useGlobalEvents(enabled: boolean, projectName?: string): void {
|
|
|
104
114
|
useEffect(() => {
|
|
105
115
|
if (!enabled || !projectName) return;
|
|
106
116
|
clientRef.current?.send(JSON.stringify({ type: "watch", projectName }));
|
|
107
|
-
void syncRunningSessions(
|
|
117
|
+
void syncRunningSessions();
|
|
108
118
|
}, [enabled, projectName]);
|
|
109
119
|
}
|
|
@@ -22,7 +22,8 @@ export interface SlashItemsPayload {
|
|
|
22
22
|
* codex tab and a Claude tab in one project get different lists — and two codex
|
|
23
23
|
* tabs can differ too when they are bound to different accounts.
|
|
24
24
|
*/
|
|
25
|
-
const
|
|
25
|
+
const CACHE_TTL_MS = 60_000;
|
|
26
|
+
const cache = new Map<string, { promise: Promise<SlashItemsPayload>; expiresAt: number }>();
|
|
26
27
|
|
|
27
28
|
export function fetchSlashItems(
|
|
28
29
|
projectName: string,
|
|
@@ -31,7 +32,7 @@ export function fetchSlashItems(
|
|
|
31
32
|
): Promise<SlashItemsPayload> {
|
|
32
33
|
const key = `${projectName}\0${providerId ?? ""}\0${sessionId ?? ""}`;
|
|
33
34
|
const cached = cache.get(key);
|
|
34
|
-
if (cached) return cached;
|
|
35
|
+
if (cached && Date.now() < cached.expiresAt) return cached.promise;
|
|
35
36
|
|
|
36
37
|
const query = new URLSearchParams();
|
|
37
38
|
if (providerId) query.set("providerId", providerId);
|
|
@@ -43,11 +44,11 @@ export function fetchSlashItems(
|
|
|
43
44
|
.then((data) => ({ items: data.items ?? [], recentNames: data.recentNames ?? [] }))
|
|
44
45
|
.catch((e) => {
|
|
45
46
|
// Don't cache failures — the next mount should retry.
|
|
46
|
-
cache.delete(key);
|
|
47
|
+
if (cache.get(key)?.promise === p) cache.delete(key);
|
|
47
48
|
throw e;
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
cache.set(key, p);
|
|
51
|
+
cache.set(key, { promise: p, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
51
52
|
return p;
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
import { api
|
|
1
|
+
import { api } from "@/lib/api-client";
|
|
2
2
|
import { useStreamingStore } from "@/stores/streaming-store";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Reconcile
|
|
5
|
+
* Reconcile the streaming indicators (tab-strip spinner, title/favicon, screen wake lock)
|
|
6
6
|
* against the server's session registry.
|
|
7
7
|
*
|
|
8
|
-
* Runs on every `/ws/global` (re)connect and on project switch.
|
|
9
|
-
*
|
|
10
|
-
* - a turn running in a tab that is not mounted (tabs mount lazily, so its phase
|
|
11
|
-
*
|
|
12
|
-
* - an `idle` broadcast that landed while the global socket was down → indicator
|
|
13
|
-
*
|
|
8
|
+
* Runs on every `/ws/global` (re)connect and on project switch. Three gaps it closes, none of
|
|
9
|
+
* which anything else can:
|
|
10
|
+
* - a turn running in a tab that is not mounted (tabs mount lazily, so its phase never reaches
|
|
11
|
+
* this client) → indicator missing;
|
|
12
|
+
* - an `idle` broadcast that landed while the global socket was down → indicator stuck on;
|
|
13
|
+
* - a session the server forgot without ever going idle — a restart mid-turn, an id the
|
|
14
|
+
* provider re-keyed → an entry with no `idle` coming at all.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not scoped to a project. The server's list covers every project, so the whole
|
|
17
|
+
* map is replaced in one shot; a per-project sync could only reach the project on screen and
|
|
18
|
+
* left everything else to rot.
|
|
14
19
|
*/
|
|
15
|
-
export async function syncRunningSessions(
|
|
16
|
-
if (!projectName) return;
|
|
20
|
+
export async function syncRunningSessions(): Promise<void> {
|
|
17
21
|
try {
|
|
18
|
-
const running = await api.get<{ sessionId: string }[]>(
|
|
19
|
-
|
|
22
|
+
const running = await api.get<{ sessionId: string; projectName: string }[]>(
|
|
23
|
+
"/api/chat/sessions/running",
|
|
20
24
|
);
|
|
21
|
-
useStreamingStore
|
|
22
|
-
.getState()
|
|
23
|
-
.replaceProjectStreaming(projectName, running.map((s) => s.sessionId));
|
|
25
|
+
useStreamingStore.getState().replaceAllStreaming(running);
|
|
24
26
|
} catch {
|
|
25
27
|
// Never block boot or a reconnect on an indicator.
|
|
26
28
|
}
|
|
@@ -9,13 +9,27 @@ interface StreamingStore {
|
|
|
9
9
|
/** Mark a session as streaming or idle */
|
|
10
10
|
setStreaming: (sessionId: string, streaming: boolean, projectName?: string) => void;
|
|
11
11
|
/**
|
|
12
|
-
* Reconcile
|
|
13
|
-
*
|
|
14
|
-
* `/ws/global` was down
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* Reconcile the whole map against the server's registry, which is authoritative.
|
|
13
|
+
*
|
|
14
|
+
* Needed because an `idle` broadcast missed while `/ws/global` was down — a tablet
|
|
15
|
+
* asleep, a network change, a server restart mid-turn on an upgrade — otherwise leaves an
|
|
16
|
+
* entry that nothing can ever clear.
|
|
17
|
+
*
|
|
18
|
+
* App-wide, not per project. A per-project reconcile could only reach the project the user
|
|
19
|
+
* happened to be looking at, so a stale entry from any other project survived every sync
|
|
20
|
+
* and stayed in the map for the life of the page. That was invisible to the favicon and the
|
|
21
|
+
* title, which filter by project, but not to the screen wake lock, which asks whether
|
|
22
|
+
* anything at all is running and so never let the screen sleep again.
|
|
17
23
|
*/
|
|
18
|
-
|
|
24
|
+
replaceAllStreaming: (running: { sessionId: string; projectName: string }[]) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Forget a session that the server re-keyed under a new id.
|
|
27
|
+
*
|
|
28
|
+
* The rename is announced on the global bus because every later phase change uses the new
|
|
29
|
+
* id: the old one's `idle` is never coming. Codex re-keys every session (its thread id is
|
|
30
|
+
* not PPM's), and CLI providers do it as soon as they read their real id from the output.
|
|
31
|
+
*/
|
|
32
|
+
dropSession: (sessionId: string) => void;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
export const useStreamingStore = create<StreamingStore>((set) => ({
|
|
@@ -29,14 +43,13 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
|
|
|
29
43
|
else next.delete(sessionId);
|
|
30
44
|
return { sessions: next };
|
|
31
45
|
}),
|
|
32
|
-
|
|
46
|
+
replaceAllStreaming: (running) =>
|
|
47
|
+
set(() => ({ sessions: new Map(running.map((s) => [s.sessionId, s.projectName])) })),
|
|
48
|
+
dropSession: (sessionId) =>
|
|
33
49
|
set((state) => {
|
|
34
|
-
|
|
50
|
+
if (!state.sessions.has(sessionId)) return state;
|
|
35
51
|
const next = new Map(state.sessions);
|
|
36
|
-
|
|
37
|
-
if (project === projectName && !running.has(id)) next.delete(id);
|
|
38
|
-
}
|
|
39
|
-
for (const id of sessionIds) next.set(id, projectName);
|
|
52
|
+
next.delete(sessionId);
|
|
40
53
|
return { sessions: next };
|
|
41
54
|
}),
|
|
42
55
|
}));
|
|
@@ -50,8 +63,8 @@ export const useStreamingStore = create<StreamingStore>((set) => ({
|
|
|
50
63
|
* whenever any one of them was working: three PWA windows, one busy, three busy-looking
|
|
51
64
|
* icons, and no way to tell which.
|
|
52
65
|
*
|
|
53
|
-
* No new state is needed for this
|
|
54
|
-
*
|
|
66
|
+
* No new state is needed for this: the map records the project per session, which the server's
|
|
67
|
+
* running list supplies on every reconcile.
|
|
55
68
|
*/
|
|
56
69
|
export const selectProjectStreaming =
|
|
57
70
|
(projectName: string | undefined) =>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./react-X4jUntEf.js";var t=e(e=>({sessions:new Map,setStreaming:(t,n,r)=>e(e=>{let i=new Map(e.sessions);return n?i.set(t,r??i.get(t)??``):i.delete(t),{sessions:i}}),replaceProjectStreaming:(t,n)=>e(e=>{let r=new Set(n),i=new Map(e.sessions);for(let[n,a]of e.sessions)a===t&&!r.has(n)&&i.delete(n);for(let e of n)i.set(e,t);return{sessions:i}})})),n=e=>t=>{if(!e)return!1;for(let n of t.sessions.values())if(n===e)return!0;return!1};export{t as n,n as t};
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { clearSessionUnreadMany } from "../../services/db.service.ts";
|
|
3
|
-
import { ok, err } from "../../types/api.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Unread routes that are deliberately *not* project-scoped.
|
|
7
|
-
*
|
|
8
|
-
* The per-session read route lives under `/api/project/:projectName/chat`, but it never
|
|
9
|
-
* read the project segment — and being mounted there means the project middleware 404s
|
|
10
|
-
* first. That makes the entries most in need of clearing the ones that cannot be:
|
|
11
|
-
* a session whose project was renamed or deleted, or one PPM never recorded a project
|
|
12
|
-
* for. Unread state is keyed by session, so clearing it does not need a project at all.
|
|
13
|
-
*/
|
|
14
|
-
export const chatUnreadRoutes = new Hono();
|
|
15
|
-
|
|
16
|
-
/** POST /chat/sessions/read — mark many sessions as read, in one request */
|
|
17
|
-
chatUnreadRoutes.post("/sessions/read", async (c) => {
|
|
18
|
-
try {
|
|
19
|
-
const body = await c.req.json<{ sessionIds?: unknown }>();
|
|
20
|
-
const ids = Array.isArray(body.sessionIds)
|
|
21
|
-
? body.sessionIds.filter((v): v is string => typeof v === "string" && v.length > 0)
|
|
22
|
-
: null;
|
|
23
|
-
if (!ids) return c.json(err("sessionIds must be an array of session ids"), 400);
|
|
24
|
-
|
|
25
|
-
clearSessionUnreadMany(ids);
|
|
26
|
-
// One broadcast per session, but one request and one DB transaction: "Clear all" used
|
|
27
|
-
// to be N requests fanned out to every connected device.
|
|
28
|
-
const { broadcastGlobalEvent } = await import("../ws/chat.ts");
|
|
29
|
-
for (const id of ids) {
|
|
30
|
-
broadcastGlobalEvent({ type: "session:unread_changed", sessionId: id, unreadCount: 0, unreadType: null, projectName: "" });
|
|
31
|
-
}
|
|
32
|
-
return c.json(ok({ cleared: ids.length }));
|
|
33
|
-
} catch (e) {
|
|
34
|
-
return c.json(err((e as Error).message), 500);
|
|
35
|
-
}
|
|
36
|
-
});
|