Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Button } from "@superset/ui/button";
import { Spinner } from "@superset/ui/spinner";
import { useCallback, useState } from "react";
import { splitFrontMatter } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/splitFrontMatter";
import { ErrorState } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState";
import { FileViewToggle } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle";
import { LoadingState } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState";
Expand All @@ -9,7 +10,6 @@ import {
orderForToggle,
resolveActivePaneView,
} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry";
import { splitFrontMatter } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/splitFrontMatter";
import type { SharedFileDocument } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore";
import type { FilePaneData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LuFileText,
LuGauge,
LuLayers,
LuNotebookPen,
LuPlus,
LuPuzzle,
LuSearch,
Expand Down Expand Up @@ -139,12 +140,16 @@ export function DashboardSidebarHeader({
const isUsageOpen = !!matchRoute({ to: "/usage", fuzzy: true });
const isPluginsOpen = !!matchRoute({ to: "/plugins", fuzzy: true });
const isPagesOpen = !!matchRoute({ to: "/pages", fuzzy: true });
const isMemoryOpen = !!matchRoute({ to: "/memory", fuzzy: true });
// `?? false`: the hook returns undefined until PostHog flags resolve.
// Dev builds bypass the flag — the local dev account isn't in the
// @superset.sh release condition.
const isPluginsEnabled =
(useFeatureFlagEnabled(FEATURE_FLAGS.PLUGINS) ?? false) ||
env.NODE_ENV === "development";
const isMemoryEnabled =
(useFeatureFlagEnabled(FEATURE_FLAGS.MEMORY) ?? false) ||
env.NODE_ENV === "development";
const { myFailedCount } = useFailedAutomations();

const {
Expand Down Expand Up @@ -206,6 +211,10 @@ export function DashboardSidebarHeader({
navigate({ to: "/plugins" });
};

const handleMemoryClick = () => {
navigate({ to: "/memory" });
};

const handlePullRequestsClick = () => {
navigate({
to: "/pull-requests",
Expand Down Expand Up @@ -385,6 +394,28 @@ export function DashboardSidebarHeader({
<TooltipContent side="right">Usage</TooltipContent>
</Tooltip>

{isMemoryEnabled && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleMemoryClick}
aria-label="Memory"
aria-current={isMemoryOpen ? "page" : undefined}
className={cn(
"flex size-7 items-center justify-center rounded-md transition-colors",
isMemoryOpen
? "bg-fill-selected text-muted-foreground"
: "text-muted-foreground hover:bg-fill-hover",
)}
>
<LuNotebookPen className="size-3.5" strokeWidth={1.5} />
</button>
</TooltipTrigger>
<TooltipContent side="right">Memory</TooltipContent>
</Tooltip>
)}

{isPagesEnabled && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -631,6 +662,27 @@ export function DashboardSidebarHeader({
<span className="flex-1 text-left">Usage</span>
</button>

{isMemoryEnabled && (
<button
type="button"
onClick={handleMemoryClick}
aria-label="Memory"
aria-current={isMemoryOpen ? "page" : undefined}
className={cn(
"flex h-7 w-full items-center gap-2 rounded-md px-2 text-[13px] font-medium transition-colors",
isMemoryOpen
? "bg-fill-selected text-foreground"
: "text-muted-foreground hover:bg-fill-hover hover:text-foreground",
)}
>
<LuNotebookPen
className="size-4 shrink-0 text-muted-foreground"
strokeWidth={1.5}
/>
<span className="flex-1 text-left">Memory</span>
</button>
)}

{isPagesEnabled && (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function DashboardLayout() {
matchRoute({ to: "/usage", fuzzy: true }) !== false ||
matchRoute({ to: "/plugins", fuzzy: true }) !== false ||
matchRoute({ to: "/pages", fuzzy: true }) !== false ||
matchRoute({ to: "/memory", fuzzy: true }) !== false ||
matchRoute({ to: "/v2-workspaces", fuzzy: true }) !== false;
const versionMismatch =
(isV2CloudEnabled && onV1WorkspaceRoute) ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { HOST_AGENT_PRESETS } from "@superset/shared/host-agent-presets";
import { Skeleton } from "@superset/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
import {
MemoryAgentList,
type MemoryAgentRow,
} from "./components/MemoryAgentList";
import { MemoryEditor } from "./components/MemoryEditor";
import { entryGroupLabel, MemoryFileList } from "./components/MemoryFileList";
import {
AGENT_MEMORY_FILES_QUERY_KEY,
AGENT_MEMORY_LIST_QUERY_KEY,
} from "./constants";
import { targetKey } from "./utils/targetKey";

const routeApi = getRouteApi("/_authenticated/_dashboard/memory/");

const PRESET_LABELS = new Map(
HOST_AGENT_PRESETS.map((preset) => [preset.presetId, preset.label]),
);

export function MemoryView() {
const hostService = useLocalHostService();
const { activeHostUrl } = hostService;
const navigate = useNavigate();
const { agent: agentFromRoute } = routeApi.useSearch();

const listQuery = useQuery({
queryKey: [...AGENT_MEMORY_LIST_QUERY_KEY, activeHostUrl] as const,
enabled: !!activeHostUrl,
queryFn: () => {
if (!activeHostUrl) return [];
return getHostServiceClientByUrl(activeHostUrl).agentMemory.list.query();
},
// Agents rewrite these files mid-session; refocusing the app is the
// earliest moment fresh counts/mtimes can matter.
staleTime: 15_000,
refetchOnWindowFocus: "always",
});
const configsQuery = useV2AgentConfigs(activeHostUrl);

const rows: MemoryAgentRow[] = (listQuery.data ?? []).map((entry) => {
const config = configsQuery.data?.find(
(candidate) => candidate.presetId === entry.presetId,
);
return {
presetId: entry.presetId,
fileCount: entry.fileCount,
label:
config?.label ?? PRESET_LABELS.get(entry.presetId) ?? entry.presetId,
iconId: config?.iconId ?? null,
};
});

const selectedPresetId = rows.some((row) => row.presetId === agentFromRoute)
? (agentFromRoute as string)
: (rows[0]?.presetId ?? null);
const selected =
rows.find((row) => row.presetId === selectedPresetId) ?? null;

const filesQuery = useQuery({
queryKey: [
...AGENT_MEMORY_FILES_QUERY_KEY,
activeHostUrl,
selectedPresetId,
] as const,
enabled: !!activeHostUrl && !!selectedPresetId,
queryFn: () => {
if (!activeHostUrl || !selectedPresetId) return [];
return getHostServiceClientByUrl(
activeHostUrl,
).agentMemory.listFiles.query({ agent: selectedPresetId });
},
staleTime: 15_000,
refetchOnWindowFocus: "always",
});

const [selectedFileKey, setSelectedFileKey] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection when the agent changes
useEffect(() => {
setSelectedFileKey(null);
}, [selectedPresetId]);

const fileEntries = filesQuery.data ?? [];
const selectedEntry =
fileEntries.find((entry) => targetKey(entry.target) === selectedFileKey) ??
fileEntries[0] ??
null;

const unavailableMessage = getHostServiceUnavailableMessage(hostService, {
action: "load agent memory",
});

return (
<div className="flex h-full w-full flex-col">
<header className="shrink-0 border-b px-6 pb-4">
<h1 className="text-lg font-semibold">Memory</h1>
<p className="mt-0.5 text-sm text-muted-foreground">
What each agent remembers on this machine — global instructions,
per-project and per-worktree instruction files, and auto-memory notes.
</p>
</header>
{listQuery.isError || (!activeHostUrl && !listQuery.isLoading) ? (
<div className="p-6 text-sm text-destructive">
Couldn't load agent memory:{" "}
{listQuery.error instanceof Error
? listQuery.error.message
: unavailableMessage}
</div>
) : listQuery.isLoading || !listQuery.data ? (
<ListSkeleton />
) : (
<div className="flex min-h-0 flex-1">
<MemoryAgentList
rows={rows}
selectedPresetId={selectedPresetId}
onSelect={(presetId) =>
void navigate({
to: "/memory",
search: { agent: presetId },
replace: true,
})
}
/>
{filesQuery.isLoading ? (
<FileListSkeleton />
) : (
<MemoryFileList
entries={fileEntries}
selectedKey={
selectedEntry ? targetKey(selectedEntry.target) : null
}
onSelect={(entry) => setSelectedFileKey(targetKey(entry.target))}
/>
)}
<div className="min-w-0 flex-1">
{filesQuery.isError ? (
<div className="p-6 text-sm text-destructive">
Couldn't load files:{" "}
{filesQuery.error instanceof Error
? filesQuery.error.message
: unavailableMessage}
</div>
) : selected && selectedEntry && activeHostUrl ? (
<MemoryEditor
key={`${activeHostUrl}:${selected.presetId}:${targetKey(selectedEntry.target)}`}
hostUrl={activeHostUrl}
presetId={selected.presetId}
target={selectedEntry.target}
label={`${selected.label} — ${entryGroupLabel(selectedEntry)}`}
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
No agents with known memory files.
</div>
)}
</div>
</div>
)}
</div>
);
}

function ListSkeleton() {
return (
<div className="flex min-h-0 flex-1">
<div className="w-64 shrink-0 space-y-2 border-r p-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
<div className="flex-1 p-6">
<Skeleton className="h-6 w-64" />
<Skeleton className="mt-4 h-40 w-full" />
</div>
</div>
);
}

function FileListSkeleton() {
return (
<div className="w-72 shrink-0 space-y-2 border-r p-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-6 w-full" />
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { cn } from "@superset/ui/utils";
import { AgentIcon } from "renderer/components/AgentIcon";

export interface MemoryAgentRow {
presetId: string;
/** Existing memory files across global + project + auto-memory scopes. */
fileCount: number;
label: string;
iconId: string | null;
}

interface MemoryAgentListProps {
rows: MemoryAgentRow[];
selectedPresetId: string | null;
onSelect: (presetId: string) => void;
}

export function MemoryAgentList({
rows,
selectedPresetId,
onSelect,
}: MemoryAgentListProps) {
return (
<div className="flex w-64 shrink-0 flex-col gap-0.5 overflow-y-auto border-r p-3">
{rows.map((row) => {
const isActive = row.presetId === selectedPresetId;
return (
<button
key={row.presetId}
type="button"
onClick={() => onSelect(row.presetId)}
aria-current={isActive ? "true" : undefined}
className={cn(
"flex items-center gap-2.5 rounded-md px-2 py-1.5 text-left transition-colors",
isActive ? "bg-fill-selected" : "hover:bg-fill-hover",
)}
>
<AgentIcon
iconId={row.iconId}
presetId={row.presetId}
className="size-5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{row.label}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{row.fileCount > 0
? `${row.fileCount} memory ${row.fileCount === 1 ? "file" : "files"}`
: "No memory yet"}
</span>
</span>
</button>
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MemoryAgentList, type MemoryAgentRow } from "./MemoryAgentList";
Loading
Loading