@hienlh/ppm 0.9.53 → 0.9.55

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 (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/web/assets/{chat-tab-DvNEQYEe.js → chat-tab-SfXtOm9d.js} +1 -1
  3. package/dist/web/assets/{code-editor-CoT017Ah.js → code-editor-DAZvtAlT.js} +1 -1
  4. package/dist/web/assets/database-viewer-C5fco1jm.js +1 -0
  5. package/dist/web/assets/{diff-viewer-D0tuen4I.js → diff-viewer-ShRSPvsf.js} +1 -1
  6. package/dist/web/assets/{extension-webview-Ba5aeo9r.js → extension-webview-CWJRMPfV.js} +1 -1
  7. package/dist/web/assets/{git-graph-BnJrVPxJ.js → git-graph-h0QmXMdZ.js} +1 -1
  8. package/dist/web/assets/{index-DUQgLj0D.js → index-CDlrGSwd.js} +4 -4
  9. package/dist/web/assets/{index-BEfMoc_W.css → index-DVuSY0BZ.css} +1 -1
  10. package/dist/web/assets/keybindings-store-wbHg-S_v.js +1 -0
  11. package/dist/web/assets/{markdown-renderer-BuGSrE3y.js → markdown-renderer-CSEmmMWt.js} +1 -1
  12. package/dist/web/assets/{port-forwarding-tab-DsbrWNUP.js → port-forwarding-tab-Cts6tMFn.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-Bh6YmZPq.js → postgres-viewer-CiQC1sf9.js} +1 -1
  14. package/dist/web/assets/{settings-tab-BnzFtexC.js → settings-tab-CQx6aHtO.js} +1 -1
  15. package/dist/web/assets/sqlite-viewer-FQfCkjU6.js +1 -0
  16. package/dist/web/assets/{terminal-tab-fnZvscaH.js → terminal-tab-C2SnOqxn.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-BdcKAZ69.js → use-monaco-theme-VPgvhMpB.js} +1 -1
  18. package/dist/web/index.html +2 -2
  19. package/dist/web/sw.js +1 -1
  20. package/docs/codebase-summary.md +52 -13
  21. package/docs/project-changelog.md +45 -1
  22. package/docs/project-roadmap.md +1 -1
  23. package/docs/system-architecture.md +121 -9
  24. package/package.json +1 -1
  25. package/src/cli/commands/bot-cmd.ts +144 -240
  26. package/src/server/routes/database.ts +31 -0
  27. package/src/server/routes/settings.ts +13 -0
  28. package/src/server/routes/sqlite.ts +14 -0
  29. package/src/services/database/postgres-adapter.ts +8 -0
  30. package/src/services/database/sqlite-adapter.ts +5 -0
  31. package/src/services/db.service.ts +109 -1
  32. package/src/services/postgres.service.ts +12 -0
  33. package/src/services/ppmbot/ppmbot-delegation.ts +112 -0
  34. package/src/services/ppmbot/ppmbot-service.ts +194 -369
  35. package/src/services/ppmbot/ppmbot-session.ts +85 -108
  36. package/src/services/ppmbot/ppmbot-telegram.ts +5 -16
  37. package/src/services/sqlite.service.ts +10 -0
  38. package/src/types/config.ts +1 -3
  39. package/src/types/database.ts +3 -0
  40. package/src/types/ppmbot.ts +21 -0
  41. package/src/web/components/database/database-viewer.tsx +50 -8
  42. package/src/web/components/database/use-database.ts +13 -1
  43. package/src/web/components/settings/ppmbot-settings-section.tsx +87 -26
  44. package/src/web/components/sqlite/sqlite-data-grid.tsx +55 -8
  45. package/src/web/components/sqlite/sqlite-viewer.tsx +1 -0
  46. package/src/web/components/sqlite/use-sqlite.ts +16 -1
  47. package/dist/web/assets/database-viewer-C3wK7cDk.js +0 -1
  48. package/dist/web/assets/keybindings-store-CkGFjxkX.js +0 -1
  49. package/dist/web/assets/sqlite-viewer-Cu3_hf07.js +0 -1
  50. package/docs/streaming-input-guide.md +0 -267
  51. package/snapshot-state.md +0 -1526
  52. package/test-session-ops.mjs +0 -444
  53. package/test-tokens.mjs +0 -212
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { chatService } from "../chat.service.ts";
@@ -8,107 +8,102 @@ import {
8
8
  createPPMBotSession,
9
9
  deactivatePPMBotSession,
10
10
  touchPPMBotSession,
11
- getRecentPPMBotSessions,
12
11
  getDistinctPPMBotProjectNames,
13
- setSessionTitle,
14
12
  } from "../db.service.ts";
15
13
  import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
16
14
  import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
17
15
 
18
- export class PPMBotSessionManager {
19
- /** In-memory cache: telegramChatId → active session */
20
- private activeSessions = new Map<string, PPMBotActiveSession>();
16
+ export const DEFAULT_COORDINATOR_IDENTITY = `# PPMBot — AI Project Coordinator
21
17
 
22
- /**
23
- * Get active session for chatId. If none exists, create one for the
24
- * given project (or default project from config).
25
- */
26
- async getOrCreateSession(
27
- chatId: string,
28
- projectName?: string,
29
- ): Promise<PPMBotActiveSession> {
30
- const cached = this.activeSessions.get(chatId);
31
- if (cached && (!projectName || cached.projectName === projectName)) {
32
- touchPPMBotSession(cached.sessionId);
33
- return cached;
34
- }
18
+ You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram.
35
19
 
36
- const input = projectName || this.getDefaultProject();
37
- const resolvedProject = input
38
- ? this.resolveProject(input)
39
- : this.getFallbackProject();
40
- if (!resolvedProject) {
41
- throw new Error(`Project not found: "${projectName || "(default)"}"`);
42
- }
20
+ ## Role
21
+ - Answer direct questions immediately (coding, general knowledge, quick advice)
22
+ - Delegate project-specific tasks to subagents using \`ppm bot delegate\`
23
+ - Track delegated task status and report results proactively
24
+ - Remember user preferences across conversations
25
+ - Act as a team leader coordinating work across multiple projects
43
26
 
44
- const dbSession = getActivePPMBotSession(chatId, resolvedProject.name);
45
- if (dbSession) {
46
- return this.resumeFromDb(chatId, dbSession, resolvedProject);
47
- }
27
+ ## Decision Framework
28
+ 1. Can I answer this directly without project context? → Answer now
29
+ 2. Does this reference a specific project or need file access? → Delegate
30
+ 3. Is this about PPM config or bot management? → Handle directly
31
+ 4. Ambiguous project? → Ask user to clarify
48
32
 
49
- return this.createNewSession(chatId, resolvedProject);
50
- }
33
+ ## Coordination Tools (via Bash)
51
34
 
52
- /** Switch to a different project. Deactivates current session. */
53
- async switchProject(
54
- chatId: string,
55
- projectName: string,
56
- ): Promise<PPMBotActiveSession> {
57
- await this.closeSession(chatId);
58
- return this.getOrCreateSession(chatId, projectName);
59
- }
35
+ ### Delegate a task to a project
36
+ ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
37
+ Returns task ID. Tell user you're working on it.
60
38
 
61
- /** Close (deactivate) the current session for a chatId */
62
- async closeSession(chatId: string): Promise<void> {
63
- const active = this.activeSessions.get(chatId);
64
- if (active) {
65
- deactivatePPMBotSession(active.sessionId);
66
- this.activeSessions.delete(chatId);
67
- }
68
- }
39
+ ### Check task status
40
+ ppm bot task-status <task-id>
69
41
 
70
- /** Get active session from cache (no DB hit) */
71
- getActiveSession(chatId: string): PPMBotActiveSession | null {
72
- return this.activeSessions.get(chatId) ?? null;
73
- }
42
+ ### Get task result
43
+ ppm bot task-result <task-id>
74
44
 
75
- /** List recent sessions for a chat (from DB) */
76
- listRecentSessions(chatId: string, limit = 10): PPMBotSessionRow[] {
77
- return getRecentPPMBotSessions(chatId, limit);
78
- }
45
+ ### List recent tasks
46
+ ppm bot tasks
79
47
 
80
- /** Resume a specific session by 1-indexed position in history */
81
- async resumeSessionById(
82
- chatId: string,
83
- sessionIndex: number,
84
- ): Promise<PPMBotActiveSession | null> {
85
- const sessions = getRecentPPMBotSessions(chatId, 20);
86
- const target = sessions[sessionIndex - 1];
87
- if (!target) return null;
48
+ ## Response Style
49
+ - Keep responses concise (Telegram context — mobile-friendly)
50
+ - Use short paragraphs, no walls of text
51
+ - When delegating: acknowledge immediately, notify on completion
52
+ - Support Vietnamese and English naturally
88
53
 
89
- await this.closeSession(chatId);
54
+ ## Important
55
+ - When delegating, write an enriched prompt with full context — not just the raw user message
56
+ - Include relevant details: what the user wants, which files/features, acceptance criteria
57
+ - Each delegation creates a fresh AI session in the target project workspace
58
+ `;
90
59
 
91
- const project = this.resolveProject(target.project_name);
92
- if (!project) return null;
60
+ /** Ensure ~/.ppm/bot/ workspace exists with coordinator.md */
61
+ export function ensureCoordinatorWorkspace(): void {
62
+ const botDir = join(homedir(), ".ppm", "bot");
63
+ const coordinatorMd = join(botDir, "coordinator.md");
64
+ const settingsDir = join(botDir, ".claude");
65
+ const settingsFile = join(settingsDir, "settings.local.json");
93
66
 
94
- return this.resumeFromDb(chatId, target, project);
67
+ mkdirSync(botDir, { recursive: true });
68
+ mkdirSync(settingsDir, { recursive: true });
69
+
70
+ if (!existsSync(coordinatorMd)) {
71
+ writeFileSync(coordinatorMd, DEFAULT_COORDINATOR_IDENTITY);
72
+ }
73
+ if (!existsSync(settingsFile)) {
74
+ writeFileSync(settingsFile, JSON.stringify({
75
+ permissions: { allow: ["Bash", "Read", "Write", "Edit", "Glob", "Grep"] },
76
+ }, null, 2));
95
77
  }
78
+ }
79
+
80
+ export class PPMBotSessionManager {
81
+ /** In-memory cache: telegramChatId → coordinator session */
82
+ private coordinatorSessions = new Map<string, PPMBotActiveSession>();
96
83
 
97
- /** Resume a session by session ID prefix match */
98
- async resumeSessionByIdPrefix(
99
- chatId: string,
100
- prefix: string,
101
- ): Promise<PPMBotActiveSession | null> {
102
- const sessions = getRecentPPMBotSessions(chatId, 20);
103
- const target = sessions.find((s) => s.session_id.startsWith(prefix));
104
- if (!target) return null;
84
+ /** Get or create coordinator session for chatId (always ~/.ppm/bot/) */
85
+ async getCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
86
+ const cached = this.coordinatorSessions.get(chatId);
87
+ if (cached) {
88
+ touchPPMBotSession(cached.sessionId);
89
+ return cached;
90
+ }
105
91
 
106
- await this.closeSession(chatId);
92
+ // Check DB for existing coordinator session
93
+ const dbSession = getActivePPMBotSession(chatId, "bot");
94
+ if (dbSession) {
95
+ return this.resumeFromDb(chatId, dbSession);
96
+ }
107
97
 
108
- const project = this.resolveProject(target.project_name);
109
- if (!project) return null;
98
+ return this.createCoordinatorSession(chatId);
99
+ }
110
100
 
111
- return this.resumeFromDb(chatId, target, project);
101
+ /** Rotate coordinator session (context window near limit) */
102
+ async rotateCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
103
+ const old = this.coordinatorSessions.get(chatId);
104
+ if (old) deactivatePPMBotSession(old.sessionId);
105
+ this.coordinatorSessions.delete(chatId);
106
+ return this.createCoordinatorSession(chatId);
112
107
  }
113
108
 
114
109
  /**
@@ -120,7 +115,6 @@ export class PPMBotSessionManager {
120
115
  if (!projects?.length) return null;
121
116
 
122
117
  const lower = input.toLowerCase();
123
-
124
118
  const exact = projects.find((p) => p.name.toLowerCase() === lower);
125
119
  if (exact) return { name: exact.name, path: exact.path };
126
120
 
@@ -130,13 +124,6 @@ export class PPMBotSessionManager {
130
124
  return null;
131
125
  }
132
126
 
133
- /** Update session title (e.g. after first message) */
134
- updateSessionTitle(sessionId: string, firstMessage: string): void {
135
- const preview = firstMessage.slice(0, 60).replace(/\n/g, " ");
136
- const title = `[PPM] ${preview}`;
137
- setSessionTitle(sessionId, title);
138
- }
139
-
140
127
  /** Get list of available project names (config + sessions history) */
141
128
  getProjectNames(): string[] {
142
129
  const configured = (configService.get("projects") as ProjectConfig[])?.map((p) => p.name) ?? [];
@@ -147,13 +134,7 @@ export class PPMBotSessionManager {
147
134
 
148
135
  // ── Private ─────────────────────────────────────────────────────
149
136
 
150
- private getDefaultProject(): string {
151
- const cfg = configService.get("clawbot") as PPMBotConfig | undefined;
152
- return cfg?.default_project || "";
153
- }
154
-
155
- /** Fallback project when nothing is configured: ~/.ppm/bot/ */
156
- private getFallbackProject(): { name: string; path: string } {
137
+ private getCoordinatorProject(): { name: string; path: string } {
157
138
  const botDir = join(homedir(), ".ppm", "bot");
158
139
  if (!existsSync(botDir)) mkdirSync(botDir, { recursive: true });
159
140
  return { name: "bot", path: botDir };
@@ -164,16 +145,14 @@ export class PPMBotSessionManager {
164
145
  return cfg?.default_provider || configService.get("ai").default_provider;
165
146
  }
166
147
 
167
- private async createNewSession(
168
- chatId: string,
169
- project: { name: string; path: string },
170
- ): Promise<PPMBotActiveSession> {
148
+ private async createCoordinatorSession(chatId: string): Promise<PPMBotActiveSession> {
149
+ const project = this.getCoordinatorProject();
171
150
  const providerId = this.getDefaultProvider();
172
151
 
173
152
  const session = await chatService.createSession(providerId, {
174
153
  projectName: project.name,
175
154
  projectPath: project.path,
176
- title: `[PPM] New session`,
155
+ title: `[PPM] Coordinator`,
177
156
  });
178
157
 
179
158
  createPPMBotSession(chatId, session.id, providerId, project.name, project.path);
@@ -186,21 +165,19 @@ export class PPMBotSessionManager {
186
165
  projectPath: project.path,
187
166
  };
188
167
 
189
- this.activeSessions.set(chatId, active);
168
+ this.coordinatorSessions.set(chatId, active);
190
169
  return active;
191
170
  }
192
171
 
193
- private async resumeFromDb(
194
- chatId: string,
195
- dbSession: PPMBotSessionRow,
196
- project: { name: string; path: string },
197
- ): Promise<PPMBotActiveSession> {
172
+ private async resumeFromDb(chatId: string, dbSession: PPMBotSessionRow): Promise<PPMBotActiveSession> {
173
+ const project = this.getCoordinatorProject();
174
+
198
175
  try {
199
176
  await chatService.resumeSession(dbSession.provider_id, dbSession.session_id);
200
177
  } catch {
201
178
  console.warn(`[ppmbot] Failed to resume session ${dbSession.session_id}, creating new`);
202
179
  deactivatePPMBotSession(dbSession.session_id);
203
- return this.createNewSession(chatId, project);
180
+ return this.createCoordinatorSession(chatId);
204
181
  }
205
182
 
206
183
  touchPPMBotSession(dbSession.session_id);
@@ -213,7 +190,7 @@ export class PPMBotSessionManager {
213
190
  projectPath: project.path,
214
191
  };
215
192
 
216
- this.activeSessions.set(chatId, active);
193
+ this.coordinatorSessions.set(chatId, active);
217
194
  return active;
218
195
  }
219
196
  }
@@ -10,10 +10,9 @@ const POLL_TIMEOUT = 25;
10
10
  const MIN_EDIT_INTERVAL = 1000;
11
11
  const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{30,50}$/;
12
12
 
13
- /** Known PPMBot slash commands */
13
+ /** Known PPMBot slash commands (coordinator: 3 public + 1 hidden) */
14
14
  const COMMANDS = new Set([
15
- "start", "project", "new", "sessions", "resume",
16
- "status", "stop", "memory", "forget", "remember", "restart", "version", "help",
15
+ "start", "status", "help", "restart",
17
16
  ]);
18
17
 
19
18
  export type UpdateHandler = (update: TelegramUpdate) => Promise<void>;
@@ -42,19 +41,9 @@ export class PPMBotTelegram {
42
41
  try {
43
42
  await this.callApi("setMyCommands", {
44
43
  commands: [
45
- { command: "start", description: "Greeting + list projects" },
46
- { command: "project", description: "Switch/list projects" },
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: "restart", description: "Restart PPM server" },
56
- { command: "version", description: "Show PPM version" },
57
- { command: "help", description: "Show all commands" },
44
+ { command: "start", description: "Welcome + list projects" },
45
+ { command: "status", description: "Running tasks + delegations" },
46
+ { command: "help", description: "Show commands" },
58
47
  ],
59
48
  });
60
49
  console.log("[ppmbot] Commands registered");
@@ -137,6 +137,16 @@ class SqliteService {
137
137
  db.run(`UPDATE "${table}" SET "${column}" = ? WHERE "${pkColumn}" = ?`, [value as never, rowid]);
138
138
  }
139
139
 
140
+ /** Delete a row by primary key */
141
+ deleteRow(
142
+ projectPath: string, dbPath: string, table: string,
143
+ pkValue: unknown, pkColumn = "rowid",
144
+ ): void {
145
+ const abs = this.resolvePath(projectPath, dbPath);
146
+ const db = this.open(abs);
147
+ db.run(`DELETE FROM "${table}" WHERE "${pkColumn}" = ?`, [pkValue as never]);
148
+ }
149
+
140
150
  /** Close all cached databases (for shutdown) */
141
151
  closeAll() {
142
152
  for (const absPath of this.cache.keys()) this.close(absPath);
@@ -11,7 +11,6 @@ export interface TelegramConfig {
11
11
  export interface PPMBotConfig {
12
12
  enabled: boolean;
13
13
  default_provider: string;
14
- default_project: string;
15
14
  system_prompt: string;
16
15
  show_tool_calls: boolean;
17
16
  show_thinking: boolean;
@@ -102,8 +101,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
102
101
  clawbot: {
103
102
  enabled: false,
104
103
  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.",
104
+ system_prompt: "",
107
105
  show_tool_calls: true,
108
106
  show_thinking: false,
109
107
  permission_mode: "bypassPermissions",
@@ -47,4 +47,7 @@ export interface DatabaseAdapter {
47
47
  updateCell(config: DbConnectionConfig, table: string, opts: {
48
48
  schema?: string; pkColumn: string; pkValue: unknown; column: string; value: unknown;
49
49
  }): Promise<void>;
50
+ deleteRow(config: DbConnectionConfig, table: string, opts: {
51
+ schema?: string; pkColumn: string; pkValue: unknown;
52
+ }): Promise<void>;
50
53
  }
@@ -90,6 +90,27 @@ export interface MemoryRecallResult {
90
90
  rank?: number;
91
91
  }
92
92
 
93
+ /** Bot task row from SQLite */
94
+ export interface BotTask {
95
+ id: string;
96
+ chatId: string;
97
+ projectName: string;
98
+ projectPath: string;
99
+ prompt: string;
100
+ status: BotTaskStatus;
101
+ resultSummary: string | null;
102
+ resultFull: string | null;
103
+ sessionId: string | null;
104
+ error: string | null;
105
+ reported: boolean;
106
+ timeoutMs: number;
107
+ createdAt: number;
108
+ startedAt: number | null;
109
+ completedAt: number | null;
110
+ }
111
+
112
+ export type BotTaskStatus = "pending" | "running" | "completed" | "failed" | "timeout";
113
+
93
114
  /** Paired chat row from SQLite */
94
115
  export interface PPMBotPairedChat {
95
116
  id: number;
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useMemo, useEffect, useRef } from "react";
2
- import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
2
+ import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw, Trash2 } from "lucide-react";
3
3
  import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
4
4
  import CodeMirror from "@uiw/react-codemirror";
5
5
  import { sql, PostgreSQL, SQLite } from "@codemirror/lang-sql";
@@ -51,7 +51,7 @@ export function DatabaseViewer({ metadata }: Props) {
51
51
  {/* Data grid */}
52
52
  <div className={`flex-1 overflow-hidden ${queryPanelOpen ? "max-h-[60%]" : ""}`}>
53
53
  <DataGrid tableData={db.tableData} schema={db.schema} loading={db.loading}
54
- page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} />
54
+ page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} onRowDelete={db.deleteRow} />
55
55
  </div>
56
56
 
57
57
  {/* Query editor */}
@@ -67,14 +67,16 @@ export function DatabaseViewer({ metadata }: Props) {
67
67
  }
68
68
 
69
69
  /* ---------- Data Grid ---------- */
70
- function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: {
70
+ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate, onRowDelete }: {
71
71
  tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
72
72
  schema: DbColumnInfo[]; loading: boolean; page: number;
73
73
  onPageChange: (p: number) => void;
74
74
  onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
75
+ onRowDelete?: (pkCol: string, pkVal: unknown) => void;
75
76
  }) {
76
77
  const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
77
78
  const [editValue, setEditValue] = useState("");
79
+ const [confirmDeleteIdx, setConfirmDeleteIdx] = useState<number | null>(null);
78
80
 
79
81
  const pkCol = useMemo(() => schema.find((c) => c.pk)?.name ?? null, [schema]);
80
82
 
@@ -96,8 +98,16 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
96
98
 
97
99
  const cancelEdit = useCallback(() => setEditingCell(null), []);
98
100
 
99
- const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
100
- (tableData?.columns ?? []).map((col) => ({
101
+ const handleDelete = useCallback((rowIdx: number) => {
102
+ if (!tableData || !pkCol || !onRowDelete) return;
103
+ const row = tableData.rows[rowIdx];
104
+ if (!row) return;
105
+ onRowDelete(pkCol, row[pkCol]);
106
+ setConfirmDeleteIdx(null);
107
+ }, [tableData, pkCol, onRowDelete]);
108
+
109
+ const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
110
+ const dataCols: ColumnDef<Record<string, unknown>>[] = (tableData?.columns ?? []).map((col) => ({
101
111
  id: col,
102
112
  accessorFn: (row) => row[col],
103
113
  header: () => <span className={schema.find((c) => c.name === col)?.pk ? "font-bold" : ""}>{col}</span>,
@@ -118,8 +128,40 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
118
128
  </span>
119
129
  );
120
130
  },
121
- })),
122
- [tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol]);
131
+ }));
132
+
133
+ if (onRowDelete && pkCol) {
134
+ dataCols.push({
135
+ id: "_actions",
136
+ header: () => null,
137
+ cell: ({ row }) => {
138
+ const rowIdx = row.index;
139
+ const isConfirming = confirmDeleteIdx === rowIdx;
140
+ if (isConfirming) {
141
+ return (
142
+ <span className="flex items-center gap-1 whitespace-nowrap">
143
+ <button type="button" onClick={() => handleDelete(rowIdx)}
144
+ className="text-destructive text-[10px] font-medium hover:underline">Confirm</button>
145
+ <button type="button" onClick={() => setConfirmDeleteIdx(null)}
146
+ className="text-muted-foreground text-[10px] hover:underline">Cancel</button>
147
+ </span>
148
+ );
149
+ }
150
+ return (
151
+ <button type="button" onClick={() => setConfirmDeleteIdx(rowIdx)}
152
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive transition-opacity"
153
+ title="Delete row">
154
+ <Trash2 className="size-3" />
155
+ </button>
156
+ );
157
+ },
158
+ size: 60,
159
+ });
160
+ }
161
+
162
+ return dataCols;
163
+ },
164
+ [tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol, onRowDelete, confirmDeleteIdx, handleDelete]);
123
165
 
124
166
  const table = useReactTable({ data: tableData?.rows ?? [], columns, getCoreRowModel: getCoreRowModel() });
125
167
 
@@ -150,7 +192,7 @@ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate
150
192
  </thead>
151
193
  <tbody>
152
194
  {table.getRowModel().rows.map((row) => (
153
- <tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
195
+ <tr key={row.id} className="group hover:bg-muted/30 border-b border-border/50">
154
196
  {row.getVisibleCells().map((cell) => (
155
197
  <td key={cell.id} className="px-2 py-1 max-w-[300px]">
156
198
  {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -111,10 +111,22 @@ export function useDatabase(connectionId: number) {
111
111
  }
112
112
  }, [base, selectedTable, selectedSchema, fetchTableData]);
113
113
 
114
+ const deleteRow = useCallback(async (pkColumn: string, pkValue: unknown) => {
115
+ if (!selectedTable) return;
116
+ const t = selectedTable;
117
+ const s = selectedSchema;
118
+ try {
119
+ await api.del(`${base}/row`, { table: t, schema: s, pkColumn, pkValue });
120
+ fetchTableData(t, s);
121
+ } catch (e) {
122
+ setError((e as Error).message);
123
+ }
124
+ }, [base, selectedTable, selectedSchema, fetchTableData]);
125
+
114
126
  return {
115
127
  selectedTable, selectTable, tableData, schema,
116
128
  loading, error, page, setPage: changePage,
117
129
  queryResult, queryError, queryLoading, executeQuery,
118
- updateCell, refreshData: fetchTableData,
130
+ updateCell, deleteRow, refreshData: fetchTableData,
119
131
  };
120
132
  }