@hienlh/ppm 0.22.0 → 0.22.2

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 (147) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CLAUDE.md +1 -1
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/cli-reference.md +4 -4
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/dist/web/assets/accounts-settings-section-CLEAA_Jc.js +1 -0
  7. package/dist/web/assets/{ai-resource-editor-CdDpOwjh.js → ai-resource-editor-C4ecid2g.js} +1 -1
  8. package/dist/web/assets/ai-settings-section-BOwGdiyH.js +1 -0
  9. package/dist/web/assets/ai-settings-section-Cqz13gRt.js +1 -0
  10. package/dist/web/assets/{api-client-C1ZwRL4Y.js → api-client-Jn3njtNw.js} +1 -1
  11. package/dist/web/assets/api-client-rSFnnCj4.js +1 -0
  12. package/dist/web/assets/{api-settings-_7Q-ULxp.js → api-settings-BxijvPBR.js} +1 -1
  13. package/dist/web/assets/{api-themes-DRAERo4Q.js → api-themes-CSUarkzD.js} +1 -1
  14. package/dist/web/assets/{appearance-settings-section-DLoPK0LD.js → appearance-settings-section-DbA8L-Vz.js} +1 -1
  15. package/dist/web/assets/{audio-preview-DsYvgKqj.js → audio-preview-CxJd8wRV.js} +1 -1
  16. package/dist/web/assets/{branch-review-tab-G84Ojzae.js → branch-review-tab-CMTa2uSR.js} +1 -1
  17. package/dist/web/assets/chat-tab-BpdgqjSL.js +15 -0
  18. package/dist/web/assets/{code-editor-D04iUPsZ.js → code-editor-B7y7-5KE.js} +4 -4
  19. package/dist/web/assets/{codex-daily-guard-BSccLRGT.js → codex-daily-guard-ChDPiXDx.js} +1 -1
  20. package/dist/web/assets/{conflict-editor-C4ay676e.js → conflict-editor-Axs9Ad7A.js} +3 -3
  21. package/dist/web/assets/{csv-preview-DbwEmG7o.js → csv-preview-DTUAW2HW.js} +1 -1
  22. package/dist/web/assets/{database-viewer-S6LsALjX.js → database-viewer-qXDiWeXj.js} +1 -1
  23. package/dist/web/assets/{diff-viewer-3oBA7vgQ.js → diff-viewer-BtLiSpqe.js} +1 -1
  24. package/dist/web/assets/{docx-preview-CXuk-emm.js → docx-preview-Dxvy6h13.js} +1 -1
  25. package/dist/web/assets/{editor-language-service-DJmaKVY0.js → editor-language-service-D75GuLXP.js} +1 -1
  26. package/dist/web/assets/{explorer-body-BQ0jyAGv.js → explorer-body-xg1-MhlW.js} +2 -2
  27. package/dist/web/assets/{explorer-window-content-jdkWt34J.js → explorer-window-content-BeQocW3i.js} +1 -1
  28. package/dist/web/assets/extension-manager-section-Bm2v3P-n.js +1 -0
  29. package/dist/web/assets/{extension-webview-BGSDUbdK.js → extension-webview-CzxrAHyL.js} +1 -1
  30. package/dist/web/assets/{file-icons-BXdvggK2.js → file-icons-wS-OmEVu.js} +1 -1
  31. package/dist/web/assets/{file-store-ojgMXgAw.js → file-store-Ds3qJ4hC.js} +1 -1
  32. package/dist/web/assets/{files-settings-section-Gnyd7han.js → files-settings-section-Bd-_GeZU.js} +1 -1
  33. package/dist/web/assets/{format-file-meta-C77h04cZ.js → format-file-meta-PPi3ptkm.js} +1 -1
  34. package/dist/web/assets/{general-settings-section-V6Khh15V.js → general-settings-section-CfNPHQUk.js} +1 -1
  35. package/dist/web/assets/{git-log-panel-Bi513qjT.js → git-log-panel-CtGxPzH6.js} +1 -1
  36. package/dist/web/assets/{git-repo-store-88Z8v-pF.js → git-repo-store-BCEPfUGT.js} +1 -1
  37. package/dist/web/assets/{glide-data-grid-DnQwYSmQ.js → glide-data-grid-iSo2vP0q.js} +1 -1
  38. package/dist/web/assets/{group-chat-tab-DpczdwyV.js → group-chat-tab-D1lx0zz-.js} +1 -1
  39. package/dist/web/assets/{image-preview-CzCc4_Do.js → image-preview-DeGD0HJu.js} +1 -1
  40. package/dist/web/assets/index-CN0G8uYY.js +52 -0
  41. package/dist/web/assets/{jira-watcher-section-f2HUbGDY.js → jira-watcher-section-kyifZPTv.js} +1 -1
  42. package/dist/web/assets/keybindings-store-L5vV8DEJ.js +1 -0
  43. package/dist/web/assets/{keyboard-shortcuts-section-BpvVEw_Y.js → keyboard-shortcuts-section-D2uJq4x9.js} +1 -1
  44. package/dist/web/assets/markdown-renderer-BX1Q00sC.js +3 -0
  45. package/dist/web/assets/markdown-renderer-CLUux0vA.js +1 -0
  46. package/dist/web/assets/{media-url-yfqHpPz0.js → media-url-DKK0YHxL.js} +1 -1
  47. package/dist/web/assets/{mobile-explorer-sheet-Bol3XAO1.js → mobile-explorer-sheet-BHQDzpwK.js} +1 -1
  48. package/dist/web/assets/notification-store-BHgSy-ci.js +1 -0
  49. package/dist/web/assets/panel-store-D20Zuf3i.js +1 -0
  50. package/dist/web/assets/{pdf-preview-CmBI6E7d.js → pdf-preview-DAcfhfGm.js} +1 -1
  51. package/dist/web/assets/{postgres-viewer-8ZAqEALv.js → postgres-viewer-DPRsmgP5.js} +1 -1
  52. package/dist/web/assets/{ppmbot-settings-section-DKJUA3Kq.js → ppmbot-settings-section-B5N1jHgm.js} +1 -1
  53. package/dist/web/assets/{problems-panel-CFcoUx8h.js → problems-panel-D_wZ4_Qg.js} +1 -1
  54. package/dist/web/assets/{process-table-EKr2dqG6.js → process-table-DahflyQn.js} +1 -1
  55. package/dist/web/assets/{project-framework-store-B70Q3dNM.js → project-framework-store-ViZgce6y.js} +1 -1
  56. package/dist/web/assets/{project-store-B87hLRSk.js → project-store-hNvwfqX3.js} +1 -1
  57. package/dist/web/assets/{proxy-settings-section-B7qRVl8w.js → proxy-settings-section-CLg_nlxn.js} +1 -1
  58. package/dist/web/assets/{query-audit-section-By6qyqdB.js → query-audit-section-BrfSo0s_.js} +1 -1
  59. package/dist/web/assets/{remote-desktop-clipboard-notice-0WDYsyCA.js → remote-desktop-clipboard-notice-Fi6cieGM.js} +1 -1
  60. package/dist/web/assets/{remote-desktop-mobile-sheet-COYDg16H.js → remote-desktop-mobile-sheet-lLZ-oK73.js} +2 -2
  61. package/dist/web/assets/{remote-desktop-mobile-view-2DqSuS2U.js → remote-desktop-mobile-view-j3v-4rYa.js} +1 -1
  62. package/dist/web/assets/{remote-desktop-readiness-gate-Dm_qMJMH.js → remote-desktop-readiness-gate-CeLvcX_w.js} +1 -1
  63. package/dist/web/assets/{remote-desktop-window-content-UodhsF7W.js → remote-desktop-window-content-lwbkLsTC.js} +1 -1
  64. package/dist/web/assets/{run-in-terminal-CdefEe_R.js → run-in-terminal-D7W4IKb9.js} +1 -1
  65. package/dist/web/assets/{schedules-settings-section-bG2lYfGv.js → schedules-settings-section-C8OS0o80.js} +1 -1
  66. package/dist/web/assets/{send-to-chat-F207yH3u.js → send-to-chat-DNKhmHbi.js} +1 -1
  67. package/dist/web/assets/{settings-body-C06bFarv.js → settings-body-DgN22Z0W.js} +2 -2
  68. package/dist/web/assets/{settings-store-krC6bc79.js → settings-store-CsVSVgLp.js} +2 -2
  69. package/dist/web/assets/{settings-tab-34RSaASd.js → settings-tab-ChOs94iB.js} +1 -1
  70. package/dist/web/assets/{settings-window-content-Dr_2lmHH.js → settings-window-content-BnKMmg2a.js} +1 -1
  71. package/dist/web/assets/{sql-query-editor-BgRH0cHD.js → sql-query-editor-BJZIIQsm.js} +1 -1
  72. package/dist/web/assets/{sqlite-viewer-DUxGslpa.js → sqlite-viewer--cMeCZum.js} +1 -1
  73. package/dist/web/assets/{system-monitor-body-IZuLn7Qh.js → system-monitor-body-CXiIrn0N.js} +2 -2
  74. package/dist/web/assets/system-monitor-tab-BJKrTSl0.js +1 -0
  75. package/dist/web/assets/{system-monitor-window-content-CEaFISgj.js → system-monitor-window-content-Dy9fZJE3.js} +1 -1
  76. package/dist/web/assets/{tab-host-window-content-D0TN8Wrq.js → tab-host-window-content-BezqrAwC.js} +1 -1
  77. package/dist/web/assets/{tab-store-OJa1GPCq.js → tab-store-krAuHQ6_.js} +1 -1
  78. package/dist/web/assets/{team-member-sheet-DDPNcCjd.js → team-member-sheet-CVM2QC3H.js} +2 -2
  79. package/dist/web/assets/{team-member-window-content-DCiW_1-E.js → team-member-window-content-D4RYKplm.js} +1 -1
  80. package/dist/web/assets/{terminal-tab-4ypP0NDY.js → terminal-tab-jikhQLct.js} +3 -3
  81. package/dist/web/assets/{tool-cards-Dd6E7Q3k.js → tool-cards-D5jCQvqn.js} +2 -2
  82. package/dist/web/assets/{use-blob-url-CPtCcY3i.js → use-blob-url-DMGlPCQD.js} +1 -1
  83. package/dist/web/assets/{use-inline-blame-vrfFHuaR.js → use-inline-blame-BF8Y1KfV.js} +3 -3
  84. package/dist/web/assets/{use-lsp-CsuZu49X.js → use-lsp-Bx4oVtlk.js} +1 -1
  85. package/dist/web/assets/{use-monaco-theme-BIVYJBIn.js → use-monaco-theme-DHczXLMb.js} +1 -1
  86. package/dist/web/assets/{use-websocket-BMbufhX7.js → use-websocket-BWZkbleJ.js} +1 -1
  87. package/dist/web/assets/{video-preview-Byj8P_jA.js → video-preview-D191TvX9.js} +1 -1
  88. package/dist/web/assets/{window-pip-registry-yD6esSju.js → window-pip-registry-D1oXq-Dq.js} +1 -1
  89. package/dist/web/assets/ws-auth-BG-cdrQ8.js +1 -0
  90. package/dist/web/index.html +13 -13
  91. package/dist/web/sw.js +1 -1
  92. package/docs/architecture/ai-chat-and-providers.md +9 -0
  93. package/docs/architecture/workspace-and-ui.md +13 -0
  94. package/package.json +1 -1
  95. package/packages/ext-git-graph/src/webview-html.test.ts +30 -5
  96. package/packages/ext-git-graph/src/webview-html.ts +99 -31
  97. package/src/cli/commands/init.ts +1 -0
  98. package/src/providers/codex-app-server/codex-history.ts +26 -7
  99. package/src/providers/codex-app-server/codex-provider.ts +6 -4
  100. package/src/server/routes/chat.ts +1 -1
  101. package/src/server/routes/settings.ts +11 -5
  102. package/src/services/config.service.ts +2 -0
  103. package/src/services/edge-forwarder.ts +191 -191
  104. package/src/services/windows-process-tree.ts +351 -351
  105. package/src/shared/codex-daily-guard.ts +12 -5
  106. package/src/types/chat.ts +1 -1
  107. package/src/types/config.ts +12 -1
  108. package/src/web/components/chat/chat-tab.tsx +162 -38
  109. package/src/web/components/chat/message-input.tsx +24 -0
  110. package/src/web/components/chat/model-thinking-selector.tsx +25 -5
  111. package/src/web/components/chat/new-chat-provider-gate.tsx +68 -0
  112. package/src/web/components/extensions/extension-tree-view.tsx +4 -11
  113. package/src/web/components/layout/command-palette.tsx +2 -18
  114. package/src/web/components/layout/editor-panel.tsx +1 -1
  115. package/src/web/components/layout/reparenting-tab.tsx +4 -2
  116. package/src/web/components/layout/tab-bar.tsx +1 -1
  117. package/src/web/components/settings/accounts/account-card.tsx +2 -2
  118. package/src/web/components/settings/accounts/codex-accounts-section.tsx +4 -4
  119. package/src/web/components/settings/ai-settings-section.tsx +36 -0
  120. package/src/web/components/settings/extension-manager-section.tsx +8 -1
  121. package/src/web/hooks/use-draft.ts +25 -4
  122. package/src/web/hooks/use-usage.ts +6 -4
  123. package/src/web/lib/api-client.ts +7 -1
  124. package/src/web/lib/api-settings.ts +1 -0
  125. package/src/web/lib/extension-icons.ts +70 -0
  126. package/src/web/lib/new-chat-provider.ts +8 -0
  127. package/src/web/lib/tab-type-icons.ts +14 -0
  128. package/src/web/stores/panel-store.ts +46 -3
  129. package/dist/web/assets/accounts-settings-section-BVr9-FZJ.js +0 -1
  130. package/dist/web/assets/ai-settings-section-B1hQyjAd.js +0 -1
  131. package/dist/web/assets/ai-settings-section-X2UOfSaS.js +0 -1
  132. package/dist/web/assets/api-client-pQL53cDQ.js +0 -1
  133. package/dist/web/assets/chat-tab-DQJfjm8E.js +0 -15
  134. package/dist/web/assets/extension-manager-section-DG1guILi.js +0 -1
  135. package/dist/web/assets/index-DU68xk06.js +0 -52
  136. package/dist/web/assets/keybindings-store-DCRsnKg5.js +0 -1
  137. package/dist/web/assets/markdown-renderer-Bcaf9_bA.js +0 -1
  138. package/dist/web/assets/markdown-renderer-DGJp8HC3.js +0 -3
  139. package/dist/web/assets/notification-store-BXt54VIn.js +0 -1
  140. package/dist/web/assets/panel-store-CboGJAwh.js +0 -1
  141. package/dist/web/assets/system-monitor-tab-DiSA490Z.js +0 -1
  142. package/dist/web/assets/ws-auth-CEYVQ8NH.js +0 -1
  143. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  144. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  145. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  146. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  147. package/src/web/hooks/use-delayed-flag.ts +0 -35
