@hienlh/ppm 0.20.4 → 0.20.6

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 (106) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/cli-reference.md +4 -4
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/dist/web/assets/{accounts-settings-section-BcI3ithh.js → accounts-settings-section-B-F7U-gq.js} +1 -1
  6. package/dist/web/assets/{ai-resource-editor-C3i7VkUS.js → ai-resource-editor-qRHwpktf.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-DIqSLISN.js → ai-settings-section-BePrVceb.js} +1 -1
  8. package/dist/web/assets/ai-settings-section-DSlsTrpf.js +1 -0
  9. package/dist/web/assets/{appearance-settings-section-B9n42QPH.js → appearance-settings-section-BMlkj9U2.js} +1 -1
  10. package/dist/web/assets/chat-tab-DmqV3MlC.js +15 -0
  11. package/dist/web/assets/{code-editor-iTD62P14.js → code-editor-C4CriX9c.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-idh_kBSa.js → conflict-editor-LfQoRcK-.js} +1 -1
  13. package/dist/web/assets/{csv-preview-B2PR7z0o.js → csv-preview-BEYKn68B.js} +1 -1
  14. package/dist/web/assets/{database-viewer-DSv_pvEq.js → database-viewer-BL9R-ciJ.js} +1 -1
  15. package/dist/web/assets/{diff-viewer-6X5S1lw3.js → diff-viewer-D91SI_zb.js} +1 -1
  16. package/dist/web/assets/{explorer-body-CrtXpmZM.js → explorer-body-ColVUjgk.js} +1 -1
  17. package/dist/web/assets/{explorer-window-content-D11FZUgq.js → explorer-window-content-VSMJfZC2.js} +1 -1
  18. package/dist/web/assets/{extension-manager-section-Cz8fvK1u.js → extension-manager-section-B-BRHGu1.js} +1 -1
  19. package/dist/web/assets/{extension-webview-DzZeXQsO.js → extension-webview-DufzanN9.js} +1 -1
  20. package/dist/web/assets/{files-settings-section-pc4jOun8.js → files-settings-section-Do5YbTUK.js} +1 -1
  21. package/dist/web/assets/{general-settings-section-DIZIA54q.js → general-settings-section-B-n_PYzs.js} +1 -1
  22. package/dist/web/assets/{git-log-panel-BkcIfZIE.js → git-log-panel-BQzOHgxG.js} +1 -1
  23. package/dist/web/assets/{glide-data-grid-nA5LsoJK.js → glide-data-grid-BUS44oLM.js} +1 -1
  24. package/dist/web/assets/{group-chat-tab-DyzfaPol.js → group-chat-tab-CkHXqorZ.js} +1 -1
  25. package/dist/web/assets/{index-CuYtJ5xF.css → index-B9Wbjl0o.css} +1 -1
  26. package/dist/web/assets/{index-DJM59ogE.js → index-Cx1meAwb.js} +4 -4
  27. package/dist/web/assets/{jira-watcher-section-UEtpcxM9.js → jira-watcher-section-BQ4hVqWo.js} +1 -1
  28. package/dist/web/assets/{keybindings-store-BV0MMt1c.js → keybindings-store-yYx88hvw.js} +1 -1
  29. package/dist/web/assets/{keyboard-shortcuts-section-sY56ThaI.js → keyboard-shortcuts-section-BZUYdXGx.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-Bkrk39pO.js → markdown-renderer-BA3C2L-a.js} +1 -1
  31. package/dist/web/assets/{markdown-renderer-Bthd7hxl.js → markdown-renderer-CMFkajd7.js} +1 -1
  32. package/dist/web/assets/{mobile-explorer-sheet--NKsnNJn.js → mobile-explorer-sheet-gvO7a80O.js} +1 -1
  33. package/dist/web/assets/{notification-store-Bv4fnyvR.js → notification-store-CCh6a8RD.js} +1 -1
  34. package/dist/web/assets/{notifications-settings-section-BGSLkMuE.js → notifications-settings-section-JQ3l-Qp-.js} +1 -1
  35. package/dist/web/assets/{pdf-preview-DE0wVfyM.js → pdf-preview-B-JAU8JV.js} +1 -1
  36. package/dist/web/assets/{postgres-viewer-C0S8dCbA.js → postgres-viewer-B--Y59n5.js} +1 -1
  37. package/dist/web/assets/{ppmbot-settings-section-B2AZ8whe.js → ppmbot-settings-section-D1SCOc0_.js} +1 -1
  38. package/dist/web/assets/{process-table-BmT34-_s.js → process-table-D9z7cEmb.js} +1 -1
  39. package/dist/web/assets/{proxy-settings-section-BYwWFWvn.js → proxy-settings-section-y7VwH0Hf.js} +1 -1
  40. package/dist/web/assets/{query-audit-section-Dj1-npew.js → query-audit-section-Ca_gRQ_s.js} +1 -1
  41. package/dist/web/assets/{remote-desktop-mobile-sheet-bFIYIQIj.js → remote-desktop-mobile-sheet-CdO2aMXl.js} +2 -2
  42. package/dist/web/assets/{remote-desktop-mobile-view-BbrHPmPU.js → remote-desktop-mobile-view-CGQ8vSf1.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-readiness-gate-Dme8r7Jp.js → remote-desktop-readiness-gate-BmC3b05e.js} +1 -1
  44. package/dist/web/assets/{remote-desktop-window-content-BdbszoSx.js → remote-desktop-window-content-Cdaoltdz.js} +1 -1
  45. package/dist/web/assets/{schedules-settings-section-rczhjkeM.js → schedules-settings-section--0s10CfA.js} +1 -1
  46. package/dist/web/assets/{settings-body-CwaQg_uc.js → settings-body-DEMqFm5z.js} +2 -2
  47. package/dist/web/assets/{settings-tab-C8l9-_9E.js → settings-tab-Dh1FpBny.js} +1 -1
  48. package/dist/web/assets/{settings-window-content-EA1489gi.js → settings-window-content-3KrQ6imZ.js} +1 -1
  49. package/dist/web/assets/{sql-query-editor-BQsKZVtz.js → sql-query-editor-Clo90z8-.js} +1 -1
  50. package/dist/web/assets/{sqlite-viewer-Di8s3Z1m.js → sqlite-viewer-C08QQR_L.js} +1 -1
  51. package/dist/web/assets/{system-monitor-body-Dlknm4Uo.js → system-monitor-body-30-TFH3a.js} +2 -2
  52. package/dist/web/assets/{system-monitor-tab-LZRO6_DP.js → system-monitor-tab-ChIXfOWg.js} +1 -1
  53. package/dist/web/assets/{system-monitor-window-content-BFDfhqDd.js → system-monitor-window-content-C1Cd_pT7.js} +1 -1
  54. package/dist/web/assets/{tab-host-window-content-Bh7064Yw.js → tab-host-window-content-C6eG_Rf1.js} +1 -1
  55. package/dist/web/assets/{team-member-sheet-CU68IqU0.js → team-member-sheet-BhatQbL1.js} +2 -2
  56. package/dist/web/assets/{team-member-window-content-BYtro89j.js → team-member-window-content-BN3luct-.js} +1 -1
  57. package/dist/web/assets/{terminal-tab-qfSPxRtZ.js → terminal-tab-afylLiLY.js} +1 -1
  58. package/dist/web/assets/tool-cards-Ct1QdT8c.js +9 -0
  59. package/dist/web/assets/{use-accounts-data-pAQw6nCa.js → use-accounts-data-C0bvcMhi.js} +1 -1
  60. package/dist/web/assets/{use-monaco-theme-DT5Q8kuf.js → use-monaco-theme-cgPbRDUm.js} +1 -1
  61. package/dist/web/assets/{use-remote-desktop-display-choice-CYQsrOMC.js → use-remote-desktop-display-choice-DSqF1Rgx.js} +1 -1
  62. package/dist/web/assets/use-websocket-BQssVSIR.js +1 -0
  63. package/dist/web/assets/{video-preview-B2dk48oQ.js → video-preview-BTzstVhM.js} +1 -1
  64. package/dist/web/index.html +2 -2
  65. package/dist/web/sw.js +1 -1
  66. package/docs/architecture/ai-chat-and-providers.md +88 -0
  67. package/package.json +1 -1
  68. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  69. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  70. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  71. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  72. package/schemas/ppm-config.schema.json +18 -1
  73. package/src/providers/claude-agent-sdk.ts +21 -13
  74. package/src/providers/cli-provider-base.ts +3 -1
  75. package/src/providers/codex-app-server/codex-event-mapper.ts +4 -2
  76. package/src/providers/codex-app-server/codex-history.ts +5 -3
  77. package/src/providers/codex-app-server/codex-provider.ts +16 -2
  78. package/src/providers/codex-app-server/codex-rollout-header.ts +2 -1
  79. package/src/providers/cursor-cli/cursor-history.ts +4 -2
  80. package/src/server/routes/settings.ts +15 -1
  81. package/src/server/ws/chat.ts +3 -3
  82. package/src/services/chat.service.ts +95 -2
  83. package/src/services/codex-native-memory.ts +66 -0
  84. package/src/services/edge-forwarder.ts +191 -191
  85. package/src/services/jsonl-transcript-parser.ts +2 -1
  86. package/src/services/provider-shared-context.ts +152 -0
  87. package/src/services/windows-process-tree.ts +351 -351
  88. package/src/shared/provider-context.ts +13 -0
  89. package/src/types/api.ts +1 -1
  90. package/src/types/chat.ts +7 -1
  91. package/src/types/config.ts +29 -0
  92. package/src/web/components/chat/chat-history-bar.tsx +6 -3
  93. package/src/web/components/chat/chat-tab.tsx +7 -0
  94. package/src/web/components/chat/message-list.tsx +1 -1
  95. package/src/web/components/chat/tool-cards.tsx +10 -0
  96. package/src/web/components/settings/ai-settings-section.tsx +34 -5
  97. package/src/web/components/settings/codex-context-settings.tsx +100 -0
  98. package/src/web/hooks/use-chat.ts +71 -14
  99. package/src/web/hooks/use-websocket.ts +14 -7
  100. package/src/web/lib/api-settings.ts +3 -0
  101. package/src/web/lib/ws-client.ts +55 -4
  102. package/.release-node-shim.sh +0 -10
  103. package/dist/web/assets/ai-settings-section-COdsN_5h.js +0 -1
  104. package/dist/web/assets/chat-tab-D9sk38e8.js +0 -15
  105. package/dist/web/assets/tool-cards-CY9J4ux4.js +0 -9
  106. package/dist/web/assets/use-websocket-mkNA1M8U.js +0 -1
