@hienlh/ppm 0.9.41 → 0.9.42

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 (57) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/dist/web/assets/{chat-tab-CrkhvVjF.js → chat-tab-BSQOFDle.js} +2 -2
  3. package/dist/web/assets/{code-editor-BfMyExLp.js → code-editor-eDYb_XML.js} +2 -2
  4. package/dist/web/assets/{csv-preview--ZSEumXf.js → csv-preview-sx6DC51G.js} +1 -1
  5. package/dist/web/assets/{database-viewer-CeRUrZKj.js → database-viewer-nP78XqEF.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-D2p3WTMS.js → diff-viewer-DTMtBxHM.js} +1 -1
  7. package/dist/web/assets/{extension-webview-DQWAHMlR.js → extension-webview-DzWz--CI.js} +1 -1
  8. package/dist/web/assets/{git-graph-BWRMlCdK.js → git-graph-D_6NTVVT.js} +1 -1
  9. package/dist/web/assets/index-BEfMoc_W.css +2 -0
  10. package/dist/web/assets/index-D48IQVYU.js +30 -0
  11. package/dist/web/assets/keybindings-store-BaWyhjXJ.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-C7lKs47M.js → markdown-renderer-CxJg37If.js} +1 -1
  13. package/dist/web/assets/port-forwarding-tab-DBBJ3z8x.js +1 -0
  14. package/dist/web/assets/{postgres-viewer-Cr9jpBNd.js → postgres-viewer-CQ3coJ1p.js} +1 -1
  15. package/dist/web/assets/{settings-tab-DKy-YDg2.js → settings-tab-CE8H5NiY.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-9AmeF-Zs.js → sqlite-viewer-Ccm-un47.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-DFhB4Rxh.js → terminal-tab-DnlFNbY6.js} +1 -1
  18. package/dist/web/assets/{use-monaco-theme-B7XLw-OX.js → use-monaco-theme-hwg4tMW2.js} +1 -1
  19. package/dist/web/index.html +2 -2
  20. package/dist/web/sw.js +1 -1
  21. package/docs/codebase-summary.md +33 -3
  22. package/docs/project-changelog.md +47 -0
  23. package/docs/project-roadmap.md +14 -7
  24. package/docs/system-architecture.md +65 -2
  25. package/package.json +1 -1
  26. package/src/server/index.ts +10 -3
  27. package/src/server/routes/{browser-preview.ts → port-forwarding.ts} +7 -7
  28. package/src/server/routes/settings.ts +83 -17
  29. package/src/services/config.service.ts +1 -1
  30. package/src/services/db.service.ts +285 -1
  31. package/src/services/ppmbot/ppmbot-formatter.ts +88 -0
  32. package/src/services/ppmbot/ppmbot-memory.ts +333 -0
  33. package/src/services/ppmbot/ppmbot-service.ts +545 -0
  34. package/src/services/ppmbot/ppmbot-session.ts +199 -0
  35. package/src/services/ppmbot/ppmbot-streamer.ts +288 -0
  36. package/src/services/ppmbot/ppmbot-telegram.ts +279 -0
  37. package/src/services/telegram-notification.service.ts +44 -21
  38. package/src/types/config.ts +25 -1
  39. package/src/types/ppmbot.ts +103 -0
  40. package/src/web/components/chat/chat-history-bar.tsx +8 -3
  41. package/src/web/components/layout/command-palette.tsx +1 -1
  42. package/src/web/components/layout/editor-panel.tsx +1 -1
  43. package/src/web/components/layout/mobile-nav.tsx +1 -1
  44. package/src/web/components/layout/tab-bar.tsx +1 -1
  45. package/src/web/components/layout/tab-content.tsx +3 -3
  46. package/src/web/components/{browser/browser-tab.tsx → ports/port-forwarding-tab.tsx} +5 -5
  47. package/src/web/components/settings/ppmbot-settings-section.tsx +355 -0
  48. package/src/web/components/settings/settings-tab.tsx +10 -5
  49. package/src/web/hooks/use-url-sync.ts +3 -3
  50. package/src/web/stores/panel-utils.ts +2 -2
  51. package/src/web/stores/tab-store.ts +1 -1
  52. package/dist/web/assets/browser-tab--V6I70pH.js +0 -1
  53. package/dist/web/assets/index-C7esr4gM.css +0 -2
  54. package/dist/web/assets/index-DU6UVgQY.js +0 -30
  55. package/dist/web/assets/keybindings-store-BE2T8jM9.js +0 -1
  56. /package/dist/web/assets/{dist-DKlZwvf8.js → dist-C40JmyoH.js} +0 -0
  57. /package/dist/web/assets/{lib-BeaDXEkP.js → lib-mag4ySk-.js} +0 -0
