@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +2 -1
  4. package/dist/web/assets/{accounts-settings-section-D65EHybd.js → accounts-settings-section-B54qbh-_.js} +1 -1
  5. package/dist/web/assets/{ai-resource-editor-DmOGQ_1E.js → ai-resource-editor-BXuRvCii.js} +1 -1
  6. package/dist/web/assets/{ai-settings-section-Dlv_XYy3.js → ai-settings-section-ByXxu5Jp.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-DP53HaV9.js → ai-settings-section-D8-z9POX.js} +1 -1
  8. package/dist/web/assets/{appearance-settings-section-WuJwZW7v.js → appearance-settings-section-OZIxYrXu.js} +1 -1
  9. package/dist/web/assets/{chat-tab-B3tKoaUT.js → chat-tab-CYhKZEQb.js} +7 -7
  10. package/dist/web/assets/{code-editor-8BjOSWul.js → code-editor-CtMXFx5E.js} +2 -2
  11. package/dist/web/assets/{conflict-editor-D2JPf7dR.js → conflict-editor-DCTjpFtC.js} +1 -1
  12. package/dist/web/assets/{csv-preview-DfHSVE7o.js → csv-preview-oAb_uhuk.js} +1 -1
  13. package/dist/web/assets/{database-viewer-BwSf-V1L.js → database-viewer-C-qFE_XB.js} +1 -1
  14. package/dist/web/assets/{diff-viewer-CQU6j7uQ.js → diff-viewer-DPc5AiW_.js} +1 -1
  15. package/dist/web/assets/{explorer-body-B2FmdPAx.js → explorer-body-CFvpR1ZN.js} +1 -1
  16. package/dist/web/assets/{explorer-window-content-CtSMepys.js → explorer-window-content-DplJ_PMM.js} +1 -1
  17. package/dist/web/assets/{extension-manager-section-CBRXxG8W.js → extension-manager-section-Da827Nnv.js} +1 -1
  18. package/dist/web/assets/{extension-webview-yfMof39w.js → extension-webview-BxGlHycs.js} +1 -1
  19. package/dist/web/assets/{files-settings-section-Dxn0H4jj.js → files-settings-section-B0ED_J-Q.js} +1 -1
  20. package/dist/web/assets/{general-settings-section-C3ccyyOe.js → general-settings-section-nuoOnutx.js} +1 -1
  21. package/dist/web/assets/{git-log-panel-d2XBsNFP.js → git-log-panel-DK1MWTgZ.js} +1 -1
  22. package/dist/web/assets/{glide-data-grid-5Un6IKEP.js → glide-data-grid-DbE9VFqa.js} +1 -1
  23. package/dist/web/assets/{group-chat-tab-COcbheaZ.js → group-chat-tab-c6DU8xYR.js} +1 -1
  24. package/dist/web/assets/{index-DB-bD6Cx.js → index-DgJJy_D0.js} +5 -5
  25. package/dist/web/assets/{jira-watcher-section-BmDvZZ76.js → jira-watcher-section-CAjH1D-v.js} +1 -1
  26. package/dist/web/assets/{keybindings-store-DAMW7K3e.js → keybindings-store-Hupd6B3g.js} +1 -1
  27. package/dist/web/assets/{keyboard-shortcuts-section-BsZKKzf2.js → keyboard-shortcuts-section-BZNwSm4a.js} +1 -1
  28. package/dist/web/assets/{markdown-renderer-Dy4Vz3VW.js → markdown-renderer-CXuSaI_B.js} +1 -1
  29. package/dist/web/assets/{markdown-renderer-BtrHvth9.js → markdown-renderer-DOTEwBPU.js} +1 -1
  30. package/dist/web/assets/{mobile-explorer-sheet-BL1t1Ptr.js → mobile-explorer-sheet-CKuKGmoo.js} +1 -1
  31. package/dist/web/assets/{notification-store-DIwuBHMP.js → notification-store-CaP8GdjN.js} +1 -1
  32. package/dist/web/assets/{notifications-settings-section-CpnG8Ep4.js → notifications-settings-section-BXOwi-as.js} +1 -1
  33. package/dist/web/assets/{pdf-preview-9Olujr_E.js → pdf-preview-DUCPeqdU.js} +1 -1
  34. package/dist/web/assets/{postgres-viewer-qQtI_Qan.js → postgres-viewer-Dw5GcVrG.js} +1 -1
  35. package/dist/web/assets/{ppmbot-settings-section-C_yNqYo1.js → ppmbot-settings-section-BDhcXJn2.js} +1 -1
  36. package/dist/web/assets/{process-table-DH95ASxx.js → process-table-Dr-usEYu.js} +1 -1
  37. package/dist/web/assets/{proxy-settings-section-7OpR2hAD.js → proxy-settings-section-DYuVzg21.js} +1 -1
  38. package/dist/web/assets/{query-audit-section-CHncxP-Z.js → query-audit-section-fYtxmDrt.js} +1 -1
  39. package/dist/web/assets/{remote-desktop-mobile-sheet-Db21Gh-Y.js → remote-desktop-mobile-sheet-BcJZbFnp.js} +2 -2
  40. package/dist/web/assets/{remote-desktop-mobile-view-CiKtuPVS.js → remote-desktop-mobile-view-BV0WkCO0.js} +1 -1
  41. package/dist/web/assets/{remote-desktop-readiness-gate-BSBIPU4d.js → remote-desktop-readiness-gate-BejKUMGH.js} +1 -1
  42. package/dist/web/assets/{remote-desktop-window-content-CJOxXllZ.js → remote-desktop-window-content-6F1Oaygj.js} +1 -1
  43. package/dist/web/assets/{schedules-settings-section-Q5X3kNJ8.js → schedules-settings-section-DVv3TujZ.js} +1 -1
  44. package/dist/web/assets/{settings-body-5804QGDH.js → settings-body-BGsYkBkl.js} +2 -2
  45. package/dist/web/assets/{settings-tab-DgJP4K6b.js → settings-tab-BUoekCNf.js} +1 -1
  46. package/dist/web/assets/{settings-window-content-lyNwahx3.js → settings-window-content-CgBwhXex.js} +1 -1
  47. package/dist/web/assets/{sql-query-editor-DH3H3gvx.js → sql-query-editor-DNp7d1Fp.js} +1 -1
  48. package/dist/web/assets/{sqlite-viewer-DkmaTXae.js → sqlite-viewer-CxAYuvMa.js} +1 -1
  49. package/dist/web/assets/streaming-store-9_9j70zt.js +1 -0
  50. package/dist/web/assets/{system-monitor-body-KuahlPOb.js → system-monitor-body-CeQHDXUr.js} +2 -2
  51. package/dist/web/assets/{system-monitor-tab-BK-nYh25.js → system-monitor-tab-Ba3dB3lY.js} +1 -1
  52. package/dist/web/assets/{system-monitor-window-content-C5TkD158.js → system-monitor-window-content-D1u2-cq_.js} +1 -1
  53. package/dist/web/assets/{tab-host-window-content-BwDnxvbx.js → tab-host-window-content-gCdL-dfg.js} +1 -1
  54. package/dist/web/assets/{team-member-sheet-Cg82OxYB.js → team-member-sheet-D6HBfQBL.js} +2 -2
  55. package/dist/web/assets/{team-member-window-content-FxBRzgMW.js → team-member-window-content-CSB85Rx7.js} +1 -1
  56. package/dist/web/assets/{terminal-tab-Bro7kJBK.js → terminal-tab-BoYE74U4.js} +1 -1
  57. package/dist/web/assets/{tool-cards-BDmz33Y9.js → tool-cards-D5Bfnd4O.js} +2 -2
  58. package/dist/web/assets/{use-accounts-data-CvfdFWm3.js → use-accounts-data-DF_fHEHm.js} +1 -1
  59. package/dist/web/assets/{use-monaco-theme-D3Owwbo5.js → use-monaco-theme-qPgB4FLP.js} +1 -1
  60. package/dist/web/assets/{use-remote-desktop-display-choice-DOg4xZ-8.js → use-remote-desktop-display-choice-CdLY-Uvx.js} +1 -1
  61. package/dist/web/assets/{use-websocket-4y-ICdd7.js → use-websocket-C0qf6WqW.js} +1 -1
  62. package/dist/web/assets/{video-preview-C70-MurL.js → video-preview-Ccps7Y-_.js} +1 -1
  63. package/dist/web/index.html +2 -2
  64. package/dist/web/sw.js +1 -1
  65. package/package.json +1 -1
  66. package/src/providers/codex-app-server/codex-provider.ts +4 -0
  67. package/src/providers/registry.ts +12 -1
  68. package/src/server/index.ts +2 -2
  69. package/src/server/routes/chat-global.ts +53 -0
  70. package/src/server/routes/chat.ts +3 -0
  71. package/src/server/ws/chat.ts +15 -3
  72. package/src/services/db.service.ts +33 -2
  73. package/src/types/chat.ts +2 -0
  74. package/src/types/config.ts +14 -0
  75. package/src/web/components/chat/message-input.tsx +3 -4
  76. package/src/web/hooks/use-global-events.ts +12 -2
  77. package/src/web/lib/slash-items-cache.ts +5 -4
  78. package/src/web/lib/sync-running-sessions.ts +17 -15
  79. package/src/web/stores/streaming-store.ts +27 -14
  80. package/dist/web/assets/streaming-store-DDst3zA7.js +0 -1
  81. 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: { type: "cli", cli_command: "codex", permission_mode: "bypassPermissions" },
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
  }
