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.
+ setDismissed(true)}
+ className="ml-auto text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-100"
+ aria-label="Dismiss"
+ >
+
+
+
+ );
+ }
+
+ 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.
+
+ {
+ try {
+ await keepResource({ resourceType, resourceId });
+ toast.success(`"${resourceName}" marked as kept`);
+ onKept?.();
+ } catch (err: any) {
+ toast.error(err?.message ?? "Failed to mark as kept");
+ }
+ }}
+ >
+
+ Keep
+
+
+ );
+}
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 (
- {name}
+
+ {visibility && onSetVisibility ? (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ void runSetVisibility(
+ visibility === "private" ? "org" : "private",
+ );
+ }}
+ aria-label={
+ visibility === "private"
+ ? "Share with org"
+ : "Make private"
+ }
+ className="shrink-0"
+ >
+
+
+ ) : (
+ 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 } = {}) {
+ {/* Overview link */}
+
+
+ Overview
+
+
{/* Data Sources link */}
Dashboards
+ {unclaimedDashboardCount > 0 && (
+
+
+
+
+ {unclaimedDashboardCount}
+
+
+
+ {unclaimedDashboardCount} unclaimed
+
+
+ )}
+
+
+
+ setDashFilter((f) => (f === "all" ? "org" : "all"))
+ }
+ className={cn(
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-all focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring",
+ dashFilter === "org"
+ ? "text-blue-400"
+ : "text-muted-foreground/45 opacity-0 hover:bg-sidebar-accent hover:text-foreground group-hover/section:opacity-100",
+ )}
+ aria-label={
+ dashFilter === "org"
+ ? "Showing org-shared only"
+ : "Filter by org-shared"
+ }
+ >
+
+
+
+
+ {dashFilter === "org"
+ ? "Showing org-shared (click for all)"
+ : "Show org-shared only"}
+
+
))}
- {visibleDashboards.length > SIDEBAR_PREVIEW_COUNT && (
+ {filteredDashboards.length > SIDEBAR_PREVIEW_COUNT && (
setDashShowAll(!dashShowAll)}
className="flex items-center gap-1 px-3 py-1 text-[11px] text-muted-foreground/70 hover:text-primary"
>
{dashShowAll
? "Show less"
- : `Show ${visibleDashboards.length - SIDEBAR_PREVIEW_COUNT} more`}
+ : `Show ${filteredDashboards.length - SIDEBAR_PREVIEW_COUNT} more`}
)}
{sqlDashboardsLoading &&
@@ -1849,12 +2151,53 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
>
Analyses
+ {unclaimedAnalysisCount > 0 && (
+
+
+
+
+ {unclaimedAnalysisCount}
+
+
+
+ {unclaimedAnalysisCount} unclaimed
+
+
+ )}
+
+
+
+ setAnalysisFilter((f) => (f === "all" ? "org" : "all"))
+ }
+ className={cn(
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-all focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring",
+ analysisFilter === "org"
+ ? "text-blue-400"
+ : "text-muted-foreground/45 opacity-0 hover:bg-sidebar-accent hover:text-foreground group-hover/section:opacity-100",
+ )}
+ aria-label={
+ analysisFilter === "org"
+ ? "Showing org-shared only"
+ : "Filter by org-shared"
+ }
+ >
+
+
+
+
+ {analysisFilter === "org"
+ ? "Showing org-shared (click for all)"
+ : "Show org-shared only"}
+
+
handleAnalysisDelete(a)}
onRename={(name) => handleAnalysisRename(a, name)}
+ visibility={a.visibility}
+ onSetVisibility={(v) => handleAnalysisSetVisibility(a, v)}
onPrefetch={() => prefetchAnalysis(a.id)}
/>
))}
- {sortedAnalyses.length > SIDEBAR_PREVIEW_COUNT && (
+ {filteredAnalyses.length > SIDEBAR_PREVIEW_COUNT && (
setAnalysesShowAll(!analysesShowAll)}
className="flex items-center gap-1 px-3 py-1 text-[11px] text-muted-foreground/70 hover:text-primary"
>
{analysesShowAll
? "Show less"
- : `Show ${sortedAnalyses.length - SIDEBAR_PREVIEW_COUNT} more`}
+ : `Show ${filteredAnalyses.length - SIDEBAR_PREVIEW_COUNT} more`}
)}
{analysesLoading &&
diff --git a/templates/analytics/app/pages/adhoc/explorer/components/SqlPreview.tsx b/templates/analytics/app/pages/adhoc/explorer/components/SqlPreview.tsx
index 8607e0e68..c2994a5f7 100644
--- a/templates/analytics/app/pages/adhoc/explorer/components/SqlPreview.tsx
+++ b/templates/analytics/app/pages/adhoc/explorer/components/SqlPreview.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { IconChevronRight, IconCopy, IconCheck } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { SqlHighlight } from "@/components/SqlHighlight";
interface SqlPreviewProps {
sql: string;
@@ -33,9 +34,10 @@ export function SqlPreview({ sql }: SqlPreviewProps) {
{expanded && (
-
- {sql}
-
+
= {
@@ -33,11 +34,7 @@ const SOURCE_LABELS: Record = {
interface ViewSqlPopoverProps {
panel: SqlPanel;
- /** SQL with `{{var}}` placeholders interpolated. Used to show what's actually
- * being executed against the data source. */
resolvedSql?: string;
- /** Persist a SQL-only edit. Should throw on validation failure so the
- * popover can keep open and surface the error inline. */
onSaveSql: (sql: string) => Promise;
children: ReactNode;
}
@@ -218,9 +215,10 @@ export function ViewSqlPopover({
{showResolved ? "Hide" : "Show"} resolved SQL (with filter values)
{showResolved && (
-
- {resolvedSql}
-
+
)}
)}
diff --git a/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx b/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx
index 2b1b951e2..5f5c26a14 100644
--- a/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx
+++ b/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx
@@ -34,10 +34,12 @@ import {
import {
IconArchive,
IconArchiveOff,
+ IconClock,
IconDots,
IconPencil,
IconPlus,
IconTrash,
+ IconUser,
} from "@tabler/icons-react";
import {
DropdownMenu,
@@ -70,6 +72,7 @@ import { interpolate } from "./interpolate";
import { AddPanelPopover, PanelEditorDialog } from "./PanelEditorDialog";
import { ViewsMenu } from "./ViewsMenu";
import BlankDashboard from "../BlankDashboard";
+import { KeepBanner } from "@/components/KeepBanner";
import {
clampDashboardColumns,
clampPanelWidth,
@@ -123,6 +126,10 @@ type FetchedDashboard = {
id: string;
config: SqlDashboardConfig;
archivedAt: string | null;
+ keptAt: string | null;
+ updatedAt: string | null;
+ ownerEmail: string | null;
+ visibility: "private" | "org" | "public" | null;
};
async function fetchDashboard(id: string): Promise {
@@ -140,6 +147,13 @@ async function fetchDashboard(id: string): Promise {
panels: data.panels ?? [],
},
archivedAt: typeof data.archivedAt === "string" ? data.archivedAt : null,
+ keptAt: typeof data.keptAt === "string" ? data.keptAt : null,
+ updatedAt: typeof data.updatedAt === "string" ? data.updatedAt : null,
+ ownerEmail: typeof data.ownerEmail === "string" ? data.ownerEmail : null,
+ visibility:
+ data.visibility === "org" || data.visibility === "public"
+ ? data.visibility
+ : "private",
};
}
@@ -174,6 +188,14 @@ export default function SqlDashboardPage() {
const [dashboard, setDashboard] = useState(null);
const [archivedAt, setArchivedAt] = useState(null);
+ const [keptAt, setKeptAt] = useState(null);
+ const [dashboardUpdatedAt, setDashboardUpdatedAt] = useState(
+ null,
+ );
+ const [dashboardOwner, setDashboardOwner] = useState(null);
+ const [dashboardVisibility, setDashboardVisibility] = useState<
+ "private" | "org" | "public"
+ >("private");
const [editingName, setEditingName] = useState(false);
const [nameInput, setNameInput] = useState("");
const [editingDescription, setEditingDescription] = useState(false);
@@ -330,6 +352,10 @@ export default function SqlDashboardPage() {
if (fetched && fetched.id !== dashboardId) return;
setDashboard(fetched?.config ?? null);
setArchivedAt(fetched?.archivedAt ?? null);
+ setKeptAt(fetched?.keptAt ?? null);
+ setDashboardUpdatedAt(fetched?.updatedAt ?? null);
+ setDashboardOwner(fetched?.ownerEmail ?? null);
+ setDashboardVisibility(fetched?.visibility ?? "private");
setLoaded(true);
if (fetched && viewedDashboardIdRef.current !== dashboardId) {
viewedDashboardIdRef.current = dashboardId;
@@ -948,6 +974,60 @@ export default function SqlDashboardPage() {
return (
+ {dashboardId && dashboard && (
+
setKeptAt(new Date().toISOString())}
+ />
+ )}
+ {/* Author, last updated, and visibility metadata */}
+
+ {dashboardUpdatedAt && (
+
+
+ Updated{" "}
+ {new Date(dashboardUpdatedAt).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}
+
+ )}
+ {dashboardOwner && (
+
+
+ {dashboardOwner.split("@")[0]}
+
+ )}
+
+
+ {dashboardVisibility === "public"
+ ? "Public"
+ : dashboardVisibility === "org"
+ ? "Shared with org"
+ : "Private"}
+
+
+
{/* Description (click to edit) */}
{editingDescription ? (