@@ -0,0 +1,279 @@
1
+ import type {
2
+ TelegramUpdate,
3
+ TelegramMessage,
4
+ TelegramSentMessage,
5
+ PPMBotCommand,
6
+ } from "../../types/ppmbot.ts";
7
+
8
+ const TELEGRAM_API = "https://api.telegram.org/bot";
9
+ const POLL_TIMEOUT = 25;
10
+ const MIN_EDIT_INTERVAL = 1000;
11
+ const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
12
+
13
+ /** Known PPMBot slash commands */
14
+ const COMMANDS = new Set([
15
+ "start", "project", "new", "sessions", "resume",
16
+ "status", "stop", "memory", "forget", "remember", "help",
17
+ ]);
18
+
19
+ export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
20
+
21
+ export class PPMBotTelegram {
22
+ private token: string;
23
+ private offset = 0;
24
+ private running = false;
25
+ private abortController: AbortController | null = null;
26
+ private retryCount = 0;
27
+
28
+ /** Track last edit time per chatId:messageId to throttle */
29
+ private lastEditTime = new Map<string, number>();
30
+
31
+ constructor(token: string) {
32
+ if (!BOT_TOKEN_RE.test(token)) {
33
+ throw new Error("Invalid Telegram bot token format");
34
+ }
35
+ this.token = token;
36
+ }
37
+
38
+ // ── Polling ─────────────────────────────────────────────────────
39
+
40
+ /** Register bot commands with Telegram so they show in the menu */
41
+ async registerCommands(): Promise<void> {
42
+ try {
43
+ await this.callApi("setMyCommands", {
44
+ commands: [
45
+ { command: "start", description: "Greeting + list projects" },
46
+ { command: "project", description: "Switch project" },
47
+ { command: "new", description: "Fresh session (current project)" },
48
+ { command: "sessions", description: "List recent sessions" },
49
+ { command: "resume", description: "Resume a previous session" },
50
+ { command: "status", description: "Current project/session info" },
51
+ { command: "stop", description: "End current session" },
52
+ { command: "memory", description: "Show project memories" },
53
+ { command: "forget", description: "Remove matching memories" },
54
+ { command: "remember", description: "Save a fact" },
55
+ { command: "help", description: "Show all commands" },
56
+ ],
57
+ });
58
+ console.log("[ppmbot] Commands registered");
59
+ } catch (err) {
60
+ console.warn("[ppmbot] Failed to register commands:", (err as Error).message);
61
+ }
62
+ }
63
+
64
+ /** Start long-polling loop. Calls handler for each update. */
65
+ async startPolling(handler: UpdateHandler): Promise<void> {
66
+ if (this.running) return;
67
+ this.running = true;
68
+ this.retryCount = 0;
69
+
70
+ // Register commands on startup
71
+ await this.registerCommands();
72
+
73
+ console.log("[ppmbot] Polling started");
74
+
75
+ while (this.running) {
76
+ try {
77
+ const updates = await this.getUpdates();
78
+ this.retryCount = 0;
79
+
80
+ for (const update of updates) {
81
+ this.offset = update.update_id + 1;
82
+ // Fire-and-forget: don't block polling on handler execution
83
+ // Per-chatId serialization is handled by processing lock in service
84
+ handler(update).catch((err) => {
85
+ console.error("[ppmbot] Handler error:", (err as Error).message);
86
+ });
87
+ }
88
+ } catch (err) {
89
+ if (!this.running) break;
90
+ this.retryCount++;
91
+ const delay = Math.min(1000 * 2 ** this.retryCount, 30_000);
92
+ console.error(
93
+ `[ppmbot] Poll error (retry ${this.retryCount}): ${(err as Error).message}. Retrying in ${delay}ms`,
94
+ );
95
+ await Bun.sleep(delay);
96
+ }
97
+ }
98
+
99
+ console.log("[ppmbot] Polling stopped");
100
+ }
101
+
102
+ /** Stop polling gracefully */
103
+ stop(): void {
104
+ this.running = false;
105
+ this.abortController?.abort();
106
+ this.lastEditTime.clear();
107
+ }
108
+
109
+ get isRunning(): boolean {
110
+ return this.running;
111
+ }
112
+
113
+ // ── Telegram API Methods ────────────────────────────────────────
114
+
115
+ /** Fetch updates via long-polling */
116
+ private async getUpdates(): Promise<TelegramUpdate[]> {
117
+ this.abortController = new AbortController();
118
+ const fetchTimeout = setTimeout(
119
+ () => this.abortController?.abort(),
120
+ (POLL_TIMEOUT + 10) * 1000,
121
+ );
122
+
123
+ try {
124
+ const res = await fetch(`${TELEGRAM_API}${this.token}/getUpdates`, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({
128
+ offset: this.offset,
129
+ timeout: POLL_TIMEOUT,
130
+ allowed_updates: ["message"],
131
+ }),
132
+ signal: this.abortController.signal,
133
+ });
134
+
135
+ const json = (await res.json()) as { ok: boolean; result?: TelegramUpdate[] };
136
+ if (!json.ok || !json.result) return [];
137
+ return json.result;
138
+ } finally {
139
+ clearTimeout(fetchTimeout);
140
+ this.abortController = null;
141
+ }
142
+ }
143
+
144
+ /** Send a text message */
145
+ async sendMessage(
146
+ chatId: number | string,
147
+ text: string,
148
+ parseMode: "HTML" | "Markdown" = "HTML",
149
+ ): Promise<TelegramSentMessage | null> {
150
+ try {
151
+ const res = await this.callApi("sendMessage", {
152
+ chat_id: chatId,
153
+ text,
154
+ parse_mode: parseMode,
155
+ disable_web_page_preview: true,
156
+ });
157
+ const json = (await res.json()) as { ok: boolean; result?: TelegramSentMessage; description?: string };
158
+ if (!json.ok) {
159
+ console.error(`[ppmbot] sendMessage failed: ${json.description}`);
160
+ return null;
161
+ }
162
+ return json.result ?? null;
163
+ } catch (err) {
164
+ console.error(`[ppmbot] sendMessage error: ${(err as Error).message}`);
165
+ return null;
166
+ }
167
+ }
168
+
169
+ /** Edit an existing message text (throttled at 1s intervals) */
170
+ async editMessage(
171
+ chatId: number | string,
172
+ messageId: number,
173
+ text: string,
174
+ parseMode: "HTML" | "Markdown" = "HTML",
175
+ ): Promise<boolean> {
176
+ const key = `${chatId}:${messageId}`;
177
+ const now = Date.now();
178
+ const lastEdit = this.lastEditTime.get(key) ?? 0;
179
+ if (now - lastEdit < MIN_EDIT_INTERVAL) return false;
180
+
181
+ this.lastEditTime.set(key, now);
182
+
183
+ try {
184
+ const res = await this.callApi("editMessageText", {
185
+ chat_id: chatId,
186
+ message_id: messageId,
187
+ text,
188
+ parse_mode: parseMode,
189
+ disable_web_page_preview: true,
190
+ });
191
+ const json = (await res.json()) as { ok: boolean; description?: string };
192
+ if (!json.ok) {
193
+ if (json.description?.includes("not modified")) return true;
194
+ console.error(`[ppmbot] editMessage failed: ${json.description}`);
195
+ return false;
196
+ }
197
+ return true;
198
+ } catch (err) {
199
+ console.error(`[ppmbot] editMessage error: ${(err as Error).message}`);
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /** Force-edit (bypass throttle) — used for final message */
205
+ async editMessageFinal(
206
+ chatId: number | string,
207
+ messageId: number,
208
+ text: string,
209
+ parseMode: "HTML" | "Markdown" = "HTML",
210
+ ): Promise<boolean> {
211
+ const key = `${chatId}:${messageId}`;
212
+ this.lastEditTime.delete(key);
213
+ return this.editMessage(chatId, messageId, text, parseMode);
214
+ }
215
+
216
+ /** Send "typing" chat action */
217
+ async sendTyping(chatId: number | string): Promise<void> {
218
+ try {
219
+ await this.callApi("sendChatAction", {
220
+ chat_id: chatId,
221
+ action: "typing",
222
+ });
223
+ } catch {
224
+ // Best-effort, ignore errors
225
+ }
226
+ }
227
+
228
+ /** Delete a message */
229
+ async deleteMessage(chatId: number | string, messageId: number): Promise<void> {
230
+ try {
231
+ await this.callApi("deleteMessage", {
232
+ chat_id: chatId,
233
+ message_id: messageId,
234
+ });
235
+ } catch {
236
+ // Best-effort
237
+ }
238
+ }
239
+
240
+ // ── Command Parsing ─────────────────────────────────────────────
241
+
242
+ /** Parse a Telegram message into a PPMBotCommand if it starts with / */
243
+ static parseCommand(message: TelegramMessage): PPMBotCommand | null {
244
+ const text = message.text ?? message.caption ?? "";
245
+ if (!text.startsWith("/")) return null;
246
+
247
+ const match = text.match(/^\/(\w+)(?:@\S+)?\s*(.*)/s);
248
+ if (!match) return null;
249
+
250
+ const command = match[1]!.toLowerCase();
251
+ if (!COMMANDS.has(command)) return null;
252
+
253
+ return {
254
+ command,
255
+ args: match[2]?.trim() ?? "",
256
+ chatId: message.chat.id,
257
+ messageId: message.message_id,
258
+ userId: message.from?.id ?? 0,
259
+ username: message.from?.username,
260
+ };
261
+ }
262
+
263
+ // ── Private Helpers ─────────────────────────────────────────────
264
+
265
+ private async callApi(method: string, body: Record<string, unknown>): Promise<Response> {
266
+ const controller = new AbortController();
267
+ const timeout = setTimeout(() => controller.abort(), 10_000);
268
+ try {
269
+ return await fetch(`${TELEGRAM_API}${this.token}/${method}`, {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/json" },
272
+ body: JSON.stringify(body),
273
+ signal: controller.signal,
274
+ });
275
+ } finally {
276
+ clearTimeout(timeout);
277
+ }
278
+ }
279
+ }
@@ -1,6 +1,7 @@
1
1
  import { configService } from "./config.service.ts";
2
2
  import { tunnelService } from "./tunnel.service.ts";
3
3
  import { getLocalIp } from "../lib/network-utils.ts";
4
+ import { getApprovedPairedChats } from "./db.service.ts";
4
5
  import type { TelegramConfig } from "../types/config.ts";
5
6
  import type { NotificationPayload } from "./notification.service.ts";
6
7
 
@@ -12,12 +13,15 @@ function escapeHtml(str: string): string {
12
13
  }
13
14
 
14
15
  class TelegramNotificationService {
15
- /** Send notification to Telegram. No-op if not configured. */
16
+ /** Send notification to all approved paired chats. No-op if not configured. */
16
17
  async send(payload: NotificationPayload): Promise<void> {
17
18
  const config = configService.get("telegram") as TelegramConfig | undefined;
18
- if (!config?.bot_token || !config?.chat_id) return;
19
+ if (!config?.bot_token) return;
19
20
  if (!BOT_TOKEN_RE.test(config.bot_token)) return;
20
21
 
22
+ const approvedChats = getApprovedPairedChats();
23
+ if (approvedChats.length === 0) return;
24
+
21
25
  const deviceName = (configService.get("device_name") as string) || "PPM";
22
26
  const deepLink = this.buildDeepLink(payload);
23
27
 
@@ -27,31 +31,50 @@ class TelegramNotificationService {
27
31
  text += `\n\n<a href="${deepLink}">Open in PPM</a>`;
28
32
  }
29
33
 
30
- await this.callApi(config.bot_token, config.chat_id, text);
34
+ // Send to all approved paired chats in parallel
35
+ await Promise.allSettled(
36
+ approvedChats.map((chat) =>
37
+ this.callApi(config.bot_token, chat.telegram_chat_id, text),
38
+ ),
39
+ );
31
40
  }
32
41
 
33
- /** Send a test message. Returns { ok, error? } */
34
- async sendTest(botToken: string, chatId: string): Promise<{ ok: boolean; error?: string }> {
42
+ /** Send a test message to all approved paired chats. Returns { ok, error? } */
43
+ async sendTest(botToken: string): Promise<{ ok: boolean; error?: string }> {
35
44
  if (!BOT_TOKEN_RE.test(botToken)) return { ok: false, error: "Invalid bot token format" };
45
+
46
+ const approvedChats = getApprovedPairedChats();
47
+ if (approvedChats.length === 0) {
48
+ return { ok: false, error: "No approved paired chats. Pair a device in PPMBot settings first." };
49
+ }
50
+
36
51
  const deviceName = (configService.get("device_name") as string) || "PPM";
37
52
  const text = `<b>${escapeHtml(deviceName)} — Test</b>\nTelegram notifications are working!`;
38
- const controller = new AbortController();
39
- const timeout = setTimeout(() => controller.abort(), 10_000);
40
- try {
41
- const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
42
- method: "POST",
43
- headers: { "Content-Type": "application/json" },
44
- body: JSON.stringify({ chat_id: chatId, text, parse_mode: "HTML" }),
45
- signal: controller.signal,
46
- });
47
- const json = (await res.json()) as { ok: boolean; description?: string };
48
- if (!json.ok) return { ok: false, error: json.description || "Unknown error" };
49
- return { ok: true };
50
- } catch (e) {
51
- return { ok: false, error: (e as Error).message };
52
- } finally {
53
- clearTimeout(timeout);
53
+
54
+ const results = await Promise.allSettled(
55
+ approvedChats.map(async (chat) => {
56
+ const controller = new AbortController();
57
+ const timeout = setTimeout(() => controller.abort(), 10_000);
58
+ try {
59
+ const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ chat_id: chat.telegram_chat_id, text, parse_mode: "HTML" }),
63
+ signal: controller.signal,
64
+ });
65
+ const json = (await res.json()) as { ok: boolean; description?: string };
66
+ if (!json.ok) throw new Error(json.description || "Unknown error");
67
+ } finally {
68
+ clearTimeout(timeout);
69
+ }
70
+ }),
71
+ );
72
+
73
+ const failed = results.filter((r) => r.status === "rejected");
74
+ if (failed.length === results.length) {
75
+ return { ok: false, error: (failed[0] as PromiseRejectedResult).reason?.message || "All sends failed" };
54
76
  }
