@hienlh/ppm 0.19.3 → 0.19.4

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 (81) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/accounts-settings-section-CCFQV-RB.js +1 -0
  5. package/dist/web/assets/{ai-resource-editor-BqFT1EUB.js → ai-resource-editor-BTWds-aC.js} +1 -1
  6. package/dist/web/assets/{ai-settings-section-CY6spbEx.js → ai-settings-section-CmoBsSpY.js} +1 -1
  7. package/dist/web/assets/{ai-settings-section-Po3K8j-g.js → ai-settings-section-DjbYj4wK.js} +1 -1
  8. package/dist/web/assets/{appearance-settings-section-L9Z-nej_.js → appearance-settings-section-vLmakLtA.js} +1 -1
  9. package/dist/web/assets/chat-tab-CLRlS8Jc.js +15 -0
  10. package/dist/web/assets/code-editor-CCwVu5F5.js +7 -0
  11. package/dist/web/assets/{conflict-editor-BoRPwL3L.js → conflict-editor-CvWYcbwD.js} +1 -1
  12. package/dist/web/assets/{csv-preview-BpoJqZQi.js → csv-preview-DzuDuyE_.js} +1 -1
  13. package/dist/web/assets/{database-viewer-I-9cER1L.js → database-viewer-D4XGEDHm.js} +1 -1
  14. package/dist/web/assets/{diff-viewer-_xHZsxvr.js → diff-viewer-B5N1X61R.js} +1 -1
  15. package/dist/web/assets/explorer-body-BxLLjthF.js +2 -0
  16. package/dist/web/assets/{explorer-window-content-DD6Z15pm.js → explorer-window-content-CL1Cipj3.js} +1 -1
  17. package/dist/web/assets/{extension-manager-section-Cu9D9Udt.js → extension-manager-section-BpyGq15A.js} +1 -1
  18. package/dist/web/assets/{extension-webview-Diz2OzVq.js → extension-webview-BM8FXhuN.js} +1 -1
  19. package/dist/web/assets/{files-settings-section-B1e7BgKQ.js → files-settings-section-B0_aR1nL.js} +1 -1
  20. package/dist/web/assets/{general-settings-section-DWdHHRDy.js → general-settings-section-C1EJzjhU.js} +1 -1
  21. package/dist/web/assets/{git-log-panel-CMMsvU64.js → git-log-panel-D1yGTUE_.js} +1 -1
  22. package/dist/web/assets/{glide-data-grid-pKQuyYFj.js → glide-data-grid-Cu0STKML.js} +11 -11
  23. package/dist/web/assets/{group-chat-tab-U5u0Loii.js → group-chat-tab-BfcvkRX5.js} +1 -1
  24. package/dist/web/assets/{index--KWVW-Ga.js → index-B1bzKRjJ.js} +4 -4
  25. package/dist/web/assets/index-nP3uxXvR.css +2 -0
  26. package/dist/web/assets/{jira-watcher-section-uA5oyB8s.js → jira-watcher-section-DZh-Z2MK.js} +1 -1
  27. package/dist/web/assets/{keybindings-store-Ccf0etMJ.js → keybindings-store-CFwgcnpZ.js} +1 -1
  28. package/dist/web/assets/{keyboard-shortcuts-section-B7IxJHiI.js → keyboard-shortcuts-section-JBtcJGM3.js} +1 -1
  29. package/dist/web/assets/{markdown-renderer-DDcIGQEd.js → markdown-renderer-Bo8GgGXl.js} +1 -1
  30. package/dist/web/assets/{markdown-renderer-CY1epGRc.js → markdown-renderer-DY60UZNW.js} +1 -1
  31. package/dist/web/assets/{mobile-explorer-sheet-CqkwbCRl.js → mobile-explorer-sheet-C59rPw7Z.js} +1 -1
  32. package/dist/web/assets/{notification-store-BNSYk3E7.js → notification-store-Bd6L0K_Q.js} +1 -1
  33. package/dist/web/assets/{notifications-settings-section-v_628h4B.js → notifications-settings-section-ARQlF-6E.js} +1 -1
  34. package/dist/web/assets/{pdf-preview-CI3q1MnH.js → pdf-preview-CRShdbBS.js} +1 -1
  35. package/dist/web/assets/{postgres-viewer-jaR0FYJ_.js → postgres-viewer-BNT-4yYl.js} +2 -2
  36. package/dist/web/assets/{ppmbot-settings-section-BET4VmKb.js → ppmbot-settings-section-Bq4YAcFW.js} +1 -1
  37. package/dist/web/assets/{process-table-D1r-Gupi.js → process-table-CjiXW0rg.js} +1 -1
  38. package/dist/web/assets/{proxy-settings-section-0DvNh1B-.js → proxy-settings-section-wRbiyQiA.js} +1 -1
  39. package/dist/web/assets/{query-audit-section-BA5rT6Sh.js → query-audit-section-C5RmhRSu.js} +1 -1
  40. package/dist/web/assets/{remote-desktop-mobile-sheet-CPa3XElX.js → remote-desktop-mobile-sheet-BF-75_03.js} +2 -2
  41. package/dist/web/assets/{remote-desktop-mobile-view-De_a4skd.js → remote-desktop-mobile-view-C9KR9I9G.js} +1 -1
  42. package/dist/web/assets/{remote-desktop-readiness-gate-CPrDIv2d.js → remote-desktop-readiness-gate-BnKJicB2.js} +1 -1
  43. package/dist/web/assets/{remote-desktop-window-content-CG1StfCE.js → remote-desktop-window-content-awXsNw_R.js} +1 -1
  44. package/dist/web/assets/{schedules-settings-section-B2f7V7Xr.js → schedules-settings-section-BOecKJzl.js} +1 -1
  45. package/dist/web/assets/{settings-body-omv04jbb.js → settings-body-DGi5DXY6.js} +2 -2
  46. package/dist/web/assets/{settings-tab-NdnBTqyj.js → settings-tab-GM9zTRul.js} +1 -1
  47. package/dist/web/assets/{settings-window-content-Chou1JGa.js → settings-window-content-Dvhxo4_H.js} +1 -1
  48. package/dist/web/assets/{sql-query-editor-Dk9y3jqy.js → sql-query-editor-BmkGFzcL.js} +1 -1
  49. package/dist/web/assets/{sqlite-viewer-4xFhkLYy.js → sqlite-viewer-DbziVQXQ.js} +1 -1
  50. package/dist/web/assets/{system-monitor-body-B_1iMVOI.js → system-monitor-body-CbHW5aYm.js} +2 -2
  51. package/dist/web/assets/{system-monitor-tab-B2j9mWBW.js → system-monitor-tab-C7EZLMIo.js} +1 -1
  52. package/dist/web/assets/{system-monitor-window-content-BOjq53uU.js → system-monitor-window-content-C_w4w81e.js} +1 -1
  53. package/dist/web/assets/{tab-host-window-content-CW_dlYlW.js → tab-host-window-content-BSP1SEXS.js} +1 -1
  54. package/dist/web/assets/{team-member-sheet-CkR4qO7V.js → team-member-sheet-C9N0iA1-.js} +2 -2
  55. package/dist/web/assets/{team-member-window-content-CmF5ia3n.js → team-member-window-content-CM2QFv-s.js} +1 -1
  56. package/dist/web/assets/{terminal-tab-HXZkicIc.js → terminal-tab-jWYELm8y.js} +2 -2
  57. package/dist/web/assets/{tool-cards-jA1BTLZj.js → tool-cards-jefdOWVJ.js} +4 -4
  58. package/dist/web/assets/use-accounts-data-MhMl0Y4Z.js +1 -0
  59. package/dist/web/assets/{use-monaco-theme-BcQqKVbE.js → use-monaco-theme-BbAd7DXr.js} +1 -1
  60. package/dist/web/assets/{use-remote-desktop-display-choice-DY5BWRTo.js → use-remote-desktop-display-choice-DlfMIWq3.js} +1 -1
  61. package/dist/web/assets/{use-websocket-DetaY8OI.js → use-websocket-Bq8X_SUI.js} +1 -1
  62. package/dist/web/assets/{video-preview-C0uH2228.js → video-preview-BGh3B0Lu.js} +1 -1
  63. package/dist/web/index.html +2 -2
  64. package/dist/web/sw.js +1 -1
  65. package/package.json +1 -1
  66. package/src/services/proxy-agent-anthropic-bridge.ts +93 -0
  67. package/src/services/proxy-agent-bridge.ts +100 -0
  68. package/src/services/proxy-agent-turn.ts +161 -0
  69. package/src/services/proxy-anthropic-format.ts +157 -0
  70. package/src/services/proxy-openai-format.ts +148 -0
  71. package/src/web/components/chat/usage-badge.tsx +41 -4
  72. package/src/web/components/settings/accounts/account-card.tsx +27 -3
  73. package/src/web/components/settings/accounts/accounts-pane-header.tsx +22 -2
  74. package/src/web/components/settings/accounts/claude-accounts-section.tsx +4 -3
  75. package/src/web/components/settings/accounts/codex-accounts-section.tsx +11 -4
  76. package/dist/web/assets/accounts-settings-section-LtSVxNNp.js +0 -1
  77. package/dist/web/assets/chat-tab-BIO-VhWT.js +0 -15
  78. package/dist/web/assets/code-editor-B9lVjElM.js +0 -7
  79. package/dist/web/assets/explorer-body-DapwsOnp.js +0 -2
  80. package/dist/web/assets/index-CvGx7wBQ.css +0 -2
  81. package/dist/web/assets/use-accounts-data-BI_5qNvr.js +0 -1
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Anthropic Messages API wire format for the provider-scoped proxy.
3
+ *
4
+ * The mirror of `proxy-openai-format.ts`: same job, the other dialect, so
5
+ * `/proxy/<provider>/v1/messages` and `/proxy/<provider>/v1/chat/completions`
6
+ * expose the same agent through whichever SDK the caller already uses.
7
+ */
8
+
9
+ /** One entry of the Anthropic `messages` array. */
10
+ export interface AnthropicMessage {
11
+ role?: string;
12
+ content?: string | Array<{ type?: string; text?: string }> | null;
13
+ }
14
+
15
+ export interface AnthropicMessagesBody {
16
+ model?: string;
17
+ messages?: AnthropicMessage[];
18
+ /** Anthropic carries instructions top-level, not as a message role. */
19
+ system?: string | Array<{ type?: string; text?: string }>;
20
+ stream?: boolean;
21
+ }
22
+
23
+ /** Concatenate the text blocks of a content field, ignoring everything else. */
24
+ function textOf(content: AnthropicMessage["content"] | AnthropicMessagesBody["system"]): string {
25
+ if (typeof content === "string") return content;
26
+ if (Array.isArray(content)) {
27
+ return content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n");
28
+ }
29
+ return "";
30
+ }
31
+
32
+ /**
33
+ * Flatten an Anthropic request into a single prompt plus its system text.
34
+ *
35
+ * Non-text blocks (notably `image`) are dropped: the agent turn has no way to
36
+ * carry an inline image today.
37
+ */
38
+ export function buildPromptFromAnthropicMessages(
39
+ body: AnthropicMessagesBody,
40
+ ): { prompt: string; systemPrompt?: string } {
41
+ const parts = (body.messages ?? []).map((m) => {
42
+ const role = m.role === "assistant" ? "Assistant" : "Human";
43
+ return `${role}: ${textOf(m.content)}`;
44
+ });
45
+ const systemPrompt = textOf(body.system) || undefined;
46
+ return { prompt: parts.join("\n\n"), systemPrompt };
47
+ }
48
+
49
+ /** True when any message carries a content block this format cannot forward. */
50
+ export function hasUnsupportedAnthropicBlocks(body: AnthropicMessagesBody): boolean {
51
+ return (body.messages ?? []).some((m) =>
52
+ Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
53
+ );
54
+ }
55
+
56
+ const JSON_HEADERS = {
57
+ "Content-Type": "application/json",
58
+ "Access-Control-Allow-Origin": "*",
59
+ } as const;
60
+
61
+ export const ANTHROPIC_SSE_HEADERS = {
62
+ "Content-Type": "text/event-stream",
63
+ "Cache-Control": "no-cache",
64
+ "Connection": "keep-alive",
65
+ "Access-Control-Allow-Origin": "*",
66
+ } as const;
67
+
68
+ /** Anthropic errors are `{type:"error", error:{type, message}}`, not OpenAI's shape. */
69
+ export function anthropicError(status: number, message: string): Response {
70
+ const type = status === 404 ? "not_found_error" : status === 400 ? "invalid_request_error" : "api_error";
71
+ return new Response(
72
+ JSON.stringify({ type: "error", error: { type, message } }),
73
+ { status, headers: JSON_HEADERS },
74
+ );
75
+ }
76
+
77
+ export interface AnthropicUsage {
78
+ inputTokens: number;
79
+ outputTokens: number;
80
+ }
81
+
82
+ function messageId(): string {
83
+ return `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
84
+ }
85
+
86
+ /** Build a non-streaming Messages response. */
87
+ export function messageResponse(text: string, model: string, usage?: AnthropicUsage): Response {
88
+ return new Response(JSON.stringify({
89
+ id: messageId(),
90
+ type: "message",
91
+ role: "assistant",
92
+ model,
93
+ content: [{ type: "text", text }],
94
+ stop_reason: "end_turn",
95
+ stop_sequence: null,
96
+ usage: {
97
+ input_tokens: usage?.inputTokens ?? 0,
98
+ output_tokens: usage?.outputTokens ?? 0,
99
+ },
100
+ }), { status: 200, headers: JSON_HEADERS });
101
+ }
102
+
103
+ /**
104
+ * Emits the Anthropic streaming event sequence for one response.
105
+ *
106
+ * Anthropic SSE is named-event based (`event:` line plus `data:`), and clients
107
+ * reject a stream that skips the message_start / content_block_* / message_stop
108
+ * envelope — so the envelope is written even when no text arrives.
109
+ */
110
+ export class MessageStreamWriter {
111
+ private readonly encoder = new TextEncoder();
112
+ private readonly id = messageId();
113
+ private outputTokens = 0;
114
+
115
+ constructor(
116
+ private readonly controller: ReadableStreamDefaultController<Uint8Array>,
117
+ private readonly model: string,
118
+ ) {}
119
+
120
+ private emit(event: string, data: Record<string, unknown>): void {
121
+ this.controller.enqueue(this.encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
122
+ }
123
+
124
+ /** message_start + the single text block every response here uses. */
125
+ open(inputTokens = 0): void {
126
+ this.emit("message_start", {
127
+ type: "message_start",
128
+ message: {
129
+ id: this.id, type: "message", role: "assistant", model: this.model,
130
+ content: [], stop_reason: null, stop_sequence: null,
131
+ usage: { input_tokens: inputTokens, output_tokens: 0 },
132
+ },
133
+ });
134
+ this.emit("content_block_start", {
135
+ type: "content_block_start", index: 0, content_block: { type: "text", text: "" },
136
+ });
137
+ }
138
+
139
+ text(content: string): void {
140
+ if (!content) return;
141
+ this.emit("content_block_delta", {
142
+ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: content },
143
+ });
144
+ }
145
+
146
+ /** Close the block and the message. `usage` reports what the turn actually cost. */
147
+ close(usage?: AnthropicUsage, stopReason = "end_turn"): void {
148
+ this.emit("content_block_stop", { type: "content_block_stop", index: 0 });
149
+ this.emit("message_delta", {
150
+ type: "message_delta",
151
+ delta: { stop_reason: stopReason, stop_sequence: null },
152
+ usage: { output_tokens: usage?.outputTokens ?? this.outputTokens },
153
+ });
154
+ this.emit("message_stop", { type: "message_stop" });
155
+ this.controller.close();
156
+ }
157
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * OpenAI Chat Completions wire format — the shared translation layer between
3
+ * PPM's proxy bridges and the shape an OpenAI client expects.
4
+ *
5
+ * Both proxy bridges speak this: the Claude SDK bridge (`proxy-openai-bridge.ts`)
6
+ * and the provider-agnostic agent bridge (`proxy-agent-bridge.ts`). Keeping the
7
+ * wire format in one place is what stops the two from drifting into subtly
8
+ * different `chat.completion` payloads.
9
+ */
10
+
11
+ /** One entry of the OpenAI `messages` array (content is string or block array). */
12
+ export interface OpenAiMessage {
13
+ role?: string;
14
+ content?: string | Array<{ type?: string; text?: string }> | null;
15
+ }
16
+
17
+ export interface OpenAiChatBody {
18
+ model?: string;
19
+ messages?: OpenAiMessage[];
20
+ stream?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Flatten OpenAI `messages` into a single prompt plus a system prompt.
25
+ *
26
+ * Non-text blocks (notably `image_url`) are dropped: neither bridge can carry
27
+ * an inline image today. Callers that must not silently lose an attachment
28
+ * should check `hasUnsupportedBlocks` first.
29
+ */
30
+ export function buildPromptFromOpenAiMessages(
31
+ body: OpenAiChatBody,
32
+ ): { prompt: string; systemPrompt?: string } {
33
+ const messages = body.messages ?? [];
34
+ let systemPrompt: string | undefined;
35
+ const conversationParts: string[] = [];
36
+
37
+ for (const m of messages) {
38
+ const text = typeof m.content === "string"
39
+ ? m.content
40
+ : Array.isArray(m.content)
41
+ ? m.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n")
42
+ : String(m.content ?? "");
43
+
44
+ if (m.role === "system") {
45
+ systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
46
+ } else {
47
+ const role = m.role === "assistant" ? "Assistant" : "Human";
48
+ conversationParts.push(`${role}: ${text}`);
49
+ }
50
+ }
51
+
52
+ return { prompt: conversationParts.join("\n\n"), systemPrompt };
53
+ }
54
+
55
+ /** True when any message carries a content block this format cannot forward. */
56
+ export function hasUnsupportedBlocks(body: OpenAiChatBody): boolean {
57
+ return (body.messages ?? []).some((m) =>
58
+ Array.isArray(m.content) && m.content.some((b) => b.type && b.type !== "text"),
59
+ );
60
+ }
61
+
62
+ const JSON_HEADERS = {
63
+ "Content-Type": "application/json",
64
+ "Access-Control-Allow-Origin": "*",
65
+ } as const;
66
+
67
+ export const SSE_HEADERS = {
68
+ "Content-Type": "text/event-stream",
69
+ "Cache-Control": "no-cache",
70
+ "Connection": "keep-alive",
71
+ "Access-Control-Allow-Origin": "*",
72
+ } as const;
73
+
74
+ export function openAiError(status: number, message: string): Response {
75
+ return new Response(
76
+ JSON.stringify({ error: { message, type: "server_error", code: String(status) } }),
77
+ { status, headers: JSON_HEADERS },
78
+ );
79
+ }
80
+
81
+ /** Token counts for the `usage` object; omitted fields report as 0. */
82
+ export interface OpenAiUsage {
83
+ promptTokens?: number;
84
+ completionTokens?: number;
85
+ }
86
+
87
+ /** Build a non-streaming `chat.completion` response. */
88
+ export function completionResponse(
89
+ content: string,
90
+ model: string,
91
+ usage?: OpenAiUsage,
92
+ ): Response {
93
+ const promptTokens = usage?.promptTokens ?? 0;
94
+ const completionTokens = usage?.completionTokens ?? 0;
95
+ return new Response(JSON.stringify({
96
+ id: `chatcmpl-${Date.now()}`,
97
+ object: "chat.completion",
98
+ created: Math.floor(Date.now() / 1000),
99
+ model,
100
+ choices: [{
101
+ index: 0,
102
+ message: { role: "assistant", content },
103
+ finish_reason: "stop",
104
+ }],
105
+ usage: {
106
+ prompt_tokens: promptTokens,
107
+ completion_tokens: completionTokens,
108
+ total_tokens: promptTokens + completionTokens,
109
+ },
110
+ }), { status: 200, headers: JSON_HEADERS });
111
+ }
112
+
113
+ /** Emits `chat.completion.chunk` frames for one streamed response. */
114
+ export class ChunkWriter {
115
+ private readonly encoder = new TextEncoder();
116
+ private readonly id = `chatcmpl-${Date.now()}`;
117
+ private readonly created = Math.floor(Date.now() / 1000);
118
+
119
+ constructor(
120
+ private readonly controller: ReadableStreamDefaultController<Uint8Array>,
121
+ private readonly model: string,
122
+ ) {}
123
+
124
+ /** One SSE frame carrying a delta (and optionally the finish reason). */
125
+ send(delta: Record<string, unknown>, finishReason: string | null = null): void {
126
+ const payload = {
127
+ id: this.id, object: "chat.completion.chunk", created: this.created, model: this.model,
128
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
129
+ };
130
+ this.controller.enqueue(this.encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
131
+ }
132
+
133
+ /** OpenAI clients treat the role-only first chunk as the start of the message. */
134
+ open(): void {
135
+ this.send({ role: "assistant", content: "" });
136
+ }
137
+
138
+ text(content: string): void {
139
+ if (content) this.send({ content });
140
+ }
141
+
142
+ /** Terminal frame plus the `[DONE]` sentinel every OpenAI client waits for. */
143
+ close(finishReason = "stop"): void {
144
+ this.send({}, finishReason);
145
+ this.controller.enqueue(this.encoder.encode("data: [DONE]\n\n"));
146
+ this.controller.close();
147
+ }
148
+ }
@@ -6,9 +6,14 @@
6
6
  * controls, which is what let the two drift apart. The account cards are the same component
7
7
  * the Settings pane renders — passing no action callbacks is what makes them read-only, so
8
8
  * there is one card implementation rather than a display twin.
9
+ *
10
+ * Accounts sit in a row that scrolls sideways, not a vertical stack: this panel exists to
11
+ * compare them, and stacked in a 350px strip that meant scrolling past one account to see
12
+ * the next. Fullscreen lays the same cards out as a grid when there are too many to fit.
9
13
  */
10
14
 
11
- import { Activity, ExternalLink, RefreshCw, X } from "lucide-react";
15
+ import { useState } from "react";
16
+ import { Activity, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from "lucide-react";
12
17
  import type { UsageInfo } from "../../../types/chat";
13
18
  import { openSettings } from "@/components/settings/open-settings";
14
19
  import { AccountCard } from "@/components/settings/accounts/account-card";
@@ -61,6 +66,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
61
66
  // Fetching is gated on visibility: the panel is collapsed most of the time, and its
62
67
  // usage endpoint is the expensive one.
63
68
  const { usages, accounts, activeAccountId, initialLoading, refreshing, flashIds, reload } = useAccountsData(visible);
69
+ const [isFullscreen, setIsFullscreen] = useState(false);
64
70
 
65
71
  if (!visible) return null;
66
72
 
@@ -68,8 +74,19 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
68
74
  const hasCost = usage.queryCostUsd != null || usage.totalCostUsd != null;
69
75
  const hasPerAccountUsage = usages.length > 0;
70
76
 
77
+ // Roughly square, so the cards fill the viewport instead of leaving a long empty column.
78
+ const fsCount = usages.length || 1;
79
+ const fsCols = Math.ceil(Math.sqrt(fsCount));
80
+ const fsRows = Math.ceil(fsCount / fsCols);
81
+
71
82
  return (
72
- <div className="relative border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
83
+ <div
84
+ className={`relative border-b border-border bg-surface px-3 py-2.5 ${
85
+ isFullscreen
86
+ ? "fixed inset-0 z-50 flex flex-col gap-2.5 overflow-hidden"
87
+ : "space-y-2.5 max-h-[350px] overflow-y-auto"
88
+ }`}
89
+ >
73
90
  <div className="flex items-center justify-between shrink-0">
74
91
  <div className="flex items-center gap-2">
75
92
  <span className="text-xs font-semibold text-text-primary">Usage</span>
@@ -85,6 +102,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
85
102
  >
86
103
  Manage accounts <ExternalLink className="size-3" />
87
104
  </button>
105
+ {hasPerAccountUsage && (
106
+ <button
107
+ onClick={() => setIsFullscreen((v) => !v)}
108
+ className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
109
+ title={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
110
+ aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen view"}
111
+ >
112
+ {isFullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
113
+ </button>
114
+ )}
88
115
  {onReload && (
89
116
  <button
90
117
  onClick={() => { onReload(); void reload(); }}
@@ -97,7 +124,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
97
124
  </button>
98
125
  )}
99
126
  <button
100
- onClick={onClose}
127
+ onClick={() => { setIsFullscreen(false); onClose(); }}
101
128
  className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
102
129
  aria-label="Close usage panel"
103
130
  >
@@ -107,7 +134,16 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
107
134
  </div>
108
135
 
109
136
  {hasPerAccountUsage || initialLoading ? (
110
- <div className="space-y-1.5">
137
+ <div
138
+ className={isFullscreen
139
+ ? "flex-1 min-h-0 grid gap-2 overflow-hidden"
140
+ // Same classes as AccountCardRow, with the panel's wider padding to clear.
141
+ : "flex gap-2 overflow-x-auto pb-1 -mx-3 px-3 snap-x snap-mandatory scrollbar-thin"}
142
+ style={isFullscreen ? {
143
+ gridTemplateColumns: `repeat(${fsCols}, minmax(0, 1fr))`,
144
+ gridTemplateRows: `repeat(${fsRows}, minmax(0, 1fr))`,
145
+ } : undefined}
146
+ >
111
147
  {initialLoading ? (
112
148
  <p className="text-[10px] text-text-subtle">Loading...</p>
113
149
  ) : (
@@ -118,6 +154,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
118
154
  isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
119
155
  accountInfo={accountMap.get(entry.accountId)}
120
156
  flash={flashIds.has(entry.accountId)}
157
+ layout={isFullscreen ? "grid" : "strip"}
121
158
  />
122
159
  ))
123
160
  )}
@@ -1,6 +1,12 @@
1
1
  /**
2
- * One account in the list: name, badges, per-account controls, its rate-limit buckets, and a
3
- * footer of token facts.
2
+ * One account: name, badges, per-account controls, its rate-limit buckets, and a footer of
3
+ * token facts.
4
+ *
5
+ * Three layouts, because the two callers want opposite things. Settings manages accounts one
6
+ * at a time, so `list` gives each card the full width. The chat panel is for comparing them
7
+ * at a glance, so `strip` makes fixed-width cards that scroll sideways and `grid` fills a
8
+ * cell of the fullscreen view — stacked vertically, comparing two accounts meant scrolling
9
+ * past the one above.
4
10
  *
5
11
  * An expired account (past `expiresAt` AND no refresh token) is dimmed and loses every
6
12
  * control except delete — toggling or exporting a token the server can no longer renew only
@@ -25,10 +31,19 @@ export interface AccountCardProps {
25
31
  onViewProfile?: (profile: OAuthProfileData, accountId: string) => void;
26
32
  /** Brief highlight when this account's usage numbers just changed. */
27
33
  flash?: boolean;
34
+ /** `list` fills the width, `strip` is a fixed-width card in a sideways scroller, `grid`
35
+ * fills a cell of the fullscreen grid. */
36
+ layout?: "list" | "strip" | "grid";
28
37
  }
29
38
 
39
+ // Fixed widths so a row scrolls instead of squeezing. Two of them: a read-only card holds a
40
+ // name and its bars, but a card that also carries view/export/toggle/delete needs room for
41
+ // four controls beside the name, and at the read-only width that header wraps.
42
+ const STRIP_WIDTH = { readOnly: "min-w-[220px]", withActions: "min-w-[300px]" } as const;
43
+
30
44
  export function AccountCard({
31
45
  entry, isActive, accountInfo, onToggle, toggling, onDelete, onExport, onViewProfile, flash,
46
+ layout = "list",
32
47
  }: AccountCardProps) {
33
48
  const { usage } = entry;
34
49
  const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
@@ -38,12 +53,21 @@ export function AccountCard({
38
53
  && accountInfo.expiresAt < Math.floor(Date.now() / 1000)
39
54
  );
40
55
  const ts = tokenStatus(accountInfo);
56
+ const hasActions = Boolean(onToggle || onDelete || onExport || onViewProfile);
57
+
58
+ const layoutClass = layout === "list"
59
+ ? ""
60
+ : layout === "grid"
61
+ // The cell owns the height, so the card spreads its rows into it.
62
+ ? "flex flex-col justify-evenly overflow-hidden min-h-0"
63
+ : `shrink-0 snap-start ${hasActions ? STRIP_WIDTH.withActions : STRIP_WIDTH.readOnly}`;
41
64
 
42
65
  return (
43
66
  <AccountCardShell
44
67
  active={isActive}
45
68
  flash={flash}
46
- className={isExpired ? "opacity-50" : undefined}
69
+ dense={layout !== "list"}
70
+ className={[layoutClass, isExpired ? "opacity-50" : ""].filter(Boolean).join(" ") || undefined}
47
71
  data-testid="account-card"
48
72
  data-account-id={entry.accountId}
49
73
  >
@@ -65,23 +65,43 @@ export function AccountsPaneMessage({ message, onDismiss }: {
65
65
  );
66
66
  }
67
67
 
68
+ /**
69
+ * The row accounts are laid out in: a sideways scroller, one card per account.
70
+ *
71
+ * Horizontal everywhere — the chat panel and both settings panes — because the point of the
72
+ * list is comparing accounts, and stacked vertically you scroll past one to reach the next.
73
+ * Shared so the three callers cannot end up scrolling in different directions.
74
+ */
75
+ export function AccountCardRow({ children }: { children: React.ReactNode }) {
76
+ return (
77
+ // Negative margin then padding: cards scroll edge to edge while still clearing the
78
+ // pane's own padding at rest. Snap so a swipe lands on a card rather than between two.
79
+ <div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 snap-x snap-mandatory scrollbar-thin">
80
+ {children}
81
+ </div>
82
+ );
83
+ }
84
+
68
85
  /**
69
86
  * The card one account is drawn in.
70
87
  *
71
88
  * Shared so a Claude row and a Codex row are the same object on screen even though what goes
72
89
  * inside them differs.
73
90
  */
74
- export function AccountCardShell({ active, flash, children, ...rest }: {
91
+ export function AccountCardShell({ active, flash, dense, children, ...rest }: {
75
92
  active?: boolean;
76
93
  /** Brief highlight when this account's numbers just changed. */
77
94
  flash?: boolean;
95
+ /** Tighter padding, for a card that has to fit a fixed narrow width. */
96
+ dense?: boolean;
78
97
  children: React.ReactNode;
79
98
  } & React.HTMLAttributes<HTMLDivElement>) {
80
99
  return (
81
100
  <div
82
101
  {...rest}
83
102
  className={[
84
- "rounded-md border p-3 space-y-2 transition-colors duration-500",
103
+ "rounded-md border transition-colors duration-500",
104
+ dense ? "p-2.5 space-y-1.5" : "p-3 space-y-2",
85
105
  flash ? "bg-primary/10 border-primary/40" : "",
86
106
  active ? "border-primary/30 bg-primary/5" : "border-border/50",
87
107
  rest.className ?? "",
@@ -10,7 +10,7 @@
10
10
  import { useState } from "react";
11
11
  import { Download, KeyRound, Loader2, Plus, Settings, Upload, FlaskConical } from "lucide-react";
12
12
  import { Button } from "@/components/ui/button";
13
- import { AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
13
+ import { AccountCardRow, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
14
14
  import { patchAccount, deleteAccount, type OAuthProfileData } from "../../../lib/api-settings";
15
15
  import { AccountProfilePanel } from "./account-profile-panel";
16
16
  import { AccountDeleteConfirm } from "./account-delete-confirm";
@@ -117,9 +117,10 @@ export function ClaudeAccountsSection() {
117
117
  <p className="text-xs text-muted-foreground">Add one to start using PPM.</p>
118
118
  </div>
119
119
  ) : (
120
- <div className="space-y-2">
120
+ <AccountCardRow>
121
121
  {usages.map((entry) => (
122
122
  <AccountCard
123
+ layout="strip"
123
124
  key={entry.accountId}
124
125
  entry={entry}
125
126
  isActive={entry.accountId === activeAccountId}
@@ -132,7 +133,7 @@ export function ClaudeAccountsSection() {
132
133
  flash={flashIds.has(entry.accountId)}
133
134
  />
134
135
  ))}
135
- </div>
136
+ </AccountCardRow>
136
137
  )}
137
138
  </section>
138
139
 
@@ -9,7 +9,7 @@
9
9
  import { useState } from "react";
10
10
  import { Download, KeyRound, Loader2, Plus, Settings, Trash2, Upload } from "lucide-react";
11
11
  import { Button } from "@/components/ui/button";
12
- import { AccountCardShell, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
12
+ import { AccountCardRow, AccountCardShell, AccountsPaneHeader, AccountsPaneMessage } from "./accounts-pane-header";
13
13
  import { AccountUsageBar } from "./account-bucket-row";
14
14
  import { CodexAddAccountDialog } from "./codex-add-account-dialog";
15
15
  import { CodexBackupDialog } from "./codex-backup-dialog";
@@ -70,11 +70,18 @@ export function CodexAccountsSection() {
70
70
  </p>
71
71
  </div>
72
72
  ) : (
73
- <div className="space-y-2">
73
+ <AccountCardRow>
74
74
  {c.accounts.map((a) => {
75
75
  const u = c.usages[a.id] ?? {};
76
76
  return (
77
- <AccountCardShell key={a.id} data-testid="account-card" data-account-id={a.id}>
77
+ <AccountCardShell
78
+ key={a.id}
79
+ dense
80
+ // Matches the Claude card that also carries a control beside the name.
81
+ className="min-w-[300px] shrink-0 snap-start"
82
+ data-testid="account-card"
83
+ data-account-id={a.id}
84
+ >
78
85
  <div className="flex items-center gap-2">
79
86
  <span className="text-sm font-medium truncate flex-1 min-w-0">{a.label}</span>
80
87
  <span className="text-[10px] uppercase tracking-wide text-text-subtle border border-border rounded px-1 shrink-0">
@@ -98,7 +105,7 @@ export function CodexAccountsSection() {
98
105
  </AccountCardShell>
99
106
  );
100
107
  })}
101
- </div>
108
+ </AccountCardRow>
102
109
  )}
103
110
  </section>
104
111