@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.
- package/CHANGELOG.md +28 -0
- package/README.md +86 -313
- package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
- package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-D6OuzcC-.js} +1 -1
- package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-BxUpM_uA.js} +1 -1
- package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DAhrHpNM.js} +1 -1
- package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
- package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
- package/dist/web/assets/index-BU_07_oW.js +29 -0
- package/dist/web/assets/index-CBQhXXeV.css +2 -0
- package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-CvGYO9sH.js} +2 -2
- package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
- package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-Bwsxb41F.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-DfgaCbWT.js} +1 -1
- package/dist/web/assets/terminal-tab-D27e4ZTD.js +36 -0
- package/dist/web/index.html +4 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/lib/network-utils.ts +12 -0
- package/src/server/index.ts +3 -79
- package/src/server/routes/database.ts +57 -0
- package/src/server/routes/fs-browse.ts +67 -0
- package/src/server/routes/settings.ts +52 -0
- package/src/server/routes/tunnel.ts +1 -12
- package/src/server/ws/chat.ts +30 -3
- package/src/services/config.service.ts +1 -1
- package/src/services/fs-browse.service.ts +216 -0
- package/src/services/notification.service.ts +42 -0
- package/src/services/telegram-notification.service.ts +106 -0
- package/src/types/config.ts +6 -0
- package/src/web/app.tsx +61 -18
- package/src/web/components/chat/message-list.tsx +8 -105
- package/src/web/components/chat/question-card.tsx +334 -0
- package/src/web/components/database/connection-form-dialog.tsx +15 -6
- package/src/web/components/database/connection-import-export.tsx +116 -0
- package/src/web/components/database/database-sidebar.tsx +12 -8
- package/src/web/components/database/use-connections.ts +13 -1
- package/src/web/components/layout/add-project-form.tsx +23 -12
- package/src/web/components/layout/command-palette.tsx +1 -1
- package/src/web/components/layout/draggable-tab.tsx +10 -2
- package/src/web/components/layout/mobile-nav.tsx +42 -3
- package/src/web/components/layout/project-bar.tsx +16 -8
- package/src/web/components/layout/tab-bar.tsx +55 -4
- package/src/web/components/projects/dir-suggest.tsx +22 -12
- package/src/web/components/settings/settings-tab.tsx +135 -94
- package/src/web/components/settings/telegram-settings-section.tsx +113 -0
- package/src/web/components/ui/accordion.tsx +64 -0
- package/src/web/components/ui/browse-button.tsx +42 -0
- package/src/web/components/ui/file-browser-picker.tsx +374 -0
- package/src/web/hooks/use-chat.ts +29 -0
- package/src/web/hooks/use-notification-badge.ts +20 -0
- package/src/web/hooks/use-tab-overflow.ts +91 -0
- package/src/web/hooks/use-url-sync.ts +5 -2
- package/src/web/index.html +1 -0
- package/src/web/lib/favicon.ts +21 -0
- package/src/web/lib/notification-sounds.ts +61 -0
- package/src/web/stores/notification-store.ts +83 -0
- package/src/web/stores/project-store.ts +0 -14
- package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
- package/dist/web/assets/git-graph-Dju1rygf.js +0 -1
- package/dist/web/assets/index-DSg2VjxL.css +0 -2
- package/dist/web/assets/index-DXOEmhRm.js +0 -21
- package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
- package/dist/web/assets/postgres-viewer-DaNYnInA.js +0 -1
- package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
- /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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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();
|
package/src/types/config.ts
CHANGED
|
@@ -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 } =
|
|
116
|
-
const projects = useProjectStore.getState()
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
619
|
-
{questions
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
}
|