77
+ return { ok: true };
55
78
  }
56
79
 
57
80
  private buildDeepLink(payload: NotificationPayload): string | null {
@@ -6,7 +6,17 @@ export interface PushConfig {
6
6
 
7
7
  export interface TelegramConfig {
8
8
  bot_token: string;
9
- chat_id: string;
9
+ }
10
+
11
+ export interface PPMBotConfig {
12
+ enabled: boolean;
13
+ default_provider: string;
14
+ default_project: string;
15
+ system_prompt: string;
16
+ show_tool_calls: boolean;
17
+ show_thinking: boolean;
18
+ permission_mode: string;
19
+ debounce_ms: number;
10
20
  }
11
21
 
12
22
  export type ThemeConfig = "light" | "dark" | "system";
@@ -21,6 +31,7 @@ export interface PpmConfig {
21
31
  ai: AIConfig;
22
32
  push?: PushConfig;
23
33
  telegram?: TelegramConfig;
34
+ clawbot?: PPMBotConfig;
24
35
  cloud_url?: string;
25
36
  }
26
37
 
@@ -85,6 +96,19 @@ export const DEFAULT_CONFIG: PpmConfig = {
85
96
  },
86
97
  },
87
98
  },
99
+ telegram: {
100
+ bot_token: "",
101
+ },
102
+ clawbot: {
103
+ enabled: false,
104
+ default_provider: "claude",
105
+ default_project: "",
106
+ system_prompt: "You are PPMBot, a helpful AI coding assistant on Telegram. Keep responses concise and mobile-friendly. Use short paragraphs. When showing code, use compact examples. Be direct and helpful.",
107
+ show_tool_calls: true,
108
+ show_thinking: false,
109
+ permission_mode: "bypassPermissions",
110
+ debounce_ms: 2000,
111
+ },
88
112
  };
