@hienlh/ppm 0.13.20 → 0.13.21
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 +7 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-settings-section-DR5BueEL.js → ai-settings-section-DN4egS8e.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CZBayZMd.js +1 -0
- package/dist/web/assets/{audio-preview-DwyrUe-V.js → audio-preview-Bit1BkEv.js} +1 -1
- package/dist/web/assets/chat-tab-LuR2CwiB.js +12 -0
- package/dist/web/assets/code-editor-DES3rcVN.js +8 -0
- package/dist/web/assets/{conflict-editor-C8vTvS9w.js → conflict-editor-upKOD9uO.js} +1 -1
- package/dist/web/assets/{csv-preview-Bo-N3GHl.js → csv-preview-BIfojSWd.js} +1 -1
- package/dist/web/assets/{data-grid-overlay-editor-DqcDQ9st.js → data-grid-overlay-editor-DZIqEOsz.js} +1 -1
- package/dist/web/assets/{database-viewer-_RTlPC26.js → database-viewer-N6OCfZs9.js} +1 -1
- package/dist/web/assets/{diff-viewer-P2Dc__bQ.js → diff-viewer-B1JmhayU.js} +1 -1
- package/dist/web/assets/{esm-Dvc8oJly.js → esm-UZtw2QcY.js} +1 -1
- package/dist/web/assets/{extension-webview-CHqtkQBd.js → extension-webview-BHHiMswb.js} +2 -2
- package/dist/web/assets/gitGraph-HDMCJU4V-CboO1wK8.js +1 -0
- package/dist/web/assets/{glide-data-grid-9TPVejSQ.js → glide-data-grid-DBN29kPX.js} +6 -6
- package/dist/web/assets/{image-preview--nh-wHgF.js → image-preview-XYXkVEGO.js} +1 -1
- package/dist/web/assets/index-C5sLGvFC.css +2 -0
- package/dist/web/assets/{index-xpTdWKsA.js → index-EaYSB9U9.js} +13 -13
- package/dist/web/assets/info-3K5VOQVL-D_qKNgUf.js +1 -0
- package/dist/web/assets/keybindings-store-fGywATlN.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bsow9WVr.js → markdown-renderer-DSFZBOpD.js} +3 -3
- package/dist/web/assets/notification-store-Dz9dmEg3.js +1 -0
- package/dist/web/assets/{number-overlay-editor-XTjjEXtk.js → number-overlay-editor-CewUR5pB.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-XtGc2GdX.js +1 -0
- package/dist/web/assets/{pdf-preview-BqntOcNA.js → pdf-preview-Bz2JkLQ6.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-DNZ5YtCW.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-WWRLWcTB.js → port-forwarding-tab-s0cGnGgx.js} +1 -1
- package/dist/web/assets/{postgres-viewer-g7-3kOzD.js → postgres-viewer-DwELE9sG.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-uCGpAvZE.js +1 -0
- package/dist/web/assets/{settings-store-D2MtC9tm.js → settings-store-CVrIYYCB.js} +2 -2
- package/dist/web/assets/settings-tab-D6zXU5c_.js +1 -0
- package/dist/web/assets/{sql-query-editor-CultKZsI.js → sql-query-editor-CMPsQprT.js} +1 -1
- package/dist/web/assets/sqlite-viewer-BL0Z_xor.js +1 -0
- package/dist/web/assets/terminal-tab-CqSN73E-.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-DQvivjBa.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CugUkORI.js → use-monaco-theme-BePWbY58.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CPtQ2zua.js → vendor-mermaid-Cl50p6TB.js} +2 -2
- package/dist/web/assets/{video-preview-C4PxtiOc.js → video-preview-Y5NIrm_u.js} +1 -1
- package/dist/web/index.html +6 -6
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +13 -1
- package/src/server/routes/chat.ts +73 -3
- package/src/server/routes/database.ts +11 -2
- package/src/server/ws/chat.ts +12 -3
- package/src/services/autostart-generator.ts +2 -2
- package/src/services/autostart-register.ts +6 -3
- package/src/services/db.service.ts +41 -1
- package/src/services/supervisor.ts +20 -7
- package/src/web/app.tsx +8 -0
- package/src/web/components/chat/chat-history-bar.tsx +43 -13
- package/src/web/components/chat/chat-tab.tsx +3 -0
- package/src/web/components/chat/session-list-panel.tsx +15 -8
- package/src/web/components/chat/session-picker.tsx +33 -5
- package/src/web/components/database/connection-list.tsx +55 -205
- package/src/web/components/database/connection-row.tsx +104 -0
- package/src/web/components/database/database-sidebar.tsx +1 -1
- package/src/web/components/database/schema-table-tree.tsx +98 -0
- package/src/web/components/database/use-connections.ts +9 -6
- package/src/web/hooks/use-chat.ts +9 -2
- package/src/web/hooks/use-debounced-value.ts +10 -0
- package/src/web/stores/notification-store.ts +42 -0
- package/dist/web/assets/architecture-PBZL5I3N-7JKY4P1L.js +0 -1
- package/dist/web/assets/chat-tab-DqS9Qk3O.js +0 -12
- package/dist/web/assets/code-editor-DwdeigGe.js +0 -8
- package/dist/web/assets/gitGraph-HDMCJU4V-Daf9rhiF.js +0 -1
- package/dist/web/assets/index-nC9UURj4.css +0 -2
- package/dist/web/assets/info-3K5VOQVL-gn0pjNiT.js +0 -1
- package/dist/web/assets/keybindings-store-C0XkvJcm.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Csaeizjc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-DatkjxTH.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-BnGB20hR.js +0 -1
- package/dist/web/assets/settings-tab-DO3s244B.js +0 -1
- package/dist/web/assets/sqlite-viewer-BtYh66b0.js +0 -1
- package/dist/web/assets/terminal-tab-C25rc_34.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-CgEYv38e.js +0 -1
- /package/dist/web/assets/{api-settings-DowGyuVy.js → api-settings-DnHv6JgF.js} +0 -0
- /package/dist/web/assets/{data-grid-types-DqqspyVw.js → data-grid-types-BISkUXAY.js} +0 -0
- /package/dist/web/assets/{dist-_jZs3YZC.js → dist-B1I_4Jtc.js} +0 -0
- /package/dist/web/assets/{dist-D1SZxtVS.js → dist-CcDNqGjt.js} +0 -0
- /package/dist/web/assets/{katex-DzXRfQ_m.js → katex-Bqvo_ZG0.js} +0 -0
- /package/dist/web/assets/{lib-Dub8DlCJ.js → lib-Bu71-TFS.js} +0 -0
- /package/dist/web/assets/{use-blob-url-DGY5qKiT.js → use-blob-url-QX-XajU8.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-Dyfw49hJ.js → vendor-xterm-K3_Xwigj.js} +0 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ChevronRight, ChevronDown, RefreshCw, Pencil, Trash2, Lock, Search } from "lucide-react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import type { Connection, CachedTable } from "./use-connections";
|
|
4
|
+
import { SchemaTableTree, type ColumnInfo } from "./schema-table-tree";
|
|
5
|
+
|
|
6
|
+
interface ConnectionRowProps {
|
|
7
|
+
conn: Connection;
|
|
8
|
+
isExpanded: boolean;
|
|
9
|
+
isRefreshing: boolean;
|
|
10
|
+
tables: CachedTable[];
|
|
11
|
+
schemas: Map<string, CachedTable[]>;
|
|
12
|
+
isSingleSchema: boolean;
|
|
13
|
+
filter: string;
|
|
14
|
+
expandedTables: Set<string>;
|
|
15
|
+
loadingColumns: Set<string>;
|
|
16
|
+
columnCache?: Map<string, ColumnInfo[]>;
|
|
17
|
+
columnErrors?: Set<string>;
|
|
18
|
+
refreshError?: string;
|
|
19
|
+
hasGroup: boolean;
|
|
20
|
+
onToggle: (id: number, autoRefresh: boolean) => void;
|
|
21
|
+
onRefresh: (id: number) => void;
|
|
22
|
+
onEdit: (conn: Connection) => void;
|
|
23
|
+
onDelete: (id: number) => void;
|
|
24
|
+
onOpenTable: (conn: Connection, tableName: string, schemaName: string) => void;
|
|
25
|
+
onToggleTable: (connId: number, tableName: string, schemaName: string) => void;
|
|
26
|
+
onFilterChange: (connId: number, value: string) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ConnectionRow({
|
|
30
|
+
conn, isExpanded, isRefreshing, tables, schemas, isSingleSchema,
|
|
31
|
+
filter, expandedTables, loadingColumns, columnCache, columnErrors,
|
|
32
|
+
refreshError, hasGroup,
|
|
33
|
+
onToggle, onRefresh, onEdit, onDelete, onOpenTable, onToggleTable, onFilterChange,
|
|
34
|
+
}: ConnectionRowProps) {
|
|
35
|
+
const handleToggle = () => onToggle(conn.id, !isExpanded && tables.length === 0);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div>
|
|
39
|
+
<div className={cn("group flex items-center gap-1 py-1 hover:bg-surface-elevated transition-colors", hasGroup ? "pl-3 pr-2" : "px-2")}>
|
|
40
|
+
<button onClick={handleToggle} className="shrink-0 text-text-subtle hover:text-foreground transition-colors">
|
|
41
|
+
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
|
42
|
+
</button>
|
|
43
|
+
<span className="shrink-0 size-2 rounded-full border border-border" style={{ backgroundColor: conn.color ?? "transparent" }} />
|
|
44
|
+
<button className="flex-1 text-left text-xs truncate hover:text-primary transition-colors" onClick={handleToggle}>
|
|
45
|
+
{conn.name}
|
|
46
|
+
</button>
|
|
47
|
+
<span className="shrink-0 text-[9px] text-text-subtle uppercase px-1 rounded bg-surface-elevated">
|
|
48
|
+
{conn.type === "postgres" ? "PG" : "DB"}
|
|
49
|
+
</span>
|
|
50
|
+
{conn.readonly === 1 && <span title="Readonly"><Lock className="shrink-0 size-2.5 text-text-subtle" /></span>}
|
|
51
|
+
<div className="flex can-hover:hidden can-hover:group-hover:flex items-center gap-0.5 shrink-0">
|
|
52
|
+
<button onClick={() => onRefresh(conn.id)} disabled={isRefreshing} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Refresh tables">
|
|
53
|
+
<RefreshCw className={cn("size-3", isRefreshing && "animate-spin")} />
|
|
54
|
+
</button>
|
|
55
|
+
<button onClick={() => onEdit(conn)} className="p-0.5 text-text-subtle hover:text-foreground transition-colors" title="Edit">
|
|
56
|
+
<Pencil className="size-3" />
|
|
57
|
+
</button>
|
|
58
|
+
<button onClick={() => onDelete(conn.id)} className="p-0.5 text-text-subtle hover:text-red-500 transition-colors" title="Delete">
|
|
59
|
+
<Trash2 className="size-3" />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{isExpanded && (
|
|
65
|
+
<div className="ml-[11px] border-l border-dashed border-border pl-1">
|
|
66
|
+
{isRefreshing && tables.length === 0 && <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>}
|
|
67
|
+
{!isRefreshing && tables.length === 0 && (
|
|
68
|
+
refreshError
|
|
69
|
+
? <p className="text-[10px] text-red-500 px-2 py-1 break-all">{refreshError}</p>
|
|
70
|
+
: <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
|
|
71
|
+
)}
|
|
72
|
+
{tables.length > 0 && (
|
|
73
|
+
<>
|
|
74
|
+
{tables.length > 5 && (
|
|
75
|
+
<div className="flex items-center gap-1 px-2 py-0.5">
|
|
76
|
+
<Search className="size-2.5 text-text-subtle shrink-0" />
|
|
77
|
+
<input
|
|
78
|
+
type="text"
|
|
79
|
+
value={filter}
|
|
80
|
+
onChange={(e) => onFilterChange(conn.id, e.target.value)}
|
|
81
|
+
placeholder="Filter tables…"
|
|
82
|
+
className="w-full text-[10px] bg-transparent border-none outline-none text-foreground placeholder:text-text-subtle"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
<SchemaTableTree
|
|
87
|
+
connId={conn.id}
|
|
88
|
+
schemas={schemas}
|
|
89
|
+
isSingleSchema={isSingleSchema}
|
|
90
|
+
filter={filter}
|
|
91
|
+
expandedTables={expandedTables}
|
|
92
|
+
loadingColumns={loadingColumns}
|
|
93
|
+
columnCache={columnCache}
|
|
94
|
+
columnErrors={columnErrors}
|
|
95
|
+
onToggleTable={onToggleTable}
|
|
96
|
+
onOpenTable={(tableName, schemaName) => onOpenTable(conn, tableName, schemaName)}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -24,7 +24,7 @@ export function DatabaseSidebar() {
|
|
|
24
24
|
|
|
25
25
|
const handleDelete = async (id: number) => {
|
|
26
26
|
if (!confirm("Delete this connection?")) return;
|
|
27
|
-
await deleteConnection(id);
|
|
27
|
+
try { await deleteConnection(id); } catch { /* server error — connection list will stay in sync on next fetch */ }
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const handleCreate = async (data: CreateConnectionData) => {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ChevronRight, ChevronDown, Database, Key, Link2 } from "lucide-react";
|
|
2
|
+
import type { CachedTable } from "./use-connections";
|
|
3
|
+
|
|
4
|
+
export interface ColumnInfo {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
nullable: boolean;
|
|
8
|
+
pk: boolean;
|
|
9
|
+
fk: { table: string; column: string } | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SchemaTableTreeProps {
|
|
13
|
+
connId: number;
|
|
14
|
+
schemas: Map<string, CachedTable[]>;
|
|
15
|
+
isSingleSchema: boolean;
|
|
16
|
+
filter: string;
|
|
17
|
+
expandedTables: Set<string>;
|
|
18
|
+
loadingColumns: Set<string>;
|
|
19
|
+
columnCache?: Map<string, ColumnInfo[]>;
|
|
20
|
+
columnErrors?: Set<string>;
|
|
21
|
+
onToggleTable: (connId: number, tableName: string, schemaName: string) => void;
|
|
22
|
+
onOpenTable: (tableName: string, schemaName: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SchemaTableTree({
|
|
26
|
+
connId, schemas, isSingleSchema, filter,
|
|
27
|
+
expandedTables, loadingColumns, columnCache, columnErrors,
|
|
28
|
+
onToggleTable, onOpenTable,
|
|
29
|
+
}: SchemaTableTreeProps) {
|
|
30
|
+
const filterLower = filter.toLowerCase();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="overflow-y-auto max-h-[40vh]">
|
|
34
|
+
{Array.from(schemas.entries()).map(([schemaName, tables]) => {
|
|
35
|
+
const filteredTables = filterLower
|
|
36
|
+
? tables.filter((t) => t.tableName.toLowerCase().includes(filterLower))
|
|
37
|
+
: tables;
|
|
38
|
+
if (filteredTables.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div key={schemaName}>
|
|
42
|
+
{!isSingleSchema && (
|
|
43
|
+
<p className="px-2 py-0.5 text-[9px] font-semibold text-text-subtle uppercase tracking-wider">{schemaName}</p>
|
|
44
|
+
)}
|
|
45
|
+
{filteredTables.map((t) => {
|
|
46
|
+
const tableKey = `${connId}:${t.schemaName}.${t.tableName}`;
|
|
47
|
+
const isTableExpanded = expandedTables.has(tableKey);
|
|
48
|
+
const isLoadingCols = loadingColumns.has(tableKey);
|
|
49
|
+
const columns = columnCache?.get(tableKey);
|
|
50
|
+
const hasError = columnErrors?.has(tableKey);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div key={tableKey}>
|
|
54
|
+
<div className="flex items-center gap-1 pl-2 pr-2 py-0.5 hover:bg-surface-elevated transition-colors group/table">
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => onToggleTable(connId, t.tableName, t.schemaName)}
|
|
57
|
+
className="shrink-0 text-text-subtle hover:text-foreground transition-colors"
|
|
58
|
+
>
|
|
59
|
+
{isTableExpanded ? <ChevronDown className="size-2.5" /> : <ChevronRight className="size-2.5" />}
|
|
60
|
+
</button>
|
|
61
|
+
<Database className="size-2.5 shrink-0 text-text-subtle" />
|
|
62
|
+
<button
|
|
63
|
+
onClick={() => onOpenTable(t.tableName, t.schemaName)}
|
|
64
|
+
className="flex-1 text-left text-[11px] text-text-secondary hover:text-foreground transition-colors truncate"
|
|
65
|
+
>
|
|
66
|
+
{t.tableName}
|
|
67
|
+
</button>
|
|
68
|
+
<span className="text-[9px] text-text-subtle">{t.rowCount}</span>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{isTableExpanded && (
|
|
72
|
+
<div className="ml-[18px] border-l border-dotted border-border pl-2">
|
|
73
|
+
{isLoadingCols && <p className="text-[9px] text-text-subtle px-1 py-0.5">Loading…</p>}
|
|
74
|
+
{hasError && <p className="text-[9px] text-red-500 px-1 py-0.5">Failed to load columns</p>}
|
|
75
|
+
{columns && columns.map((col) => (
|
|
76
|
+
<div key={col.name} className="flex items-center gap-1 px-1 py-px text-[10px] text-text-subtle" title={col.fk ? `FK → ${col.fk.table}.${col.fk.column}` : undefined}>
|
|
77
|
+
{col.pk && <Key className="size-2.5 text-amber-500 shrink-0" />}
|
|
78
|
+
{col.fk && <Link2 className="size-2.5 text-blue-400 shrink-0" />}
|
|
79
|
+
{!col.pk && !col.fk && <span className="size-2.5 shrink-0" />}
|
|
80
|
+
<span className="truncate">{col.name}{col.nullable ? "?" : ""}</span>
|
|
81
|
+
<span className="ml-auto text-[9px] text-text-subtle/60 shrink-0">{col.type}</span>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
{!isLoadingCols && !hasError && !columns && <p className="text-[9px] text-text-subtle px-1 py-0.5">No columns</p>}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
})}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
{filter && Array.from(schemas.values()).every((t) => !t.some((x) => x.tableName.toLowerCase().includes(filterLower))) && (
|
|
94
|
+
<p className="text-[10px] text-text-subtle px-2 py-1">No match</p>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { api } from "../../lib/api-client";
|
|
3
3
|
|
|
4
4
|
export interface Connection {
|
|
@@ -104,17 +104,20 @@ export function useConnections() {
|
|
|
104
104
|
}, []);
|
|
105
105
|
|
|
106
106
|
/** Fetch column metadata for a table (lazy loaded for schema tree) */
|
|
107
|
-
|
|
108
|
-
const
|
|
107
|
+
type ColumnInfo = { name: string; type: string; nullable: boolean; pk: boolean; fk: { table: string; column: string } | null };
|
|
108
|
+
const [columnCache, setColumnCache] = useState<Map<string, ColumnInfo[]>>(new Map());
|
|
109
|
+
const columnCacheRef = useRef(columnCache);
|
|
110
|
+
columnCacheRef.current = columnCache;
|
|
111
|
+
const fetchColumns = useCallback(async (connId: number, table: string, schema?: string): Promise<ColumnInfo[]> => {
|
|
109
112
|
const cacheKey = `${connId}:${schema ?? "main"}.${table}`;
|
|
110
|
-
const cached =
|
|
113
|
+
const cached = columnCacheRef.current.get(cacheKey);
|
|
111
114
|
if (cached) return cached;
|
|
112
|
-
const cols = await api.get<
|
|
115
|
+
const cols = await api.get<ColumnInfo[]>(
|
|
113
116
|
`/api/db/connections/${connId}/schema?table=${encodeURIComponent(table)}${schema ? `&schema=${encodeURIComponent(schema)}` : ""}`,
|
|
114
117
|
);
|
|
115
118
|
setColumnCache((prev) => new Map(prev).set(cacheKey, cols));
|
|
116
119
|
return cols;
|
|
117
|
-
}, [
|
|
120
|
+
}, []);
|
|
118
121
|
|
|
119
122
|
const exportConnections = useCallback(async () => {
|
|
120
123
|
return api.get<{ version: number; exported_at: string; connections: unknown[] }>("/api/db/connections/export");
|
|
@@ -300,7 +300,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
300
300
|
});
|
|
301
301
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
302
302
|
const nType = ev.tool === "AskUserQuestion" ? "question" : "approval_request";
|
|
303
|
-
|
|
303
|
+
// Unread state added via server-side session:unread_changed broadcast — only play sound + toast here
|
|
304
304
|
playNotificationSound(nType);
|
|
305
305
|
// Persistent toast with action to navigate to the waiting session
|
|
306
306
|
const sid = sessionIdRef.current;
|
|
@@ -417,7 +417,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
417
417
|
setContextWindowPct(ev.contextWindowPct);
|
|
418
418
|
}
|
|
419
419
|
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
420
|
-
|
|
420
|
+
// Unread state added via server-side session:unread_changed broadcast — only play sound here
|
|
421
421
|
playNotificationSound("done");
|
|
422
422
|
}
|
|
423
423
|
// Cancel any pending throttled sync — done handler writes final state directly
|
|
@@ -481,6 +481,13 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
481
481
|
return;
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
// Cross-tab/device unread sync — server broadcasts when unread state changes
|
|
485
|
+
if ((data as any).type === "session:unread_changed") {
|
|
486
|
+
const { sessionId: sid, unreadCount, unreadType, projectName: pn } = data as any;
|
|
487
|
+
useNotificationStore.getState().handleUnreadChanged(sid, unreadCount, unreadType, pn);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
484
491
|
// Dispatch global Jira events so components can listen via window events
|
|
485
492
|
if (typeof (data as any).type === "string" && (data as any).type.startsWith("jira:")) {
|
|
486
493
|
window.dispatchEvent(new CustomEvent((data as any).type, { detail: data }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
4
|
+
const [debounced, setDebounced] = useState(value);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const timer = setTimeout(() => setDebounced(value), delayMs);
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
}, [value, delayMs]);
|
|
9
|
+
return debounced;
|
|
10
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
|
+
import { api } from "@/lib/api-client";
|
|
2
3
|
|
|
3
4
|
interface NotificationEntry {
|
|
4
5
|
count: number;
|
|
@@ -31,6 +32,10 @@ interface NotificationStore {
|
|
|
31
32
|
addNotification: (sessionId: string, type: string, projectName: string) => void;
|
|
32
33
|
clearForSession: (sessionId: string) => void;
|
|
33
34
|
clearAll: () => void;
|
|
35
|
+
/** Hydrate from backend on app load */
|
|
36
|
+
loadFromServer: (projectName: string) => Promise<void>;
|
|
37
|
+
/** Handle WS broadcast for cross-tab/device sync */
|
|
38
|
+
handleUnreadChanged: (sessionId: string, unreadCount: number, unreadType: string | null, projectName: string) => void;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export const useNotificationStore = create<NotificationStore>()((set) => ({
|
|
@@ -56,9 +61,46 @@ export const useNotificationStore = create<NotificationStore>()((set) => ({
|
|
|
56
61
|
next.delete(sessionId);
|
|
57
62
|
return { notifications: next };
|
|
58
63
|
});
|
|
64
|
+
// Fire-and-forget: persist to server so other tabs/devices sync
|
|
65
|
+
api.post(`/api/chat/sessions/${encodeURIComponent(sessionId)}/read`).catch(() => {});
|
|
59
66
|
},
|
|
60
67
|
|
|
61
68
|
clearAll: () => set({ notifications: new Map() }),
|
|
69
|
+
|
|
70
|
+
loadFromServer: async (projectName: string) => {
|
|
71
|
+
try {
|
|
72
|
+
const entries = await api.get<Array<{ sessionId: string; unreadCount: number; unreadType: string | null; projectName: string | null }>>(
|
|
73
|
+
`/api/project/${encodeURIComponent(projectName)}/chat/sessions/unread`,
|
|
74
|
+
);
|
|
75
|
+
set(() => {
|
|
76
|
+
const next = new Map<string, NotificationEntry>();
|
|
77
|
+
for (const e of entries) {
|
|
78
|
+
if (e.unreadCount > 0) {
|
|
79
|
+
next.set(e.sessionId, { count: e.unreadCount, type: e.unreadType || "done", projectName: e.projectName || "" });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { notifications: next };
|
|
83
|
+
});
|
|
84
|
+
} catch { /* server may not support yet — keep empty */ }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
handleUnreadChanged: (sessionId, unreadCount, unreadType, projectName) => {
|
|
88
|
+
set((state) => {
|
|
89
|
+
const next = new Map(state.notifications);
|
|
90
|
+
if (unreadCount === 0) {
|
|
91
|
+
next.delete(sessionId);
|
|
92
|
+
} else {
|
|
93
|
+
// unreadCount === -1 means "incremented, re-fetch actual count not available" — just +1 locally
|
|
94
|
+
const existing = next.get(sessionId);
|
|
95
|
+
next.set(sessionId, {
|
|
96
|
+
count: unreadCount > 0 ? unreadCount : (existing?.count ?? 0) + 1,
|
|
97
|
+
type: unreadType || "done",
|
|
98
|
+
projectName: projectName || existing?.projectName || "",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { notifications: next };
|
|
102
|
+
});
|
|
103
|
+
},
|
|
62
104
|
}));
|
|
63
105
|
|
|
64
106
|
/** Derived: total unread count across all sessions */
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{G as e}from"./vendor-mermaid-CPtQ2zua.js";export{e as createArchitectureServices};
|