@@ -2,6 +2,7 @@ import type { LimitBucket } from "../types/chat.ts";
2
2
 
3
3
  const DAY_MS = 24 * 60 * 60 * 1000;
4
4
  const WEEK_DAYS = 7;
5
+ const WORK_DAYS = 5;
5
6
 
6
7
  export interface DailyGuardState {
7
8
  day: number;
@@ -10,18 +11,24 @@ export interface DailyGuardState {
10
11
  blocked: boolean;
11
12
  }
12
13
 
13
- /** Split a weekly quota into seven daily envelopes. */
14
+ /** Five weekday envelopes, using reset-anchored 24h slots and UTC weekdays on every client. */
14
15
  export function dailyGuardState(bucket: LimitBucket | undefined, now = Date.now()): DailyGuardState | null {
15
16
  if (!bucket || bucket.windowHours !== 168) return null;
16
17
  const resetsAt = new Date(bucket.resetsAt).getTime();
17
- if (!Number.isFinite(resetsAt) || resetsAt <= now) return null;
18
+ if (!Number.isFinite(now) || !Number.isFinite(resetsAt) || resetsAt <= now) return null;
18
19
 
19
20
  const startedAt = resetsAt - WEEK_DAYS * DAY_MS;
20
- const day = Math.min(WEEK_DAYS, Math.max(1, Math.ceil((now - startedAt) / DAY_MS)));
21
- const cap = day / WEEK_DAYS;
21
+ if (now < startedAt) return null;
22
+ const elapsedSlot = Math.min(WEEK_DAYS - 1, Math.floor((now - startedAt) / DAY_MS));
23
+ let day = 0;
24
+ for (let slot = 0; slot <= elapsedSlot; slot++) {
25
+ const weekday = new Date(startedAt + slot * DAY_MS).getUTCDay();
26
+ if (weekday !== 0 && weekday !== 6) day++;
27
+ }
28
+ const cap = day / WORK_DAYS;
22
29
  return { day, cap, used: bucket.utilization, blocked: bucket.utilization >= cap };
23
30
  }
24
31
 
25
32
  export function dailyGuardMessage(state: DailyGuardState): string {
26
- return `Daily guard reached: ${Math.round(state.used * 100)}% used; day ${state.day}/7 allows ${Math.round(state.cap * 100)}%. Turn off Daily guard on this account to continue.`;
33
+ return `Daily guard reached: ${Math.round(state.used * 100)}% used; weekday ${state.day}/5 allows ${Math.round(state.cap * 100)}%. The cap increases on weekdays only. Turn off Daily guard on this account to continue.`;
27
34
  }
package/src/types/chat.ts CHANGED
@@ -70,7 +70,7 @@ export interface AIProvider {
70
70
  /** Drop runtime skill discovery results after a user requests a refresh. */
71
71
  invalidateSkillsCache?(): void;
72
72
  /** Provider-specific usage/quota (rate limits). Used by GET /chat/usage. */
73
- getUsage?(sessionId?: string): Promise<UsageInfo>;
73
+ getUsage?(sessionId?: string, pickedAccountId?: string): Promise<UsageInfo>;
74
74
  /** True when a live streaming subprocess exists for this session */
75
75
  hasStreamingSession?(sessionId: string): boolean;
76
76
  /** Prompt-cache lifetime for this session, in ms — how long holding its subprocess pays. */
@@ -74,8 +74,12 @@ export interface ProjectConfig {
74
74
  image?: string;
75
75
  }
76
76
 
77
+ export type NewChatProviderMode = "default" | "follow-focus";
78
+
77
79
  export interface AIConfig {
78
80
  default_provider: string;
81
+ /** Missing in legacy settings: use the configured default provider. */
82
+ new_chat_provider_mode?: NewChatProviderMode;
79
83
  /** Share project rules and memory between providers. Unset defaults to true. */
80
84
  share_provider_context?: boolean;
81
85
  providers: Record<string, AIProviderConfig>;
@@ -140,6 +144,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
140
144
  projects: [],
141
145
  ai: {
142
146
  default_provider: "claude",
147
+ new_chat_provider_mode: "follow-focus",
143
148
  share_provider_context: true,
144
149
  providers: {
145
150
  claude: {
@@ -280,7 +285,7 @@ export function validateCodexContextConfig(config: Partial<AIProviderConfig>): s
280
285
 
281
286
  /** Validate default_provider references an existing provider key */
282
287
  export function validateDefaultProvider(defaultProvider: string, providers: Record<string, unknown>): string | null {
283
- if (!providers[defaultProvider]) {
288
+ if (!Object.hasOwn(providers, defaultProvider)) {
284
289
  return `default_provider "${defaultProvider}" not found in providers`;
285
290
  }
286
291
  return null;
@@ -293,6 +298,12 @@ export function validateDefaultProvider(defaultProvider: string, providers: Reco
293
298
  export function sanitizeConfig(config: PpmConfig): boolean {
294
299
  let dirty = false;
295
300
 
301
+ // Preserve legacy behavior on upgrade; only fresh configs opt into following focus.
302
+ if (config.ai.new_chat_provider_mode !== "default" && config.ai.new_chat_provider_mode !== "follow-focus") {
303
+ config.ai.new_chat_provider_mode = "default";
304
+ dirty = true;
305
+ }
306
+
296
307
  if (typeof config.ai.share_provider_context !== "boolean") {
297
308
  config.ai.share_provider_context = true;
298
309
  dirty = true;
@@ -20,6 +20,7 @@ import { MessageInput, type ChatAttachment, type MessagePriority } from "./messa
20
20
  import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
21
21
  import { FilePicker } from "./file-picker";
22
22
  import { ChatHistoryBar } from "./chat-history-bar";
23
+ import { NewChatProviderGate } from "./new-chat-provider-gate";
23
24
  import { useDraft, type DraftAttachment } from "@/hooks/use-draft";
24
25
 
25
26
  import type { DragEvent } from "react";
@@ -31,7 +32,24 @@ interface ChatTabProps {
31
32
  tabId?: string;
32
33
  }
33
34
 
35
+ /**
36
+ * How long a first message may wait for its session to be created, and then for the
37
+ * new socket to report connected, before it is handed back to the user. Generous
38
+ * because a codex app-server cold start alone takes ~30 s; the point is only that
39
+ * "forever, silently" is not an option.
40
+ */
41
+ const SESSION_CREATE_TIMEOUT_MS = 30_000;
42
+ const PENDING_SEND_TIMEOUT_MS = 45_000;
43
+
34
44
  export function ChatTab({ metadata, tabId }: ChatTabProps) {
45
+ return tabId && metadata ? (
46
+ <NewChatProviderGate tabId={tabId} metadata={metadata}>
47
+ <ChatTabContent metadata={metadata} tabId={tabId} />
48
+ </NewChatProviderGate>
49
+ ) : <ChatTabContent metadata={metadata} tabId={tabId} />;
50
+ }
51
+
52
+ function ChatTabContent({ metadata, tabId }: ChatTabProps) {
35
53
  const [sessionId, setSessionId] = useState<string | null>(
36
54
  (metadata?.sessionId as string) ?? null,
37
55
  );
@@ -72,8 +90,17 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
72
90
  (metadata?.permissionMode as string) ?? undefined,
73
91
  );
74
92
 
75
- // Pending message to send after WS connects (replaces unreliable setTimeout)
76
- const pendingSendRef = useRef<{ content: string; permissionMode?: string; images?: Array<{ data: string; mediaType: string }>; imagePaths?: string[] } | null>(null);
93
+ // Pending message to send after WS connects (replaces unreliable setTimeout).
94
+ // `draftId` is the draft it was composed under, deleted only once the message has
95
+ // really been handed to the socket; until then that row is the only other copy.
96
+ const pendingSendRef = useRef<{
97
+ content: string;
98
+ draftId: string;
99
+ permissionMode?: string;
100
+ images?: Array<{ data: string; mediaType: string }>;
101
+ imagePaths?: string[];
102
+ } | null>(null);
103
+ const pendingSendTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
77
104
 
78
105
  // Drag-and-drop state
79
106
  const [isDragging, setIsDragging] = useState(false);
@@ -90,16 +117,17 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
90
117
  // Usage runs independently — auto-refreshes on interval. Scoped to this session so the
91
118
  // account shown is the one bound to it, not whichever session ran most recently.
92
119
  const { usageInfo, usageLoading, lastFetchedAt, refreshUsage, reloadUsage } =
93
- useUsage(projectName, providerId, sessionId ?? undefined);
120
+ useUsage(projectName, providerId, sessionId ?? undefined,
121
+ metadata?.pickedAccountProvider === providerId ? metadata?.pickedAccountId as string | undefined : undefined);
94
122
 
95
123
  // Draft auto-save/restore
96
- const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
124
+ const { draft, draftLoading, saveDraft, clearDraft, cancelPendingSave } = useDraft(projectName, sessionId);
97
125
 
98
126
  // Load global default permission mode on mount (if no per-session override)
99
127
  useEffect(() => {
100
128
  if (permissionMode) return;
101
129
  getAISettings().then((s) => {
102
- const provider = s.providers[s.default_provider ?? "claude"];
130
+ const provider = s.providers[providerId];
103
131
  setPermissionMode(provider?.permission_mode ?? "bypassPermissions");
104
132
  }).catch(() => {});
105
133
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -291,15 +319,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
291
319
  const primaryTeam = teamActivity?.teamNames?.[0] ?? "";
292
320
  const { members: teamMembers } = useTeamActivityFeed(primaryTeam, !!primaryTeam);
293
321
 
294
- // Flush pending message once WS connects (replaces unreliable setTimeout)
295
- useEffect(() => {
296
- if (isConnected && pendingSendRef.current) {
297
- const { content, permissionMode: pm, images: pendingImages, imagePaths: pendingPaths } = pendingSendRef.current;
298
- pendingSendRef.current = null;
299
- sendMessage(content, { permissionMode: pm, ...(pendingImages?.length && { images: pendingImages }), ...(pendingPaths?.length && { imagePaths: pendingPaths }) });
300
- }
301
- }, [isConnected, sendMessage]);
302
-
303
322
  // Auto-clear notification badge when this tab is active and document is visible.
304
323
  // Checks ALL panels (not just focused) so split-panel scenarios also clear.
305
324
  useEffect(() => {
@@ -335,20 +354,92 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
335
354
 
336
355
  // Pending fork message — show in input for user to edit, not auto-send
337
356
  const [forkDraft, setForkDraft] = useState<string | undefined>(metadata?.pendingMessage as string | undefined);
338
- // Pending edit: when set, the next send forks at `anchorMsgId` and continues
339
- // in THIS tab (swap sessionId) instead of opening a new tab.
340
- // anchorMsgId = fork anchor (prev message); ownMsgId = the edited message itself (for highlight).
341
- const [editFork, setEditFork] = useState<{ anchorMsgId?: string; ownMsgId?: string } | null>(null);
342
- // Local echo of a just-sent edited message. The forked session's WS connect can
343
- // be slow (codex app-server cold start ~30s), and the real optimistic message is
344
- // only added after `isConnected`. Show this immediately so the edit doesn't
345
- // vanish while the new session spins up; cleared once the real message arrives.
346
357
  // True from the moment an edit is submitted until the forked session starts
347
358
  // responding. Drives a "working" indicator so the ~10s fork + codex connect
348
359
  // doesn't leave the user staring at a frozen screen. (No optimistic message
349
360
  // echo — appending it to the still-visible source transcript would render the
350
361
  // edit in the wrong place; the real message appears once the fork loads.)
351
362
  const [editForking, setEditForking] = useState(false);
363
+
364
+ /**
365
+ * A message that could not be sent, on its way back into the composer. A nonce
366
+ * rather than the bare string: the text is often identical to the draft the
367
+ * composer was prefilled with, and a value that does not change applies nothing.
368
+ */
369
+ const [restore, setRestore] = useState<{ text: string; nonce: number } | null>(null);
370
+
371
+ /**
372
+ * Give an unsent message back to the user.
373
+ *
374
+ * The composer empties itself the moment Enter is pressed, and the first send of a
375
+ * new tab still has a session to create and a socket to open before anything leaves
376
+ * the browser. Whichever of those fails, the text goes back into the input — the
377
+ * composer re-saves it as the draft so a reload keeps it — and the failure is said
378
+ * out loud: a cleared input over an unchanged transcript reads as nothing having
379
+ * happened at all.
380
+ *
381
+ * What comes back is the full message as it would have been sent, attachment
382
+ * markers included. The files themselves are already uploaded and the markers name
383
+ * them, so re-sending still hands the model every file; the composer has no way to
384
+ * re-attach them as chips, and a picture-only message would otherwise come back as
385
+ * nothing at all.
386
+ */
387
+ const restoreUnsentMessage = useCallback((content: string, reason: string) => {
388
+ setRestore({ text: content, nonce: Date.now() });
389
+ toast.error("Message not sent", { description: `${reason} Your text is back in the input.` });
390
+ }, []);
391
+
392
+ /**
393
+ * Drop a pending send that never got its socket; hand the text back.
394
+ *
395
+ * The draft it was composed under is cleared first: the composer re-saves the
396
+ * restored text under the session the tab is on now, and leaving the old row
397
+ * behind would prefill the next new tab with this message as well.
398
+ */
399
+ const abandonPendingSend = useCallback((reason: string) => {
400
+ if (pendingSendTimerRef.current) { clearTimeout(pendingSendTimerRef.current); pendingSendTimerRef.current = null; }
401
+ setEditForking(false);
402
+ const pending = pendingSendRef.current;
403
+ if (!pending) return;
404
+ pendingSendRef.current = null;
405
+ clearDraft(pending.draftId);
406
+ restoreUnsentMessage(pending.content, reason);
407
+ }, [restoreUnsentMessage, clearDraft]);
408
+
409
+ /**
410
+ * Queue a message for the moment the (new) session's socket reports connected.
411
+ * Bounded: a socket that never says hello would otherwise keep the text in a ref
412
+ * with nothing on screen, for as long as the tab lives.
413
+ */
414
+ const queuePendingSend = useCallback((pending: NonNullable<typeof pendingSendRef.current>) => {
415
+ pendingSendRef.current = pending;
416
+ if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
417
+ pendingSendTimerRef.current = setTimeout(
418
+ () => abandonPendingSend("The chat did not connect in time."),
419
+ PENDING_SEND_TIMEOUT_MS,
420
+ );
421
+ }, [abandonPendingSend]);
422
+
423
+ // Flush pending message once WS connects (replaces unreliable setTimeout)
424
+ useEffect(() => {
425
+ if (isConnected && pendingSendRef.current) {
426
+ const { content, draftId, permissionMode: pm, images: pendingImages, imagePaths: pendingPaths } = pendingSendRef.current;
427
+ pendingSendRef.current = null;
428
+ if (pendingSendTimerRef.current) { clearTimeout(pendingSendTimerRef.current); pendingSendTimerRef.current = null; }
429
+ sendMessage(content, { permissionMode: pm, ...(pendingImages?.length && { images: pendingImages }), ...(pendingPaths?.length && { imagePaths: pendingPaths }) });
430
+ clearDraft(draftId);
431
+ }
432
+ }, [isConnected, sendMessage, clearDraft]);
433
+
434
+ // A closed tab takes its pending message with it; the timer must not fire into an
435
+ // unmounted component.
436
+ useEffect(() => () => {
437
+ if (pendingSendTimerRef.current) clearTimeout(pendingSendTimerRef.current);
438
+ }, []);
439
+ // Pending edit: when set, the next send forks at `anchorMsgId` and continues
440
+ // in THIS tab (swap sessionId) instead of opening a new tab.
441
+ // anchorMsgId = fork anchor (prev message); ownMsgId = the edited message itself (for highlight).
442
+ const [editFork, setEditFork] = useState<{ anchorMsgId?: string; ownMsgId?: string } | null>(null);
352
443
  // Bumped to tell MessageInput to clear its textarea when an edit is cancelled.
353
444
  const [clearInputSignal, setClearInputSignal] = useState(0);
354
445
  // True while a same-tree version swap loads: versions share an identical prefix,
@@ -399,13 +490,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
399
490
  }, [projectName, providerId]);
400
491
 
401
492
  const handleSelectSession = useCallback((session: SessionInfo) => {
402
- setEditForking(false);
493
+ // A message still waiting for its own session's socket must not ride the
494
+ // connect of the one picked here: `isConnected` is session-scoped, so the flush
495
+ // would fire on the selected conversation and post it there.
496
+ abandonPendingSend("You switched to another chat before it connected.");
403
497
  setSessionId(session.id);
404
498
  setProviderId(session.providerId);
405
499
  if (tabId) updateTab(tabId, { title: session.title || "Chat" });
406
500
  // Immediately clear notification for the selected session
407
501
  useNotificationStore.getState().clearForSession(session.id);
408
- }, [tabId, updateTab]);
502
+ }, [tabId, updateTab, abandonPendingSend]);
409
503
 
410
504
  /** Fork current session and open new tab with the forked session, resending userMessage */
411
505
  const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
@@ -461,43 +555,52 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
461
555
  // session's codex connect can take ~10s; don't await in silence.
462
556
  setEditForking(true);
463
557
  try {
558
+ // Bounded like session creation below, and with the same accepted cost: a
559
+ // fork the server finished after the abort is a stray sibling in the tree.
464
560
  const forked = await api.post<{ id: string }>(
465
561
  `${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}&mode=edit`,
466
562
  { messageId: anchorMsgId },
563
+ { signal: AbortSignal.timeout(SESSION_CREATE_TIMEOUT_MS) },
467
564
  );
468
565
  // The tree gained a sibling. Swapping sessionId below refetches
469
566
  // /messages, which carries a fresh versionMap, so the switcher's n/m
470
567
  // counts update without any cache to invalidate.
471
568
  // Queue the edited message — flushed by the connect effect once the WS
472
- // reconnects to the forked session.
473
- pendingSendRef.current = { content: fullContent, permissionMode };
569
+ // reconnects to the forked session. The draft was composed under the
570
+ // source session, so that is the one to clear when it goes.
571
+ queuePendingSend({ content: fullContent, draftId: sessionId, permissionMode });
474
572
  // Swap the current tab to the forked session (no new tab).
475
573
  setStaleSwap(true);
476
574
  if (tabId) updateTab(tabId, { metadata: { ...metadata, sessionId: forked.id } });
477
575
  setSessionId(forked.id);
478
576
  } catch (e) {
479
577
  setEditForking(false);
480
- const msg = (e as Error)?.message || "Unknown error";
578
+ const msg = (e as Error)?.name === "TimeoutError"
579
+ ? "The server did not answer in time."
580
+ : (e as Error)?.message || "Unknown error";
481
581
  toast.error("Cannot edit from this message", {
482
582
  description: msg.includes("not found") || msg.includes("Invalid upToMessageId")
483
583
  ? "The original message is no longer available in the session transcript."
484
584
  : msg,
485
585
  });
586
+ // The edited text was cleared from the composer on Enter — put it back.
587
+ setRestore({ text: fullContent, nonce: Date.now() });
486
588
  }
487
589
  },
488
- [sessionId, projectName, providerId, permissionMode, tabId, updateTab, metadata],
590
+ [sessionId, projectName, providerId, permissionMode, tabId, updateTab, metadata, queuePendingSend],
489
591
  );
490
592
 
491
593
  /** Swap THIS tab to another version's session (version switcher prev/next) */
492
594
  const handleSwitchVersion = useCallback(
493
595
  (targetSessionId: string) => {
494
596
  if (!targetSessionId || targetSessionId === sessionId) return;
495
- setEditForking(false);
597
+ // Same as handleSelectSession: a queued edit must not land in the version switched to.
598
+ abandonPendingSend("You switched to another version before it connected.");
496
599
  setStaleSwap(true);
497
600
  if (tabId) updateTab(tabId, { metadata: { ...metadata, sessionId: targetSessionId } });
498
601
  setSessionId(targetSessionId);
499
602
  },
500
- [sessionId, tabId, updateTab, metadata],
603
+ [sessionId, tabId, updateTab, metadata, abandonPendingSend],
501
604
  );
502
605
 
503
606
  /** Build message content with file references and inline text snippets prepended */
@@ -559,6 +662,11 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
559
662
  if (!sessionId) {
560
663
  try {
561
664
  const pName = projectName;
665
+ // Bounded: the composer is already empty by now, and a create call that hangs
666
+ // (a stale tunnel connection after a long idle is the usual way) would otherwise
667
+ // leave it empty over an unchanged transcript for as long as the tab lives.
668
+ // Accepted cost: a create the server completed after the abort leaves one empty
669
+ // session behind, which the retry does not reuse.
562
670
  const session = await api.post<Session>(`${projectUrl(pName)}/chat/sessions`, {
563
671
  providerId,
564
672
  title: content.slice(0, 50),
@@ -567,20 +675,27 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
567
675
  // The account this tab claimed on open and has been displaying since. Redeeming
568
676
  // it here is what makes that display true rather than a guess.
569
677
  accountId: pickedAccountId,
570
- });
678
+ }, { signal: AbortSignal.timeout(SESSION_CREATE_TIMEOUT_MS) });
571
679
  setSessionId(session.id);
572
680
  setProviderId(session.providerId);
573
- // Queue message — will be sent by effect when WS reports isConnected
574
- pendingSendRef.current = { content: fullContent, permissionMode, images, imagePaths };
681
+ // Queue message — will be sent by effect when WS reports isConnected. It was
682
+ // composed under the new-tab draft, which is what to clear once it goes.
683
+ queuePendingSend({ content: fullContent, draftId: "__new__", permissionMode, images, imagePaths });
575
684
  return;
576
685
  } catch (e) {
577
686
  console.error("Failed to create session:", e);
687
+ const msg = (e as Error)?.name === "TimeoutError"
688
+ ? "The server did not answer in time."
689
+ : `Could not start the chat: ${(e as Error)?.message || "unknown error"}.`;
690
+ restoreUnsentMessage(fullContent, msg);
578
691
  return;
579
692
  }
580
693
  }
581
694
  sendMessage(fullContent, { permissionMode, priority, ...(images.length > 0 && { images }), ...(imagePaths.length > 0 && { imagePaths }) });
695
+ // Only now: the message is on (or queued for) a live session's socket.
696
+ clearDraft();
582
697
  },
583
- [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata],
698
+ [sessionId, providerId, projectName, sendMessage, buildMessageWithAttachments, permissionMode, metadata, pickedAccountId, queuePendingSend, restoreUnsentMessage, clearDraft],
584
699
  );
585
700
 
586
701
  // Read through a ref so handleInputSend keeps a stable identity — it is passed to
@@ -588,7 +703,15 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
588
703
  const slashItemsRef = useRef(slashItems);
589
704
  slashItemsRef.current = slashItems;
590
705
 
591
- /** Stable wrapper for MessageInput onSend — clears forkDraft + draft and delegates to handleSend */
706
+ /**
707
+ * Stable wrapper for MessageInput onSend — drops the prefill and delegates.
708
+ *
709
+ * The draft is NOT deleted here. The composer has already emptied itself, so the
710
+ * saved draft is the only other copy of the text until the send actually happens;
711
+ * each send path deletes it at the moment the message reaches a socket, and puts
712
+ * the text back when it cannot. Only the save still waiting on its debounce is
713
+ * dropped, or it would land under whichever session the tab is on a second later.
714
+ */
592
715
  const handleInputSend = useCallback(
593
716
  (content: string, attachments: ChatAttachment[], priority?: MessagePriority) => {
594
717
  // Client-handled built-ins act on the UI, so they must not reach the SDK.
@@ -605,16 +728,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
605
728
  }
606
729
 
607
730
  setForkDraft(undefined);
608
- clearDraft();
731
+ cancelPendingSave();
609
732
  if (editFork && sessionId && projectName) {
610
733
  const anchor = editFork.anchorMsgId;
611
734
  setEditFork(null);
612
735
  void handleEditSend(buildMessageWithAttachments(content, attachments), anchor);
613
736
  return;
614
737
  }
615
- handleSend(content, attachments, priority);
738
+ void handleSend(content, attachments, priority);
616
739
  },
617
- [handleSend, clearDraft, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
740
+ [handleSend, clearDraft, cancelPendingSave, editFork, sessionId, projectName, handleEditSend, buildMessageWithAttachments, handleNewSession],
618
741
  );
619
742
 
620
743
  // Past user messages for the composer's ArrowUp/Down recall. Read through a ref
@@ -894,6 +1017,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
894
1017
  autoFocus={!(metadata?.sessionId) || !!forkDraft}
895
1018
  initialValue={forkDraft ?? draft?.content}
896
1019
  clearSignal={clearInputSignal}
1020
+ restore={restore}
897
1021
  projectName={projectName}
898
1022
  onSlashStateChange={handleSlashStateChange}
899
1023
  onSlashItemsLoaded={handleSlashItemsLoaded}
@@ -95,6 +95,13 @@ interface MessageInputProps {
95
95
  initialValue?: string;
96
96
  /** Bumping this counter clears the textarea (e.g. parent cancels an edit). */
97
97
  clearSignal?: number;
98
+ /**
99
+ * A message that could not be sent, handed back. A fresh `nonce` applies it even
100
+ * when `text` equals the last one — `initialValue` cannot do this, because it only
101
+ * reacts to a *changed* value, and the text of a failed send is often exactly the
102
+ * draft the composer was already prefilled with.
103
+ */
104
+ restore?: { text: string; nonce: number } | null;
98
105
  /** Called on content change for draft auto-save */
99
106
  onContentChange?: (content: string, attachments?: Array<{ name: string; path: string }>) => void;
100
107
  /** Returns this session's user messages, oldest first — powers ArrowUp/Down recall */
@@ -144,6 +151,7 @@ export const MessageInput = memo(function MessageInput({
144
151
  onExternalPathsConsumed,
145
152
  initialValue,
146
153
  clearSignal,
154
+ restore,
147
155
  onContentChange,
148
156
  getUserHistory,
149
157
  autoFocus,
@@ -317,6 +325,22 @@ export const MessageInput = memo(function MessageInput({
317
325
  if (clearSignal) writeTextareas("");
318
326
  }, [clearSignal]); // eslint-disable-line react-hooks/exhaustive-deps
319
327
 
328
+ // An unsent message coming back. The send can fail up to ~45 s after Enter, by which
329
+ // time the user may be typing something new — so the text goes AFTER what is there
330
+ // rather than over it. Reported as a content change so it is saved as the draft
331
+ // again; `writeTextareas` alone tells nobody.
332
+ useEffect(() => {
333
+ if (!restore?.nonce || !restore.text) return;
334
+ const current = valueRef.current;
335
+ const next = current.trim() ? `${current}\n\n${restore.text}` : restore.text;
336
+ writeTextareas(next);
337
+ onContentChange?.(next, attachments.filter((a) => a.status === "ready" && a.serverPath).map((a) => ({ name: a.name, path: a.serverPath! })));
338
+ setTimeout(() => {
339
+ const ta = getVisibleTextarea();
340
+ if (ta) { ta.focus(); ta.selectionStart = ta.selectionEnd = ta.value.length; }
341
+ }, 50);
342
+ }, [restore?.nonce]); // eslint-disable-line react-hooks/exhaustive-deps
343
+
320
344
  // Auto-focus on mount when requested
321
345
  useEffect(() => {
322
346
  if (!autoFocus) return;
@@ -46,17 +46,36 @@ export function ModelThinkingSelector({
46
46
  providerId,
47
47
  disabled,
48
48
  }: ModelThinkingSelectorProps) {
49
- const [models, setModels] = useState<ModelOption[]>([]);
49
+ const [modelResult, setModelResult] = useState<{
50
+ projectName: string;
51
+ providerId: string;
52
+ models: ModelOption[];
53
+ error?: boolean;
54
+ } | null>(null);
55
+ // Never render another provider/project's options, even before effects run.
56
+ const result = modelResult?.projectName === projectName && modelResult.providerId === providerId
57
+ ? modelResult : null;
58
+ const models = result?.models ?? [];
59
+ const loading = Boolean(projectName && providerId && !result);
60
+ const modelStatus = loading ? "Loading models..." : result?.error
61
+ ? "Unable to load models" : models.length === 0 ? "No models available" : null;
50
62
  const [open, setOpen] = useState(false);
51
63
  const panelRef = useRef<HTMLDivElement>(null);
52
64
  const isMobile = useIsMobile();
53
65
 
54
66
  useEffect(() => {
67
+ let active = true;
68
+ setModelResult(null);
55
69
  if (!projectName || !providerId) return;
56
70
  api
57
71
  .get<ModelOption[]>(`${projectUrl(projectName)}/chat/providers/${providerId}/models`)
58
- .then(setModels)
59
- .catch(() => {});
72
+ .then((models) => {
73
+ if (active) setModelResult({ projectName, providerId, models });
74
+ })
75
+ .catch(() => {
76
+ if (active) setModelResult({ projectName, providerId, models: [], error: true });
77
+ });
78
+ return () => { active = false; };
60
79
  }, [projectName, providerId]);
61
80
 
62
81
  useEffect(() => {
@@ -77,9 +96,9 @@ export function ModelThinkingSelector({
77
96
  }, [open, isMobile]);
78
97
 
79
98
  const current = models.find((m) => m.value === model);
80
- const modelDisplay = current ? shortLabel(current.label) : model ? shortLabel(model) : "Model";
99
+ const modelDisplay = loading ? "Loading models..." : current ? shortLabel(current.label) : "Model";
81
100
  const effortValue = effort ?? DEFAULT_EFFORT;
82
- const showModelList = models.length > 1;
101
+ const showModelList = models.length > 0 || Boolean(modelStatus);
83
102
  const chipText = chipLabel(modelDisplay, effortValue);
84
103
 
85
104
  const pick = (fn: () => void) => {
@@ -93,6 +112,7 @@ export function ModelThinkingSelector({
93
112
  {showModelList && (
94
113
  <div className="flex-1 min-w-0 py-1">
95
114
  <SectionLabel>Model</SectionLabel>
115
+ {modelStatus && <div role="status" className="px-3 py-2 text-xs text-text-secondary">{modelStatus}</div>}
96
116
  {models.map((m) => (
97
117
  <OptionRow key={m.value} active={m.value === model} onClick={() => pick(() => onModelChange(m.value))}>
98
118
  <span className="flex-1 truncate">{shortLabel(m.label)}</span>
@@ -0,0 +1,68 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { getAISettings, type AISettings } from "@/lib/api-settings";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import { resolveNewChatProvider } from "@/lib/new-chat-provider";
5
+ import { usePanelStore } from "@/stores/panel-store";
6
+
7
+ /** Do not mount chat (or claim an account) until its initial provider is known. */
8
+ export function NewChatProviderGate({ tabId, metadata, children }: {
9
+ tabId: string;
10
+ metadata: Record<string, unknown>;
11
+ children: ReactNode;
12
+ }) {
13
+ const [error, setError] = useState(false);
14
+ const [attempt, setAttempt] = useState(0);
15
+ const [unavailable, setUnavailable] = useState<{
16
+ provider: string; settings: AISettings; alternatives: Array<{ id: string; name: string }>;
17
+ } | null>(null);
18
+ const pending = metadata.providerPending === true;
19
+ const source = metadata.focusedProviderOnOpen as string | undefined;
20
+ const projectName = metadata.projectName as string | undefined;
21
+
22
+ function selectProvider(providerId: string, settings: AISettings) {
23
+ const store = usePanelStore.getState();
24
+ const tab = store.getPanelForTab(tabId)?.tabs.find((t) => t.id === tabId);
25
+ if (!tab?.metadata?.providerPending) return;
26
+ store.updateTab(tabId, { metadata: {
27
+ ...tab.metadata,
28
+ providerId,
29
+ providerPending: undefined,
30
+ focusedProviderOnOpen: undefined,
31
+ permissionMode: tab.metadata.permissionMode ?? settings.providers[providerId]?.permission_mode ?? "bypassPermissions",
32
+ } });
33
+ }
34
+
35
+ useEffect(() => {
36
+ if (!pending) return;
37
+ let cancelled = false;
38
+ setError(false);
39
+ setUnavailable(null);
40
+ Promise.all([
41
+ getAISettings(),
42
+ projectName ? api.get<Array<{ id: string; name: string }>>(`${projectUrl(projectName)}/chat/providers`) : Promise.resolve(null),
43
+ ]).then(([settings, providers]) => {
44
+ if (cancelled) return;
45
+ const providerId = resolveNewChatProvider(settings, source);
46
+ if (providers && !providers.some((p) => p.id === providerId)) {
47
+ setUnavailable({ provider: providerId, settings, alternatives: providers });
48
+ return;
49
+ }
50
+ selectProvider(providerId, settings);
51
+ }).catch(() => { if (!cancelled) setError(true); });
52
+ return () => { cancelled = true; };
53
+ }, [pending, source, projectName, tabId, attempt]);
54
+
55
+ if (!pending) return children;
56
+ if (unavailable) return <div className="flex h-full flex-col items-center justify-center gap-3 p-4 text-sm text-muted-foreground" role="status">
57
+ <p>{unavailable.provider} is not available. Choose a provider for this chat.</p>
58
+ {unavailable.alternatives.map((p) => <button key={p.id} className="text-primary underline"
59
+ onClick={() => selectProvider(p.id, unavailable.settings)}>Use {p.name}</button>)}
60
+ <button className="text-primary underline" onClick={() => setAttempt((n) => n + 1)}>Check again</button>
61
+ </div>;
62
+ return <div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground" role="status">
63
+ {error ? <>
64
+ Could not load chat settings.
65
+ <button className="text-primary underline" onClick={() => setAttempt((n) => n + 1)}>Retry</button>
66
+ </> : "Preparing chat…"}
67
+ </div>;
68
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useState, useRef, useMemo } from "react";
2
- import { ChevronRight, ChevronDown, RefreshCw, Pencil, Trash2, Plus, Search } from "@/lib/icons";
2
+ import { ChevronRight, ChevronDown, RefreshCw } from "@/lib/icons";
3
3
  import { useExtensionStore, type TreeItemUI, type TreeItemAction } from "@/stores/extension-store";
4
+ import { extensionIcon } from "@/lib/extension-icons";
4
5
  import { cn } from "@/lib/utils";
5
6
 
6
7
  interface ExtensionTreeViewProps {
@@ -22,14 +23,6 @@ function executeCommand(command: string, args?: unknown[]) {
22
23
  }));
23
24
  }
24
25
 
25
- const ACTION_ICONS: Record<string, React.ElementType> = {
26
- refresh: RefreshCw,
27
- edit: Pencil,
28
- trash: Trash2,
29
- plus: Plus,
30
- search: Search,
31
- };
32
-
33
26
  /** Generic TreeView renderer for extension-contributed tree data */
34
27
  export function ExtensionTreeView({ viewId, className }: ExtensionTreeViewProps) {
35
28
  const items = useExtensionStore((s) => s.treeViews[viewId]) ?? [];
@@ -75,7 +68,7 @@ export function ExtensionTreeView({ viewId, className }: ExtensionTreeViewProps)
75
68
  <div className="flex items-center gap-0.5">
76
69
  {viewMeta.headerActions.map((action) => {
77
70
  const iconName = action.icon ?? inferIcon(action.command);
78
- const Icon = ACTION_ICONS[iconName] ?? RefreshCw;
71
+ const Icon = extensionIcon(iconName) ?? RefreshCw;
79
72
  return (
80
73
  <button
81
74
  key={action.command}
@@ -213,7 +206,7 @@ function TreeNode({ item, depth, viewId }: { item: TreeItemUI; depth: number; vi
213
206
 
214
207
  function ActionButton({ action }: { action: TreeItemAction }) {
215
208
  const [spinning, setSpinning] = useState(false);
216
- const Icon = ACTION_ICONS[action.icon] ?? RefreshCw;
209
+ const Icon = extensionIcon(action.icon) ?? RefreshCw;
217
210
  const isTrash = action.icon === "trash";
218
211
 
219
212
  const handleClick = useCallback((e: React.MouseEvent) => {