89
113
 
90
114
  const VALID_TYPES = ["agent-sdk", "cli", "mock"] as const;
@@ -0,0 +1,103 @@
1
+ /** Telegram update object (subset we care about) */
2
+ export interface TelegramUpdate {
3
+ update_id: number;
4
+ message?: TelegramMessage;
5
+ edited_message?: TelegramMessage;
6
+ }
7
+
8
+ export interface TelegramMessage {
9
+ message_id: number;
10
+ from?: { id: number; first_name: string; username?: string };
11
+ chat: { id: number; type: "private" | "group" | "supergroup" };
12
+ date: number;
13
+ text?: string;
14
+ caption?: string;
15
+ }
16
+
17
+ /** Sent message result from Telegram API */
18
+ export interface TelegramSentMessage {
19
+ message_id: number;
20
+ chat: { id: number };
21
+ date: number;
22
+ }
23
+
24
+ /** PPMBot session row from SQLite */
25
+ export interface PPMBotSessionRow {
26
+ id: number;
27
+ telegram_chat_id: string;
28
+ session_id: string;
29
+ provider_id: string;
30
+ project_name: string;
31
+ project_path: string;
32
+ is_active: number;
33
+ created_at: number;
34
+ last_message_at: number;
35
+ }
36
+
37
+ /** PPMBot memory row from SQLite */
38
+ export interface PPMBotMemoryRow {
39
+ id: number;
40
+ project: string;
41
+ content: string;
42
+ category: PPMBotMemoryCategory;
43
+ importance: number;
44
+ created_at: number;
45
+ updated_at: number;
46
+ session_id: string | null;
47
+ superseded_by: number | null;
48
+ }
49
+
50
+ export type PPMBotMemoryCategory =
51
+ | "fact"
52
+ | "decision"
53
+ | "preference"
54
+ | "architecture"
55
+ | "issue";
56
+
57
+ /** Active session state tracked in memory (not DB) */
58
+ export interface PPMBotActiveSession {
59
+ telegramChatId: string;
60
+ sessionId: string;
61
+ providerId: string;
62
+ projectName: string;
63
+ projectPath: string;
64
+ /** Telegram message ID being edited for streaming */
65
+ currentMessageId?: number;
66
+ /** Debounce timer for rapid messages */
67
+ debounceTimer?: ReturnType<typeof setTimeout>;
68
+ /** Accumulated debounced text */
69
+ debouncedText?: string;
70
+ }
71
+
72
+ /** Parsed command from Telegram message */
73
+ export interface PPMBotCommand {
74
+ command: string;
75
+ args: string;
76
+ chatId: number;
77
+ messageId: number;
78
+ userId: number;
79
+ username?: string;
80
+ }
81
+
82
+ /** Memory recall result with relevance score */
83
+ export interface MemoryRecallResult {
84
+ id: number;
85
+ content: string;
86
+ category: PPMBotMemoryCategory;
87
+ importance: number;
88
+ project: string;
89
+ /** FTS5 rank score (lower = more relevant) */
90
+ rank?: number;
91
+ }
92
+
93
+ /** Paired chat row from SQLite */
94
+ export interface PPMBotPairedChat {
95
+ id: number;
96
+ telegram_chat_id: string;
97
+ telegram_user_id: string | null;
98
+ display_name: string | null;
99
+ pairing_code: string | null;
100
+ status: "pending" | "approved" | "revoked";
101
+ created_at: number;
102
+ approved_at: number | null;
103
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users } from "lucide-react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2, Users, Bot } from "lucide-react";
3
3
  import { Activity } from "lucide-react";
