11import { 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" ;
52import AddRoundedIcon from "@mui/icons-material/AddRounded" ;
63import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined" ;
74import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined" ;
@@ -12,19 +9,17 @@ import ViewInArOutlinedIcon from "@mui/icons-material/ViewInArOutlined";
129import ForumOutlinedIcon from "@mui/icons-material/ForumOutlined" ;
1310import DataObjectOutlinedIcon from "@mui/icons-material/DataObjectOutlined" ;
1411import RecordVoiceOverOutlinedIcon from "@mui/icons-material/RecordVoiceOverOutlined" ;
12+ import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined" ;
1513import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded" ;
1614
1715import {
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" ;
2823import { useCreateTimeline } from "../../hooks/useTimelineSequence" ;
2924import {
3025 useCreateStoryboard ,
@@ -34,21 +29,13 @@ import {
3429import { useCreateApplication } from "../../hooks/useApplications" ;
3530import { useCreateScript } from "../../hooks/script/useScripts" ;
3631import { useCreateJsScript } from "../../hooks/jsScript/useJsScripts" ;
32+ import { useCreateSkill } from "../../hooks/skills/useSkills" ;
3733import { useAssetStore } from "../../stores/AssetStore" ;
3834import { useNotificationStore } from "../../stores/NotificationStore" ;
3935import { useWorkflowManager } from "../../contexts/WorkflowManagerContext" ;
4036import 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. */
5441const 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
10889interface 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 */
163138const 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