@@ -0,0 +1,13 @@
1
+ const START = "<ppm-shared-context>";
2
+ const END = "</ppm-shared-context>";
3
+
4
+ /** Transport metadata is not part of the user's message, even in native transcripts. */
5
+ export function withSharedContext(message: string, context?: string): string {
6
+ return context ? `${START}\n${context.replaceAll(END, "[end context]")}\n${END}\n\n${message}` : message;
7
+ }
8
+
9
+ export function stripSharedContext(message: string): string {
10
+ if (!message.startsWith(`${START}\n`)) return message;
11
+ const end = message.indexOf(`${END}\n\n`);
12
+ return end < 0 ? "" : message.slice(end + END.length + 2);
13
+ }
package/src/types/api.ts CHANGED
@@ -62,7 +62,7 @@ export type ChatWsServerMessage =
62
62
  | { type: "text"; content: string; parentToolUseId?: string }
63
63
  | { type: "thinking"; content: string; parentToolUseId?: string }
64
64
  | { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string }
65
- | { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
65
+ | { type: "tool_result"; output: string; isError?: boolean; exitCode?: number; toolUseId?: string; parentToolUseId?: string }
66
66
  | { type: "bash_output"; toolUseId: string; content: string; lineCount: number }
67
67
  | { type: "background_registry"; sessionId: string; shells: BackgroundShell[] }
68
68
  | { type: "subagent_status"; toolUseId: string; status: import("../shared/background-agent-status").BackgroundAgentStatus }
package/src/types/chat.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export interface SendMessageOpts {
2
+ /** PPM's bounded, project-scoped shared instructions; kept separate from user text. */
3
+ sharedContext?: string;
2
4
  permissionMode?: import("./config").PermissionMode | string;
3
5
  priority?: 'now' | 'next' | 'later';
4
6
  images?: Array<{ data: string; mediaType: string }>;
@@ -25,6 +27,10 @@ export interface SendMessageOpts {
25
27
  export interface AIProvider {
26
28
  id: string;
27
29
  name: string;
30
+ /** Handles opts.sharedContext without using it as the user's saved message/title. */
31
+ supportsSharedContext?: boolean;
32
+ /** Additional instruction/memory sources; never return credentials or transcripts. */
33
+ getSharedContextSources?(projectPath: string): Array<{ path: string; directory?: boolean }>;
28
34
 
29
35
  // Session lifecycle (required)
30
36
  createSession(config: SessionConfig): Promise<Session>;
@@ -180,7 +186,7 @@ export type ChatEvent =
180
186
  * Absent on a launched-but-unfinished agent — the card renders that as still running. */
181
187
  bgStatus?: import("../shared/background-agent-status").BackgroundAgentStatus;
182
188
  }
183
- | { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
189
+ | { type: "tool_result"; output: string; isError?: boolean; exitCode?: number; toolUseId?: string; parentToolUseId?: string }
184
190
  | { type: "approval_request"; requestId: string; tool: string; input: unknown }
185
191
  | { type: "error"; message: string }
186
192
  | { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number; contextWindowPct?: number; costUsd?: number; lastMessageUuid?: string; usage?: import("../shared/turn-usage").TurnUsage }
@@ -76,6 +76,8 @@ export interface ProjectConfig {
76
76
 
77
77
  export interface AIConfig {
78
78
  default_provider: string;
79
+ /** Share project rules and memory between providers. Unset defaults to true. */
80
+ share_provider_context?: boolean;
79
81
  providers: Record<string, AIProviderConfig>;
80
82
  }
81
83
 
@@ -109,6 +111,10 @@ export interface AIProviderConfig {
109
111
 
110
112
  // CLI-specific (Cursor, Codex, Gemini)
111
113
  cli_command?: string;
114
+
115
+ // Codex config overrides in tokens. Null/unset inherits Codex's configuration.
116
+ model_context_window?: number | null;
117
+ model_auto_compact_token_limit?: number | null;
112
118
  }
113
119
 
114
120
  /**
@@ -134,6 +140,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
134
140
  projects: [],
135
141
  ai: {
136
142
  default_provider: "claude",
143
+ share_provider_context: true,
137
144
  providers: {
138
145
  claude: {
139
146
  type: "agent-sdk",
@@ -203,6 +210,7 @@ function migrateThemeValue(theme: unknown): ThemeConfig | null {
203
210
  /** Validate AI provider config fields. Returns array of error messages (empty = valid). */
204
211
  export function validateAIProviderConfig(config: Partial<AIProviderConfig>): string[] {
205
212
  const errors: string[] = [];
213
+ errors.push(...validateCodexContextConfig(config));
206
214
  if (config.type != null && !VALID_TYPES.includes(config.type as any)) {
207
215
  errors.push(`type must be one of: ${VALID_TYPES.join(", ")}`);
208
216
  }
@@ -254,6 +262,22 @@ export function validateAIProviderConfig(config: Partial<AIProviderConfig>): str
254
262
  return errors;
255
263
  }
256
264
 
265
+ /** Also validate the merged provider config so partial updates cannot break the pair. */
266
+ export function validateCodexContextConfig(config: Partial<AIProviderConfig>): string[] {
267
+ const errors: string[] = [];
268
+ for (const key of ["model_context_window", "model_auto_compact_token_limit"] as const) {
269
+ const value = config[key];
270
+ if (value != null && (!Number.isSafeInteger(value) || value < 1)) {
271
+ errors.push(`${key} must be a positive safe integer or null`);
272
+ }
273
+ }
274
+ if (config.model_context_window != null && config.model_auto_compact_token_limit != null
275
+ && config.model_auto_compact_token_limit > config.model_context_window) {
276
+ errors.push("model_auto_compact_token_limit must not exceed model_context_window");
277
+ }
278
+ return errors;
279
+ }
280
+
257
281
  /** Validate default_provider references an existing provider key */
258
282
  export function validateDefaultProvider(defaultProvider: string, providers: Record<string, unknown>): string | null {
259
283
  if (!providers[defaultProvider]) {
@@ -269,6 +293,11 @@ export function validateDefaultProvider(defaultProvider: string, providers: Reco
269
293
  export function sanitizeConfig(config: PpmConfig): boolean {
270
294
  let dirty = false;
271
295
 
296
+ if (typeof config.ai.share_provider_context !== "boolean") {
297
+ config.ai.share_provider_context = true;
298
+ dirty = true;
299
+ }
300
+
272
301
  // Migrate/repair theme (legacy string → {style, mode})
273
302
  const migrated = migrateThemeValue(config.theme);
274
303
  if (migrated) {
@@ -279,8 +279,11 @@ export function ChatHistoryBar({
279
279
  // Usage badge display — Claude (SDK) and Codex both expose usage limits
280
280
  const isClaudeProvider = !providerId || providerId === "claude";
281
281
  const isCodexProvider = providerId === "codex";
282
- const fiveHourPct = usageInfo.fiveHour != null ? Math.round(usageInfo.fiveHour * 100) : null;
283
- const sevenDayPct = usageInfo.sevenDay != null ? Math.round(usageInfo.sevenDay * 100) : null;
282
+ // Account events can arrive before the new quota snapshot. Never attach the
283
+ // previous account's numbers to the newly selected account's name.
284
+ const usageMatchesAccount = !pickedAccountId || pickedAccountId === usageInfo.activeAccountId;
285
+ const fiveHourPct = usageMatchesAccount && usageInfo.fiveHour != null ? Math.round(usageInfo.fiveHour * 100) : null;
286
+ const sevenDayPct = usageMatchesAccount && usageInfo.sevenDay != null ? Math.round(usageInfo.sevenDay * 100) : null;
284
287
  const worstPct = Math.max(fiveHourPct ?? 0, sevenDayPct ?? 0);
285
288
  const usageColor = fiveHourPct != null || sevenDayPct != null ? pctColor(worstPct) : "text-text-subtle";
286
289
  // Order matters, and the obvious order is wrong. With no session, the usage endpoint has
@@ -288,7 +291,7 @@ export function ChatHistoryBar({
288
291
  // so letting it win would show a name that has nothing to do with this tab, which is the
289
292
  // exact confusion the claim exists to remove. The claim (and, once a turn is running, the
290
293
  // account the stream reports) is specific to this chat and outranks it.
291
- const accountLabel = pickedAccountLabel ?? usageInfo.activeAccountLabel ?? null;
294
+ const accountLabel = pickedAccountLabel ?? (usageMatchesAccount ? usageInfo.activeAccountLabel : null) ?? null;
292
295
 
293
296
  return (
294
297
  <div className="border-b border-border/50">
@@ -262,6 +262,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
262
262
  if (liveAccount) setServingAccount(liveAccount);
263
263
  }, [liveAccount]);
264
264
 
265
+ // Automatic rotation keeps the same session id, so useUsage's polling scope
266
+ // does not change. Re-read its binding as soon as the stream names an account.
267
+ const liveAccountId = liveAccount?.id;
268
+ useEffect(() => {
269
+ if (liveAccountId) void reloadUsage();
270
+ }, [liveAccountId, reloadUsage]);
271
+
265
272
  // A different conversation has a different account; carrying this one's over would label
266
273
  // it wrongly until the next turn corrected it.
267
274
  useEffect(() => { setServingAccount(null); }, [sessionId]);
@@ -1180,7 +1180,7 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
1180
1180
  if (g.kind === "tool" && !g.result && g.tool.type === "tool_use") {
1181
1181
  const embedded = (g.tool as any).result;
1182
1182
  if (embedded) {
1183
- g.result = { type: "tool_result", output: embedded.output, isError: embedded.isError } as ChatEvent;
1183
+ g.result = { type: "tool_result", output: embedded.output, isError: embedded.isError, exitCode: embedded.exitCode } as ChatEvent;
1184
1184
  }
1185
1185
  }
1186
1186
  }
@@ -175,6 +175,11 @@ export function ToolCard({
175
175
  const { toolName, input } = extractToolInfo(tool);
176
176
  const hasResult = result?.type === "tool_result";
177
177
  const isError = hasResult && !!(result as any).isError;
178
+ // Codex sends an exit code for shell tools. Keep it visible: output can contain
179
+ // useful rows even when PowerShell recorded a non-terminating error and exited 1.
180
+ const exitCode = hasResult && typeof (result as any).exitCode === "number"
181
+ ? (result as any).exitCode as number
182
+ : undefined;
178
183
  const hasAnswers = toolName === "AskUserQuestion" && !!(input as any)?.answers;
179
184
  const wasApproved = tool.type === "approval_request" && (tool as any).approved != null;
180
185
  const isSubagent = (toolName === "Agent" || toolName === "Task") && tool.type === "tool_use";
@@ -256,6 +261,11 @@ export function ToolCard({
256
261
  {bgRunning && (
257
262
  <span className="text-[10px] text-primary">running…</span>
258
263
  )}
264
+ {exitCode != null && (
265
+ <span className={`text-[10px] font-mono ${exitCode === 0 ? "text-text-3" : "text-error"}`}>
266
+ exit {exitCode}
267
+ </span>
268
+ )}
259
269
  {isError || bgFailed
260
270
  ? <XCircle className="size-3.5 text-error" />
261
271
  : isDone
@@ -14,6 +14,7 @@ import { getAISettings, updateAISettings, type AISettings } from "@/lib/api-sett
14
14
  import { api } from "@/lib/api-client";
15
15
  import { ProviderBadge } from "@/components/chat/provider-selector";
16
16
  import { openSettings } from "./open-settings";
17
+ import { CodexContextSettings } from "./codex-context-settings";
17
18
  import type { ModelOption } from "../../../types/chat";
18
19
 
19
20
  const EFFORT_OPTIONS = [
@@ -73,23 +74,26 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
73
74
  const config = settings?.providers[activeTab];
74
75
  const isSdkProvider = config?.type === "agent-sdk" || (!config?.type && activeTab === "claude");
75
76
 
76
- const handleSave = async (field: string, value: unknown) => {
77
- if (!settings) return;
77
+ const handleSettingsSave = async (patch: Partial<AISettings>) => {
78
+ if (!settings) return false;
78
79
  setSaving(true);
79
80
  setError(null);
80
81
  try {
81
- const updated = await updateAISettings({
82
- providers: { [activeTab]: { [field]: value } },
83
- });
82
+ const updated = await updateAISettings(patch);
84
83
  setSettings(updated);
85
84
  setRevision((r) => r + 1);
85
+ return true;
86
86
  } catch (e) {
87
87
  setError((e as Error).message);
88
+ return false;
88
89
  } finally {
89
90
  setSaving(false);
90
91
  }
91
92
  };
92
93
 
94
+ const handleSave = (field: string, value: unknown) =>
95
+ handleSettingsSave({ providers: { [activeTab]: { [field]: value } } });
96
+
93
97
  const labelSize = compact ? "text-[11px]" : "text-sm";
94
98
  const headingSize = compact ? "text-xs" : "text-sm";
95
99
  const gapSize = compact ? "space-y-2" : "space-y-4";
@@ -116,6 +120,25 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
116
120
  <div className={gapSize}>
117
121
  <h3 className={`${headingSize} font-medium text-text-secondary`}>AI Settings</h3>
118
122
 
123
+ <div className="flex items-start justify-between gap-3 rounded-md border border-border p-3">
124
+ <div className={fieldGap}>
125
+ <Label htmlFor="ai-share-provider-context" className={compact ? labelSize : undefined}>
126
+ Share rules and memory between providers
127
+ </Label>
128
+ <p id="ai-share-provider-context-description" className={`${compact ? "text-[9px]" : "text-[11px]"} text-muted-foreground`}>
129
+ Share project rules and memory, plus global instruction files, across providers.
130
+ Applies from the next project-chat message. Turning off keeps context already sent.
131
+ </p>
132
+ </div>
133
+ <Switch
134
+ id="ai-share-provider-context"
135
+ aria-describedby="ai-share-provider-context-description"
136
+ checked={settings.share_provider_context ?? true}
137
+ disabled={saving}
138
+ onCheckedChange={(checked) => handleSettingsSave({ share_provider_context: checked })}
139
+ />
140
+ </div>
141
+
119
142
  {/* Provider tabs */}
120
143
  {providerTabs.length > 1 && (
121
144
  <div className="flex gap-0.5 border-b border-border/50 -mx-1 px-1">
@@ -173,6 +196,12 @@ export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
173
196
  </div>
174
197
  )}
175
198
 
199
+ {activeTab === "codex" && (
200
+ <CodexContextSettings key={JSON.stringify([config?.model_context_window, config?.model_auto_compact_token_limit])} config={config}
201
+ compact={compact} saving={saving}
202
+ onSave={(patch) => handleSettingsSave({ providers: { codex: patch } })} />
203
+ )}
204
+
176
205
  {/* SDK-specific fields */}
177
206
  {isSdkProvider && (
178
207
  <>
@@ -0,0 +1,100 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import { Input } from "@/components/ui/input";
3
+ import { Label } from "@/components/ui/label";
4
+ import type { AIProviderSettings } from "@/lib/api-settings";
5
+
6
+ type ContextSettings = Pick<AIProviderSettings, "model_context_window" | "model_auto_compact_token_limit">;
7
+
8
+ interface Props {
9
+ config?: ContextSettings;
10
+ compact?: boolean;
11
+ saving: boolean;
12
+ onSave: (settings: ContextSettings) => Promise<boolean>;
13
+ }
14
+
15
+ const WINDOW_PRESETS = [128000, 272000, 400000, 512000, 872000, 1000000];
16
+ const COMPACT_PRESETS = [100000, 200000, 230000, 350000, 450000, 750000, 900000];
17
+
18
+ function TokenLimitSelect({ id, label, value, presets, disabled, compact, onChange }: {
19
+ id: string; label: string; value: string; presets: number[]; disabled: boolean;
20
+ compact?: boolean; onChange: (value: string) => void;
21
+ }) {
22
+ const [custom, setCustom] = useState(Boolean(value) && !presets.includes(Number(value)));
23
+ const size = compact ? "text-[11px]" : "text-sm";
24
+ return (
25
+ <div className="space-y-1">
26
+ <Label htmlFor={id} className={size}>{label}</Label>
27
+ <select id={id} value={custom ? "custom" : value || "default"} disabled={disabled}
28
+ aria-describedby="codex-context-help"
29
+ className={`w-full min-h-11 sm:min-h-0 rounded-md border border-input bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 ${compact ? "h-7" : "h-9"} ${size}`}
30
+ onChange={(e) => {
31
+ const next = e.target.value;
32
+ setCustom(next === "custom");
33
+ if (next !== "custom") onChange(next === "default" ? "" : next);
34
+ }}>
35
+ <option value="default">Codex default</option>
36
+ {presets.map((tokens) => <option key={tokens} value={String(tokens)}>
37
+ {tokens === 1000000 ? "1M" : `${tokens / 1000}K`} tokens
38
+ </option>)}
39
+ <option value="custom">Custom...</option>
40
+ </select>
41
+ {custom && <Input id={`${id}-custom`} aria-label={`Custom ${label.toLowerCase()}`}
42
+ type="number" min={1} step={1} required value={value} disabled={disabled}
43
+ placeholder="Enter tokens" aria-describedby="codex-context-help"
44
+ className={`min-h-11 sm:min-h-0 ${compact ? "h-7 text-[11px]" : ""}`}
45
+ onChange={(e) => onChange(e.target.value)} />}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function CodexContextSettings({ config, compact, saving, onSave }: Props) {
51
+ const [windowTokens, setWindowTokens] = useState(String(config?.model_context_window ?? ""));
52
+ const [compactTokens, setCompactTokens] = useState(String(config?.model_auto_compact_token_limit ?? ""));
53
+ const [error, setError] = useState<string | null>(null);
54
+ const textSize = compact ? "text-[11px]" : "text-sm";
55
+ const helpSize = compact ? "text-[9px]" : "text-[11px]";
56
+
57
+ async function save(event: FormEvent) {
58
+ event.preventDefault();
59
+ const windowValue = windowTokens.trim() ? Number(windowTokens) : null;
60
+ const compactValue = compactTokens.trim() ? Number(compactTokens) : null;
61
+ if ([windowValue, compactValue].some((v) => v !== null && (!Number.isSafeInteger(v) || v <= 0))) {
62
+ setError("Enter positive whole numbers of tokens, or leave blank for Codex defaults.");
63
+ return;
64
+ }
65
+ if (windowValue !== null && compactValue !== null && compactValue > windowValue) {
66
+ setError("Auto-compact threshold must not exceed the context window.");
67
+ return;
68
+ }
69
+ setError(null);
70
+ await onSave({ model_context_window: windowValue, model_auto_compact_token_limit: compactValue });
71
+ }
72
+
73
+ return (
74
+ <form onSubmit={save} className="space-y-2 rounded-md border border-border p-3">
75
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
76
+ <TokenLimitSelect id="codex-context-window" label="Context window (tokens)"
77
+ value={windowTokens} presets={WINDOW_PRESETS} disabled={saving} compact={compact}
78
+ onChange={(value) => { setWindowTokens(value); setError(null); }} />
79
+ <TokenLimitSelect id="codex-compact-threshold" label="Auto-compact threshold (tokens)"
80
+ value={compactTokens} presets={COMPACT_PRESETS} disabled={saving} compact={compact}
81
+ onChange={(value) => { setCompactTokens(value); setError(null); }} />
82
+ </div>
83
+ <p id="codex-context-help" className={`${helpSize} text-muted-foreground`}>
84
+ Choose Codex default to use Codex configuration and model defaults, or Custom to enter a value.
85
+ Presets are shortcuts, not a list of limits supported by your model;
86
+ increasing these values does not unlock a larger context window.
87
+ Applies to new sessions and sessions resumed after a PPM restart.
88
+ </p>
89
+ {error && <p role="alert" className={`${textSize} text-error`}>{error}</p>}
90
+ <div className="flex flex-wrap items-center justify-between gap-2">
91
+ <a href="https://learn.chatgpt.com/docs/config-file/config-reference" target="_blank" rel="noreferrer"
92
+ className={`${helpSize} text-primary underline`}>Codex context documentation</a>
93
+ <button type="submit" disabled={saving}
94
+ className={`min-h-11 sm:min-h-0 rounded-md border border-border px-3 py-1.5 hover:bg-surface-elevated disabled:opacity-50 ${textSize}`}>
95
+ {saving ? "Saving..." : "Save context settings"}
96
+ </button>
97
+ </div>
98
+ </form>
99
+ );
100
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useMemo, startTransition } from "react";
2
2
  import { useWebSocket } from "./use-websocket";
3
- import { api, getAuthToken, projectUrl } from "@/lib/api-client";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
4
  import { flattenWithExpansions, prefixPreCompactIds } from "@/lib/flatten-expansions";
5
5
  import { useStreamingStore } from "@/stores/streaming-store";
6
6
  import { usePanelStore } from "@/stores/panel-store";
@@ -188,6 +188,12 @@ export function useChat(
188
188
  const pendingMessageRef = useRef<string | null>(null);
189
189
  const sendRef = useRef<(data: string) => void>(() => {});
190
190
  const refetchRef = useRef<(() => void) | null>(null);
191
+ const historyActivityRef = useRef(0);
192
+ const historyReconciledRef = useRef(0);
193
+ const turnFinalizedRef = useRef(false);
194
+ const historyRequestRef = useRef<AbortController | null>(null);
195
+ const queuedReplayMessagesRef = useRef<MessageEvent[]>([]);
196
+ const handleMessageRef = useRef<((event: MessageEvent) => void) | null>(null);
191
197
  /** True while replaying turn_events — suppresses setPendingApproval */
192
198
  const isReplayingRef = useRef(false);
193
199
  /** toolUseIds of AskUserQuestion tool_use events. The approval_request event
@@ -712,6 +718,7 @@ export function useChat(
712
718
  const finalContent = streamingContentRef.current;
713
719
  const finalEvents = [...streamingEventsRef.current];
714
720
  const finalAccount = streamingAccountRef.current;
721
+ turnFinalizedRef.current = !!finalContent || finalEvents.length > 0;
715
722
  const doneUuid = ev.lastMessageUuid as string | undefined;
716
723
  const doneUsage = ev.usage;
717
724
  setMessages((prev) => {
@@ -765,6 +772,11 @@ export function useChat(
765
772
  // Ignore keepalive pings
766
773
  if ((data as any).type === "ping") return;
767
774
 
775
+ if (isReplayingRef.current) {
776
+ queuedReplayMessagesRef.current.push(event);
777
+ return;
778
+ }
779
+
768
780
  // file:changed, session:unread_changed and jira:* are app-wide and now arrive
769
781
  // on the global bus (`use-global-events.ts`) instead of here — a chat socket is
770
782
  // not guaranteed to exist since chat tabs mount lazily.
@@ -773,6 +785,7 @@ export function useChat(
773
785
  // bubble. The sender never receives this echo (server excludes the sender),
774
786
  // so no dedupe against the optimistic append is needed.
775
787
  if ((data as any).type === "user_message") {
788
+ historyActivityRef.current++;
776
789
  const content = (data as any).content as string;
777
790
  setMessages((prev) => {
778
791
  const last = prev[prev.length - 1];
@@ -829,12 +842,21 @@ export function useChat(
829
842
  // Handle phase transitions from BE
830
843
  if ((data as any).type === "phase_changed") {
831
844
  const p = (data as any).phase as SessionPhase;
845
+ const wasActive = phaseRef.current !== "idle";
846
+ if (!wasActive && p !== "idle") turnFinalizedRef.current = false;
847
+ if (p !== "idle") historyActivityRef.current++;
832
848
  setPhase(p);
833
849
  phaseRef.current = p;
834
850
  setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
835
851
  // Safety: idle phase means no turn running — ensure compact indicator does not linger.
836
852
  // BE should broadcast compact_status=done too, but this is a belt-and-braces clear.
837
- if (p === "idle") setCompactStatus(null);
853
+ if (p === "idle") {
854
+ setCompactStatus(null);
855
+ setStatusMessage(null);
856
+ // Completion can arrive after lost content/done frames. Reconcile once
857
+ // the turn is idle so the answer appears without a manual reload.
858
+ if (wasActive && !turnFinalizedRef.current) refetchRef.current?.();
859
+ }
838
860
  return;
839
861
  }
840
862
 
@@ -844,7 +866,9 @@ export function useChat(
844
866
  setConnectedSessionId((data as any).sessionId ?? sessionIdRef.current);
845
867
  const state = data as any;
846
868
  const p = state.phase as SessionPhase;
869
+ if (p !== "idle") historyActivityRef.current++;
847
870
  const wasIdle = phaseRef.current === "idle";
871
+ if (wasIdle && p !== "idle") turnFinalizedRef.current = false;
848
872
  setPhase(p);
849
873
  phaseRef.current = p;
850
874
  if (state.sessionTitle) setSessionTitle(state.sessionTitle);
@@ -895,6 +919,7 @@ export function useChat(
895
919
 
896
920
  // Handle turn_events (reconnect sync with rAF chunking)
897
921
  if ((data as any).type === "turn_events") {
922
+ historyActivityRef.current++;
898
923
  const events = (data as any).events as unknown[];
899
924
  const userMessage = (data as any).userMessage as string | null;
900
925
  if (!events?.length && !userMessage) { setIsReconnecting(false); return; }
@@ -961,6 +986,10 @@ export function useChat(
961
986
  replayRafRef.current = 0;
962
987
  isReplayingRef.current = false;
963
988
  setIsReconnecting(false);
989
+ // Preserve websocket order: live text/done must follow the snapshot,
990
+ // even when rebuilding a large snapshot takes several frames.
991
+ const queued = queuedReplayMessagesRef.current.splice(0);
992
+ for (const message of queued) handleMessageRef.current?.(message);
964
993
  }
965
994
  };
966
995
  replayRafRef.current = requestAnimationFrame(processChunk);
@@ -970,6 +999,7 @@ export function useChat(
970
999
  // Route content events through processStreamEvent
971
1000
  processStreamEvent(data);
972
1001
  }, [processStreamEvent]);
1002
+ handleMessageRef.current = handleMessage;
973
1003
 
974
1004
  const wsUrl = sessionId && projectName
975
1005
  ? `/ws/project/${encodeURIComponent(projectName)}/chat/${sessionId}`
@@ -979,6 +1009,14 @@ export function useChat(
979
1009
  url: wsUrl,
980
1010
  onMessage: handleMessage,
981
1011
  autoConnect: !!sessionId && !!projectName,
1012
+ idleTimeoutMs: 45_000, // Server sends a heartbeat every 15 seconds.
1013
+ onConnectionChange: (connected) => {
1014
+ if (!connected) {
1015
+ setIsConnected(false);
1016
+ setIsReconnecting(true);
1017
+ }
1018
+ // session_state, rather than TCP open, confirms chat synchronization.
1019
+ },
982
1020
  });
983
1021
 
984
1022
  // Keep sendRef in sync so handleMessage can flush queued messages
@@ -987,6 +1025,9 @@ export function useChat(
987
1025
  // Load history and reset state when session changes
988
1026
  useEffect(() => {
989
1027
  let cancelled = false;
1028
+ const historyReconciled = historyReconciledRef.current;
1029
+ turnFinalizedRef.current = false;
1030
+ historyActivityRef.current++;
990
1031
 
991
1032
  // Keep the user's unconfirmed model/thinking picks across the draft→real transition
992
1033
  // (null → id), but drop them when switching between two existing sessions so one
@@ -1060,7 +1101,7 @@ export function useChat(
1060
1101
  `${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`,
1061
1102
  )
1062
1103
  .then((data: any) => {
1063
- if (cancelled) return;
1104
+ if (cancelled || historyReconciledRef.current !== historyReconciled) return;
1064
1105
  // Tolerate the pre-versionMap shape (a bare array): a browser running a
1065
1106
  // cached bundle from before the upgrade would otherwise render an empty
1066
1107
  // history with no error.
@@ -1093,7 +1134,9 @@ export function useChat(
1093
1134
  historyLoadedAtRef.current = Date.now();
1094
1135
  })
1095
1136
  .catch(() => {
1096
- if (!cancelled) setMessages((prev) => prev.filter((m) => !staleIds.has(m.id)));
1137
+ if (!cancelled && historyReconciledRef.current === historyReconciled) {
1138
+ setMessages((prev) => prev.filter((m) => !staleIds.has(m.id)));
1139
+ }
1097
1140
  })
1098
1141
  .finally(() => {
1099
1142
  if (!cancelled) setMessagesLoading(false);
@@ -1104,6 +1147,14 @@ export function useChat(
1104
1147
 
1105
1148
  return () => {
1106
1149
  cancelled = true;
1150
+ historyRequestRef.current?.abort();
1151
+ historyRequestRef.current = null;
1152
+ if (syncRafRef.current) clearTimeout(syncRafRef.current);
1153
+ syncRafRef.current = 0;
1154
+ if (replayRafRef.current) cancelAnimationFrame(replayRafRef.current);
1155
+ replayRafRef.current = 0;
1156
+ isReplayingRef.current = false;
1157
+ queuedReplayMessagesRef.current = [];
1107
1158
  };
1108
1159
  }, [sessionId, providerId, projectName, updateTeamActivity, loadTeamDetail]);
1109
1160
 
@@ -1112,8 +1163,10 @@ export function useChat(
1112
1163
  // An attachment-only message is legitimate now that images travel with it: the
1113
1164
  // caller may have nothing to say beyond the picture.
1114
1165
  if (!content.trim() && !opts?.images?.length) return;
1166
+ historyActivityRef.current++;
1115
1167
 
1116
1168
  const isFollowUp = phaseRef.current !== "idle";
1169
+ turnFinalizedRef.current = false;
1117
1170
 
1118
1171
  if (isFollowUp) {
1119
1172
  // Cancel pending throttled sync before finalizing
@@ -1302,30 +1355,34 @@ export function useChat(
1302
1355
  }, [wsReconnect]);
1303
1356
 
1304
1357
  const refetchMessages = useCallback(() => {
1305
- if (!sessionId || !projectName) return;
1358
+ if (!sessionId || !projectName || phaseRef.current !== "idle") return;
1359
+ historyRequestRef.current?.abort();
1360
+ const request = new AbortController();
1361
+ historyRequestRef.current = request;
1362
+ const activity = historyActivityRef.current;
1306
1363
  // No setMessagesLoading(true) here — keep current messages visible while
1307
1364
  // fetching in the background (stale-while-revalidate). The initial load
1308
1365
  // in the session-change useEffect already handles the first-time loading screen.
1309
- fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
1310
- headers: { Authorization: `Bearer ${getAuthToken()}` },
1311
- })
1312
- .then((r) => r.json())
1313
- .then((json: any) => {
1366
+ api.get<any>(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, { signal: request.signal })
1367
+ .then((data: any) => {
1314
1368
  // A turn may have started while this fetch was in flight (e.g. edit→fork
1315
1369
  // swaps the session, session_state arrives idle and triggers this refetch,
1316
1370
  // then the queued edited message sends and moves phase off idle). The
1317
1371
  // fetched history predates that send, so replacing now would drop the
1318
1372
  // just-sent user message — leaving it missing until a manual reload.
1319
- if (phaseRef.current !== "idle") return;
1373
+ if (request.signal.aborted || historyRequestRef.current !== request
1374
+ || historyActivityRef.current !== activity || phaseRef.current !== "idle"
1375
+ || sessionIdRef.current !== sessionId || projectNameRef.current !== projectName
1376
+ || isReplayingRef.current) return;
1320
1377
  // Same back-compat unwrap as the initial load above.
1321
- const payload = json.ok
1322
- ? (Array.isArray(json.data) ? { messages: json.data, versionMap: undefined } : json.data)
1323
- : null;
1378
+ const payload = Array.isArray(data) ? { messages: data, versionMap: undefined } : data;
1324
1379
  // versionMap is refreshed independently of the length guard below: an
1325
1380
  // edit changes the branch tree without necessarily changing history
1326
1381
  // length, and the `n/m` counts must not go stale.
1327
1382
  if (payload?.versionMap) setVersionMap(payload.versionMap);
1328
1383
  if (Array.isArray(payload?.messages) && payload.messages.length > 0) {
1384
+ historyReconciledRef.current++;
1385
+ if (syncRafRef.current) { clearTimeout(syncRafRef.current); syncRafRef.current = 0; }
1329
1386
  setMessages(payload.messages);
1330
1387
  streamingContentRef.current = "";
1331
1388
  streamingEventsRef.current = [];
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
- import { WsClient } from "@/lib/ws-client";
2
+ import { WsClient, type WsClientOptions } from "@/lib/ws-client";
3
3
 
4
- interface UseWebSocketOptions {
4
+ interface UseWebSocketOptions extends WsClientOptions {
5
5
  url: string;
6
6
  onMessage?: (event: MessageEvent) => void;
7
7
  autoConnect?: boolean;
@@ -11,16 +11,23 @@ export function useWebSocket({
11
11
  url,
12
12
  onMessage,
13
13
  autoConnect = true,
14
+ idleTimeoutMs,
15
+ onConnectionChange,
14
16
  }: UseWebSocketOptions) {
15
17
  const clientRef = useRef<WsClient | null>(null);
18
+ const onMessageRef = useRef(onMessage);
19
+ const onConnectionChangeRef = useRef(onConnectionChange);
20
+ onMessageRef.current = onMessage;
21
+ onConnectionChangeRef.current = onConnectionChange;
16
22
 
17
23
  useEffect(() => {
18
- const client = new WsClient(url);
24
+ const client = new WsClient(url, {
25
+ idleTimeoutMs,
26
+ onConnectionChange: (connected) => onConnectionChangeRef.current?.(connected),
27
+ });
19
28
  clientRef.current = client;
20
29
 
21
- if (onMessage) {
22
- client.onMessage(onMessage);
23
- }
30
+ client.onMessage((event) => onMessageRef.current?.(event));
24
31
 
25
32
  if (autoConnect) {
26
33
  client.connect();
@@ -30,7 +37,7 @@ export function useWebSocket({
30
37
  client.disconnect();
31
38
  clientRef.current = null;
32
39
  };
33
- }, [url, autoConnect]); // eslint-disable-line react-hooks/exhaustive-deps
40
+ }, [url, autoConnect, idleTimeoutMs]);
34
41
 
35
42
  const send = useCallback((data: string | ArrayBuffer) => {
36
43
  clientRef.current?.send(data);
@@ -205,11 +205,14 @@ export interface AIProviderSettings {
205
205
  system_prompt?: string;
206
206
  agent_teams?: boolean;
207
207
  context_1m?: boolean;
208
+ model_context_window?: number | null;
209
+ model_auto_compact_token_limit?: number | null;
208
210
  inherit_claude_mcp?: boolean;
209
211
  }
210
212
 
211
213
  export interface AISettings {
212
214
  default_provider: string;
215
+ share_provider_context?: boolean;
213
216
  providers: Record<string, AIProviderSettings>;
214
217
  }
215
218