@@ -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 { chatUnreadRoutes } from "./routes/chat-unread.ts";
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", chatUnreadRoutes);
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);
@@ -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(projectName?: string): { sessionId: string; phase: SessionPhase }[] {
263
- const running: { sessionId: string; phase: SessionPhase }[] = [];
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
- export const CURRENT_SCHEMA_VERSION = 44;
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
- function runMigrations(database: Database): void {
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 */
@@ -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
- // Load slash items via the shared per-project cache. The list is identical for
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(projectRef.current);
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(projectName);
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 cache = new Map<string, Promise<SlashItemsPayload>>();
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, projectUrl } from "@/lib/api-client";
1
+ import { api } from "@/lib/api-client";
2
2
  import { useStreamingStore } from "@/stores/streaming-store";
3
3
 
4
4
  /**
5
- * Reconcile a project's streaming indicators (tab-strip spinner, title/favicon)
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. Two gaps it
9
- * closes, both of which nothing else can:
10
- * - a turn running in a tab that is not mounted (tabs mount lazily, so its phase
11
- * never reaches this client) → indicator missing;
12
- * - an `idle` broadcast that landed while the global socket was down → indicator
13
- * stuck on until a full page reload.
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(projectName: string | undefined): Promise<void> {
16
- if (!projectName) return;
20
+ export async function syncRunningSessions(): Promise<void> {
17
21
  try {
18
- const running = await api.get<{ sessionId: string }[]>(
19
- `${projectUrl(projectName)}/chat/sessions/running`,
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 one project's streaming set against the server's registry, which
13
- * is authoritative. Needed because an `idle` broadcast missed while
14
- * `/ws/global` was down would otherwise leave a spinner running forever —
15
- * nothing else ever clears it. Scoped per project: the running list only
16
- * covers one project, so entries from other projects must survive.
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
- replaceProjectStreaming: (projectName: string, sessionIds: string[]) => void;
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
- replaceProjectStreaming: (projectName, sessionIds) =>
46
+ replaceAllStreaming: (running) =>
47
+ set(() => ({ sessions: new Map(running.map((s) => [s.sessionId, s.projectName])) })),
48
+ dropSession: (sessionId) =>
33
49
  set((state) => {
34
- const running = new Set(sessionIds);
50
+ if (!state.sessions.has(sessionId)) return state;
35
51
  const next = new Map(state.sessions);
36
- for (const [id, project] of state.sessions) {
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. The map already records the project per session so
54
- * `replaceProjectStreaming` can reconcile one project without touching another's entries.
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
- });