@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/web/assets/{api-client-ANLU-Irq.js → api-client-BxCvlogn.js} +1 -1
  3. package/dist/web/assets/chat-tab-AwRs7rWS.js +7 -0
  4. package/dist/web/assets/code-editor-BviTme00.js +1 -0
  5. package/dist/web/assets/diff-viewer-CCZM_VBl.js +4 -0
  6. package/dist/web/assets/git-graph-UCZZ6fX6.js +1 -0
  7. package/dist/web/assets/index-BxHR8fUA.css +2 -0
  8. package/dist/web/assets/index-yvVRZ65D.js +21 -0
  9. package/dist/web/assets/{input-D-F4ITU0.js → input-Bzyi1GeB.js} +1 -1
  10. package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → jsx-runtime-Bzk8w7Zh.js} +1 -1
  11. package/dist/web/assets/markdown-renderer-DzVh1Ft8.js +59 -0
  12. package/dist/web/assets/{rotate-ccw-BesidNnx.js → rotate-ccw-ZqeedZLA.js} +1 -1
  13. package/dist/web/assets/settings-store-DikslxSJ.js +1 -0
  14. package/dist/web/assets/settings-tab-C-AGuxll.js +1 -0
  15. package/dist/web/assets/tab-store-BNgVKR5w.js +1 -0
  16. package/dist/web/assets/terminal-tab-CnbdkUFt.js +36 -0
  17. package/dist/web/assets/{use-monaco-theme-CsNwoeyj.js → use-monaco-theme-BFv4d2_j.js} +2 -2
  18. package/dist/web/assets/{utils-bntUtdc7.js → utils-EM9hC5pN.js} +1 -1
  19. package/dist/web/index.html +8 -9
  20. package/dist/web/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/src/cli/commands/init.ts +2 -2
  23. package/src/cli/commands/status.ts +66 -1
  24. package/src/cli/commands/stop.ts +39 -2
  25. package/src/index.ts +4 -2
  26. package/src/providers/claude-agent-sdk.ts +30 -21
  27. package/src/server/helpers/resolve-project.ts +2 -2
  28. package/src/server/index.ts +2 -1
  29. package/src/server/routes/chat.ts +1 -0
  30. package/src/server/ws/chat.ts +4 -2
  31. package/src/services/claude-usage.service.ts +34 -0
  32. package/src/services/config.service.ts +11 -1
  33. package/src/types/api.ts +1 -2
  34. package/src/types/chat.ts +1 -2
  35. package/src/web/components/chat/attachment-chips.tsx +1 -1
  36. package/src/web/components/chat/chat-history-bar.tsx +7 -3
  37. package/src/web/components/chat/chat-tab.tsx +4 -2
  38. package/src/web/components/chat/message-input.tsx +13 -14
  39. package/src/web/components/chat/message-list.tsx +5 -4
  40. package/src/web/components/chat/tool-cards.tsx +3 -6
  41. package/src/web/components/editor/code-editor.tsx +2 -1
  42. package/src/web/components/editor/diff-viewer.tsx +43 -22
  43. package/src/web/components/explorer/file-tree.tsx +3 -3
  44. package/src/web/components/git/git-graph.tsx +2 -1
  45. package/src/web/components/git/git-status-panel.tsx +166 -89
  46. package/src/web/components/layout/command-palette.tsx +2 -1
  47. package/src/web/components/layout/mobile-drawer.tsx +2 -2
  48. package/src/web/components/layout/mobile-nav.tsx +1 -1
  49. package/src/web/components/layout/panel-layout.tsx +16 -16
  50. package/src/web/components/layout/split-drop-overlay.tsx +3 -3
  51. package/src/web/components/shared/markdown-renderer.tsx +16 -10
  52. package/src/web/hooks/use-chat.ts +10 -17
  53. package/src/web/hooks/use-terminal.ts +66 -23
  54. package/src/web/hooks/use-usage.ts +1 -14
  55. package/src/web/lib/utils.ts +5 -0
  56. package/src/web/stores/panel-store.ts +15 -14
  57. package/src/web/stores/panel-utils.ts +12 -10
  58. package/src/web/stores/settings-store.ts +1 -1
  59. package/dist/web/assets/chat-tab-d_HzPDhE.js +0 -7
  60. package/dist/web/assets/code-editor-DFAu3knd.js +0 -1
  61. package/dist/web/assets/diff-viewer-Bue0mOJY.js +0 -4
  62. package/dist/web/assets/git-graph-Cjq-lK5h.js +0 -1
  63. package/dist/web/assets/index-D_IIxtVN.js +0 -21
  64. package/dist/web/assets/index-DhsWierF.css +0 -2
  65. package/dist/web/assets/markdown-renderer-B9l76G5h.js +0 -59
  66. package/dist/web/assets/react-WvgCEYPV.js +0 -1
  67. package/dist/web/assets/settings-store-CGtTcr8r.js +0 -1
  68. package/dist/web/assets/settings-tab-BDPgdHPI.js +0 -1
  69. package/dist/web/assets/tab-store-Dq1kMOkJ.js +0 -1
  70. 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((s) => s.sessionId === sessionId);
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, 1);
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 — extract utilization percentages
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
- const usage: Record<string, number> = {};
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
- // Yield final cost + any rate limit info from result
517
- const usage: Record<string, unknown> = {};
518
- if (result.total_cost_usd != null) usage.totalCostUsd = result.total_cost_usd;
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;
@@ -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 + "/index.ts", "__serve__",
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],
@@ -40,6 +40,7 @@ chatRoutes.get("/usage", async (c) => {
40
40
  weekly: usage.weekly,
41
41
  weeklyOpus: usage.weeklyOpus,
42
42
  weeklySonnet: usage.weeklySonnet,
43
+ totalCostUsd: usage.totalCostUsd,
43
44
  }));
44
45
  });
45
46
 
@@ -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
- if (existsSync(p)) {
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: "usage"; usage: { totalCostUsd?: number; fiveHour?: number; sevenDay?: number } }
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-3 pt-2">
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
- {lastFetchedAt && (
146
- <span className="text-text-subtle/50 font-normal text-[9px] ml-0.5">{relativeTime(lastFetchedAt)}</span>
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, mergeUsage } =
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, { onUsageEvent: mergeUsage });
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
- textareaRef.current.style.height = "auto";
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={textareaRef}
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-32"
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.split("/").pop() ?? "";
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.split("/").pop() ?? "image"}
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.split("/").pop() ?? "document.pdf"}
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.split("/").pop()}</span>
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.split("/").pop() ?? 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.split("/").pop() ?? 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?.split("/").pop() ?? "Untitled";
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
- <DiffEditor
168
- height="100%"
169
- language={language}
170
- original={original}
171
- modified={modified}
172
- theme={monacoTheme}
173
- options={{
174
- fontSize: 13,
175
- fontFamily: "Menlo, Monaco, Consolas, monospace",
176
- wordWrap: wordWrap ? "on" : "off",
177
- renderSideBySide,
178
- readOnly: true,
179
- automaticLayout: true,
180
- scrollBeyondLastLine: false,
181
- }}
182
- loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
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
  );