diff --git a/.changeset/chat-history-panel.md b/.changeset/chat-history-panel.md new file mode 100644 index 000000000..b052bbeca --- /dev/null +++ b/.changeset/chat-history-panel.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add chat history panel: a dedicated History tab in the agent panel header that shows a searchable list of all previous conversations. Clicking a conversation switches back to chat mode with that thread active. diff --git a/packages/core/src/client/MultiTabAssistantChat.tsx b/packages/core/src/client/MultiTabAssistantChat.tsx index 54b57d3d3..21c0f6c29 100644 --- a/packages/core/src/client/MultiTabAssistantChat.tsx +++ b/packages/core/src/client/MultiTabAssistantChat.tsx @@ -1504,6 +1504,20 @@ export function MultiTabAssistantChat({ [openTabIds, switchThread], ); + // Listen for history panel thread selection (from AgentPanel history mode) + const openFromHistoryRef = useRef(openFromHistory); + openFromHistoryRef.current = openFromHistory; + useEffect(() => { + function handleOpenThread(e: Event) { + const threadId = (e as CustomEvent<{ threadId: string }>).detail + ?.threadId; + if (threadId) openFromHistoryRef.current(threadId); + } + window.addEventListener("agent-panel:open-thread", handleOpenThread); + return () => + window.removeEventListener("agent-panel:open-thread", handleOpenThread); + }, []); + // Listen for agent-task-open events (from AgentTaskCard "Open" button) useEffect(() => { function handleOpenTask(e: Event) { diff --git a/templates/analytics/actions/keep-resource.ts b/templates/analytics/actions/keep-resource.ts new file mode 100644 index 000000000..9b0b075d5 --- /dev/null +++ b/templates/analytics/actions/keep-resource.ts @@ -0,0 +1,58 @@ +import { defineAction } from "@agent-native/core"; +import { + getRequestUserEmail, + getRequestOrgId, +} from "@agent-native/core/server"; +import { z } from "zod"; +import { keepDashboard, keepAnalysis } from "../server/lib/dashboards-store"; + +function resolveScope() { + const orgId = getRequestOrgId() || null; + const email = getRequestUserEmail(); + if (!email) throw new Error("no authenticated user"); + return { orgId, email }; +} + +export default defineAction({ + description: + "Mark a dashboard or analysis as 'kept' during the one-time cleanup pass. " + + "All existing resources were made org-visible so teammates can review them. " + + "Resources without a keep mark will be deleted after the pass is complete. " + + "Any org member with read access can keep a resource.", + schema: z.object({ + resourceType: z + .enum(["dashboard", "analysis"]) + .describe("Whether to keep a dashboard or an analysis"), + resourceId: z.string().describe("The ID of the resource to keep"), + }), + run: async (args) => { + const ctx = resolveScope(); + if (args.resourceType === "dashboard") { + const dash = await keepDashboard(args.resourceId, ctx); + if (!dash) { + throw new Error( + `Dashboard "${args.resourceId}" not found (or you don't have access).`, + ); + } + return { + id: dash.id, + name: dash.title, + keptAt: dash.keptAt, + message: `Dashboard "${dash.title}" marked as kept — it will survive the cleanup pass.`, + }; + } else { + const analysis = await keepAnalysis(args.resourceId, ctx); + if (!analysis) { + throw new Error( + `Analysis "${args.resourceId}" not found (or you don't have access).`, + ); + } + return { + id: analysis.id, + name: analysis.name, + keptAt: analysis.keptAt, + message: `Analysis "${analysis.name}" marked as kept — it will survive the cleanup pass.`, + }; + } + }, +}); diff --git a/templates/analytics/app/components/KeepBanner.tsx b/templates/analytics/app/components/KeepBanner.tsx new file mode 100644 index 000000000..b5f704dcb --- /dev/null +++ b/templates/analytics/app/components/KeepBanner.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { IconBookmark, IconBookmarkFilled, IconX } from "@tabler/icons-react"; +import { useActionMutation } from "@agent-native/core/client"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +interface KeepBannerProps { + resourceType: "dashboard" | "analysis"; + resourceId: string; + resourceName: string; + keptAt: string | null | undefined; + onKept?: () => void; +} + +/** + * Banner shown on dashboards and analyses during the one-time cleanup pass. + * Users click "Keep" to mark a resource as wanted; unclaimed resources will + * be deleted after the pass ends and visibility returns to private-by-default. + */ +export function KeepBanner({ + resourceType, + resourceId, + resourceName, + keptAt, + onKept, +}: KeepBannerProps) { + const [dismissed, setDismissed] = useState(false); + const { mutateAsync: keepResource, isPending } = + useActionMutation("keep-resource"); + + if (dismissed) return null; + + if (keptAt) { + return ( +
+ + Marked as kept — this will survive the cleanup pass. + +
+ ); + } + + return ( +
+ + + Cleanup pass: all dashboards and analyses are + temporarily org-visible. Click Keep to mark this one as + wanted — anything unclaimed will be deleted and everything goes private + again after the pass. + + +
+ ); +} diff --git a/templates/analytics/app/components/SqlHighlight.tsx b/templates/analytics/app/components/SqlHighlight.tsx new file mode 100644 index 000000000..7b7990c9a --- /dev/null +++ b/templates/analytics/app/components/SqlHighlight.tsx @@ -0,0 +1,301 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +const KEYWORDS = new Set([ + "SELECT", + "FROM", + "WHERE", + "JOIN", + "LEFT", + "RIGHT", + "INNER", + "OUTER", + "FULL", + "CROSS", + "ON", + "AND", + "OR", + "NOT", + "IN", + "LIKE", + "ILIKE", + "IS", + "NULL", + "TRUE", + "FALSE", + "GROUP", + "BY", + "ORDER", + "HAVING", + "LIMIT", + "OFFSET", + "WITH", + "AS", + "CASE", + "WHEN", + "THEN", + "ELSE", + "END", + "DISTINCT", + "UNION", + "ALL", + "INTERSECT", + "EXCEPT", + "INSERT", + "INTO", + "VALUES", + "UPDATE", + "SET", + "DELETE", + "CREATE", + "TABLE", + "VIEW", + "INDEX", + "DROP", + "ALTER", + "ADD", + "COLUMN", + "PRIMARY", + "KEY", + "FOREIGN", + "REFERENCES", + "CONSTRAINT", + "DEFAULT", + "UNIQUE", + "CHECK", + "EXISTS", + "BETWEEN", + "ASC", + "DESC", + "NULLS", + "FIRST", + "LAST", + "OVER", + "PARTITION", + "ROWS", + "RANGE", + "UNBOUNDED", + "PRECEDING", + "FOLLOWING", + "CURRENT", + "ROW", + "WINDOW", + "LATERAL", + "ARRAY", + "STRUCT", + "UNNEST", + "TABLESAMPLE", + "SYSTEM", + "BERNOULLI", + "PERCENT", + "FETCH", + "NEXT", + "ONLY", + "TIES", + "RECURSIVE", + "TEMPORARY", + "TEMP", + "IF", + "IFNULL", + "IFF", + "QUALIFY", + "PIVOT", + "UNPIVOT", + "MATCH", + "AGAINST", + "NATURAL", + "USING", + "REPLACE", + "IGNORE", + "ROLLUP", + "CUBE", + "GROUPING", + "SETS", + "DATE", + "DATETIME", + "TIMESTAMP", + "TIME", + "INTERVAL", + "CAST", + "CONVERT", + "TRY_CAST", + "SAFE_CAST", + "EXTRACT", + "EPOCH", + "YEAR", + "MONTH", + "DAY", + "HOUR", + "MINUTE", + "SECOND", + "WEEK", + "QUARTER", + "DOW", + "DOY", +]); + +type TokenType = + | "keyword" + | "function" + | "string" + | "comment" + | "number" + | "operator" + | "punctuation" + | "variable" + | "plain"; + +interface Token { + type: TokenType; + value: string; +} + +function tokenize(sql: string): Token[] { + const tokens: Token[] = []; + let i = 0; + while (i < sql.length) { + if (sql[i] === "-" && sql[i + 1] === "-") { + const end = sql.indexOf("\n", i); + const value = end === -1 ? sql.slice(i) : sql.slice(i, end); + tokens.push({ type: "comment", value }); + i += value.length; + continue; + } + if (sql[i] === "/" && sql[i + 1] === "*") { + const end = sql.indexOf("*/", i + 2); + const value = end === -1 ? sql.slice(i) : sql.slice(i, end + 2); + tokens.push({ type: "comment", value }); + i += value.length; + continue; + } + if (sql[i] === "'") { + let j = i + 1; + while (j < sql.length) { + if (sql[j] === "'" && sql[j + 1] === "'") { + j += 2; + } else if (sql[j] === "'") { + j++; + break; + } else { + j++; + } + } + tokens.push({ type: "string", value: sql.slice(i, j) }); + i = j; + continue; + } + if (sql[i] === '"') { + let j = i + 1; + while (j < sql.length && sql[j] !== '"') j++; + if (j < sql.length) j++; + tokens.push({ type: "plain", value: sql.slice(i, j) }); + i = j; + continue; + } + if (sql[i] === "`") { + let j = i + 1; + while (j < sql.length && sql[j] !== "`") j++; + if (j < sql.length) j++; + tokens.push({ type: "plain", value: sql.slice(i, j) }); + i = j; + continue; + } + if (sql[i] === "{" && sql[i + 1] === "{") { + const end = sql.indexOf("}}", i + 2); + const value = end === -1 ? sql.slice(i) : sql.slice(i, end + 2); + tokens.push({ type: "variable", value }); + i += value.length; + continue; + } + if ( + /[0-9]/.test(sql[i]) || + (sql[i] === "." && /[0-9]/.test(sql[i + 1] ?? "")) + ) { + let j = i; + while (j < sql.length && /[0-9._eExX]/.test(sql[j])) j++; + tokens.push({ type: "number", value: sql.slice(i, j) }); + i = j; + continue; + } + if (/[A-Za-z_]/.test(sql[i])) { + let j = i; + while (j < sql.length && /[A-Za-z0-9_]/.test(sql[j])) j++; + const word = sql.slice(i, j); + const upper = word.toUpperCase(); + let k = j; + while (k < sql.length && sql[k] === " ") k++; + const isFunction = sql[k] === "("; + if (isFunction && !KEYWORDS.has(upper)) { + tokens.push({ type: "function", value: word }); + } else if (KEYWORDS.has(upper)) { + tokens.push({ type: "keyword", value: word }); + } else { + tokens.push({ type: "plain", value: word }); + } + i = j; + continue; + } + const twoChar = sql.slice(i, i + 2); + if (["<>", "!=", "<=", ">=", "::", "||"].includes(twoChar)) { + tokens.push({ type: "operator", value: twoChar }); + i += 2; + continue; + } + const ch = sql[i]; + if ("=<>+-*/%!".includes(ch)) { + tokens.push({ type: "operator", value: ch }); + } else if ("(),;.[]".includes(ch)) { + tokens.push({ type: "punctuation", value: ch }); + } else { + tokens.push({ type: "plain", value: ch }); + } + i++; + } + return tokens; +} + +const TOKEN_CLASS: Record = { + keyword: "text-blue-600 dark:text-blue-400 font-semibold", + function: "text-violet-600 dark:text-violet-400", + string: "text-green-700 dark:text-green-400", + comment: "text-slate-400 dark:text-slate-500 italic", + number: "text-amber-600 dark:text-amber-400", + operator: "text-rose-500 dark:text-rose-400", + punctuation: "text-slate-500 dark:text-slate-400", + variable: "text-orange-600 dark:text-orange-400 font-medium", + plain: "", +}; + +interface SqlHighlightProps { + sql: string; + className?: string; + preClassName?: string; +} + +export function SqlHighlight({ + sql, + className, + preClassName, +}: SqlHighlightProps) { + const tokens = tokenize(sql); + const nodes: ReactNode[] = tokens.map((tok, idx) => { + const cls = TOKEN_CLASS[tok.type]; + if (!cls) return tok.value; + return ( + + {tok.value} + + ); + }); + return ( +
+      {nodes}
+    
+ ); +} diff --git a/templates/analytics/app/components/layout/Sidebar.tsx b/templates/analytics/app/components/layout/Sidebar.tsx index 672a2c4b7..5821e87c3 100644 --- a/templates/analytics/app/components/layout/Sidebar.tsx +++ b/templates/analytics/app/components/layout/Sidebar.tsx @@ -31,6 +31,12 @@ import { IconSearch, IconArchive, IconArchiveOff, + IconLink, + IconLock, + IconBuilding, + IconFilter, + IconHome, + IconBookmark, } from "@tabler/icons-react"; import { getIdToken } from "@/lib/auth"; import { @@ -48,6 +54,8 @@ type SidebarDashboard = { subviews?: DashboardSubview[]; source: "static" | "sql"; archivedAt?: string | null; + keptAt?: string | null; + visibility?: Visibility; }; import { Tooltip, @@ -270,6 +278,8 @@ function SortableRow({ onArchive, onPrefetch, archived, + visibility, + onSetVisibility, children, }: { id: string; @@ -287,6 +297,8 @@ function SortableRow({ onArchive?: (action: "archive" | "restore") => Promise | void; onPrefetch?: () => void; archived?: boolean; + visibility?: Visibility; + onSetVisibility?: (visibility: Visibility) => Promise | void; children?: React.ReactNode; }) { const { @@ -363,6 +375,32 @@ function SortableRow({ [name, onArchive], ); + const runSetVisibility = useCallback( + async (visibility: Visibility) => { + setMenuOpen(false); + if (!onSetVisibility) return; + try { + await onSetVisibility(visibility); + } catch (e) { + toast.error( + e instanceof Error + ? `Couldn't update visibility: ${e.message}` + : "Couldn't update visibility", + ); + } + }, + [onSetVisibility], + ); + + const copyLink = useCallback(() => { + const url = window.location.origin + href; + navigator.clipboard.writeText(url).then( + () => toast.success("Link copied"), + () => toast.error("Couldn't copy link"), + ); + setMenuOpen(false); + }, [href]); + return (
+ ) : ( + visibility && + )} + {name} + {name} @@ -459,6 +521,28 @@ function SortableRow({ Rename + {onSetVisibility && visibility !== undefined && ( + { + e.preventDefault(); + void runSetVisibility( + visibility === "private" ? "org" : "private", + ); + }} + > + {visibility === "private" ? ( + + ) : ( + + )} + {visibility === "private" ? "Share with org" : "Make private"} + + )} + + + Copy link + + {onArchive ? ( <> {archived ? ( @@ -554,6 +638,7 @@ function SortableDashboardItem({ onArchive, onPrefetch, views, + onSetVisibility, }: { d: SidebarDashboard; isActive: boolean; @@ -568,6 +653,10 @@ function SortableDashboardItem({ ) => Promise; onPrefetch?: (d: SidebarDashboard) => void; views?: DashboardView[]; + onSetVisibility?: ( + d: SidebarDashboard, + visibility: Visibility, + ) => Promise; }) { const href = `/adhoc/${d.id}`; const { mutateAsync: deleteView } = useDeleteDashboardView(); @@ -620,6 +709,12 @@ function SortableDashboardItem({ onArchive={onArchive ? (action) => onArchive(d, action) : undefined} onPrefetch={() => onPrefetch?.(d)} archived={!!d.archivedAt} + visibility={d.visibility} + onSetVisibility={ + d.source === "sql" && onSetVisibility + ? (v) => onSetVisibility(d, v) + : undefined + } > {isActive && allSubviews.length > 0 && (
@@ -887,12 +982,40 @@ function setStaticDashboardRenames(renames: Record): void { } } +type Visibility = "private" | "org" | "public"; + type SqlDashboardListItem = { id: string; name: string; archivedAt: string | null; + keptAt: string | null; + visibility?: Visibility; }; +function VisibilityDot({ visibility }: { visibility: Visibility }) { + const colors: Record = { + private: "bg-yellow-400", + org: "bg-blue-400", + public: "bg-green-400", + }; + const labels: Record = { + private: "Private", + org: "Shared with org", + public: "Public", + }; + return ( + + + + + {labels[visibility]} + + ); +} + async function fetchSqlDashboardsByArchived( archived: "active" | "archived", ): Promise { @@ -915,6 +1038,11 @@ async function fetchSqlDashboardsByArchived( ? d.name : "Untitled dashboard", archivedAt: typeof d.archivedAt === "string" ? d.archivedAt : null, + keptAt: typeof d.keptAt === "string" ? d.keptAt : null, + visibility: + d.visibility === "org" || d.visibility === "public" + ? d.visibility + : ("private" as Visibility), })); } @@ -922,7 +1050,9 @@ const fetchSqlDashboards = () => fetchSqlDashboardsByArchived("active"); const fetchArchivedSqlDashboards = () => fetchSqlDashboardsByArchived("archived"); -async function fetchSidebarAnalyses(): Promise<{ id: string; name: string }[]> { +async function fetchSidebarAnalyses(): Promise< + { id: string; name: string; keptAt: string | null; visibility?: Visibility }[] +> { const token = await getIdToken(); const res = await fetch(appApiPath("/api/analyses"), { headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -938,6 +1068,11 @@ async function fetchSidebarAnalyses(): Promise<{ id: string; name: string }[]> { typeof a.name === "string" && a.name.trim().length > 0 ? a.name : "Untitled analysis", + keptAt: typeof a.keptAt === "string" ? a.keptAt : null, + visibility: + a.visibility === "org" || a.visibility === "public" + ? a.visibility + : ("private" as Visibility), })); } @@ -1037,10 +1172,12 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { getStoredBoolean(DASHBOARDS_OPEN_KEY, true), ); const [dashShowAll, setDashShowAll] = useState(false); + const [dashFilter, setDashFilter] = useState<"all" | "org">("all"); const [analysesOpen, setAnalysesOpen] = useState(() => getStoredBoolean(ANALYSES_OPEN_KEY, true), ); const [analysesShowAll, setAnalysesShowAll] = useState(false); + const [analysisFilter, setAnalysisFilter] = useState<"all" | "org">("all"); const [dashboardSortMode, setDashboardSortModeState] = useState(() => getStoredSortMode(DASHBOARD_SORT_MODE_KEY)); const [analysisSortMode, setAnalysisSortModeState] = @@ -1066,6 +1203,9 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { const { mutateAsync: renameDashboard } = useActionMutation("rename-dashboard"); const { mutateAsync: renameAnalysis } = useActionMutation("rename-analysis"); + const { mutateAsync: setResourceVisibility } = useActionMutation( + "set-resource-visibility", + ); const [sidebarWidth, setSidebarWidth] = useState(() => { if (typeof window === "undefined") return 256; const saved = localStorage.getItem("sidebar-width"); @@ -1189,12 +1329,31 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { favoriteIds, ]); + const filteredAnalyses = useMemo( + () => + analysisFilter === "org" + ? sortedAnalyses.filter( + (a) => a.visibility === "org" || a.visibility === "public", + ) + : sortedAnalyses, + [sortedAnalyses, analysisFilter], + ); + const displayedAnalyses = useMemo( () => analysesShowAll - ? sortedAnalyses - : sortedAnalyses.slice(0, SIDEBAR_PREVIEW_COUNT), - [sortedAnalyses, analysesShowAll], + ? filteredAnalyses + : filteredAnalyses.slice(0, SIDEBAR_PREVIEW_COUNT), + [filteredAnalyses, analysesShowAll], + ); + + const unclaimedDashboardCount = useMemo( + () => sqlDashboards.filter((d) => d.keptAt === null).length, + [sqlDashboards], + ); + const unclaimedAnalysisCount = useMemo( + () => analysesList.filter((a) => a.keptAt === null).length, + [analysesList], ); const activeDashboardId = useMemo(() => { @@ -1267,6 +1426,8 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { name: d.name, source: "sql", archivedAt: d.archivedAt, + keptAt: d.keptAt, + visibility: d.visibility, })); const all = [...staticItems, ...sqlItems]; if (dashboardSortMode === "alphabetical") { @@ -1294,12 +1455,22 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { popularity, ]); + const filteredDashboards = useMemo( + () => + dashFilter === "org" + ? visibleDashboards.filter( + (d) => d.visibility === "org" || d.visibility === "public", + ) + : visibleDashboards, + [visibleDashboards, dashFilter], + ); + const displayedDashboards = useMemo( () => dashShowAll - ? visibleDashboards - : visibleDashboards.slice(0, SIDEBAR_PREVIEW_COUNT), - [visibleDashboards, dashShowAll], + ? filteredDashboards + : filteredDashboards.slice(0, SIDEBAR_PREVIEW_COUNT), + [filteredDashboards, dashShowAll], ); const handleDashboardDelete = useCallback( @@ -1382,7 +1553,7 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { { queryKey: archivedKey }, (old) => [ ...(old ?? []), - { id: d.id, name: d.name, archivedAt: new Date().toISOString() }, + { id: d.id, name: d.name, archivedAt: new Date().toISOString(), keptAt: d.keptAt ?? null }, ], ); } else { @@ -1394,7 +1565,7 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { { queryKey: activeKey }, (old) => [ ...(old ?? []), - { id: d.id, name: d.name, archivedAt: null }, + { id: d.id, name: d.name, archivedAt: null, keptAt: d.keptAt ?? null }, ], ); } @@ -1498,6 +1669,81 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { [queryClient], ); + const handleDashboardSetVisibility = useCallback( + async (d: SidebarDashboard, visibility: Visibility) => { + if (d.source === "static") return; + const queryKey = ["sql-dashboards-sidebar"] as const; + const prev = getQuerySnapshots( + queryClient, + queryKey, + ); + queryClient.setQueriesData({ queryKey }, (old) => + (old ?? []).map((item) => + item.id === d.id ? { ...item, visibility } : item, + ), + ); + try { + await setResourceVisibility({ + resourceType: "dashboard", + resourceId: d.id, + visibility, + } as any); + queryClient.invalidateQueries({ queryKey }); + toast.success( + visibility === "org" + ? `"${d.name}" shared with org` + : `"${d.name}" made private`, + ); + } catch (err) { + restoreQuerySnapshots(queryClient, prev); + throw err; + } + }, + [queryClient, setResourceVisibility], + ); + + const handleAnalysisSetVisibility = useCallback( + async (a: { id: string; name: string }, visibility: Visibility) => { + const sidebarKey = ["analyses-sidebar"] as const; + const listKey = ["analyses-list"] as const; + const prevSidebar = getQuerySnapshots< + { id: string; name: string; visibility?: Visibility }[] + >(queryClient, sidebarKey); + const prevList = getQuerySnapshots(queryClient, listKey); + queryClient.setQueriesData< + { id: string; name: string; visibility?: Visibility }[] + >({ queryKey: sidebarKey }, (old) => + (old ?? []).map((item) => + item.id === a.id ? { ...item, visibility } : item, + ), + ); + queryClient.setQueriesData({ queryKey: listKey }, (old) => + (old ?? []).map((item) => + item.id === a.id ? { ...item, visibility } : item, + ), + ); + try { + await setResourceVisibility({ + resourceType: "analysis", + resourceId: a.id, + visibility, + } as any); + queryClient.invalidateQueries({ queryKey: sidebarKey }); + queryClient.invalidateQueries({ queryKey: listKey }); + toast.success( + visibility === "org" + ? `"${a.name}" shared with org` + : `"${a.name}" made private`, + ); + } catch (err) { + restoreQuerySnapshots(queryClient, prevSidebar); + restoreQuerySnapshots(queryClient, prevList); + throw err; + } + }, + [queryClient, setResourceVisibility], + ); + const handleAnalysisRename = useCallback( async (a: { id: string; name: string }, name: string) => { const trimmed = name.trim(); @@ -1658,6 +1904,20 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {