@hienlh/ppm 0.6.6 → 0.7.0

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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +86 -313
  3. package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
  4. package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-D6OuzcC-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-BxUpM_uA.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DAhrHpNM.js} +1 -1
  7. package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
  8. package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
  9. package/dist/web/assets/index-BU_07_oW.js +29 -0
  10. package/dist/web/assets/index-CBQhXXeV.css +2 -0
  11. package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-CvGYO9sH.js} +2 -2
  13. package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
  14. package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-Bwsxb41F.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-DfgaCbWT.js} +1 -1
  16. package/dist/web/assets/terminal-tab-D27e4ZTD.js +36 -0
  17. package/dist/web/index.html +4 -3
  18. package/dist/web/sw.js +1 -1
  19. package/package.json +1 -1
  20. package/src/lib/network-utils.ts +12 -0
  21. package/src/server/index.ts +3 -79
  22. package/src/server/routes/database.ts +57 -0
  23. package/src/server/routes/fs-browse.ts +67 -0
  24. package/src/server/routes/settings.ts +52 -0
  25. package/src/server/routes/tunnel.ts +1 -12
  26. package/src/server/ws/chat.ts +30 -3
  27. package/src/services/config.service.ts +1 -1
  28. package/src/services/fs-browse.service.ts +216 -0
  29. package/src/services/notification.service.ts +42 -0
  30. package/src/services/telegram-notification.service.ts +106 -0
  31. package/src/types/config.ts +6 -0
  32. package/src/web/app.tsx +61 -18
  33. package/src/web/components/chat/message-list.tsx +8 -105
  34. package/src/web/components/chat/question-card.tsx +334 -0
  35. package/src/web/components/database/connection-form-dialog.tsx +15 -6
  36. package/src/web/components/database/connection-import-export.tsx +116 -0
  37. package/src/web/components/database/database-sidebar.tsx +12 -8
  38. package/src/web/components/database/use-connections.ts +13 -1
  39. package/src/web/components/layout/add-project-form.tsx +23 -12
  40. package/src/web/components/layout/command-palette.tsx +1 -1
  41. package/src/web/components/layout/draggable-tab.tsx +10 -2
  42. package/src/web/components/layout/mobile-nav.tsx +42 -3
  43. package/src/web/components/layout/project-bar.tsx +16 -8
  44. package/src/web/components/layout/tab-bar.tsx +55 -4
  45. package/src/web/components/projects/dir-suggest.tsx +22 -12
  46. package/src/web/components/settings/settings-tab.tsx +135 -94
  47. package/src/web/components/settings/telegram-settings-section.tsx +113 -0
  48. package/src/web/components/ui/accordion.tsx +64 -0
  49. package/src/web/components/ui/browse-button.tsx +42 -0
  50. package/src/web/components/ui/file-browser-picker.tsx +374 -0
  51. package/src/web/hooks/use-chat.ts +29 -0
  52. package/src/web/hooks/use-notification-badge.ts +20 -0
  53. package/src/web/hooks/use-tab-overflow.ts +91 -0
  54. package/src/web/hooks/use-url-sync.ts +5 -2
  55. package/src/web/index.html +1 -0
  56. package/src/web/lib/favicon.ts +21 -0
  57. package/src/web/lib/notification-sounds.ts +61 -0
  58. package/src/web/stores/notification-store.ts +83 -0
  59. package/src/web/stores/project-store.ts +0 -14
  60. package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
  61. package/dist/web/assets/git-graph-Dju1rygf.js +0 -1
  62. package/dist/web/assets/index-DSg2VjxL.css +0 -2
  63. package/dist/web/assets/index-DXOEmhRm.js +0 -21
  64. package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
  65. package/dist/web/assets/postgres-viewer-DaNYnInA.js +0 -1
  66. package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
  67. /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
@@ -0,0 +1,216 @@
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ statSync,
5
+ lstatSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { resolve, basename, dirname, normalize } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ // ── Types ──────────────────────────────────────────────────────────
13
+
14
+ export interface BrowseEntry {
15
+ name: string;
16
+ path: string;
17
+ type: "file" | "directory";
18
+ size?: number;
19
+ modified: string;
20
+ }
21
+
22
+ export interface BrowseResult {
23
+ entries: BrowseEntry[];
24
+ current: string;
25
+ parent: string | null;
26
+ breadcrumbs: { name: string; path: string }[];
27
+ }
28
+
29
+ export interface BrowseOptions {
30
+ showHidden?: boolean;
31
+ }
32
+
33
+ // ── Constants ──────────────────────────────────────────────────────
34
+
35
+ const SKIP_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
36
+ const LIST_MAX_FILES = 200;
37
+ const LIST_MAX_DEPTH = 4;
38
+ const READ_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
39
+
40
+ /** Roots allowed for system-level browsing (outside project scope). */
41
+ const ALLOWED_ROOTS_POSIX = ["/Volumes", "/mnt", "/media", "/tmp", "/home"];
42
+
43
+ // ── Shared helpers ─────────────────────────────────────────────────
44
+
45
+ /** Resolve a path, expanding leading `~` to home directory. */
46
+ export function resolvePath(input: string): string {
47
+ const home = homedir();
48
+ return input.startsWith("~")
49
+ ? resolve(home, input.slice(2))
50
+ : resolve(input);
51
+ }
52
+
53
+ /** Check if an absolute path is within the allowed whitelist. */
54
+ export function isAllowedPath(resolved: string): boolean {
55
+ const home = homedir();
56
+ if (resolved === home || resolved.startsWith(home + "/")) return true;
57
+
58
+ if (process.platform === "win32") {
59
+ return /^[A-Z]:\\/i.test(resolved);
60
+ }
61
+
62
+ return ALLOWED_ROOTS_POSIX.some(
63
+ (r) => resolved === r || resolved.startsWith(r + "/"),
64
+ );
65
+ }
66
+
67
+ // ── Browse (new) ───────────────────────────────────────────────────
68
+
69
+ /** List entries of a single directory (1-level, structured). */
70
+ export function browse(
71
+ dirPath?: string,
72
+ options?: BrowseOptions,
73
+ ): BrowseResult {
74
+ const resolved = dirPath ? resolvePath(dirPath) : homedir();
75
+
76
+ if (!isAllowedPath(resolved)) {
77
+ throw Object.assign(new Error("Access denied"), { status: 403 });
78
+ }
79
+ if (!existsSync(resolved)) {
80
+ throw Object.assign(new Error("Directory not found"), { status: 404 });
81
+ }
82
+ if (!statSync(resolved).isDirectory()) {
83
+ throw Object.assign(new Error("Not a directory"), { status: 400 });
84
+ }
85
+
86
+ const raw = readdirSync(resolved, { withFileTypes: true });
87
+ const entries: BrowseEntry[] = [];
88
+
89
+ for (const entry of raw) {
90
+ if (!options?.showHidden && entry.name.startsWith(".")) continue;
91
+ if (entry.name.startsWith(".env")) continue; // always hide .env*
92
+
93
+ const fullPath = resolve(resolved, entry.name);
94
+ try {
95
+ if (lstatSync(fullPath).isSymbolicLink()) continue;
96
+ const st = statSync(fullPath);
97
+ entries.push({
98
+ name: entry.name,
99
+ path: fullPath,
100
+ type: st.isDirectory() ? "directory" : "file",
101
+ size: st.isFile() ? st.size : undefined,
102
+ modified: st.mtime.toISOString(),
103
+ });
104
+ } catch {
105
+ /* permission denied — skip */
106
+ }
107
+ }
108
+
109
+ entries.sort((a, b) => {
110
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
111
+ return a.name.localeCompare(b.name);
112
+ });
113
+
114
+ const parentDir = dirname(resolved);
115
+ return {
116
+ entries,
117
+ current: resolved,
118
+ parent: parentDir !== resolved ? parentDir : null,
119
+ breadcrumbs: buildBreadcrumbs(resolved),
120
+ };
121
+ }
122
+
123
+ function buildBreadcrumbs(
124
+ absPath: string,
125
+ ): { name: string; path: string }[] {
126
+ const home = homedir();
127
+ const parts: { name: string; path: string }[] = [];
128
+ let current = absPath;
129
+
130
+ while (current !== dirname(current)) {
131
+ if (current === home) {
132
+ parts.unshift({ name: "~", path: current });
133
+ return parts;
134
+ }
135
+ parts.unshift({ name: basename(current), path: current });
136
+ current = dirname(current);
137
+ }
138
+
139
+ // Reached filesystem root
140
+ if (!parts.length || parts[0]!.path !== current) {
141
+ parts.unshift({ name: basename(current) || "/", path: current });
142
+ }
143
+ return parts;
144
+ }
145
+
146
+ // ── List (moved from index.ts inline) ──────────────────────────────
147
+
148
+ /** Recursive file listing for command palette. */
149
+ export function list(dir: string): string[] {
150
+ const resolved = resolvePath(dir);
151
+ if (!isAllowedPath(resolved)) {
152
+ throw Object.assign(new Error("Access denied"), { status: 403 });
153
+ }
154
+
155
+ const files: string[] = [];
156
+
157
+ function walk(dirPath: string, depth: number) {
158
+ if (depth > LIST_MAX_DEPTH || files.length >= LIST_MAX_FILES) return;
159
+ let entries: import("node:fs").Dirent[];
160
+ try {
161
+ entries = readdirSync(dirPath, { withFileTypes: true });
162
+ } catch {
163
+ return;
164
+ }
165
+ for (const entry of entries) {
166
+ if (SKIP_NAMES.has(entry.name)) continue;
167
+ const full = resolve(dirPath, entry.name);
168
+ if (entry.isFile()) {
169
+ files.push(full);
170
+ if (files.length >= LIST_MAX_FILES) return;
171
+ } else if (entry.isDirectory()) {
172
+ walk(full, depth + 1);
173
+ }
174
+ }
175
+ }
176
+
177
+ walk(resolved, 0);
178
+ return files;
179
+ }
180
+
181
+ // ── Read (moved from index.ts inline) ──────────────────────────────
182
+
183
+ /** Read a file outside project scope. */
184
+ export function readSystemFile(
185
+ filePath: string,
186
+ ): { content: string; path: string } {
187
+ const resolved = resolvePath(filePath);
188
+ if (!isAllowedPath(resolved)) {
189
+ throw Object.assign(new Error("Access denied"), { status: 403 });
190
+ }
191
+ if (!existsSync(resolved)) {
192
+ throw Object.assign(new Error("File not found"), { status: 404 });
193
+ }
194
+
195
+ const st = statSync(resolved);
196
+ if (!st.isFile()) {
197
+ throw Object.assign(new Error("Not a file"), { status: 400 });
198
+ }
199
+ if (st.size > READ_MAX_SIZE) {
200
+ throw Object.assign(new Error("File too large (>5MB)"), { status: 400 });
201
+ }
202
+
203
+ const content = readFileSync(resolved, "utf-8");
204
+ return { content, path: resolved };
205
+ }
206
+
207
+ // ── Write (moved from index.ts inline) ─────────────────────────────
208
+
209
+ /** Write a file outside project scope. */
210
+ export function writeSystemFile(filePath: string, content: string): void {
211
+ const resolved = resolvePath(filePath);
212
+ if (!isAllowedPath(resolved)) {
213
+ throw Object.assign(new Error("Access denied"), { status: 403 });
214
+ }
215
+ writeFileSync(resolved, content, "utf-8");
216
+ }
@@ -0,0 +1,42 @@
1
+ import { hasActiveClient } from "../server/ws/chat.ts";
2
+
3
+ export type NotificationType = "done" | "approval_request" | "question";
4
+
5
+ export interface NotificationPayload {
6
+ title: string;
7
+ body: string;
8
+ project: string;
9
+ sessionId: string;
10
+ sessionTitle?: string;
11
+ tool?: string;
12
+ deviceName?: string;
13
+ }
14
+
15
+ class NotificationService {
16
+ /** Broadcast notification to all channels (push, telegram). Fire-and-forget. */
17
+ async broadcast(_type: NotificationType, payload: NotificationPayload): Promise<void> {
18
+ const tasks: Promise<void>[] = [];
19
+ const userOnline = hasActiveClient();
20
+
21
+ // Push notifications — always send (works as ambient alert)
22
+ tasks.push(
23
+ import("./push-notification.service.ts")
24
+ .then(({ pushService }) => pushService.notifyAll(payload.title, payload.body))
25
+ .catch(() => {}),
26
+ );
27
+
28
+ // Telegram — only when user has no active browser session
29
+ if (!userOnline) {
30
+ tasks.push(
31
+ import("./telegram-notification.service.ts")
32
+ .then(({ telegramService }) => telegramService.send(payload))
33
+ .catch(() => {}),
34
+ );
35
+ }
36
+
37
+ await Promise.allSettled(tasks);
38
+ }
39
+ }
40
+
41
+ /** Singleton notification dispatcher */
42
+ export const notificationService = new NotificationService();
@@ -0,0 +1,106 @@
1
+ import { configService } from "./config.service.ts";
2
+ import { tunnelService } from "./tunnel.service.ts";
3
+ import { getLocalIp } from "../lib/network-utils.ts";
4
+ import type { TelegramConfig } from "../types/config.ts";
5
+ import type { NotificationPayload } from "./notification.service.ts";
6
+
7
+ const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
8
+
9
+ /** Escape HTML special chars for Telegram HTML parse mode */
10
+ function escapeHtml(str: string): string {
11
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
+ }
13
+
14
+ class TelegramNotificationService {
15
+ /** Send notification to Telegram. No-op if not configured. */
16
+ async send(payload: NotificationPayload): Promise<void> {
17
+ const config = configService.get("telegram") as TelegramConfig | undefined;
18
+ if (!config?.bot_token || !config?.chat_id) return;
19
+ if (!BOT_TOKEN_RE.test(config.bot_token)) return;
20
+
21
+ const deviceName = (configService.get("device_name") as string) || "PPM";
22
+ const deepLink = this.buildDeepLink(payload);
23
+
24
+ let text = `<b>${escapeHtml(deviceName)} — ${escapeHtml(payload.title)}</b>\n`;
25
+ text += escapeHtml(payload.body);
26
+ if (deepLink) {
27
+ text += `\n\n<a href="${deepLink}">Open in PPM</a>`;
28
+ }
29
+
30
+ await this.callApi(config.bot_token, config.chat_id, text);
31
+ }
32
+
33
+ /** Send a test message. Returns { ok, error? } */
34
+ async sendTest(botToken: string, chatId: string): Promise<{ ok: boolean; error?: string }> {
35
+ if (!BOT_TOKEN_RE.test(botToken)) return { ok: false, error: "Invalid bot token format" };
36
+ const deviceName = (configService.get("device_name") as string) || "PPM";
37
+ 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);
54
+ }
55
+ }
56
+
57
+ private buildDeepLink(payload: NotificationPayload): string | null {
58
+ // Prefer tunnel URL (globally accessible), fallback to local IP
59
+ let baseUrl = tunnelService.getTunnelUrl();
60
+ if (!baseUrl) {
61
+ const localIp = getLocalIp();
62
+ const port = configService.get("port") ?? 8080;
63
+ if (localIp) {
64
+ baseUrl = `http://${localIp}:${port}`;
65
+ }
66
+ }
67
+ if (!baseUrl) return null;
68
+
69
+ const projectPath = payload.project
70
+ ? `/project/${encodeURIComponent(payload.project)}`
71
+ : "";
72
+ const query = payload.sessionId ? `?openChat=${payload.sessionId}` : "";
73
+ return `${baseUrl}${projectPath}${query}`;
74
+ }
75
+
76
+ private async callApi(token: string, chatId: string, text: string): Promise<void> {
77
+ if (!BOT_TOKEN_RE.test(token)) return;
78
+ const controller = new AbortController();
79
+ const timeout = setTimeout(() => controller.abort(), 10_000);
80
+
81
+ try {
82
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify({
86
+ chat_id: chatId,
87
+ text,
88
+ parse_mode: "HTML",
89
+ disable_web_page_preview: true,
90
+ }),
91
+ signal: controller.signal,
92
+ });
93
+ if (!res.ok) {
94
+ const errBody = await res.text();
95
+ console.error(`[telegram] sendMessage failed: ${res.status} ${errBody}`);
96
+ }
97
+ } catch (e) {
98
+ console.error(`[telegram] send error: ${(e as Error).message}`);
99
+ } finally {
100
+ clearTimeout(timeout);
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Singleton Telegram notification service */
106
+ export const telegramService = new TelegramNotificationService();
@@ -4,6 +4,11 @@ export interface PushConfig {
4
4
  vapid_subject: string;
5
5
  }
6
6
 
7
+ export interface TelegramConfig {
8
+ bot_token: string;
9
+ chat_id: string;
10
+ }
11
+
7
12
  export type ThemeConfig = "light" | "dark" | "system";
8
13
 
9
14
  export interface PpmConfig {
@@ -15,6 +20,7 @@ export interface PpmConfig {
15
20
  projects: ProjectConfig[];
16
21
  ai: AIConfig;
17
22
  push?: PushConfig;
23
+ telegram?: TelegramConfig;
18
24
  }
19
25
 
20
26
  export interface AuthConfig {
package/src/web/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useState, useCallback } from "react";
1
+ import { useEffect, useState, useCallback, useRef } from "react";
2
2
  import { Toaster } from "@/components/ui/sonner";
3
3
  import { TooltipProvider } from "@/components/ui/tooltip";
4
4
  import { PanelLayout } from "@/components/layout/panel-layout";
@@ -8,7 +8,7 @@ import { MobileNav } from "@/components/layout/mobile-nav";
8
8
  import { MobileDrawer } from "@/components/layout/mobile-drawer";
9
9
  import { ProjectBottomSheet } from "@/components/layout/project-bottom-sheet";
10
10
  import { LoginScreen } from "@/components/auth/login-screen";
11
- import { useProjectStore } from "@/stores/project-store";
11
+ import { useProjectStore, resolveOrder } from "@/stores/project-store";
12
12
  import { useTabStore } from "@/stores/tab-store";
13
13
  import {
14
14
  useSettingsStore,
@@ -18,6 +18,7 @@ import { getAuthToken } from "@/lib/api-client";
18
18
  import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
19
19
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
20
20
  import { useHealthCheck } from "@/hooks/use-health-check";
21
+ import { useNotificationBadge } from "@/hooks/use-notification-badge";
21
22
  import { CommandPalette } from "@/components/layout/command-palette";
22
23
  import { BugReportPopup } from "@/components/shared/bug-report-popup";
23
24
  import { cn } from "@/lib/utils";
@@ -45,6 +46,9 @@ export function App() {
45
46
  const fetchServerInfo = useSettingsStore((s) => s.fetchServerInfo);
46
47
  const activeProject = useProjectStore((s) => s.activeProject);
47
48
 
49
+ // Capture URL state on mount — before any effect can overwrite it
50
+ const initialUrlRef = useRef(parseUrlState());
51
+
48
52
  // Apply theme on mount and when it changes
49
53
  useEffect(() => {
50
54
  applyThemeClass(theme);
@@ -99,6 +103,19 @@ export function App() {
99
103
  // Health check — detects server crash/restart
100
104
  useHealthCheck();
101
105
 
106
+ // Notification badge — syncs document.title + favicon with unread count
107
+ useNotificationBadge();
108
+
109
+ // Warn before closing browser tab (prevents accidental Ctrl+W)
110
+ useEffect(() => {
111
+ if (authState !== "authenticated") return;
112
+ const handler = (e: BeforeUnloadEvent) => {
113
+ e.preventDefault();
114
+ };
115
+ window.addEventListener("beforeunload", handler);
116
+ return () => window.removeEventListener("beforeunload", handler);
117
+ }, [authState]);
118
+
102
119
  // Load keybindings after auth confirmed (must not call ApiClient before auth)
103
120
  useEffect(() => {
104
121
  if (authState !== "authenticated") return;
@@ -112,24 +129,50 @@ export function App() {
112
129
  if (authState !== "authenticated") return;
113
130
 
114
131
  fetchProjects().then(() => {
115
- const { projectName: urlProject, tabId: urlTab } = parseUrlState();
116
- const projects = useProjectStore.getState().projects;
117
-
118
- if (urlProject) {
119
- const matched = projects.find((p) => p.name === urlProject);
120
- if (matched) {
121
- useProjectStore.getState().setActiveProject(matched);
122
- // After switchProject runs, restore active tab from URL
123
- if (urlTab) {
124
- queueMicrotask(() => {
125
- const { tabs } = useTabStore.getState();
126
- if (tabs.some((t) => t.id === urlTab)) {
127
- useTabStore.getState().setActiveTab(urlTab);
128
- }
132
+ const { projectName: urlProject, tabId: urlTab, openChat } = initialUrlRef.current;
133
+ const { projects, customOrder } = useProjectStore.getState();
134
+ if (projects.length === 0) return;
135
+
136
+ // URL project takes priority, then fall back to first sorted project
137
+ let target = urlProject ? projects.find((p) => p.name === urlProject) : undefined;
138
+ if (!target) {
139
+ target = resolveOrder(projects, customOrder)[0];
140
+ }
141
+ if (target) {
142
+ useProjectStore.getState().setActiveProject(target);
143
+ if (urlProject && urlTab) {
144
+ queueMicrotask(() => {
145
+ const { tabs } = useTabStore.getState();
146
+ if (tabs.some((t) => t.id === urlTab)) {
147
+ useTabStore.getState().setActiveTab(urlTab);
148
+ }
149
+ });
150
+ }
151
+ }
152
+
153
+ // Deep link: ?openChat=sessionId — open/focus the chat tab
154
+ if (openChat) {
155
+ queueMicrotask(() => {
156
+ const { tabs, setActiveTab, openTab } = useTabStore.getState();
157
+ const existing = tabs.find(
158
+ (t) => t.type === "chat" && t.metadata?.sessionId === openChat,
159
+ );
160
+ if (existing) {
161
+ setActiveTab(existing.id);
162
+ } else {
163
+ openTab({
164
+ type: "chat",
165
+ title: "Chat",
166
+ projectId: target?.name ?? null,
167
+ closable: true,
168
+ metadata: { sessionId: openChat },
129
169
  });
130
170
  }
131
- return;
132
- }
171
+ // Clean up query param
172
+ const url = new URL(window.location.href);
173
+ url.searchParams.delete("openChat");
174
+ window.history.replaceState(null, "", url.pathname);
175
+ });
133
176
  }
134
177
  });
135
178
  }, [authState, fetchProjects]);
@@ -21,6 +21,8 @@ import {
21
21
  RotateCcw,
22
22
  TerminalSquare,
23
23
  } from "lucide-react";
24
+ import { QuestionCard } from "./question-card";
25
+ import type { Question } from "./question-card";
24
26
 
25
27
  interface MessageListProps {
26
28
  messages: ChatMessage[];
@@ -575,113 +577,14 @@ function AskUserQuestionCard({
575
577
  approval: { requestId: string; tool: string; input: unknown };
576
578
  onRespond: (requestId: string, approved: boolean, data?: unknown) => void;
577
579
  }) {
578
- const input = approval.input as {
579
- questions?: Array<{
580
- question: string;
581
- header?: string;
582
- options: Array<{ label: string; description?: string }>;
583
- multiSelect?: boolean;
584
- }>;
585
- };
580
+ const input = approval.input as { questions?: Question[] };
586
581
  const questions = input.questions ?? [];
587
582
 
588
- const [answers, setAnswers] = useState<Record<string, string>>({});
589
- // Track which questions have "Other" active
590
- const [otherActive, setOtherActive] = useState<Record<string, boolean>>({});
591
-
592
- const handleSelect = (question: string, label: string, multiSelect?: boolean) => {
593
- // Deactivate "Other" when selecting a predefined option
594
- setOtherActive((prev) => ({ ...prev, [question]: false }));
595
- setAnswers((prev) => {
596
- if (!multiSelect) return { ...prev, [question]: label };
597
- const current = prev[question] ?? "";
598
- const labels = current ? current.split(", ") : [];
599
- const idx = labels.indexOf(label);
600
- if (idx >= 0) labels.splice(idx, 1);
601
- else labels.push(label);
602
- return { ...prev, [question]: labels.join(", ") };
603
- });
604
- };
605
-
606
- const handleOtherToggle = (question: string) => {
607
- setOtherActive((prev) => ({ ...prev, [question]: true }));
608
- setAnswers((prev) => ({ ...prev, [question]: "" }));
609
- };
610
-
611
- const handleOtherText = (question: string, text: string) => {
612
- setAnswers((prev) => ({ ...prev, [question]: text }));
613
- };
614
-
615
- const allAnswered = questions.every((q) => answers[q.question]?.trim());
616
-
617
583
  return (
618
- <div className="rounded-lg border-2 border-accent/40 bg-accent/5 p-3 space-y-3">
619
- {questions.map((q, qi) => (
620
- <div key={qi} className="space-y-1.5">
621
- <p className="text-sm text-text-primary font-medium">
622
- {q.header ? `${q.header}: ` : ""}{q.question}
623
- </p>
624
- {q.multiSelect && (
625
- <p className="text-xs text-text-subtle">Select multiple</p>
626
- )}
627
- <div className="flex flex-col gap-1">
628
- {q.options.map((opt, oi) => {
629
- const isOther = otherActive[q.question];
630
- const selected = !isOther && (answers[q.question] ?? "").split(", ").includes(opt.label);
631
- return (
632
- <button
633
- key={oi}
634
- onClick={() => handleSelect(q.question, opt.label, q.multiSelect)}
635
- className={`text-left rounded px-2.5 py-1.5 text-xs border transition-colors ${
636
- selected
637
- ? "border-accent bg-accent/20 text-text-primary"
638
- : "border-border bg-background text-text-secondary hover:bg-surface-elevated"
639
- }`}
640
- >
641
- <span className="font-medium">{opt.label}</span>
642
- {opt.description && (
643
- <span className="text-text-subtle ml-1.5">— {opt.description}</span>
644
- )}
645
- </button>
646
- );
647
- })}
648
- {/* Other option */}
649
- {otherActive[q.question] ? (
650
- <input
651
- type="text"
652
- autoFocus
653
- placeholder="Type your answer..."
654
- value={answers[q.question] ?? ""}
655
- onChange={(e) => handleOtherText(q.question, e.target.value)}
656
- className="rounded px-2.5 py-1.5 text-xs border border-accent bg-accent/10 text-text-primary outline-none placeholder:text-text-subtle"
657
- />
658
- ) : (
659
- <button
660
- onClick={() => handleOtherToggle(q.question)}
661
- className="text-left rounded px-2.5 py-1.5 text-xs border border-dashed border-border text-text-subtle hover:bg-surface-elevated transition-colors"
662
- >
663
- Other — type your own answer
664
- </button>
665
- )}
666
- </div>
667
- </div>
668
- ))}
669
-
670
- <div className="flex gap-2 pt-1">
671
- <button
672
- onClick={() => onRespond(approval.requestId, true, answers)}
673
- disabled={!allAnswered}
674
- className="px-4 py-1.5 rounded bg-accent text-white text-xs font-medium hover:bg-accent/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
675
- >
676
- Submit
677
- </button>
678
- <button
679
- onClick={() => onRespond(approval.requestId, false)}
680
- className="px-4 py-1.5 rounded bg-surface-elevated text-text-secondary text-xs hover:bg-surface transition-colors"
681
- >
682
- Skip
683
- </button>
684
- </div>
685
- </div>
584
+ <QuestionCard
585
+ questions={questions}
586
+ onSubmit={(answers) => onRespond(approval.requestId, true, answers)}
587
+ onSkip={() => onRespond(approval.requestId, false)}
588
+ />
686
589
  );
687
590
  }