4
4
  import { api, projectUrl } from "@/lib/api-client";
5
5
  import { useTabStore } from "@/stores/tab-store";
@@ -398,9 +398,14 @@ export function ChatHistoryBar({
398
398
  <>
399
399
  <button
400
400
  onClick={() => openSession(session)}
401
- className="text-[11px] truncate flex-1 text-left"
401
+ className="text-[11px] truncate flex-1 text-left flex items-center gap-1"
402
402
  >
403
- {session.title || "Untitled"}
403
+ {session.title?.startsWith("[PPM]") && (
404
+ <Bot className="size-3 text-muted-foreground shrink-0" />
405
+ )}
406
+ {session.title?.startsWith("[PPM]")
407
+ ? session.title.slice(7)
408
+ : session.title || "Untitled"}
404
409
  </button>
405
410
  <button
406
411
  onClick={(e) => togglePin(e, session)}
@@ -161,7 +161,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
161
161
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action", shortcut: formatShortcut(getBinding("open-chat")) },
162
162
  { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
163
163
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action", shortcut: formatShortcut(getBinding("open-git-graph")) },
164
- { id: "browser", label: "Port Forwarding", icon: Globe, action: openNewTab("browser", "Ports"), keywords: "web preview localhost port forward tunnel url", group: "action" },
164
+ { id: "ports", label: "Port Forwarding", icon: Globe, action: openNewTab("ports", "Ports"), keywords: "web preview localhost port forward tunnel url", group: "action" },
165
165
  { id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
166
166
  { id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
167
167
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
@@ -25,7 +25,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
25
25
  "git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
26
26
  "git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
27
27
  settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
28
- browser: lazy(() => import("@/components/browser/browser-tab").then((m) => ({ default: m.BrowserTab }))),
28
+ ports: lazy(() => import("@/components/ports/port-forwarding-tab").then((m) => ({ default: m.PortForwardingTab }))),
29
29
  "extension-webview": lazy(() => import("@/components/extensions/extension-webview").then((m) => ({ default: m.ExtensionWebview }))),
30
30
  };
31
31
 
@@ -25,7 +25,7 @@ const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_
25
25
 
26
26
  const TAB_ICONS: Record<TabType, React.ElementType> = {
27
27
  terminal: Terminal, chat: MessageSquare, editor: FileCode, database: Database, sqlite: Database, postgres: Database,
28
- "git-graph": GitBranch, "git-diff": FileDiff, settings: Settings, browser: Globe,
28
+ "git-graph": GitBranch, "git-diff": FileDiff, settings: Settings, ports: Globe,
29
29
  "extension-webview": Puzzle,
30
30
  };
31
31
 
@@ -36,7 +36,7 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
36
36
  "git-graph": GitBranch,
37
37
  "git-diff": FileDiff,
38
38
  settings: Settings,
39
- browser: Globe,
39
+ ports: Globe,
40
40
  "extension-webview": Puzzle,
41
41
  };
42
42
 
@@ -48,9 +48,9 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
48
48
  default: m.SettingsTab,
49
49
  })),
