@hienlh/ppm 0.19.8 → 0.19.11
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-C1YP1iCb.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-DmOGQ_1E.js → ai-resource-editor-iMqzzz5c.js} +1 -1
- package/dist/web/assets/{ai-settings-section-Dlv_XYy3.js → ai-settings-section-B-1z-PRb.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DP53HaV9.js → ai-settings-section-BJPgHUqk.js} +1 -1
- package/dist/web/assets/{appearance-settings-section-WuJwZW7v.js → appearance-settings-section-2XZgB0mV.js} +1 -1
- package/dist/web/assets/chat-tab-Bn4vZVZJ.js +15 -0
- package/dist/web/assets/{code-editor-8BjOSWul.js → code-editor-DN5GJXT3.js} +2 -2
- package/dist/web/assets/{conflict-editor-D2JPf7dR.js → conflict-editor-BpdoTEfv.js} +1 -1
- package/dist/web/assets/{csv-preview-DfHSVE7o.js → csv-preview-C3DoHISX.js} +1 -1
- package/dist/web/assets/{database-viewer-BwSf-V1L.js → database-viewer-CxZ2fdcd.js} +1 -1
- package/dist/web/assets/{diff-viewer-CQU6j7uQ.js → diff-viewer-DxzD_YSO.js} +1 -1
- package/dist/web/assets/{explorer-body-B2FmdPAx.js → explorer-body-Bpe6UX0b.js} +1 -1
- package/dist/web/assets/{explorer-window-content-CtSMepys.js → explorer-window-content-DixZaLLP.js} +1 -1
- package/dist/web/assets/{extension-manager-section-CBRXxG8W.js → extension-manager-section-D_rKLp-o.js} +1 -1
- package/dist/web/assets/{extension-webview-yfMof39w.js → extension-webview-kEtrZoRZ.js} +1 -1
- package/dist/web/assets/{files-settings-section-Dxn0H4jj.js → files-settings-section-B0wLfQ9S.js} +1 -1
- package/dist/web/assets/{general-settings-section-C3ccyyOe.js → general-settings-section-DZ3xpgwh.js} +1 -1
- package/dist/web/assets/{git-log-panel-d2XBsNFP.js → git-log-panel-BNicD2In.js} +1 -1
- package/dist/web/assets/{glide-data-grid-5Un6IKEP.js → glide-data-grid-CdK5dW5L.js} +1 -1
- package/dist/web/assets/{group-chat-tab-COcbheaZ.js → group-chat-tab-D-HXdnoU.js} +1 -1
- package/dist/web/assets/{index-DB-bD6Cx.js → index-DdMq4zqb.js} +5 -5
- package/dist/web/assets/{jira-watcher-section-BmDvZZ76.js → jira-watcher-section-DL_boZNh.js} +1 -1
- package/dist/web/assets/{keybindings-store-DAMW7K3e.js → keybindings-store-DQkz4a35.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-BsZKKzf2.js → keyboard-shortcuts-section-DzQzV49E.js} +1 -1
- package/dist/web/assets/{markdown-renderer-BtrHvth9.js → markdown-renderer-BfoCOrL8.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Dy4Vz3VW.js → markdown-renderer-C35QMvZp.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet-BL1t1Ptr.js → mobile-explorer-sheet-ZHfT5IN3.js} +1 -1
- package/dist/web/assets/{notification-store-DIwuBHMP.js → notification-store-J1yP_Ebb.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-CpnG8Ep4.js → notifications-settings-section-BXCaGBVc.js} +1 -1
- package/dist/web/assets/{pdf-preview-9Olujr_E.js → pdf-preview-DbRFOtQD.js} +1 -1
- package/dist/web/assets/{postgres-viewer-qQtI_Qan.js → postgres-viewer-BrQRqTso.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-C_yNqYo1.js → ppmbot-settings-section-BEV23_SM.js} +1 -1
- package/dist/web/assets/{process-table-DH95ASxx.js → process-table-CG8d5eXs.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-7OpR2hAD.js → proxy-settings-section-Cw0Wtsv0.js} +1 -1
- package/dist/web/assets/{query-audit-section-CHncxP-Z.js → query-audit-section-50B7mQal.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-Db21Gh-Y.js → remote-desktop-mobile-sheet-DhcAyXE5.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-CiKtuPVS.js → remote-desktop-mobile-view-DPF_b_jd.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-BSBIPU4d.js → remote-desktop-readiness-gate-J7E6355i.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-CJOxXllZ.js → remote-desktop-window-content-0xsAifZH.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-Q5X3kNJ8.js → schedules-settings-section-BFzMEPTd.js} +1 -1
- package/dist/web/assets/{settings-body-5804QGDH.js → settings-body-CUTsPLea.js} +2 -2
- package/dist/web/assets/{settings-tab-DgJP4K6b.js → settings-tab-DVV26vPK.js} +1 -1
- package/dist/web/assets/{settings-window-content-lyNwahx3.js → settings-window-content-z64J3tfm.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DH3H3gvx.js → sql-query-editor-CwOcDbp4.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DkmaTXae.js → sqlite-viewer-DYdcqZv1.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-CugdEvdh.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-BK-nYh25.js → system-monitor-tab-jnPQtmvK.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-C5TkD158.js → system-monitor-window-content-CJfcQqc2.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-BwDnxvbx.js → tab-host-window-content-5Ga7BheG.js} +1 -1
- package/dist/web/assets/{team-member-sheet-Cg82OxYB.js → team-member-sheet-8FDCVZMG.js} +2 -2
- package/dist/web/assets/{team-member-window-content-FxBRzgMW.js → team-member-window-content-0JRiWRdG.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bro7kJBK.js → terminal-tab-CkxJIjii.js} +1 -1
- package/dist/web/assets/{tool-cards-BDmz33Y9.js → tool-cards-DuTlfeWN.js} +2 -2
- package/dist/web/assets/{use-accounts-data-CvfdFWm3.js → use-accounts-data--pdg01TI.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-D3Owwbo5.js → use-monaco-theme-DOhDvEKJ.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-DOg4xZ-8.js → use-remote-desktop-display-choice-B3VIBQxC.js} +1 -1
- package/dist/web/assets/{use-websocket-4y-ICdd7.js → use-websocket-D9MsDysx.js} +1 -1
- package/dist/web/assets/{video-preview-C70-MurL.js → video-preview-VL2Br0YM.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/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/types/chat.ts +2 -0
- package/src/web/components/chat/codex-usage-panel.tsx +13 -33
- 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/chat-tab-B3tKoaUT.js +0 -15
- 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
|
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)
|
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 */
|
|
@@ -1,40 +1,20 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { X, Loader2, RefreshCw } from "lucide-react";
|
|
3
3
|
import { api } from "@/lib/api-client";
|
|
4
|
+
import { AccountUsageBar } from "@/components/settings/accounts/account-bucket-row";
|
|
5
|
+
import { formatResetTime } from "@/components/settings/accounts/account-usage-format";
|
|
4
6
|
import type { UsageInfo } from "../../../types/chat";
|
|
5
7
|
|
|
6
8
|
interface CodexAccount { id: string; label: string; type: string; planType?: string | null }
|
|
7
|
-
|
|
9
|
+
type Usage = Pick<UsageInfo, "fiveHour" | "sevenDay" | "session" | "weekly">;
|
|
8
10
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
function barColor(p: number): string {
|
|
15
|
-
if (p >= 90) return "bg-error";
|
|
16
|
-
if (p >= 70) return "bg-warning";
|
|
17
|
-
return "bg-success";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function UsageBar({ label, frac }: { label: string; frac?: number }) {
|
|
11
|
+
function UsageBar({ label, frac, bucket }: {
|
|
12
|
+
label: string;
|
|
13
|
+
frac?: number;
|
|
14
|
+
bucket?: UsageInfo["session"];
|
|
15
|
+
}) {
|
|
21
16
|
const pct = frac != null ? Math.round(frac * 100) : null;
|
|
22
|
-
return (
|
|
23
|
-
<div className="space-y-1">
|
|
24
|
-
<div className="flex items-center justify-between">
|
|
25
|
-
<span className="text-xs font-medium text-text-primary">{label}</span>
|
|
26
|
-
<span className={`text-xs font-medium tabular-nums ${pct != null ? pctColor(pct) : "text-text-subtle"}`}>
|
|
27
|
-
{pct != null ? `${pct}%` : "—"}
|
|
28
|
-
</span>
|
|
29
|
-
</div>
|
|
30
|
-
<div className="h-2 rounded-full bg-border overflow-hidden">
|
|
31
|
-
<div
|
|
32
|
-
className={`h-full rounded-full transition-all ${pct != null ? barColor(pct) : ""}`}
|
|
33
|
-
style={{ width: `${Math.min(pct ?? 0, 100)}%` }}
|
|
34
|
-
/>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
17
|
+
return <AccountUsageBar label={label} pct={pct} reset={formatResetTime(bucket)} />;
|
|
38
18
|
}
|
|
39
19
|
|
|
40
20
|
/** Read-only usage panel opened from the chat toolbar badge (Claude parity).
|
|
@@ -89,8 +69,8 @@ export function CodexUsagePanel({ onClose, usage }: { onClose: () => void; usage
|
|
|
89
69
|
<span className="text-sm text-text-primary truncate flex-1 min-w-0">{usage.activeAccountLabel || "Default login"}</span>
|
|
90
70
|
<span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1">~/.codex</span>
|
|
91
71
|
</div>
|
|
92
|
-
<UsageBar label="5-Hour" frac={usage.fiveHour} />
|
|
93
|
-
<UsageBar label="Weekly" frac={usage.sevenDay} />
|
|
72
|
+
<UsageBar label="5-Hour" frac={usage.fiveHour} bucket={usage.session} />
|
|
73
|
+
<UsageBar label="Weekly" frac={usage.sevenDay} bucket={usage.weekly} />
|
|
94
74
|
</div>
|
|
95
75
|
<p className="text-[11px] text-text-subtle">Using your default <code>~/.codex</code> login. Add managed accounts in Settings → AI Provider → Codex.</p>
|
|
96
76
|
</>
|
|
@@ -105,8 +85,8 @@ export function CodexUsagePanel({ onClose, usage }: { onClose: () => void; usage
|
|
|
105
85
|
<span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1">{a.type}</span>
|
|
106
86
|
{a.planType && <span className="text-[10px] text-text-subtle">{a.planType}</span>}
|
|
107
87
|
</div>
|
|
108
|
-
<UsageBar label="5-Hour" frac={u.fiveHour} />
|
|
109
|
-
<UsageBar label="Weekly" frac={u.sevenDay} />
|
|
88
|
+
<UsageBar label="5-Hour" frac={u.fiveHour} bucket={u.session} />
|
|
89
|
+
<UsageBar label="Weekly" frac={u.sevenDay} bucket={u.weekly} />
|
|
110
90
|
</div>
|
|
111
91
|
);
|
|
112
92
|
})}
|
|
@@ -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) =>
|