@hienlh/ppm 0.8.92 → 0.8.94
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/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +3 -34
- package/dist/web/assets/api-settings-Dh4oFOpX.js +1 -0
- package/dist/web/assets/{browser-tab-CQojbRRg.js → browser-tab-DJLH0eDY.js} +1 -1
- package/dist/web/assets/chat-tab-C8HFXqGS.js +8 -0
- package/dist/web/assets/{code-editor-Dp3w7ZdH.js → code-editor-CaGdx-lS.js} +1 -1
- package/dist/web/assets/{database-viewer-DXEZ9XyO.js → database-viewer-i4Ddk6mO.js} +1 -1
- package/dist/web/assets/{diff-viewer-gjTmjJxA.js → diff-viewer-DQDS7yjv.js} +1 -1
- package/dist/web/assets/{git-graph-BPP0uvo6.js → git-graph-DUs-TN1u.js} +1 -1
- package/dist/web/assets/index-DhtLEnPD.css +2 -0
- package/dist/web/assets/{index-BiKAvKp1.js → index-Dm6RN1A1.js} +11 -11
- package/dist/web/assets/keybindings-store-qVLDZz97.js +1 -0
- package/dist/web/assets/{markdown-renderer-DJTeCvlY.js → markdown-renderer-L1NgC2Rw.js} +1 -1
- package/dist/web/assets/{postgres-viewer-D-_FH_ZH.js → postgres-viewer-_uDispGW.js} +1 -1
- package/dist/web/assets/{settings-tab-BQxPvO96.js → settings-tab-Bp4041i6.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DTZx5FY3.js → sqlite-viewer-GW-QCjHn.js} +1 -1
- package/dist/web/assets/{terminal-tab-BVllaZ_J.js → terminal-tab-E4cWujj4.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-FpL5fLOV.js → use-monaco-theme-zABXAAla.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +9 -61
- package/src/providers/cli-provider-base.ts +0 -6
- package/src/server/routes/chat.ts +14 -33
- package/src/server/routes/settings.ts +0 -27
- package/src/server/ws/chat.ts +1 -7
- package/src/services/account.service.ts +2 -2
- package/src/services/claude-usage.service.ts +7 -2
- package/src/services/cloud-ws.service.ts +0 -1
- package/src/services/cloud.service.ts +0 -1
- package/src/services/db.service.ts +23 -11
- package/src/services/mcp-config.service.ts +6 -15
- package/src/services/supervisor.ts +2 -22
- package/src/types/api.ts +0 -1
- package/src/types/chat.ts +0 -2
- package/src/web/app.tsx +2 -3
- package/src/web/components/chat/chat-history-bar.tsx +7 -21
- package/src/web/components/chat/chat-tab.tsx +10 -15
- package/src/web/components/chat/message-list.tsx +3 -7
- package/src/web/components/chat/session-picker.tsx +0 -1
- package/src/web/components/chat/usage-badge.tsx +8 -58
- package/src/web/components/layout/upgrade-banner.tsx +5 -15
- package/src/web/components/settings/settings-tab.tsx +0 -4
- package/src/web/hooks/use-chat.ts +0 -17
- package/dist/web/assets/api-settings-Bid0NHuI.js +0 -1
- package/dist/web/assets/chat-tab-DLpVS21v.js +0 -8
- package/dist/web/assets/index-CqhIj4Ko.css +0 -2
- package/dist/web/assets/keybindings-store-CRWbpzzj.js +0 -1
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/src/web/components/settings/change-password-section.tsx +0 -128
- package/test-session-ops.mjs +0 -444
- package/test-tokens.mjs +0 -212
|
@@ -84,13 +84,7 @@ export abstract class CliProvider implements AIProvider {
|
|
|
84
84
|
}));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
markAsResumed(sessionId: string): void {
|
|
88
|
-
this.messageCount.set(sessionId, 1);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
87
|
async deleteSession(sessionId: string): Promise<void> {
|
|
92
|
-
const proc = this.activeProcesses.get(sessionId);
|
|
93
|
-
if (proc) { proc.kill(); this.activeProcesses.delete(sessionId); }
|
|
94
88
|
this.sessions.delete(sessionId);
|
|
95
89
|
this.messageCount.delete(sessionId);
|
|
96
90
|
}
|
|
@@ -8,7 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
|
|
|
8
8
|
import { listSlashItems } from "../../services/slash-items.service.ts";
|
|
9
9
|
import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
|
|
10
10
|
import { getSessionLog } from "../../services/session-log.service.ts";
|
|
11
|
-
import { getSessionMapping,
|
|
11
|
+
import { getSessionMapping, getSessionProjectPath, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
|
|
12
12
|
import { ok, err } from "../../types/api.ts";
|
|
13
13
|
|
|
14
14
|
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
@@ -125,13 +125,7 @@ chatRoutes.delete("/sessions/:id", async (c) => {
|
|
|
125
125
|
try {
|
|
126
126
|
const id = c.req.param("id");
|
|
127
127
|
const providerId = c.req.query("providerId") ?? "claude";
|
|
128
|
-
const sdkId = getSessionMapping(id) ?? id;
|
|
129
|
-
// Provider-specific cleanup (JSONL, process, etc.)
|
|
130
128
|
await chatService.deleteSession(providerId, id);
|
|
131
|
-
// Shared DB cleanup
|
|
132
|
-
deleteSessionMapping(id);
|
|
133
|
-
deleteSessionTitle(sdkId);
|
|
134
|
-
unpinSession(sdkId);
|
|
135
129
|
return c.json(ok({ deleted: id }));
|
|
136
130
|
} catch (e) {
|
|
137
131
|
return c.json(err((e as Error).message), 404);
|
|
@@ -190,31 +184,16 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
|
|
|
190
184
|
const projectName = c.get("projectName");
|
|
191
185
|
const projectPath = c.get("projectPath");
|
|
192
186
|
const providerId = c.req.query("providerId") ?? "claude";
|
|
193
|
-
|
|
187
|
+
// Create a new PPM session that will fork from sourceId on first message
|
|
188
|
+
const session = await chatService.createSession(providerId, {
|
|
189
|
+
projectName,
|
|
190
|
+
projectPath,
|
|
191
|
+
title: "Forked Chat",
|
|
192
|
+
});
|
|
193
|
+
// Store fork source so WS handler knows to use forkSession on first message
|
|
194
194
|
const provider = providerRegistry.get(providerId);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (body.messageId) {
|
|
198
|
-
// Mid-fork at a specific message
|
|
199
|
-
if (!provider.forkAtMessage) {
|
|
200
|
-
return c.json(err("Provider does not support forking"), 400);
|
|
201
|
-
}
|
|
202
|
-
const result = await provider.forkAtMessage(sourceId, body.messageId, {
|
|
203
|
-
title: "Forked Chat", dir: projectPath,
|
|
204
|
-
});
|
|
205
|
-
const session = await chatService.createSession(providerId, {
|
|
206
|
-
projectName, projectPath, title: "Forked Chat",
|
|
207
|
-
});
|
|
208
|
-
setSessionMapping(session.id, result.sessionId);
|
|
209
|
-
provider.markAsResumed?.(session.id);
|
|
210
|
-
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
211
|
-
} else {
|
|
212
|
-
// No messageId (fork at first message) — create a fresh empty session
|
|
213
|
-
const session = await chatService.createSession(providerId, {
|
|
214
|
-
projectName, projectPath, title: "Forked Chat",
|
|
215
|
-
});
|
|
216
|
-
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
217
|
-
}
|
|
195
|
+
provider?.setForkSource?.(session.id, sourceId);
|
|
196
|
+
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
218
197
|
} catch (e) {
|
|
219
198
|
return c.json(err((e as Error).message), 500);
|
|
220
199
|
}
|
|
@@ -236,11 +215,13 @@ chatRoutes.get("/sessions/:id/logs", (c) => {
|
|
|
236
215
|
chatRoutes.get("/sessions/:id/debug", (c) => {
|
|
237
216
|
const ppmId = c.req.param("id");
|
|
238
217
|
const sdkId = getSessionMapping(ppmId) ?? ppmId;
|
|
239
|
-
const projectName = c.req.query("project") ?? "";
|
|
240
218
|
// Resolve JSONL path: ~/.claude/projects/<encoded-cwd>/<sdkId>.jsonl
|
|
241
219
|
const homedir = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
242
220
|
const provider = providerRegistry.get("claude") as any;
|
|
243
|
-
|
|
221
|
+
// Try in-memory first, fall back to DB-persisted project_path
|
|
222
|
+
const projectPath = provider?.activeSessions?.get(ppmId)?.projectPath
|
|
223
|
+
?? getSessionProjectPath(ppmId)
|
|
224
|
+
?? "";
|
|
244
225
|
const encodedCwd = projectPath ? projectPath.replace(/\//g, "-") : "";
|
|
245
226
|
const jsonlDir = encodedCwd ? resolve(homedir, ".claude", "projects", encodedCwd) : "";
|
|
246
227
|
const jsonlPath = jsonlDir ? resolve(jsonlDir, `${sdkId}.jsonl`) : "";
|
|
@@ -252,33 +252,6 @@ settingsRoutes.post("/telegram/test", async (c) => {
|
|
|
252
252
|
}
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
-
// ── Auth / Password ──────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
/** PUT /settings/auth/password — change the access password (token) */
|
|
258
|
-
settingsRoutes.put("/auth/password", async (c) => {
|
|
259
|
-
try {
|
|
260
|
-
const { password, confirm } = await c.req.json<{ password: string; confirm: string }>();
|
|
261
|
-
if (typeof password !== "string" || !password.trim()) {
|
|
262
|
-
return c.json(err("Password is required"), 400);
|
|
263
|
-
}
|
|
264
|
-
if (password !== confirm) {
|
|
265
|
-
return c.json(err("Passwords do not match"), 400);
|
|
266
|
-
}
|
|
267
|
-
const trimmed = password.trim();
|
|
268
|
-
if (trimmed.length < 4) {
|
|
269
|
-
return c.json(err("Password must be at least 4 characters"), 400);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const auth = configService.get("auth");
|
|
273
|
-
configService.set("auth", { ...auth, token: trimmed });
|
|
274
|
-
configService.save();
|
|
275
|
-
|
|
276
|
-
return c.json(ok({ token: trimmed }));
|
|
277
|
-
} catch (e) {
|
|
278
|
-
return c.json(err((e as Error).message), 400);
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
|
|
282
255
|
// ── Proxy ────────────────────────────────────────────────────────────
|
|
283
256
|
|
|
284
257
|
/** GET /settings/proxy — proxy status */
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -218,14 +218,8 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
218
218
|
continue;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// System events → transition connecting → thinking
|
|
221
|
+
// System events → transition connecting → thinking
|
|
222
222
|
if (evType === "system") {
|
|
223
|
-
const sub = (ev as any).subtype;
|
|
224
|
-
if (sub === "compacting") {
|
|
225
|
-
broadcast(sessionId, { type: "compact_status", status: "compacting" });
|
|
226
|
-
} else if (sub === "compact_done") {
|
|
227
|
-
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
228
|
-
}
|
|
229
223
|
if (!firstEventReceived) {
|
|
230
224
|
if (heartbeat) clearInterval(heartbeat);
|
|
231
225
|
setPhase(sessionId, "thinking");
|
|
@@ -139,7 +139,7 @@ class AccountService {
|
|
|
139
139
|
await this.refreshAccessToken(id, false);
|
|
140
140
|
return this.getWithTokens(id);
|
|
141
141
|
} catch (e) {
|
|
142
|
-
console.error(`[accounts] Pre-flight refresh failed for ${id}
|
|
142
|
+
console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
145
|
}
|
|
@@ -709,7 +709,7 @@ class AccountService {
|
|
|
709
709
|
try {
|
|
710
710
|
await this.refreshAccessToken(acc.id, false);
|
|
711
711
|
} catch (e) {
|
|
712
|
-
console.error(`[accounts] Auto-refresh failed for ${acc.id}
|
|
712
|
+
console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
|
|
713
713
|
}
|
|
714
714
|
}
|
|
715
715
|
};
|
|
@@ -273,9 +273,14 @@ export function getUsageForAccount(accountId: string): ClaudeUsage {
|
|
|
273
273
|
return row ? snapshotToUsage(row) : {};
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** Get usage for all accounts */
|
|
276
|
+
/** Get usage for all accounts (excludes expired temporary accounts) */
|
|
277
277
|
export function getAllAccountUsages(): AccountUsageEntry[] {
|
|
278
|
-
const
|
|
278
|
+
const nowS = Math.floor(Date.now() / 1000);
|
|
279
|
+
const accounts = accountService.list().filter(acc => {
|
|
280
|
+
// Exclude expired accounts without refresh token (temporary/invalid)
|
|
281
|
+
if (!accountService.hasRefreshToken(acc.id) && acc.expiresAt && acc.expiresAt < nowS) return false;
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
279
284
|
const snapshots = getAllLatestSnapshots();
|
|
280
285
|
const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
|
|
281
286
|
return accounts.map(acc => {
|
|
@@ -271,6 +271,21 @@ function runMigrations(database: Database): void {
|
|
|
271
271
|
PRAGMA user_version = 10;
|
|
272
272
|
`);
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
if (current < 11) {
|
|
276
|
+
try {
|
|
277
|
+
database.exec(`ALTER TABLE session_map ADD COLUMN project_path TEXT`);
|
|
278
|
+
} catch {
|
|
279
|
+
// Column may already exist
|
|
280
|
+
}
|
|
281
|
+
// Backfill project_path from projects table where project_name matches
|
|
282
|
+
database.exec(`
|
|
283
|
+
UPDATE session_map SET project_path = (
|
|
284
|
+
SELECT path FROM projects WHERE projects.name = session_map.project_name
|
|
285
|
+
) WHERE project_path IS NULL AND project_name IS NOT NULL
|
|
286
|
+
`);
|
|
287
|
+
database.exec(`PRAGMA user_version = 11`);
|
|
288
|
+
}
|
|
274
289
|
}
|
|
275
290
|
|
|
276
291
|
// ---------------------------------------------------------------------------
|
|
@@ -365,10 +380,15 @@ export function getSessionMapping(ppmId: string): string | null {
|
|
|
365
380
|
return row?.sdk_id ?? null;
|
|
366
381
|
}
|
|
367
382
|
|
|
368
|
-
export function
|
|
383
|
+
export function getSessionProjectPath(ppmId: string): string | null {
|
|
384
|
+
const row = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(ppmId) as { project_path: string } | null;
|
|
385
|
+
return row?.project_path ?? null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string): void {
|
|
369
389
|
getDb().query(
|
|
370
|
-
"INSERT INTO session_map (ppm_id, sdk_id, project_name) VALUES (?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = excluded.project_name",
|
|
371
|
-
).run(ppmId, sdkId, projectName ?? null);
|
|
390
|
+
"INSERT INTO session_map (ppm_id, sdk_id, project_name, project_path) VALUES (?, ?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = COALESCE(excluded.project_name, session_map.project_name), project_path = COALESCE(excluded.project_path, session_map.project_path)",
|
|
391
|
+
).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
|
|
372
392
|
}
|
|
373
393
|
|
|
374
394
|
export function getAllSessionMappings(): Record<string, string> {
|
|
@@ -424,14 +444,6 @@ export function getPinnedSessionIds(): Set<string> {
|
|
|
424
444
|
return new Set(rows.map((r) => r.session_id));
|
|
425
445
|
}
|
|
426
446
|
|
|
427
|
-
export function deleteSessionMapping(ppmId: string): void {
|
|
428
|
-
getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export function deleteSessionTitle(sessionId: string): void {
|
|
432
|
-
getDb().query("DELETE FROM session_titles WHERE session_id = ?").run(sessionId);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
447
|
// ---------------------------------------------------------------------------
|
|
436
448
|
// Push subscription helpers
|
|
437
449
|
// ---------------------------------------------------------------------------
|
|
@@ -27,22 +27,13 @@ export class McpConfigService {
|
|
|
27
27
|
|
|
28
28
|
/** List all MCP servers as Record (SDK-compatible format) */
|
|
29
29
|
list(): Record<string, McpServerConfig> {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (parsed) result[row.name] = parsed;
|
|
36
|
-
}
|
|
37
|
-
return result;
|
|
38
|
-
} catch (e) {
|
|
39
|
-
const msg = (e as Error).message ?? String(e);
|
|
40
|
-
if (msg.includes("no such table")) {
|
|
41
|
-
console.warn("[mcp] mcp_servers table not found — returning empty list");
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
44
|
-
throw e;
|
|
30
|
+
const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
|
|
31
|
+
const result: Record<string, McpServerConfig> = {};
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
const parsed = safeParse(row.config, row.name);
|
|
34
|
+
if (parsed) result[row.name] = parsed;
|
|
45
35
|
}
|
|
36
|
+
return result;
|
|
46
37
|
}
|
|
47
38
|
|
|
48
39
|
/** List as array with metadata (for UI) */
|
|
@@ -378,33 +378,17 @@ function adoptTunnel(): boolean {
|
|
|
378
378
|
const status = readStatus();
|
|
379
379
|
const pid = status.tunnelPid as number;
|
|
380
380
|
const url = status.shareUrl as string;
|
|
381
|
-
if (!pid || !url)
|
|
382
|
-
log("DEBUG", `adoptTunnel: missing tunnelPid(${pid}) or shareUrl(${url}) in status`);
|
|
383
|
-
return false;
|
|
384
|
-
}
|
|
381
|
+
if (!pid || !url) return false;
|
|
385
382
|
process.kill(pid, 0); // throws if process is dead
|
|
386
383
|
adoptedTunnelPid = pid;
|
|
387
384
|
tunnelUrl = url;
|
|
388
385
|
log("INFO", `Adopted existing tunnel (PID: ${pid}, URL: ${url})`);
|
|
389
386
|
return true;
|
|
390
|
-
} catch
|
|
391
|
-
log("WARN", `adoptTunnel: tunnel PID ${(readStatus().tunnelPid)} unreachable: ${e}`);
|
|
387
|
+
} catch {
|
|
392
388
|
return false;
|
|
393
389
|
}
|
|
394
390
|
}
|
|
395
391
|
|
|
396
|
-
/** Kill stale tunnel PID from status.json (cleanup after failed adoption) */
|
|
397
|
-
function killStaleTunnel() {
|
|
398
|
-
try {
|
|
399
|
-
const status = readStatus();
|
|
400
|
-
const pid = status.tunnelPid as number;
|
|
401
|
-
if (!pid) return;
|
|
402
|
-
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
403
|
-
log("INFO", `Killed stale tunnel (PID: ${pid})`);
|
|
404
|
-
} catch {}
|
|
405
|
-
updateStatus({ tunnelPid: null, shareUrl: null });
|
|
406
|
-
}
|
|
407
|
-
|
|
408
392
|
/** Spawn new supervisor from updated code, wait for it to be healthy, then exit */
|
|
409
393
|
async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
410
394
|
log("INFO", "Starting self-replace for upgrade");
|
|
@@ -506,8 +490,6 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
506
490
|
secretKey: device.secret_key,
|
|
507
491
|
heartbeatFn: () => {
|
|
508
492
|
const status = readStatus();
|
|
509
|
-
// Re-read device file each heartbeat to pick up name changes
|
|
510
|
-
const currentDevice = getCloudDevice();
|
|
511
493
|
return {
|
|
512
494
|
type: "heartbeat" as const,
|
|
513
495
|
tunnelUrl,
|
|
@@ -517,7 +499,6 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
|
|
|
517
499
|
availableVersion: (status.availableVersion as string) || null,
|
|
518
500
|
serverPid: serverChild?.pid ?? null,
|
|
519
501
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
520
|
-
deviceName: currentDevice?.name ?? device.name,
|
|
521
502
|
timestamp: new Date().toISOString(),
|
|
522
503
|
};
|
|
523
504
|
},
|
|
@@ -725,7 +706,6 @@ export async function runSupervisor(opts: {
|
|
|
725
706
|
startTunnelProbe(opts.port);
|
|
726
707
|
// Try adopting tunnel kept alive from previous upgrade; spawn new if dead
|
|
727
708
|
if (!adoptTunnel()) {
|
|
728
|
-
killStaleTunnel(); // kill orphaned tunnel before spawning new one
|
|
729
709
|
promises.push(spawnTunnel(opts.port));
|
|
730
710
|
}
|
|
731
711
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -44,5 +44,4 @@ export type ChatWsServerMessage =
|
|
|
44
44
|
| { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
|
|
45
45
|
| { type: "turn_events"; events: unknown[] }
|
|
46
46
|
| { type: "title_updated"; title: string }
|
|
47
|
-
| { type: "compact_status"; status: "compacting" | "done" }
|
|
48
47
|
| { type: "ping" };
|
package/src/types/chat.ts
CHANGED
|
@@ -29,8 +29,6 @@ export interface AIProvider {
|
|
|
29
29
|
listSessionsByDir?(dir: string): Promise<SessionInfo[]>;
|
|
30
30
|
ensureProjectPath?(sessionId: string, path: string): void;
|
|
31
31
|
setForkSource?(sessionId: string, sourceSessionId: string): void;
|
|
32
|
-
forkAtMessage?(sessionId: string, messageId: string, opts?: { title?: string; dir?: string }): Promise<{ sessionId: string }>;
|
|
33
|
-
markAsResumed?(sessionId: string): void;
|
|
34
32
|
isAvailable?(): Promise<boolean>;
|
|
35
33
|
listModels?(): Promise<ModelOption[]>;
|
|
36
34
|
}
|
package/src/web/app.tsx
CHANGED
|
@@ -37,7 +37,6 @@ type AuthState = "checking" | "authenticated" | "unauthenticated";
|
|
|
37
37
|
|
|
38
38
|
export function App() {
|
|
39
39
|
const [authState, setAuthState] = useState<AuthState>("checking");
|
|
40
|
-
const [upgradeBannerVisible, setUpgradeBannerVisible] = useState(false);
|
|
41
40
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
42
41
|
const [drawerTab, setDrawerTab] = useState<"explorer" | "git" | "settings" | undefined>();
|
|
43
42
|
const [projectSheetOpen, setProjectSheetOpen] = useState(false);
|
|
@@ -230,11 +229,11 @@ export function App() {
|
|
|
230
229
|
<TooltipProvider>
|
|
231
230
|
<div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
|
|
232
231
|
{/* Upgrade banner — shown when new version available */}
|
|
233
|
-
<UpgradeBanner
|
|
232
|
+
<UpgradeBanner />
|
|
234
233
|
|
|
235
234
|
{/* Mobile device name badge — floating top-left */}
|
|
236
235
|
{deviceName && (
|
|
237
|
-
<div className=
|
|
236
|
+
<div className="md:hidden fixed top-0 left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br">
|
|
238
237
|
{deviceName}
|
|
239
238
|
</div>
|
|
240
239
|
)}
|
|
@@ -16,7 +16,6 @@ interface ChatHistoryBarProps {
|
|
|
16
16
|
projectName: string;
|
|
17
17
|
usageInfo: UsageInfo;
|
|
18
18
|
contextWindowPct?: number | null;
|
|
19
|
-
compactStatus?: "compacting" | null;
|
|
20
19
|
usageLoading?: boolean;
|
|
21
20
|
refreshUsage?: () => void;
|
|
22
21
|
lastFetchedAt?: string | null;
|
|
@@ -80,7 +79,7 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
export function ChatHistoryBar({
|
|
83
|
-
projectName, usageInfo, contextWindowPct,
|
|
82
|
+
projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
|
|
84
83
|
sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
|
|
85
84
|
}: ChatHistoryBarProps) {
|
|
86
85
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
@@ -241,27 +240,14 @@ export function ChatHistoryBar({
|
|
|
241
240
|
<span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
|
|
242
241
|
</>
|
|
243
242
|
)}
|
|
244
|
-
{compactStatus === "compacting" && (
|
|
245
|
-
<>
|
|
246
|
-
<span className="text-text-subtle">·</span>
|
|
247
|
-
<span className="text-blue-400 animate-pulse">compacting...</span>
|
|
248
|
-
</>
|
|
249
|
-
)}
|
|
250
243
|
</button>
|
|
251
244
|
) : (
|
|
252
|
-
|
|
253
|
-
{
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)}
|
|
259
|
-
{compactStatus === "compacting" && (
|
|
260
|
-
<span className="text-[11px] px-1.5 py-0.5 text-blue-400 animate-pulse">
|
|
261
|
-
compacting...
|
|
262
|
-
</span>
|
|
263
|
-
)}
|
|
264
|
-
</>
|
|
245
|
+
contextWindowPct != null && (
|
|
246
|
+
<span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
|
|
247
|
+
<Activity className="size-3" />
|
|
248
|
+
<span>Ctx:{contextWindowPct}%</span>
|
|
249
|
+
</span>
|
|
250
|
+
)
|
|
265
251
|
)}
|
|
266
252
|
|
|
267
253
|
{/* Spacer */}
|
|
@@ -89,7 +89,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
89
89
|
connectingElapsed,
|
|
90
90
|
pendingApproval,
|
|
91
91
|
contextWindowPct,
|
|
92
|
-
compactStatus,
|
|
93
92
|
sessionTitle,
|
|
94
93
|
migratedSessionId,
|
|
95
94
|
sendMessage,
|
|
@@ -135,12 +134,14 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
135
134
|
}
|
|
136
135
|
}, [sessionTitle]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
137
136
|
|
|
138
|
-
//
|
|
139
|
-
const
|
|
137
|
+
// Auto-send pending message for forked sessions (set by handleFork)
|
|
138
|
+
const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
|
|
140
139
|
useEffect(() => {
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
if (pendingForkMsgRef.current && isConnected && sessionId) {
|
|
141
|
+
const msg = pendingForkMsgRef.current;
|
|
142
|
+
pendingForkMsgRef.current = undefined;
|
|
143
|
+
if (tabId) updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
|
|
144
|
+
setTimeout(() => sendMessage(msg, { permissionMode }), 100);
|
|
144
145
|
}
|
|
145
146
|
}, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
146
147
|
|
|
@@ -161,13 +162,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
161
162
|
}, [tabId, updateTab]);
|
|
162
163
|
|
|
163
164
|
/** Fork current session and open new tab with the forked session, resending userMessage */
|
|
164
|
-
const handleFork = useCallback(async (userMessage: string
|
|
165
|
+
const handleFork = useCallback(async (userMessage: string) => {
|
|
165
166
|
if (!sessionId || !projectName) return;
|
|
166
167
|
try {
|
|
167
168
|
const { api, projectUrl } = await import("@/lib/api-client");
|
|
168
169
|
const forked = await api.post<{ id: string; forkedFrom: string }>(
|
|
169
170
|
`${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}`,
|
|
170
|
-
{ messageId },
|
|
171
171
|
);
|
|
172
172
|
// Open new chat tab with forked session — it will send userMessage on connect
|
|
173
173
|
useTabStore.getState().openTab({
|
|
@@ -350,7 +350,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
350
350
|
projectName={projectName}
|
|
351
351
|
usageInfo={usageInfo}
|
|
352
352
|
contextWindowPct={contextWindowPct}
|
|
353
|
-
compactStatus={compactStatus}
|
|
354
353
|
usageLoading={usageLoading}
|
|
355
354
|
refreshUsage={refreshUsage}
|
|
356
355
|
lastFetchedAt={lastFetchedAt}
|
|
@@ -383,14 +382,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
383
382
|
|
|
384
383
|
{/* Input */}
|
|
385
384
|
<MessageInput
|
|
386
|
-
onSend={
|
|
387
|
-
if (forkDraft) setForkDraft(undefined);
|
|
388
|
-
handleSend(content, attachments, priority);
|
|
389
|
-
}}
|
|
385
|
+
onSend={handleSend}
|
|
390
386
|
isStreaming={isStreaming}
|
|
391
387
|
onCancel={cancelStreaming}
|
|
392
|
-
autoFocus={!(metadata?.sessionId)
|
|
393
|
-
initialValue={forkDraft}
|
|
388
|
+
autoFocus={!(metadata?.sessionId)}
|
|
394
389
|
projectName={projectName}
|
|
395
390
|
onSlashStateChange={handleSlashStateChange}
|
|
396
391
|
onSlashItemsLoaded={setSlashItems}
|
|
@@ -43,7 +43,7 @@ interface MessageListProps {
|
|
|
43
43
|
connectingElapsed?: number;
|
|
44
44
|
projectName?: string;
|
|
45
45
|
/** Called when user clicks Fork/Rewind — opens new forked chat tab */
|
|
46
|
-
onFork?: (userMessage: string
|
|
46
|
+
onFork?: (userMessage: string) => void;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export function MessageList({
|
|
@@ -90,17 +90,13 @@ export function MessageList({
|
|
|
90
90
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
91
91
|
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
|
|
92
92
|
<StickToBottom.Content className="p-4 space-y-4">
|
|
93
|
-
{filtered.map((msg
|
|
93
|
+
{filtered.map((msg) => (
|
|
94
94
|
<MessageBubble
|
|
95
95
|
key={msg.id}
|
|
96
96
|
message={msg}
|
|
97
97
|
isStreaming={isStreaming && msg.id.startsWith("streaming-")}
|
|
98
98
|
projectName={projectName}
|
|
99
|
-
onFork={msg.role === "user" && onFork ? () =>
|
|
100
|
-
// Pass the previous message ID so the fork includes history up to (but not including) this user message
|
|
101
|
-
const prevMsg = idx > 0 ? filtered[idx - 1] : undefined;
|
|
102
|
-
onFork(msg.content, prevMsg?.id);
|
|
103
|
-
} : undefined}
|
|
99
|
+
onFork={msg.role === "user" && onFork ? () => onFork(msg.content) : undefined}
|
|
104
100
|
/>
|
|
105
101
|
))}
|
|
106
102
|
|
|
@@ -47,7 +47,6 @@ export function SessionPicker({
|
|
|
47
47
|
|
|
48
48
|
const handleDelete = async (e: React.MouseEvent, session: SessionInfo) => {
|
|
49
49
|
e.stopPropagation();
|
|
50
|
-
if (!window.confirm("Delete this session? This cannot be undone.")) return;
|
|
51
50
|
try {
|
|
52
51
|
if (!projectName) return;
|
|
53
52
|
await api.del(
|