50
50
  ),
51
- browser: lazy(() =>
52
- import("@/components/browser/browser-tab").then((m) => ({
53
- default: m.BrowserTab,
51
+ ports: lazy(() =>
52
+ import("@/components/ports/port-forwarding-tab").then((m) => ({
53
+ default: m.PortForwardingTab,
54
54
  })),
55
55
  ),
56
56
  "extension-webview": lazy(() =>
@@ -9,7 +9,7 @@ interface TunnelInfo {
9
9
  startedAt: number;
10
10
  }
11
11
 
12
- export function BrowserTab() {
12
+ export function PortForwardingTab() {
13
13
  const [portInput, setPortInput] = useState("");
14
14
  const [tunnels, setTunnels] = useState<TunnelInfo[]>([]);
15
15
  const [loading, setLoading] = useState(false);
@@ -86,11 +86,11 @@ export function BrowserTab() {
86
86
  {/* Header + form */}
87
87
  <div className="p-4 md:p-6 border-b border-border bg-surface">
88
88
  <div className="flex items-center gap-2 mb-3">
89
- <Wifi className="size-5 text-accent" />
89
+ <Wifi className="size-5 text-primary" />
90
90
  <h2 className="text-base font-medium text-text-primary">Port Forwarding</h2>
91
91
  </div>
92
92
  <form onSubmit={handleSubmit} className="flex items-center gap-2">
93
- <div className="flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg bg-background border border-border focus-within:border-accent/50 transition-colors">
93
+ <div className="flex-1 flex items-center gap-2 px-3 py-2.5 rounded-lg bg-background border border-border focus-within:border-primary/50 transition-colors">
94
94
  <span className="text-sm text-text-subtle shrink-0">localhost:</span>
95
95
  <input
96
96
  type="number"
@@ -105,7 +105,7 @@ export function BrowserTab() {
105
105
  <button
106
106
  type="submit"
107
107
  disabled={loading || !portInput}
108
- className="px-4 py-2.5 rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 transition-colors shrink-0 min-w-[72px] flex items-center justify-center"
108
+ className="px-4 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors shrink-0 min-w-[72px] flex items-center justify-center"
109
109
  >
110
110
  {loading ? <Loader2 className="size-4 animate-spin" /> : "Forward"}
111
111
  </button>
@@ -136,7 +136,7 @@ export function BrowserTab() {
136
136
  className="flex items-center gap-3 p-3 rounded-lg bg-surface border border-border"
137
137
  >
138
138
  {/* Port badge */}
139
- <div className="shrink-0 px-2 py-1 rounded bg-accent/10 text-accent text-xs font-mono font-medium">
139
+ <div className="shrink-0 px-2 py-1 rounded bg-primary/10 text-primary text-xs font-mono font-medium">
140
140
  :{t.port}
141
141
  </div>
142
142