Skip to content

Commit 33e1180

Browse files
committed
feat(web): add New skill and drop Open entries from + New
The workspace + New menu now creates a skill the same way as the Skills panel. Open workflow/asset/chat are gone; those lists stay in the left panel.
1 parent 0e5fa0b commit 33e1180

4 files changed

Lines changed: 157 additions & 313 deletions

File tree

web/src/components/workspace/OpenMenu.tsx

Lines changed: 33 additions & 272 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
import { useCallback, useMemo, useState } from "react";
2-
import { useQuery } from "@tanstack/react-query";
3-
import { useMediaQuery } from "@mui/material";
4-
import { useTheme } from "@mui/material/styles";
52
import AddRoundedIcon from "@mui/icons-material/AddRounded";
63
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
74
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
@@ -12,19 +9,17 @@ import ViewInArOutlinedIcon from "@mui/icons-material/ViewInArOutlined";
129
import ForumOutlinedIcon from "@mui/icons-material/ForumOutlined";
1310
import DataObjectOutlinedIcon from "@mui/icons-material/DataObjectOutlined";
1411
import RecordVoiceOverOutlinedIcon from "@mui/icons-material/RecordVoiceOverOutlined";
12+
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
1513
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
1614

1715
import {
1816
Popover,
1917
MenuItemPrimitive,
20-
TextInput,
2118
FlexColumn,
2219
FlexRow,
2320
Caption,
2421
LoadingSpinner
2522
} from "../ui_primitives";
26-
import { trpcClient } from "../../trpc/client";
27-
import { useAssetSearch } from "../../serverState/useAssetSearch";
2823
import { useCreateTimeline } from "../../hooks/useTimelineSequence";
2924
import {
3025
useCreateStoryboard,
@@ -34,21 +29,13 @@ import {
3429
import { useCreateApplication } from "../../hooks/useApplications";
3530
import { useCreateScript } from "../../hooks/script/useScripts";
3631
import { useCreateJsScript } from "../../hooks/jsScript/useJsScripts";
32+
import { useCreateSkill } from "../../hooks/skills/useSkills";
3733
import { useAssetStore } from "../../stores/AssetStore";
3834
import { useNotificationStore } from "../../stores/NotificationStore";
3935
import { useWorkflowManager } from "../../contexts/WorkflowManagerContext";
4036
import useGlobalChatStore from "../../stores/GlobalChatStore";
41-
import {
42-
useWorkspaceTabsStore,
43-
type WorkspaceTabType
44-
} from "../../stores/WorkspaceTabsStore";
45-
import { assetTabType } from "./assetTabType";
46-
import { useAutoFocusEnabled } from "../../hooks/useAutoFocusEnabled";
47-
import type {
48-
WorkflowList,
49-
AssetWithPath,
50-
Thread
51-
} from "../../stores/ApiTypes";
37+
import { useWorkspaceTabsStore } from "../../stores/WorkspaceTabsStore";
38+
import { newDocumentId } from "../../lib/newDocumentId";
5239

5340
/** Render a blank white PNG to seed a "New image" canvas asset. */
5441
const createBlankImageFile = (): Promise<File> =>
@@ -97,13 +84,7 @@ interface OpenMenuProps {
9784
onClose: () => void;
9885
}
9986

100-
type MenuView =
101-
| "root"
102-
| "texts"
103-
| "storyboards"
104-
| "workflows"
105-
| "assets"
106-
| "chats";
87+
type MenuView = "root" | "texts" | "storyboards";
10788

10889
interface TextFileTemplate {
10990
label: string;
@@ -152,22 +133,10 @@ const TEXT_FILE_TEMPLATES: readonly TextFileTemplate[] = [
152133
];
153134

154135
/**
155-
* The `[+]` menu for the workspace tab bar: create a new workflow, or open an
156-
* existing workflow or asset as a tab. A lightweight stand-in for the deferred
157-
* home/launcher screen.
158-
*
159-
* On mobile it creates only — the browse sheet behind the hamburger lists
160-
* every document by category, so the "Open …" entries put the same lists
161-
* behind a second button in a top row with room for neither.
136+
* The `[+]` menu for the workspace tab bar: create a new document as a tab.
162137
*/
163138
const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
164-
const theme = useTheme();
165-
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
166139
const [view, setView] = useState<MenuView>("root");
167-
const autoFocusEnabled = useAutoFocusEnabled();
168-
const [assetQuery, setAssetQuery] = useState("");
169-
const [wfFilter, setWfFilter] = useState("");
170-
const [chatFilter, setChatFilter] = useState("");
171140
/** Label of the "New X" creator currently in flight, if any. */
172141
const [creating, setCreating] = useState<string | null>(null);
173142

@@ -184,13 +153,10 @@ const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
184153
const createApplication = useCreateApplication();
185154
const createScript = useCreateScript();
186155
const createJsScript = useCreateJsScript();
187-
const { searchAssets } = useAssetSearch();
156+
const createSkill = useCreateSkill();
188157

189158
const close = useCallback(() => {
190159
setView("root");
191-
setAssetQuery("");
192-
setWfFilter("");
193-
setChatFilter("");
194160
onClose();
195161
}, [onClose]);
196162

@@ -369,6 +335,26 @@ const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
369335
[runCreate, createJsScript, openTab]
370336
);
371337

338+
const handleNewSkill = useCallback(
339+
() =>
340+
runCreate("skill", async () => {
341+
const created = await createSkill.mutateAsync({
342+
id: newDocumentId(),
343+
name: `skill-${Date.now().toString(36)}`,
344+
description: "A reusable skill for the NodeTool agent.",
345+
content:
346+
"# New skill\n\nDescribe what this skill does and when the agent should use it."
347+
});
348+
openTab({
349+
type: "skill",
350+
ref: created.id,
351+
mode: "edit",
352+
title: created.name || "Untitled skill"
353+
});
354+
}),
355+
[runCreate, createSkill, openTab]
356+
);
357+
372358
const handleNewChat = useCallback(
373359
() =>
374360
runCreate("chat", async () => {
@@ -401,95 +387,6 @@ const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
401387
useExampleStoryboards(open && view === "storyboards");
402388
const exampleStoryboards = useMemo(() => exampleData ?? [], [exampleData]);
403389

404-
const { data: workflowList, isLoading: workflowsLoading } =
405-
useQuery<WorkflowList>({
406-
queryKey: ["open-menu", "workflows"],
407-
queryFn: () =>
408-
trpcClient.workflows.list.query({
409-
cursor: "",
410-
limit: 200
411-
}) as Promise<WorkflowList>,
412-
enabled: open && view === "workflows",
413-
staleTime: 30_000
414-
});
415-
416-
const workflows = useMemo(() => {
417-
const all = workflowList?.workflows ?? [];
418-
const needle = wfFilter.trim().toLowerCase();
419-
if (!needle) return all;
420-
return all.filter((w) => w.name.toLowerCase().includes(needle));
421-
}, [workflowList, wfFilter]);
422-
423-
const { data: threadList, isLoading: threadsLoading } = useQuery({
424-
queryKey: ["open-menu", "threads"],
425-
queryFn: () => trpcClient.threads.list.query({ limit: 100 }),
426-
enabled: open && view === "chats",
427-
staleTime: 30_000
428-
});
429-
430-
const chatThreads = useMemo(() => {
431-
const all: Thread[] = threadList?.threads ?? [];
432-
const needle = chatFilter.trim().toLowerCase();
433-
const filtered = needle
434-
? all.filter((t) => (t.title ?? "").toLowerCase().includes(needle))
435-
: all;
436-
return [...filtered].sort((a, b) =>
437-
(b.updated_at ?? "").localeCompare(a.updated_at ?? "")
438-
);
439-
}, [threadList, chatFilter]);
440-
441-
const openChat = useCallback(
442-
(thread: Thread) => {
443-
openTab({
444-
type: "chat",
445-
ref: thread.id,
446-
mode: "view",
447-
title: thread.title || "Untitled chat"
448-
});
449-
close();
450-
},
451-
[openTab, close]
452-
);
453-
454-
const trimmedAssetQuery = assetQuery.trim();
455-
const { data: assetResult, isFetching: assetsFetching } = useQuery({
456-
queryKey: ["open-menu", "assets", trimmedAssetQuery],
457-
queryFn: () => searchAssets(trimmedAssetQuery, undefined, 100),
458-
enabled: open && view === "assets" && trimmedAssetQuery.length >= 2,
459-
staleTime: 15_000
460-
});
461-
462-
const openableAssets = useMemo(() => {
463-
const assets = assetResult?.assets ?? [];
464-
return assets
465-
.map((asset) => ({ asset, type: assetTabType(asset) }))
466-
.filter(
467-
(entry): entry is { asset: AssetWithPath; type: WorkspaceTabType } =>
468-
entry.type !== null
469-
);
470-
}, [assetResult]);
471-
472-
const openWorkflow = useCallback(
473-
(id: string, name: string) => {
474-
openTab({ type: "workflow", ref: id, mode: "edit", title: name });
475-
close();
476-
},
477-
[openTab, close]
478-
);
479-
480-
const openAsset = useCallback(
481-
(asset: AssetWithPath, type: WorkspaceTabType) => {
482-
openTab({
483-
type,
484-
ref: asset.id,
485-
mode: "view",
486-
title: asset.name || "Untitled"
487-
});
488-
close();
489-
},
490-
[openTab, close]
491-
);
492-
493390
return (
494391
<Popover
495392
open={open}
@@ -558,32 +455,18 @@ const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
558455
onClick={() => void handleNewJsScript()}
559456
disabled={creating !== null}
560457
/>
458+
<MenuItemPrimitive
459+
label="New skill"
460+
icon={<AutoAwesomeOutlinedIcon fontSize="small" />}
461+
onClick={() => void handleNewSkill()}
462+
disabled={creating !== null}
463+
/>
561464
<MenuItemPrimitive
562465
label="New 3D model"
563466
icon={<ViewInArOutlinedIcon fontSize="small" />}
564467
onClick={() => void handleNewModel()}
565468
disabled={creating !== null}
566-
dividerAfter={!isMobile}
567469
/>
568-
{!isMobile && (
569-
<>
570-
<MenuItemPrimitive
571-
label="Open workflow…"
572-
hasSubmenu
573-
onClick={() => setView("workflows")}
574-
/>
575-
<MenuItemPrimitive
576-
label="Open asset…"
577-
hasSubmenu
578-
onClick={() => setView("assets")}
579-
/>
580-
<MenuItemPrimitive
581-
label="Open chat…"
582-
hasSubmenu
583-
onClick={() => setView("chats")}
584-
/>
585-
</>
586-
)}
587470
</>
588471
)}
589472

@@ -646,128 +529,6 @@ const OpenMenu = ({ anchorEl, open, onClose }: OpenMenuProps) => {
646529
))}
647530
</>
648531
)}
649-
650-
{view === "workflows" && (
651-
<>
652-
<MenuItemPrimitive
653-
label="Back"
654-
icon={<ArrowBackRoundedIcon fontSize="small" />}
655-
onClick={() => setView("root")}
656-
dividerAfter
657-
/>
658-
<FlexRow sx={{ px: 1, py: 0.5 }}>
659-
<TextInput
660-
autoFocus={autoFocusEnabled}
661-
fullWidth
662-
placeholder="Filter workflows"
663-
slotProps={{ htmlInput: { "aria-label": "Filter workflows" } }}
664-
value={wfFilter}
665-
onChange={(e) => setWfFilter(e.target.value)}
666-
/>
667-
</FlexRow>
668-
{workflowsLoading && (
669-
<FlexRow justify="center" sx={{ py: 2 }}>
670-
<LoadingSpinner />
671-
</FlexRow>
672-
)}
673-
{!workflowsLoading && workflows.length === 0 && (
674-
<Caption color="secondary" sx={{ px: 2, py: 1.5 }}>
675-
No workflows found.
676-
</Caption>
677-
)}
678-
{workflows.map((w) => (
679-
<MenuItemPrimitive
680-
key={w.id}
681-
label={w.name || "Untitled"}
682-
onClick={() => openWorkflow(w.id, w.name || "Untitled")}
683-
/>
684-
))}
685-
</>
686-
)}
687-
688-
{view === "chats" && (
689-
<>
690-
<MenuItemPrimitive
691-
label="Back"
692-
icon={<ArrowBackRoundedIcon fontSize="small" />}
693-
onClick={() => setView("root")}
694-
dividerAfter
695-
/>
696-
<FlexRow sx={{ px: 1, py: 0.5 }}>
697-
<TextInput
698-
autoFocus={autoFocusEnabled}
699-
fullWidth
700-
placeholder="Filter chats"
701-
slotProps={{ htmlInput: { "aria-label": "Filter chats" } }}
702-
value={chatFilter}
703-
onChange={(e) => setChatFilter(e.target.value)}
704-
/>
705-
</FlexRow>
706-
{threadsLoading && (
707-
<FlexRow justify="center" sx={{ py: 2 }}>
708-
<LoadingSpinner />
709-
</FlexRow>
710-
)}
711-
{!threadsLoading && chatThreads.length === 0 && (
712-
<Caption color="secondary" sx={{ px: 2, py: 1.5 }}>
713-
No chats found.
714-
</Caption>
715-
)}
716-
{chatThreads.map((thread) => (
717-
<MenuItemPrimitive
718-
key={thread.id}
719-
label={thread.title || "Untitled chat"}
720-
onClick={() => openChat(thread)}
721-
/>
722-
))}
723-
</>
724-
)}
725-
726-
{view === "assets" && (
727-
<>
728-
<MenuItemPrimitive
729-
label="Back"
730-
icon={<ArrowBackRoundedIcon fontSize="small" />}
731-
onClick={() => setView("root")}
732-
dividerAfter
733-
/>
734-
<FlexRow sx={{ px: 1, py: 0.5 }}>
735-
<TextInput
736-
autoFocus={autoFocusEnabled}
737-
fullWidth
738-
placeholder="Search assets (2+ chars)"
739-
slotProps={{ htmlInput: { "aria-label": "Search assets" } }}
740-
value={assetQuery}
741-
onChange={(e) => setAssetQuery(e.target.value)}
742-
/>
743-
</FlexRow>
744-
{trimmedAssetQuery.length < 2 && (
745-
<Caption color="secondary" sx={{ px: 2, py: 1.5 }}>
746-
Type at least 2 characters to search.
747-
</Caption>
748-
)}
749-
{trimmedAssetQuery.length >= 2 && assetsFetching && (
750-
<FlexRow justify="center" sx={{ py: 2 }}>
751-
<LoadingSpinner />
752-
</FlexRow>
753-
)}
754-
{trimmedAssetQuery.length >= 2 &&
755-
!assetsFetching &&
756-
openableAssets.length === 0 && (
757-
<Caption color="secondary" sx={{ px: 2, py: 1.5 }}>
758-
No openable assets match.
759-
</Caption>
760-
)}
761-
{openableAssets.map(({ asset, type }) => (
762-
<MenuItemPrimitive
763-
key={asset.id}
764-
label={asset.name || "Untitled"}
765-
secondary={type}
766-
onClick={() => openAsset(asset, type)}
767-
/>
768-
))}
769-
</>
770-
)}
771532
</FlexColumn>
772533
</Popover>
773534
);

0 commit comments

Comments
 (0)