@hienlh/ppm 0.5.1 → 0.5.3
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 +40 -0
- package/dist/web/assets/{api-client-ANLU-Irq.js → api-client-BxCvlogn.js} +1 -1
- package/dist/web/assets/chat-tab-AwRs7rWS.js +7 -0
- package/dist/web/assets/code-editor-BviTme00.js +1 -0
- package/dist/web/assets/diff-viewer-CCZM_VBl.js +4 -0
- package/dist/web/assets/git-graph-UCZZ6fX6.js +1 -0
- package/dist/web/assets/index-BxHR8fUA.css +2 -0
- package/dist/web/assets/index-yvVRZ65D.js +21 -0
- package/dist/web/assets/{input-D-F4ITU0.js → input-Bzyi1GeB.js} +1 -1
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → jsx-runtime-Bzk8w7Zh.js} +1 -1
- package/dist/web/assets/markdown-renderer-DzVh1Ft8.js +59 -0
- package/dist/web/assets/{rotate-ccw-BesidNnx.js → rotate-ccw-ZqeedZLA.js} +1 -1
- package/dist/web/assets/settings-store-DikslxSJ.js +1 -0
- package/dist/web/assets/settings-tab-C-AGuxll.js +1 -0
- package/dist/web/assets/tab-store-BNgVKR5w.js +1 -0
- package/dist/web/assets/terminal-tab-CnbdkUFt.js +36 -0
- package/dist/web/assets/{use-monaco-theme-CsNwoeyj.js → use-monaco-theme-BFv4d2_j.js} +2 -2
- package/dist/web/assets/{utils-bntUtdc7.js → utils-EM9hC5pN.js} +1 -1
- package/dist/web/index.html +8 -9
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/init.ts +2 -2
- package/src/cli/commands/status.ts +66 -1
- package/src/cli/commands/stop.ts +39 -2
- package/src/index.ts +4 -2
- package/src/providers/claude-agent-sdk.ts +30 -21
- package/src/server/helpers/resolve-project.ts +2 -2
- package/src/server/index.ts +2 -1
- package/src/server/routes/chat.ts +1 -0
- package/src/server/ws/chat.ts +4 -2
- package/src/services/claude-usage.service.ts +34 -0
- package/src/services/config.service.ts +11 -1
- package/src/types/api.ts +1 -2
- package/src/types/chat.ts +1 -2
- package/src/web/components/chat/attachment-chips.tsx +1 -1
- package/src/web/components/chat/chat-history-bar.tsx +7 -3
- package/src/web/components/chat/chat-tab.tsx +4 -2
- package/src/web/components/chat/message-input.tsx +13 -14
- package/src/web/components/chat/message-list.tsx +5 -4
- package/src/web/components/chat/tool-cards.tsx +3 -6
- package/src/web/components/editor/code-editor.tsx +2 -1
- package/src/web/components/editor/diff-viewer.tsx +43 -22
- package/src/web/components/explorer/file-tree.tsx +3 -3
- package/src/web/components/git/git-graph.tsx +2 -1
- package/src/web/components/git/git-status-panel.tsx +166 -89
- package/src/web/components/layout/command-palette.tsx +2 -1
- package/src/web/components/layout/mobile-drawer.tsx +2 -2
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/panel-layout.tsx +16 -16
- package/src/web/components/layout/split-drop-overlay.tsx +3 -3
- package/src/web/components/shared/markdown-renderer.tsx +16 -10
- package/src/web/hooks/use-chat.ts +10 -17
- package/src/web/hooks/use-terminal.ts +66 -23
- package/src/web/hooks/use-usage.ts +1 -14
- package/src/web/lib/utils.ts +5 -0
- package/src/web/stores/panel-store.ts +15 -14
- package/src/web/stores/panel-utils.ts +12 -10
- package/src/web/stores/settings-store.ts +1 -1
- package/dist/web/assets/chat-tab-d_HzPDhE.js +0 -7
- package/dist/web/assets/code-editor-DFAu3knd.js +0 -1
- package/dist/web/assets/diff-viewer-Bue0mOJY.js +0 -4
- package/dist/web/assets/git-graph-Cjq-lK5h.js +0 -1
- package/dist/web/assets/index-D_IIxtVN.js +0 -21
- package/dist/web/assets/index-DhsWierF.css +0 -2
- package/dist/web/assets/markdown-renderer-B9l76G5h.js +0 -59
- package/dist/web/assets/react-WvgCEYPV.js +0 -1
- package/dist/web/assets/settings-store-CGtTcr8r.js +0 -1
- package/dist/web/assets/settings-tab-BDPgdHPI.js +0 -1
- package/dist/web/assets/tab-store-Dq1kMOkJ.js +0 -1
- package/dist/web/assets/terminal-tab-BEOvTEai.js +0 -36
|
@@ -10,9 +10,9 @@ import type {
|
|
|
10
10
|
SessionInfo,
|
|
11
11
|
ChatEvent,
|
|
12
12
|
ChatMessage,
|
|
13
|
-
UsageInfo,
|
|
14
13
|
} from "./provider.interface.ts";
|
|
15
14
|
import { configService } from "../services/config.service.ts";
|
|
15
|
+
import { updateFromSdkEvent } from "../services/claude-usage.service.ts";
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -67,8 +67,6 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
67
67
|
private activeQueries = new Map<string, { close: () => void }>();
|
|
68
68
|
/** Fork source: ppmSessionId → sourceSessionId (used on first message to fork) */
|
|
69
69
|
private forkSources = new Map<string, string>();
|
|
70
|
-
/** Latest known usage/rate-limit info (shared across all sessions) */
|
|
71
|
-
private latestUsage: UsageInfo = {};
|
|
72
70
|
|
|
73
71
|
/** Read current provider config from yaml (fresh each call) */
|
|
74
72
|
private getProviderConfig(): Partial<import("../types/config.ts").AIProviderConfig> {
|
|
@@ -96,9 +94,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
96
94
|
const existing = this.activeSessions.get(sessionId);
|
|
97
95
|
if (existing) return existing;
|
|
98
96
|
|
|
97
|
+
// Check if we have a mapped SDK session ID (from a previous query)
|
|
98
|
+
const mappedSdkId = getSdkSessionId(sessionId);
|
|
99
|
+
|
|
99
100
|
try {
|
|
100
101
|
const sdkSessions = await sdkListSessions({ limit: 100 });
|
|
101
|
-
const found = sdkSessions.find(
|
|
102
|
+
const found = sdkSessions.find(
|
|
103
|
+
(s) => s.sessionId === sessionId || s.sessionId === mappedSdkId,
|
|
104
|
+
);
|
|
102
105
|
if (found) {
|
|
103
106
|
const meta: Session = {
|
|
104
107
|
id: sessionId,
|
|
@@ -114,6 +117,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
114
117
|
// SDK not available
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
// Session not found in SDK history — treat as new so first message
|
|
121
|
+
// creates a fresh SDK session instead of trying to resume.
|
|
117
122
|
const meta: Session = {
|
|
118
123
|
id: sessionId,
|
|
119
124
|
providerId: this.id,
|
|
@@ -121,7 +126,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
121
126
|
createdAt: new Date().toISOString(),
|
|
122
127
|
};
|
|
123
128
|
this.activeSessions.set(sessionId, meta);
|
|
124
|
-
this.messageCount.set(sessionId,
|
|
129
|
+
this.messageCount.set(sessionId, 0);
|
|
125
130
|
return meta;
|
|
126
131
|
}
|
|
127
132
|
|
|
@@ -263,6 +268,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
263
268
|
let assistantContent = "";
|
|
264
269
|
let resultSubtype: string | undefined;
|
|
265
270
|
let resultNumTurns: number | undefined;
|
|
271
|
+
let resultContextWindowPct: number | undefined;
|
|
266
272
|
|
|
267
273
|
try {
|
|
268
274
|
const providerConfig = this.getProviderConfig();
|
|
@@ -466,19 +472,14 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
466
472
|
continue;
|
|
467
473
|
}
|
|
468
474
|
|
|
469
|
-
// Rate limit event —
|
|
475
|
+
// Rate limit event — write to shared usage cache (REST endpoint serves it)
|
|
470
476
|
if ((msg as any).type === "rate_limit_event") {
|
|
471
477
|
const info = (msg as any).rate_limit_info;
|
|
472
478
|
if (info) {
|
|
473
479
|
const rateLimitType = info.rateLimitType as string | undefined;
|
|
474
480
|
const utilization = info.utilization as number | undefined;
|
|
475
481
|
if (rateLimitType && utilization != null) {
|
|
476
|
-
|
|
477
|
-
if (rateLimitType === "five_hour") usage.fiveHour = utilization;
|
|
478
|
-
else if (rateLimitType.startsWith("seven_day")) usage.sevenDay = utilization;
|
|
479
|
-
// Cache latest rate limits
|
|
480
|
-
Object.assign(this.latestUsage, usage);
|
|
481
|
-
yield { type: "usage", usage };
|
|
482
|
+
updateFromSdkEvent(rateLimitType, utilization);
|
|
482
483
|
}
|
|
483
484
|
}
|
|
484
485
|
continue;
|
|
@@ -513,11 +514,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
513
514
|
const result = msg as any;
|
|
514
515
|
const subtype = result.subtype as string | undefined;
|
|
515
516
|
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (Object.keys(usage).length > 0) {
|
|
520
|
-
yield { type: "usage", usage };
|
|
517
|
+
// Write cost to shared usage cache
|
|
518
|
+
if (result.total_cost_usd != null) {
|
|
519
|
+
updateFromSdkEvent(undefined, undefined, result.total_cost_usd);
|
|
521
520
|
}
|
|
522
521
|
|
|
523
522
|
// Surface non-success subtypes as errors so FE can display them
|
|
@@ -542,6 +541,19 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
542
541
|
// Store subtype and numTurns for the done event
|
|
543
542
|
resultSubtype = subtype;
|
|
544
543
|
resultNumTurns = result.num_turns as number | undefined;
|
|
544
|
+
|
|
545
|
+
// Extract context window usage from modelUsage
|
|
546
|
+
const modelUsage = (result.modelUsage ?? result.model_usage) as Record<string, any> | undefined;
|
|
547
|
+
if (modelUsage) {
|
|
548
|
+
for (const usage of Object.values(modelUsage)) {
|
|
549
|
+
const cw = usage.contextWindow ?? 0;
|
|
550
|
+
if (cw > 0) {
|
|
551
|
+
const total = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
552
|
+
resultContextWindowPct = Math.min(Math.round((total / cw) * 100), 100);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
545
557
|
break;
|
|
546
558
|
}
|
|
547
559
|
}
|
|
@@ -565,13 +577,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
565
577
|
sessionId,
|
|
566
578
|
resultSubtype: resultSubtype as any,
|
|
567
579
|
numTurns: resultNumTurns,
|
|
580
|
+
contextWindowPct: resultContextWindowPct,
|
|
568
581
|
};
|
|
569
582
|
}
|
|
570
583
|
|
|
571
|
-
/** Get latest cached usage/rate-limit info */
|
|
572
|
-
getUsage(): UsageInfo {
|
|
573
|
-
return { ...this.latestUsage };
|
|
574
|
-
}
|
|
575
584
|
|
|
576
585
|
/** Abort an active query for a session */
|
|
577
586
|
abortQuery(sessionId: string): void {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
2
|
import { configService } from "../../services/config.service.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -15,7 +15,7 @@ export function resolveProjectPath(nameOrPath: string): string {
|
|
|
15
15
|
// Path fallback — must be within a registered project
|
|
16
16
|
const abs = resolve(nameOrPath);
|
|
17
17
|
const allowed = projects.some(
|
|
18
|
-
(p) => abs === resolve(p.path) || abs.startsWith(resolve(p.path) +
|
|
18
|
+
(p) => abs === resolve(p.path) || abs.startsWith(resolve(p.path) + sep),
|
|
19
19
|
);
|
|
20
20
|
if (!allowed) throw new Error(`Project not found: ${nameOrPath}`);
|
|
21
21
|
return abs;
|
package/src/server/index.ts
CHANGED
|
@@ -282,9 +282,10 @@ export async function startServer(options: {
|
|
|
282
282
|
const { openSync } = await import("node:fs");
|
|
283
283
|
const logFile = resolve(ppmDir, "ppm.log");
|
|
284
284
|
const logFd = openSync(logFile, "a");
|
|
285
|
+
const { resolve: resolvePath } = await import("node:path");
|
|
285
286
|
const child = Bun.spawn({
|
|
286
287
|
cmd: [
|
|
287
|
-
process.execPath, "run", import.meta.dir
|
|
288
|
+
process.execPath, "run", resolvePath(import.meta.dir, "index.ts"), "__serve__",
|
|
288
289
|
String(port), host, options.config ?? "",
|
|
289
290
|
],
|
|
290
291
|
stdio: ["ignore", logFd, logFd],
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -75,6 +75,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
75
75
|
|
|
76
76
|
// Heartbeat interval — declared outside try so finally can clear it
|
|
77
77
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
78
|
+
let lastContextWindowPct: number | undefined;
|
|
78
79
|
|
|
79
80
|
try {
|
|
80
81
|
const userPreview = content.slice(0, 200);
|
|
@@ -127,7 +128,8 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
127
128
|
} else if (evType === "error") {
|
|
128
129
|
logSessionEvent(sessionId, "ERROR", ev.message ?? JSON.stringify(ev).slice(0, 300));
|
|
129
130
|
} else if (evType === "done") {
|
|
130
|
-
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"}
|
|
131
|
+
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
132
|
+
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
131
133
|
// Fire-and-forget push notification
|
|
132
134
|
import("../../services/push-notification.service.ts").then(({ pushService }) => {
|
|
133
135
|
const project = entry.projectName || "Project";
|
|
@@ -170,7 +172,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
170
172
|
} finally {
|
|
171
173
|
if (heartbeat) clearInterval(heartbeat);
|
|
172
174
|
// Always send done — guarantees FE resets isStreaming even if provider didn't yield done
|
|
173
|
-
safeSend(sessionId, { type: "done", sessionId });
|
|
175
|
+
safeSend(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
174
176
|
entry.abort = undefined;
|
|
175
177
|
entry.isStreaming = false;
|
|
176
178
|
entry.pendingApprovalEvent = undefined;
|
|
@@ -17,6 +17,8 @@ export interface ClaudeUsage {
|
|
|
17
17
|
weekly?: LimitBucket;
|
|
18
18
|
weeklyOpus?: LimitBucket;
|
|
19
19
|
weeklySonnet?: LimitBucket;
|
|
20
|
+
/** Cumulative cost from SDK result events */
|
|
21
|
+
totalCostUsd?: number;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
const API_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
@@ -153,6 +155,38 @@ export function getCachedUsage(): ClaudeUsage {
|
|
|
153
155
|
return cache;
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Merge SDK rate-limit / cost events into the cache so the REST endpoint
|
|
160
|
+
* always returns the freshest data — even when the OAuth Usage API is unreachable.
|
|
161
|
+
*/
|
|
162
|
+
export function updateFromSdkEvent(
|
|
163
|
+
rateLimitType?: string,
|
|
164
|
+
utilization?: number,
|
|
165
|
+
costUsd?: number,
|
|
166
|
+
): void {
|
|
167
|
+
if (rateLimitType && utilization != null) {
|
|
168
|
+
if (rateLimitType === "five_hour") {
|
|
169
|
+
cache.session = {
|
|
170
|
+
...(cache.session ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 5 }),
|
|
171
|
+
utilization,
|
|
172
|
+
};
|
|
173
|
+
} else if (rateLimitType.startsWith("seven_day")) {
|
|
174
|
+
const key: keyof ClaudeUsage =
|
|
175
|
+
rateLimitType === "seven_day_opus" ? "weeklyOpus"
|
|
176
|
+
: rateLimitType === "seven_day_sonnet" ? "weeklySonnet"
|
|
177
|
+
: "weekly";
|
|
178
|
+
cache[key] = {
|
|
179
|
+
...(cache[key] as LimitBucket ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 168 }),
|
|
180
|
+
utilization,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (!cache.lastFetchedAt) cache.lastFetchedAt = new Date().toISOString();
|
|
184
|
+
}
|
|
185
|
+
if (costUsd != null) {
|
|
186
|
+
cache.totalCostUsd = (cache.totalCostUsd ?? 0) + costUsd;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
156
190
|
/** Force immediate refresh (e.g. after a chat completes) */
|
|
157
191
|
export async function refreshUsageNow(): Promise<ClaudeUsage> {
|
|
158
192
|
await fetchWithRetry();
|
|
@@ -23,12 +23,18 @@ class ConfigService {
|
|
|
23
23
|
].filter(Boolean) as string[];
|
|
24
24
|
|
|
25
25
|
for (const p of searchPaths) {
|
|
26
|
-
|
|
26
|
+
const found = existsSync(p);
|
|
27
|
+
if (!found) {
|
|
28
|
+
console.log(`[config] Not found: ${p}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
27
32
|
const raw = readFileSync(p, "utf-8");
|
|
28
33
|
const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
|
|
29
34
|
if (parsed) {
|
|
30
35
|
this.config = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
|
|
31
36
|
this.configPath = p;
|
|
37
|
+
console.log(`[config] Loaded from: ${p}`);
|
|
32
38
|
// Auto-generate token if auth enabled but token is empty
|
|
33
39
|
if (this.config.auth.enabled && !this.config.auth.token) {
|
|
34
40
|
this.config.auth.token = randomBytes(16).toString("hex");
|
|
@@ -40,10 +46,14 @@ class ConfigService {
|
|
|
40
46
|
}
|
|
41
47
|
return this.config;
|
|
42
48
|
}
|
|
49
|
+
console.log(`[config] Empty or invalid YAML: ${p}`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[config] Error reading ${p}:`, (err as Error).message);
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// No config found — create default
|
|
56
|
+
console.log(`[config] No config found, creating default at ${GLOBAL_CONFIG_PATH}`);
|
|
47
57
|
this.config = this.createDefault();
|
|
48
58
|
return this.config;
|
|
49
59
|
}
|
package/src/types/api.ts
CHANGED
|
@@ -33,6 +33,5 @@ export type ChatWsServerMessage =
|
|
|
33
33
|
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string }
|
|
34
34
|
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
|
|
35
35
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
36
|
-
| { type: "
|
|
37
|
-
| { type: "done"; sessionId: string }
|
|
36
|
+
| { type: "done"; sessionId: string; contextWindowPct?: number }
|
|
38
37
|
| { type: "error"; message: string };
|
package/src/types/chat.ts
CHANGED
|
@@ -80,9 +80,8 @@ export type ChatEvent =
|
|
|
80
80
|
| { type: "tool_use"; tool: string; input: unknown; toolUseId?: string; parentToolUseId?: string; children?: ChatEvent[] }
|
|
81
81
|
| { type: "tool_result"; output: string; isError?: boolean; toolUseId?: string; parentToolUseId?: string }
|
|
82
82
|
| { type: "approval_request"; requestId: string; tool: string; input: unknown }
|
|
83
|
-
| { type: "usage"; usage: UsageInfo }
|
|
84
83
|
| { type: "error"; message: string }
|
|
85
|
-
| { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number };
|
|
84
|
+
| { type: "done"; sessionId: string; resultSubtype?: ResultSubtype; numTurns?: number; contextWindowPct?: number };
|
|
86
85
|
|
|
87
86
|
export type ToolApprovalHandler = (
|
|
88
87
|
tool: string,
|
|
@@ -10,7 +10,7 @@ export function AttachmentChips({ attachments, onRemove }: AttachmentChipsProps)
|
|
|
10
10
|
if (attachments.length === 0) return null;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<div className="flex flex-wrap gap-1.5 px-
|
|
13
|
+
<div className="flex flex-wrap gap-1.5 px-2 md:px-4 pt-2">
|
|
14
14
|
{attachments.map((att) => (
|
|
15
15
|
<div
|
|
16
16
|
key={att.id}
|
|
@@ -13,6 +13,7 @@ type PanelType = "history" | "config" | "usage" | null;
|
|
|
13
13
|
interface ChatHistoryBarProps {
|
|
14
14
|
projectName: string;
|
|
15
15
|
usageInfo: UsageInfo;
|
|
16
|
+
contextWindowPct?: number | null;
|
|
16
17
|
usageLoading?: boolean;
|
|
17
18
|
refreshUsage?: () => void;
|
|
18
19
|
lastFetchedAt?: string | null;
|
|
@@ -47,7 +48,7 @@ function pctColor(pct: number): string {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function ChatHistoryBar({
|
|
50
|
-
projectName, usageInfo, usageLoading, refreshUsage, lastFetchedAt,
|
|
51
|
+
projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
|
|
51
52
|
sessionId, onSelectSession, onBugReport, isConnected, onReconnect,
|
|
52
53
|
}: ChatHistoryBarProps) {
|
|
53
54
|
const [activePanel, setActivePanel] = useState<PanelType>(null);
|
|
@@ -142,8 +143,11 @@ export function ChatHistoryBar({
|
|
|
142
143
|
<span>5h:{fiveHourPct != null ? `${fiveHourPct}%` : "--%"}</span>
|
|
143
144
|
<span className="text-text-subtle">·</span>
|
|
144
145
|
<span>Wk:{sevenDayPct != null ? `${sevenDayPct}%` : "--%"}</span>
|
|
145
|
-
{
|
|
146
|
-
|
|
146
|
+
{contextWindowPct != null && (
|
|
147
|
+
<>
|
|
148
|
+
<span className="text-text-subtle">·</span>
|
|
149
|
+
<span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
|
|
150
|
+
</>
|
|
147
151
|
)}
|
|
148
152
|
</button>
|
|
149
153
|
|
|
@@ -50,7 +50,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
50
50
|
const version = useSettingsStore((s) => s.version);
|
|
51
51
|
|
|
52
52
|
// Usage runs independently — auto-refreshes on interval
|
|
53
|
-
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage
|
|
53
|
+
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
|
|
54
54
|
useUsage(projectName, providerId);
|
|
55
55
|
|
|
56
56
|
// Persist sessionId and providerId to tab metadata so reload restores the session
|
|
@@ -69,13 +69,14 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
69
69
|
connectingElapsed,
|
|
70
70
|
thinkingWarningThreshold,
|
|
71
71
|
pendingApproval,
|
|
72
|
+
contextWindowPct,
|
|
72
73
|
sendMessage,
|
|
73
74
|
respondToApproval,
|
|
74
75
|
cancelStreaming,
|
|
75
76
|
reconnect,
|
|
76
77
|
refetchMessages,
|
|
77
78
|
isConnected,
|
|
78
|
-
} = useChat(sessionId, providerId, projectName
|
|
79
|
+
} = useChat(sessionId, providerId, projectName);
|
|
79
80
|
|
|
80
81
|
// Auto-send pending message for forked sessions (set by handleFork)
|
|
81
82
|
const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
|
|
@@ -282,6 +283,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
282
283
|
<ChatHistoryBar
|
|
283
284
|
projectName={projectName}
|
|
284
285
|
usageInfo={usageInfo}
|
|
286
|
+
contextWindowPct={contextWindowPct}
|
|
285
287
|
usageLoading={usageLoading}
|
|
286
288
|
refreshUsage={refreshUsage}
|
|
287
289
|
lastFetchedAt={lastFetchedAt}
|
|
@@ -57,6 +57,7 @@ export function MessageInput({
|
|
|
57
57
|
const [value, setValue] = useState(initialValue ?? "");
|
|
58
58
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
59
59
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
60
|
+
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
60
61
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
61
62
|
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
62
63
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
@@ -238,7 +239,7 @@ export function MessageInput({
|
|
|
238
239
|
);
|
|
239
240
|
});
|
|
240
241
|
}
|
|
241
|
-
textareaRef.current?.focus();
|
|
242
|
+
(mobileTextareaRef.current ?? textareaRef.current)?.focus();
|
|
242
243
|
},
|
|
243
244
|
[uploadFile],
|
|
244
245
|
);
|
|
@@ -266,9 +267,8 @@ export function MessageInput({
|
|
|
266
267
|
if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
267
268
|
}
|
|
268
269
|
setAttachments([]);
|
|
269
|
-
if (textareaRef.current)
|
|
270
|
-
|
|
271
|
-
}
|
|
270
|
+
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
271
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
272
272
|
}, [value, attachments, disabled, onSend, onSlashStateChange, onFileStateChange]);
|
|
273
273
|
|
|
274
274
|
const handleKeyDown = useCallback(
|
|
@@ -320,8 +320,8 @@ export function MessageInput({
|
|
|
320
320
|
[updatePickerState],
|
|
321
321
|
);
|
|
322
322
|
|
|
323
|
-
const handleInput = useCallback(() => {
|
|
324
|
-
const el = textareaRef.current;
|
|
323
|
+
const handleInput = useCallback((e?: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
324
|
+
const el = e?.target ?? textareaRef.current;
|
|
325
325
|
if (!el) return;
|
|
326
326
|
el.style.height = "auto";
|
|
327
327
|
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
|
@@ -382,14 +382,13 @@ export function MessageInput({
|
|
|
382
382
|
|
|
383
383
|
return (
|
|
384
384
|
<div className="p-2 md:p-3 bg-background">
|
|
385
|
-
{/* Attachment chips (above input) */}
|
|
386
|
-
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
|
|
387
|
-
|
|
388
385
|
{/* Rounded input container */}
|
|
389
386
|
<div
|
|
390
387
|
className="border border-border rounded-xl md:rounded-2xl bg-surface shadow-sm cursor-text"
|
|
391
|
-
onClick={() => !disabled && textareaRef.current?.focus()}
|
|
388
|
+
onClick={() => !disabled && (mobileTextareaRef.current ?? textareaRef.current)?.focus()}
|
|
392
389
|
>
|
|
390
|
+
{/* Attachment chips (inside container, aligned with input) */}
|
|
391
|
+
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
|
|
393
392
|
{/* Mobile: single row — attach + textarea + send */}
|
|
394
393
|
<div className="flex items-end gap-1 md:hidden px-2 py-2">
|
|
395
394
|
<button
|
|
@@ -402,9 +401,9 @@ export function MessageInput({
|
|
|
402
401
|
<Paperclip className="size-4" />
|
|
403
402
|
</button>
|
|
404
403
|
<textarea
|
|
405
|
-
ref={
|
|
404
|
+
ref={mobileTextareaRef}
|
|
406
405
|
value={value}
|
|
407
|
-
onChange={(e) => { handleChange(e.target.value); handleInput(); }}
|
|
406
|
+
onChange={(e) => { handleChange(e.target.value); handleInput(e); }}
|
|
408
407
|
onKeyDown={handleKeyDown}
|
|
409
408
|
onPaste={handlePaste}
|
|
410
409
|
onDrop={handleDrop}
|
|
@@ -412,7 +411,7 @@ export function MessageInput({
|
|
|
412
411
|
placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
|
|
413
412
|
disabled={disabled}
|
|
414
413
|
rows={1}
|
|
415
|
-
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-
|
|
414
|
+
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20"
|
|
416
415
|
/>
|
|
417
416
|
{showCancel ? (
|
|
418
417
|
<button
|
|
@@ -439,7 +438,7 @@ export function MessageInput({
|
|
|
439
438
|
<textarea
|
|
440
439
|
ref={textareaRef}
|
|
441
440
|
value={value}
|
|
442
|
-
onChange={(e) => { handleChange(e.target.value); handleInput(); }}
|
|
441
|
+
onChange={(e) => { handleChange(e.target.value); handleInput(e); }}
|
|
443
442
|
onKeyDown={handleKeyDown}
|
|
444
443
|
onPaste={handlePaste}
|
|
445
444
|
onDrop={handleDrop}
|
|
@@ -5,6 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
|
5
5
|
import type { StreamingStatus } from "@/hooks/use-chat";
|
|
6
6
|
import { ToolCard } from "./tool-cards";
|
|
7
7
|
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
8
|
+
import { basename } from "@/lib/utils";
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
AlertCircle,
|
|
@@ -164,7 +165,7 @@ function parseUserAttachments(content: string): { files: string[]; text: string
|
|
|
164
165
|
|
|
165
166
|
/** Build a preview URL for an uploaded file (served from /chat/uploads/:filename) */
|
|
166
167
|
function uploadPreviewUrl(filePath: string, projectName?: string): string {
|
|
167
|
-
const filename = filePath
|
|
168
|
+
const filename = basename(filePath);
|
|
168
169
|
// Use a generic project name — the upload route is project-scoped but files are global
|
|
169
170
|
return `/api/project/${encodeURIComponent(projectName ?? "_")}/chat/uploads/${encodeURIComponent(filename)}`;
|
|
170
171
|
}
|
|
@@ -195,13 +196,13 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
195
196
|
<AuthImage
|
|
196
197
|
key={i}
|
|
197
198
|
src={uploadPreviewUrl(filePath, projectName)}
|
|
198
|
-
alt={filePath
|
|
199
|
+
alt={basename(filePath) || "image"}
|
|
199
200
|
/>
|
|
200
201
|
) : isPdfPath(filePath) ? (
|
|
201
202
|
<AuthFileLink
|
|
202
203
|
key={i}
|
|
203
204
|
src={uploadPreviewUrl(filePath, projectName)}
|
|
204
|
-
filename={filePath
|
|
205
|
+
filename={basename(filePath) || "document.pdf"}
|
|
205
206
|
mimeType="application/pdf"
|
|
206
207
|
/>
|
|
207
208
|
) : (
|
|
@@ -210,7 +211,7 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
210
211
|
className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary"
|
|
211
212
|
>
|
|
212
213
|
<FileText className="size-3.5 shrink-0" />
|
|
213
|
-
<span className="truncate max-w-40">{filePath
|
|
214
|
+
<span className="truncate max-w-40">{basename(filePath)}</span>
|
|
214
215
|
</div>
|
|
215
216
|
),
|
|
216
217
|
)}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "lucide-react";
|
|
22
22
|
import type { ChatEvent } from "../../../types/chat";
|
|
23
23
|
import { useTabStore } from "@/stores/tab-store";
|
|
24
|
+
import { basename } from "@/lib/utils";
|
|
24
25
|
|
|
25
26
|
/** Extract tool name and input from a ChatEvent */
|
|
26
27
|
function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<string, unknown> } {
|
|
@@ -165,7 +166,7 @@ function ToolDetails({
|
|
|
165
166
|
if (!projectName) return;
|
|
166
167
|
openTab({
|
|
167
168
|
type: "editor",
|
|
168
|
-
title: filePath
|
|
169
|
+
title: basename(filePath),
|
|
169
170
|
metadata: { filePath, projectName },
|
|
170
171
|
projectId: projectName,
|
|
171
172
|
closable: true,
|
|
@@ -176,7 +177,7 @@ function ToolDetails({
|
|
|
176
177
|
const openEditDiff = (filePath: string, oldStr: string, newStr: string) => {
|
|
177
178
|
openTab({
|
|
178
179
|
type: "git-diff",
|
|
179
|
-
title: `Diff ${filePath
|
|
180
|
+
title: `Diff ${basename(filePath)}`,
|
|
180
181
|
metadata: { filePath, projectName, original: oldStr, modified: newStr },
|
|
181
182
|
projectId: projectName ?? null,
|
|
182
183
|
closable: true,
|
|
@@ -453,10 +454,6 @@ function MiniMarkdown({ content, maxHeight = "max-h-48" }: { content: string; ma
|
|
|
453
454
|
return <MarkdownRenderer content={content} className={`text-text-secondary overflow-auto ${maxHeight}`} />;
|
|
454
455
|
}
|
|
455
456
|
|
|
456
|
-
function basename(path?: string): string {
|
|
457
|
-
if (!path) return "";
|
|
458
|
-
return path.split("/").pop() ?? path;
|
|
459
|
-
}
|
|
460
457
|
|
|
461
458
|
function truncate(str?: string, max = 50): string {
|
|
462
459
|
if (!str) return "";
|
|
@@ -5,6 +5,7 @@ import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
|
5
5
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
6
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
7
7
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
8
|
+
import { basename } from "@/lib/utils";
|
|
8
9
|
import { useMonacoTheme } from "@/lib/use-monaco-theme";
|
|
9
10
|
import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
|
|
10
11
|
|
|
@@ -91,7 +92,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
91
92
|
// Update tab title unsaved indicator
|
|
92
93
|
useEffect(() => {
|
|
93
94
|
if (!ownTab) return;
|
|
94
|
-
const baseName = filePath
|
|
95
|
+
const baseName = filePath ? basename(filePath) : "Untitled";
|
|
95
96
|
const newTitle = unsaved ? `${baseName} \u25CF` : baseName;
|
|
96
97
|
if (ownTab.title !== newTitle) updateTab(ownTab.id, { title: newTitle });
|
|
97
98
|
}, [unsaved]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState, useMemo } from "react";
|
|
1
|
+
import { useEffect, useState, useMemo, useRef } from "react";
|
|
2
2
|
import { DiffEditor } from "@monaco-editor/react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
@@ -43,6 +43,20 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
43
43
|
const { wordWrap, toggleWordWrap } = useSettingsStore();
|
|
44
44
|
const monacoTheme = useMonacoTheme();
|
|
45
45
|
|
|
46
|
+
// Measure container height — Monaco needs explicit pixel height on mobile
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const [containerHeight, setContainerHeight] = useState<number | undefined>();
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = containerRef.current;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
54
|
+
if (entry) setContainerHeight(Math.floor(entry.contentRect.height));
|
|
55
|
+
});
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
46
60
|
useEffect(() => {
|
|
47
61
|
if (isInline) return;
|
|
48
62
|
if (!projectName) return;
|
|
@@ -92,6 +106,10 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
92
106
|
return langFile ? getMonacoLanguage(langFile) : "plaintext";
|
|
93
107
|
}, [filePath, file1, file2]);
|
|
94
108
|
|
|
109
|
+
// Force inline on mobile (<768px) since side-by-side is too narrow
|
|
110
|
+
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
|
111
|
+
const renderSideBySide = !isMobile && expandMode === "both";
|
|
112
|
+
|
|
95
113
|
if (!projectName && !isInline) {
|
|
96
114
|
return (
|
|
97
115
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
@@ -125,9 +143,6 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
125
143
|
);
|
|
126
144
|
}
|
|
127
145
|
|
|
128
|
-
// expandMode left/right → inline diff (Monaco has no single-side mode)
|
|
129
|
-
const renderSideBySide = expandMode === "both";
|
|
130
|
-
|
|
131
146
|
const expandToggle = (
|
|
132
147
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
133
148
|
<button type="button"
|
|
@@ -163,24 +178,30 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
163
178
|
return (
|
|
164
179
|
<div className="flex flex-col h-full">
|
|
165
180
|
{/* Monaco DiffEditor */}
|
|
166
|
-
<div className="flex-1 overflow-hidden">
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
<div ref={containerRef} className="flex-1 overflow-hidden">
|
|
182
|
+
{containerHeight && containerHeight > 0 ? (
|
|
183
|
+
<DiffEditor
|
|
184
|
+
height={containerHeight}
|
|
185
|
+
language={language}
|
|
186
|
+
original={original}
|
|
187
|
+
modified={modified}
|
|
188
|
+
theme={monacoTheme}
|
|
189
|
+
options={{
|
|
190
|
+
fontSize: isMobile ? 11 : 13,
|
|
191
|
+
fontFamily: "Menlo, Monaco, Consolas, monospace",
|
|
192
|
+
wordWrap: isMobile ? "on" : wordWrap ? "on" : "off",
|
|
193
|
+
renderSideBySide,
|
|
194
|
+
readOnly: true,
|
|
195
|
+
automaticLayout: true,
|
|
196
|
+
scrollBeyondLastLine: false,
|
|
197
|
+
}}
|
|
198
|
+
loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
|
|
199
|
+
/>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="flex items-center justify-center h-full">
|
|
202
|
+
<Loader2 className="size-5 animate-spin text-text-subtle" />
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
184
205
|
</div>
|
|
185
206
|
</div>
|
|
186
207
|
);
|