@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.
- package/CHANGELOG.md +15 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{accounts-settings-section-BcI3ithh.js → accounts-settings-section-B-F7U-gq.js} +1 -1
- package/dist/web/assets/{ai-resource-editor-C3i7VkUS.js → ai-resource-editor-qRHwpktf.js} +1 -1
- package/dist/web/assets/{ai-settings-section-DIqSLISN.js → ai-settings-section-BePrVceb.js} +1 -1
- package/dist/web/assets/ai-settings-section-DSlsTrpf.js +1 -0
- package/dist/web/assets/{appearance-settings-section-B9n42QPH.js → appearance-settings-section-BMlkj9U2.js} +1 -1
- package/dist/web/assets/chat-tab-DmqV3MlC.js +15 -0
- package/dist/web/assets/{code-editor-iTD62P14.js → code-editor-C4CriX9c.js} +2 -2
- package/dist/web/assets/{conflict-editor-idh_kBSa.js → conflict-editor-LfQoRcK-.js} +1 -1
- package/dist/web/assets/{csv-preview-B2PR7z0o.js → csv-preview-BEYKn68B.js} +1 -1
- package/dist/web/assets/{database-viewer-DSv_pvEq.js → database-viewer-BL9R-ciJ.js} +1 -1
- package/dist/web/assets/{diff-viewer-6X5S1lw3.js → diff-viewer-D91SI_zb.js} +1 -1
- package/dist/web/assets/{explorer-body-CrtXpmZM.js → explorer-body-ColVUjgk.js} +1 -1
- package/dist/web/assets/{explorer-window-content-D11FZUgq.js → explorer-window-content-VSMJfZC2.js} +1 -1
- package/dist/web/assets/{extension-manager-section-Cz8fvK1u.js → extension-manager-section-B-BRHGu1.js} +1 -1
- package/dist/web/assets/{extension-webview-DzZeXQsO.js → extension-webview-DufzanN9.js} +1 -1
- package/dist/web/assets/{files-settings-section-pc4jOun8.js → files-settings-section-Do5YbTUK.js} +1 -1
- package/dist/web/assets/{general-settings-section-DIZIA54q.js → general-settings-section-B-n_PYzs.js} +1 -1
- package/dist/web/assets/{git-log-panel-BkcIfZIE.js → git-log-panel-BQzOHgxG.js} +1 -1
- package/dist/web/assets/{glide-data-grid-nA5LsoJK.js → glide-data-grid-BUS44oLM.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DyzfaPol.js → group-chat-tab-CkHXqorZ.js} +1 -1
- package/dist/web/assets/{index-CuYtJ5xF.css → index-B9Wbjl0o.css} +1 -1
- package/dist/web/assets/{index-DJM59ogE.js → index-Cx1meAwb.js} +4 -4
- package/dist/web/assets/{jira-watcher-section-UEtpcxM9.js → jira-watcher-section-BQ4hVqWo.js} +1 -1
- package/dist/web/assets/{keybindings-store-BV0MMt1c.js → keybindings-store-yYx88hvw.js} +1 -1
- package/dist/web/assets/{keyboard-shortcuts-section-sY56ThaI.js → keyboard-shortcuts-section-BZUYdXGx.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Bkrk39pO.js → markdown-renderer-BA3C2L-a.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Bthd7hxl.js → markdown-renderer-CMFkajd7.js} +1 -1
- package/dist/web/assets/{mobile-explorer-sheet--NKsnNJn.js → mobile-explorer-sheet-gvO7a80O.js} +1 -1
- package/dist/web/assets/{notification-store-Bv4fnyvR.js → notification-store-CCh6a8RD.js} +1 -1
- package/dist/web/assets/{notifications-settings-section-BGSLkMuE.js → notifications-settings-section-JQ3l-Qp-.js} +1 -1
- package/dist/web/assets/{pdf-preview-DE0wVfyM.js → pdf-preview-B-JAU8JV.js} +1 -1
- package/dist/web/assets/{postgres-viewer-C0S8dCbA.js → postgres-viewer-B--Y59n5.js} +1 -1
- package/dist/web/assets/{ppmbot-settings-section-B2AZ8whe.js → ppmbot-settings-section-D1SCOc0_.js} +1 -1
- package/dist/web/assets/{process-table-BmT34-_s.js → process-table-D9z7cEmb.js} +1 -1
- package/dist/web/assets/{proxy-settings-section-BYwWFWvn.js → proxy-settings-section-y7VwH0Hf.js} +1 -1
- package/dist/web/assets/{query-audit-section-Dj1-npew.js → query-audit-section-Ca_gRQ_s.js} +1 -1
- package/dist/web/assets/{remote-desktop-mobile-sheet-bFIYIQIj.js → remote-desktop-mobile-sheet-CdO2aMXl.js} +2 -2
- package/dist/web/assets/{remote-desktop-mobile-view-BbrHPmPU.js → remote-desktop-mobile-view-CGQ8vSf1.js} +1 -1
- package/dist/web/assets/{remote-desktop-readiness-gate-Dme8r7Jp.js → remote-desktop-readiness-gate-BmC3b05e.js} +1 -1
- package/dist/web/assets/{remote-desktop-window-content-BdbszoSx.js → remote-desktop-window-content-Cdaoltdz.js} +1 -1
- package/dist/web/assets/{schedules-settings-section-rczhjkeM.js → schedules-settings-section--0s10CfA.js} +1 -1
- package/dist/web/assets/{settings-body-CwaQg_uc.js → settings-body-DEMqFm5z.js} +2 -2
- package/dist/web/assets/{settings-tab-C8l9-_9E.js → settings-tab-Dh1FpBny.js} +1 -1
- package/dist/web/assets/{settings-window-content-EA1489gi.js → settings-window-content-3KrQ6imZ.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BQsKZVtz.js → sql-query-editor-Clo90z8-.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Di8s3Z1m.js → sqlite-viewer-C08QQR_L.js} +1 -1
- package/dist/web/assets/{system-monitor-body-Dlknm4Uo.js → system-monitor-body-30-TFH3a.js} +2 -2
- package/dist/web/assets/{system-monitor-tab-LZRO6_DP.js → system-monitor-tab-ChIXfOWg.js} +1 -1
- package/dist/web/assets/{system-monitor-window-content-BFDfhqDd.js → system-monitor-window-content-C1Cd_pT7.js} +1 -1
- package/dist/web/assets/{tab-host-window-content-Bh7064Yw.js → tab-host-window-content-C6eG_Rf1.js} +1 -1
- package/dist/web/assets/{team-member-sheet-CU68IqU0.js → team-member-sheet-BhatQbL1.js} +2 -2
- package/dist/web/assets/{team-member-window-content-BYtro89j.js → team-member-window-content-BN3luct-.js} +1 -1
- package/dist/web/assets/{terminal-tab-qfSPxRtZ.js → terminal-tab-afylLiLY.js} +1 -1
- package/dist/web/assets/tool-cards-Ct1QdT8c.js +9 -0
- package/dist/web/assets/{use-accounts-data-pAQw6nCa.js → use-accounts-data-C0bvcMhi.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-DT5Q8kuf.js → use-monaco-theme-cgPbRDUm.js} +1 -1
- package/dist/web/assets/{use-remote-desktop-display-choice-CYQsrOMC.js → use-remote-desktop-display-choice-DSqF1Rgx.js} +1 -1
- package/dist/web/assets/use-websocket-BQssVSIR.js +1 -0
- package/dist/web/assets/{video-preview-B2dk48oQ.js → video-preview-BTzstVhM.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/architecture/ai-chat-and-providers.md +88 -0
- package/package.json +1 -1
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
- package/schemas/ppm-config.schema.json +18 -1
- package/src/providers/claude-agent-sdk.ts +21 -13
- package/src/providers/cli-provider-base.ts +3 -1
- package/src/providers/codex-app-server/codex-event-mapper.ts +4 -2
- package/src/providers/codex-app-server/codex-history.ts +5 -3
- package/src/providers/codex-app-server/codex-provider.ts +16 -2
- package/src/providers/codex-app-server/codex-rollout-header.ts +2 -1
- package/src/providers/cursor-cli/cursor-history.ts +4 -2
- package/src/server/routes/settings.ts +15 -1
- package/src/server/ws/chat.ts +3 -3
- package/src/services/chat.service.ts +95 -2
- package/src/services/codex-native-memory.ts +66 -0
- package/src/services/edge-forwarder.ts +191 -191
- package/src/services/jsonl-transcript-parser.ts +2 -1
- package/src/services/provider-shared-context.ts +152 -0
- package/src/services/windows-process-tree.ts +351 -351
- package/src/shared/provider-context.ts +13 -0
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +7 -1
- package/src/types/config.ts +29 -0
- package/src/web/components/chat/chat-history-bar.tsx +6 -3
- package/src/web/components/chat/chat-tab.tsx +7 -0
- package/src/web/components/chat/message-list.tsx +1 -1
- package/src/web/components/chat/tool-cards.tsx +10 -0
- package/src/web/components/settings/ai-settings-section.tsx +34 -5
- package/src/web/components/settings/codex-context-settings.tsx +100 -0
- package/src/web/hooks/use-chat.ts +71 -14
- package/src/web/hooks/use-websocket.ts +14 -7
- package/src/web/lib/api-settings.ts +3 -0
- package/src/web/lib/ws-client.ts +55 -4
- package/.release-node-shim.sh +0 -10
- package/dist/web/assets/ai-settings-section-COdsN_5h.js +0 -1
- package/dist/web/assets/chat-tab-D9sk38e8.js +0 -15
- package/dist/web/assets/tool-cards-CY9J4ux4.js +0 -9
- 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 }
|
package/src/types/config.ts
CHANGED
|
@@ -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
|
-
|
|
283
|
-
|
|
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
|
|
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,
|
|
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")
|
|
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
|
|
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
|
-
|
|
1310
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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]);
|
|
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
|
|