diff --git a/apps/sim/.gitignore b/apps/sim/.gitignore index e90bb4dc00d..ccbc3b9426d 100644 --- a/apps/sim/.gitignore +++ b/apps/sim/.gitignore @@ -45,4 +45,4 @@ next-env.d.ts # Uploads /uploads -.trigger \ No newline at end of file +.trigger.env.local.bak* diff --git a/apps/sim/app/activity-preview/page.tsx b/apps/sim/app/activity-preview/page.tsx new file mode 100644 index 00000000000..78aadcd9427 --- /dev/null +++ b/apps/sim/app/activity-preview/page.tsx @@ -0,0 +1,195 @@ +'use client' + +/** + * TEMPORARY preview harness (delete before merge). Designs the in-chat working + * indicator: ONE shimmering status line by default, escalating to a per-agent + * breakout ONLY while ≥2 agents run concurrently, then collapsing back to a + * single line and the reply. + */ +import { useEffect, useState } from 'react' +import { + ParallelAgents, + ShimmerStatus, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view' + +type Frame = + | { kind: 'line'; text: string } + | { kind: 'parallel'; header: string; agents: { label: string; phrase: string }[] } + +interface Scene { + key: string + label: string + prompt: string + frames: Frame[] + reply: string +} + +const line = (text: string): Frame => ({ kind: 'line', text }) + +const SCENES: Scene[] = [ + { + key: 'crm', + label: 'Build CRM', + prompt: 'build a simple crm page', + frames: [ + line('Reviewing UX requirements and data model'), + line('Exploring CRM page structure and data flow'), + line('Drafting a clean CRM page layout'), + line('Wiring up the contacts table'), + ], + reply: 'Done — your CRM page is ready. Open it on the right.', + }, + { + key: 'parallel', + label: 'Parallel agents', + prompt: 'Polish my profile — refresh my skills and bio', + frames: [ + line('Reviewing your profile'), + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Scanning your experience' }, + { label: 'Biography', phrase: 'Reading your current bio' }, + ], + }, + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Determining relevant changes' }, + { label: 'Biography', phrase: 'Crafting a proposal' }, + ], + }, + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Finalizing skill updates' }, + { label: 'Biography', phrase: 'Polishing the wording' }, + ], + }, + line('Wrapping up'), + ], + reply: 'Updated your skills and bio — review the changes on the right.', + }, + { + key: 'edit', + label: 'Edit dialog', + prompt: 'Add an edit dialog to update contact details without deleting them', + frames: [ + line('Reviewing edit dialog integration plans'), + line('Adding the edit form'), + line('Saving changes in place'), + ], + reply: 'Added an edit dialog — contacts now update in place.', + }, +] + +const FRAME_MS = 1900 + +export default function ActivityPreviewPage() { + const [sceneKey, setSceneKey] = useState(SCENES[0].key) + const [idx, setIdx] = useState(0) + const [playing, setPlaying] = useState(true) + + const scene = SCENES.find((s) => s.key === sceneKey) ?? SCENES[0] + const total = scene.frames.length + const done = idx >= total + const frame = done ? null : scene.frames[idx] + + useEffect(() => { + setIdx(0) + setPlaying(true) + }, [sceneKey]) + + useEffect(() => { + if (!playing) return + if (idx >= total) { + setPlaying(false) + return + } + const t = setTimeout(() => setIdx((i) => Math.min(i + 1, total)), FRAME_MS) + return () => clearTimeout(t) + }, [playing, idx, total]) + + return ( +
+
+ {SCENES.map((s) => ( + + ))} +
+ + + + + {Math.min(idx, total)}/{total} + +
+
+ +
+
+
+
+ {scene.prompt} +
+
+ + {done ? ( +

+ {scene.reply} +

+ ) : frame?.kind === 'parallel' ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 5417fbe4a49..f71cd7ee33d 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -27,10 +27,20 @@ const VALID_RESOURCE_TYPES = new Set([ 'workflow', 'knowledgebase', 'folder', + 'filefolder', 'log', 'integration', + 'page', +]) +const GENERIC_TITLES = new Set([ + 'Table', + 'File', + 'Workflow', + 'Knowledge Base', + 'Folder', + 'File Folder', + 'Log', ]) -const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log']) export const POST = withRouteHandler(async (req: NextRequest) => { try { diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 8c20a3e8b41..3a5b9512e1c 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -85,7 +85,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) var isCollapsed = state && state.isCollapsed; if (isCollapsed) { - document.documentElement.style.setProperty('--sidebar-width', '51px'); + document.documentElement.style.setProperty('--sidebar-width', '0px'); document.documentElement.setAttribute('data-sidebar-collapsed', ''); } else { var width = state && state.sidebarWidth; @@ -119,6 +119,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } var activeTab = panelState && panelState.activeTab; + if (activeTab && activeTab !== 'toolbar' && activeTab !== 'editor') { + activeTab = 'toolbar'; + } if (activeTab) { document.documentElement.setAttribute('data-panel-active-tab', activeTab); } diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index fe041cff403..31f3de6bbea 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -76,6 +76,7 @@ import { TagInput, type TagItem, Textarea, + ThinkingLoader, TimePicker, ToastProvider, Tooltip, @@ -1007,6 +1008,39 @@ export default function PlaygroundPage() { + {/* ThinkingLoader */} +
+ +
+ + + + +
+
+ {( + [ + 'metaballs', + 'orbit', + 'relay', + 'corners', + 'burst', + 'compass', + 'squeeze', + 'maze', + ] as const + ).map((variant) => ( + +
+ + + + +
+
+ ))} +
+ {/* Icons */}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx new file mode 100644 index 00000000000..02db0e847d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useParams, usePathname, useRouter } from 'next/navigation' +import { + chipContentLabelClass, + POPOVER_ANIMATION_CLASSES, + Popover, + PopoverAnchor, + PopoverContent, + ThinkingLoader, + Tooltip, +} from '@/components/emcn' +import { BubbleChatDelay, ChevronDown } from '@/components/emcn/icons' +import { + isMothershipPageId, + MOTHERSHIP_PAGES, + type MothershipResource, +} from '@/lib/copilot/resources/types' +import { cn } from '@/lib/core/utils/cn' +import { useSidebarToggleHidden } from '@/app/workspace/[workspaceId]/components/sidebar-toggle' +import { ChatHistoryList } from '@/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' +import { useMothershipStageStore } from '@/stores/mothership-stage/store' + +const FALLBACK_TITLE = 'New chat' + +/** + * Resolves the resource the current page represents, so opening a chat keeps + * that page on screen as the staged panel resource instead of teleporting + * away. Titles are placeholders — the panel resolves live names from queries. + */ +function derivePageResource(pathname: string, workspaceId: string): MothershipResource | null { + const prefix = `/workspace/${workspaceId}/` + if (!pathname.startsWith(prefix)) return null + const [segment, detail] = pathname.slice(prefix.length).split('/') + if (segment === 'tables' && detail) return { type: 'table', id: detail, title: 'Table' } + if (segment === 'files' && detail) return { type: 'file', id: detail, title: 'File' } + if (segment === 'knowledge' && detail) { + return { type: 'knowledgebase', id: detail, title: 'Knowledge Base' } + } + if (isMothershipPageId(segment)) { + return { type: 'page', id: segment, title: MOTHERSHIP_PAGES[segment] } + } + return null +} + +interface ChatSwitcherProps { + /** + * The chat shown in the breadcrumb and highlighted in the list. Omitted on + * non-chat pages, where the most recently updated chat is shown instead. + */ + chatId?: string + /** + * Marks the new-chat empty state (home with no chat open): the chip reads + * "New chat" instead of falling back to the most recently updated chat. + */ + isNewChat?: boolean + /** + * Called with the picked chat id before navigation. The chat view uses this + * to reopen a hidden chat pane (including re-picking the current chat). + */ + onSelectChat?: (chatId: string) => void + /** + * When false, selecting a chat only fires {@link onSelectChat} — the host + * owns what happens next. The workflow editor uses this to dock the chat + * beside the canvas instead of leaving the page. + */ + navigateOnSelect?: boolean + /** + * Splits the titled chip into the canonical closed-chat double button: + * the icon+title segment invokes this directly (reopen the chat), the + * chevron opens the Recents list. Hosts pass it wherever the chat pane is + * closed; without it the titled chip stays a single dropdown trigger. + */ + onOpenChat?: () => void + /** + * The chat is generating a response — the recents icon becomes a spinner so + * the title bar signals work in progress even when the messages are off + * screen (collapsed pane, scrolled away). + */ + isWorking?: boolean +} + +/** + * The chat-switcher chip — a chat icon + title that lives at the + * top-left of every page's title bar. Clicking it opens the workspace's chat + * list inline; selecting a chat navigates to it from anywhere. + */ +export function ChatSwitcher({ + chatId, + isNewChat = false, + onSelectChat, + navigateOnSelect = true, + onOpenChat, + isWorking = false, +}: ChatSwitcherProps) { + const isHidden = useSidebarToggleHidden() + const { workspaceId } = useParams<{ workspaceId?: string }>() + const router = useRouter() + const pathname = usePathname() + const setStage = useMothershipStageStore((state) => state.setStage) + const { data: tasks = [] } = useMothershipChats(workspaceId) + const [open, setOpen] = useState(false) + + const mostRecent = useMemo( + () => + tasks.reduce<(typeof tasks)[number] | null>( + (latest, task) => (!latest || task.updatedAt > latest.updatedAt ? task : latest), + null + ), + [tasks] + ) + + if (isHidden || !workspaceId) return null + + const title = chatId + ? (tasks.find((task) => task.id === chatId)?.name ?? FALLBACK_TITLE) + : isNewChat + ? FALLBACK_TITLE + : (mostRecent?.name ?? FALLBACK_TITLE) + + const handleSelect = (selectedChatId: string) => { + setOpen(false) + onSelectChat?.(selectedChatId) + if (!navigateOnSelect) return + if (selectedChatId === chatId) return + // Opening a chat never takes away what you're looking at: the current + // page becomes the staged panel resource, and the chat slides in beside it. + const pageResource = derivePageResource(pathname, workspaceId) + if (pageResource) { + setStage(workspaceId, pageResource) + router.push(`/workspace/${workspaceId}/chat/${selectedChatId}?resource=${pageResource.id}`) + return + } + router.push(`/workspace/${workspaceId}/chat/${selectedChatId}`) + } + + /** The split chip's primary action: jump straight into the latest chat. */ + const handleOpenMostRecent = () => { + if (!mostRecent) { + setOpen(true) + return + } + handleSelect(mostRecent.id) + } + + const chipIcon = isWorking ? ( + + ) : ( + + ) + + const trigger = + !onOpenChat && !chatId && !isNewChat ? ( + /* Non-chat pages: the same titled split as everywhere — icon + the most + recent chat's name opens that chat outright; the chevron opens Recents. + Hovering either segment tints the whole pill — the hovered (or open) + segment at full fill, its sibling lighter — and the 1px bg-colored + divider slices the fills into two buttons. The fills are before-pseudos + so opacity never dims the glyphs. */ + + + + + + +

Open chat

+
+
+ + ) : onOpenChat ? ( + /* Closed-chat double button: icon+title reopens the chat outright, the + chevron opens Recents — the same pill split as the icon-only variant. */ + + + + + + +

Open chat

+
+
+ + ) : ( + /* Open-chat chip: the same split pill as the other states (divider and + all) so the control never changes design between surfaces. Both + segments open Recents — the split here is purely the family look. */ + + + + ) + + return ( + + {trigger} + {/* Mirrors the sidebar flyout's anchor rhythm: the chip sits at y 7..37 in + the 44px bar, so offset 13 lands the panel 6px below the bar, and the + -33 align offset walks back from the chip to 8px off the panel edge. */} + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts new file mode 100644 index 00000000000..345c1d5839b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts @@ -0,0 +1 @@ +export { ChatSwitcher } from './chat-switcher' diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d1..bc42ee366ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,3 +1,4 @@ +export { ChatSwitcher } from './chat-switcher' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' @@ -34,4 +35,5 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { SidebarToggle } from './sidebar-toggle' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/index.ts b/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/index.ts new file mode 100644 index 00000000000..f8fecf0b70a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/index.ts @@ -0,0 +1 @@ +export { PanelChromeProvider, usePanelChrome } from './panel-chrome-context' diff --git a/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/panel-chrome-context.tsx b/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/panel-chrome-context.tsx new file mode 100644 index 00000000000..2b8459e8295 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/panel-chrome-context/panel-chrome-context.tsx @@ -0,0 +1,39 @@ +'use client' + +import { createContext, type ReactNode, useContext, useMemo } from 'react' + +/** + * Chrome the resource panel contributes to an embedded page's own header, + * so a full page staged in the panel keeps exactly one header row. + */ +interface PanelChromeValue { + /** + * Cluster rendered before the title while the chat pane is hidden (sidebar + * toggle + chat switcher) — the panel header doubles as the title bar. + */ + leading?: ReactNode + /** The panel's trailing controls: close + the collapse-toggle spacer. */ + controls: ReactNode +} + +const PanelChromeContext = createContext(null) + +interface PanelChromeProviderProps extends PanelChromeValue { + children: ReactNode +} + +/** + * Marks the subtree as panel-hosted for header purposes: a `Resource.Header` + * rendered inside absorbs the panel's controls and becomes the single header. + * Provided by the resource panel only around views that bring their own + * header (workspace area pages, knowledge base detail). + */ +export function PanelChromeProvider({ leading, controls, children }: PanelChromeProviderProps) { + const value = useMemo(() => ({ leading, controls }), [leading, controls]) + return {children} +} + +/** The surrounding panel's header chrome, or null outside the panel. */ +export function usePanelChrome(): PanelChromeValue | null { + return useContext(PanelChromeContext) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 375e1c1dada..f6932db1f74 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -30,8 +30,14 @@ import { useIsOverflowing, } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +import { ChatSwitcher } from '@/app/workspace/[workspaceId]/components/chat-switcher' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' +import { usePanelChrome } from '@/app/workspace/[workspaceId]/components/panel-chrome-context' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { + SidebarToggle, + SidebarToggleRevealed, +} from '@/app/workspace/[workspaceId]/components/sidebar-toggle' export interface DropdownOption { label: string @@ -108,6 +114,13 @@ export const ResourceHeader = memo(function ResourceHeader({ aside, }: ResourceHeaderProps) { const headerRef = useRef(null) + /** + * Inside the chat's resource panel this header IS the panel header: it + * swaps the standalone chrome cluster for the panel's leading, compacts to + * the panel bar's 44px rhythm, and appends the panel controls (close + + * collapse-toggle spacer) after the actions. + */ + const panelChrome = usePanelChrome() /** * Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb * "breadcrumb" is just the current page, so it falls through to the static @@ -128,8 +141,27 @@ export const ResourceHeader = memo(function ResourceHeader({ : -1 return ( -
-
+
+ {/* Chrome controls live outside the overflow-hidden breadcrumb group so + the toggle's 9px pull-out (7px edge inset, matching the chat title + bar) isn't clipped. The gap-1 cluster matches the chat title bar's + toggle+switcher rhythm so the pair never shifts between pages. In + the panel the cluster comes from the panel itself (only while the + chat pane is hidden) and must escape the content's hidden scope. */} + {panelChrome ? ( + panelChrome.leading ? ( + {panelChrome.leading} + ) : null + ) : ( +
+ + +
+ )} +
{hasBreadcrumbs ? ( breadcrumbs.map((crumb, i) => { @@ -209,6 +241,9 @@ export const ResourceHeader = memo(function ResourceHeader({
)}
+ {panelChrome && ( +
{panelChrome.controls}
+ )}
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts new file mode 100644 index 00000000000..0d30ad2d645 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts @@ -0,0 +1,6 @@ +export { + SidebarToggle, + SidebarToggleHidden, + SidebarToggleRevealed, + useSidebarToggleHidden, +} from './sidebar-toggle' diff --git a/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx new file mode 100644 index 00000000000..4af8ce5770b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx @@ -0,0 +1,80 @@ +'use client' + +import { createContext, useContext } from 'react' +import { PanelLeft } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import { useSidebarStore } from '@/stores/sidebar/store' + +const SidebarToggleHiddenContext = createContext(false) + +interface SidebarToggleHiddenProps { + children: React.ReactNode +} + +/** + * Suppresses every {@link SidebarToggle} (and other title-bar chrome controls, + * e.g. the chat switcher) in the subtree. Wrap surfaces that embed full pages + * (e.g. the chat resource panel) so their headers don't duplicate the chrome. + */ +export function SidebarToggleHidden({ children }: SidebarToggleHiddenProps) { + return ( + + {children} + + ) +} + +/** + * Re-enables chrome controls inside a {@link SidebarToggleHidden} subtree — + * for the spot where an embedded page's header doubles as the title bar + * (e.g. the resource panel while the chat pane is hidden). + */ +export function SidebarToggleRevealed({ children }: SidebarToggleHiddenProps) { + return ( + + {children} + + ) +} + +/** Whether title-bar chrome controls are suppressed by {@link SidebarToggleHidden}. */ +export function useSidebarToggleHidden(): boolean { + return useContext(SidebarToggleHiddenContext) +} + +interface SidebarToggleProps { + /** Layout-only positioning for the host surface (margins, absolute placement). */ + className?: string +} + +/** + * The single sidebar control, living at the top-left of a page's title bar in + * both states. Clicking toggles the sidebar open/closed. While the sidebar is + * hidden, hovering reveals the floating menu panel (rendered by the workspace + * chrome) anchored underneath this toggle. + */ +export function SidebarToggle({ className }: SidebarToggleProps) { + const isHidden = useContext(SidebarToggleHiddenContext) + const isCollapsed = useSidebarStore((s) => s.isCollapsed) + const toggleCollapsed = useSidebarStore((s) => s.toggleCollapsed) + const openFlyout = useSidebarStore((s) => s.openFlyout) + const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose) + + if (isHidden) return null + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 16b51832dbc..93951d468b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { usePathname } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' import { useSidebarStore } from '@/stores/sidebar/store' @@ -40,6 +41,12 @@ function isFullscreenPath(pathname: string | null): boolean { * * On a direct load of a fullscreen route the wrapper mounts already collapsed, * so no slide plays (CSS transitions don't run on mount). + * + * The sidebar's single control is the `SidebarToggle` at the top-left of page + * title bars. While the sidebar is hidden, hovering that toggle opens the + * floating menu panel this chrome owns, anchored underneath the title bar so + * the toggle stays visible; an invisible left-edge hover zone opens it on + * pages without a title-bar toggle. Clicking the toggle pins the sidebar open. */ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { const pathname = usePathname() @@ -49,6 +56,16 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { const hasHydrated = useSidebarStore((s) => s._hasHydrated) const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) + const isCollapsed = useSidebarStore((s) => s.isCollapsed) + const isFlyoutOpen = useSidebarStore((s) => s.isFlyoutOpen) + const openFlyout = useSidebarStore((s) => s.openFlyout) + const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose) + const closeFlyout = useSidebarStore((s) => s.closeFlyout) + + // Hide the flyout after navigating from it. + useEffect(() => { + closeFlyout() + }, [pathname, closeFlyout]) // Remember the last non-fullscreen page so a fullscreen route's Back control // can return there, deterministically and for any trigger. @@ -86,14 +103,14 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { }, [syncSidebarWidth]) return ( -
+
- + {!isCollapsed && }
+ {/* Sidebar hidden → content goes full-bleed to the browser edge; sidebar + visible (or fullscreen route) → framed card with the 8px gutter. */}
-
+
{children}
+ {isCollapsed && !isFullscreen && ( + <> + {/* Invisible hover zone so the flyout is reachable from the screen + edge on pages whose header has no SidebarFlyoutTrigger. */} +
+ {/* Anchored below the page title bar so the SidebarToggle that opened + it stays visible above the panel. Chrome mirrors the canonical + popover surface (rounded-xl, --border-1, --bg, shadow-sm) and enter + motion (fade + zoom + slide from top, 150ms ease-out). Content-fit + like a dropdown: height tracks the menu, capped so it scrolls + internally instead of overflowing the viewport. */} +
+ +
+ + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 2358d61182f..973c3aaa601 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -162,17 +162,34 @@ function formatFileType(mimeType: string | null, filename: string): string { return mimeType ?? 'File' } -export function Files() { +interface FilesProps { + /** + * Panel-embedded mode (the chat's resource panel): folder browsing becomes + * component state instead of URL state, and opening a file is delegated to + * the host via {@link FilesProps.onOpenFile} rather than routing to the + * detail page. The standalone-only flows (`?new=1`, the `/files/[fileId]` + * detail route, the hidden-tab redirect) never apply. + */ + embedded?: boolean + /** Opens a file from the embedded list (the host stages it in the panel). */ + onOpenFile?: (fileId: string, fileName: string) => void +} + +export function Files({ embedded = false, onOpenFile }: FilesProps = {}) { const fileInputRef = useRef(null) const saveRef = useRef<(() => Promise) | null>(null) + const onOpenFileRef = useRef(onOpenFile) + onOpenFileRef.current = onOpenFile const params = useParams() const router = useRouter() const searchParams = useSearchParams() - const isNewFile = searchParams.get('new') === '1' - const currentFolderId = searchParams.get('folderId') const workspaceId = params?.workspaceId as string + const [embeddedFolderId, setEmbeddedFolderId] = useState(null) + const isNewFile = !embedded && searchParams.get('new') === '1' + const currentFolderId = embedded ? embeddedFolderId : searchParams.get('folderId') + const posthog = usePostHog() const posthogRef = useRef(posthog) posthogRef.current = posthog @@ -184,10 +201,43 @@ export function Files() { const { config: permissionConfig } = usePermissionConfig() useEffect(() => { - if (permissionConfig.hideFilesTab) { + if (permissionConfig.hideFilesTab && !embedded) { router.replace(`/workspace/${workspaceId}`) } - }, [permissionConfig.hideFilesTab, router, workspaceId]) + }, [permissionConfig.hideFilesTab, router, workspaceId, embedded]) + + /** Folder browsing: URL state on the standalone page, local state embedded. */ + const navigateToFolder = useCallback( + (folderId: string | null) => { + if (embedded) { + setEmbeddedFolderId(folderId) + return + } + router.push( + folderId + ? `/workspace/${workspaceId}/files?folderId=${folderId}` + : `/workspace/${workspaceId}/files` + ) + }, + [embedded, router, workspaceId] + ) + + /** Opening a file: the detail route standalone, delegated to the host embedded. */ + const openFileById = useCallback( + (fileId: string, fileName: string, options?: { isNew?: boolean; folderId?: string | null }) => { + if (embedded) { + onOpenFileRef.current?.(fileId, fileName) + return + } + const query = new URLSearchParams() + if (options?.isNew) query.set('new', '1') + const folderId = options?.folderId !== undefined ? options.folderId : currentFolderId + if (folderId) query.set('folderId', folderId) + const qs = query.toString() + router.push(`/workspace/${workspaceId}/files/${fileId}${qs ? `?${qs}` : ''}`) + }, + [embedded, router, workspaceId, currentFolderId] + ) const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId) const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS, isLoading: foldersLoading } = @@ -940,16 +990,12 @@ export function Files() { if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') - router.push( - currentFolderId - ? `/workspace/${workspaceId}/files?folderId=${currentFolderId}` - : `/workspace/${workspaceId}/files` - ) + navigateToFolder(currentFolderId) } } catch (err) { logger.error('Failed to delete file:', err) } - }, [workspaceId, router, currentFolderId]) + }, [workspaceId, navigateToFolder, currentFolderId]) const isDirtyRef = useRef(isDirty) isDirtyRef.current = isDirty @@ -1135,16 +1181,14 @@ export function Files() { const fileId = result.file?.id if (fileId) { justCreatedFileIdRef.current = fileId - const params = new URLSearchParams({ new: '1' }) - if (currentFolderId) params.set('folderId', currentFolderId) - router.push(`/workspace/${workspaceId}/files/${fileId}?${params.toString()}`) + openFileById(fileId, name, { isNew: true }) } } catch (err) { logger.error('Failed to create file:', err) } finally { setCreatingFile(false) } - }, [workspaceId, router, currentFolderId]) + }, [workspaceId, openFileById, currentFolderId]) const handleCreateFolder = useCallback(async () => { if (!workspaceId) return @@ -1198,17 +1242,13 @@ export function Files() { const item = contextMenuItemRef.current if (!item) return if (item.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${item.folder.id}`) + navigateToFolder(item.folder.id) closeContextMenu() return } - router.push( - item.file.folderId - ? `/workspace/${workspaceId}/files/${item.file.id}?folderId=${item.file.folderId}` - : `/workspace/${workspaceId}/files/${item.file.id}` - ) + openFileById(item.file.id, item.file.name, { folderId: item.file.folderId ?? null }) closeContextMenu() - }, [closeContextMenu, router, workspaceId]) + }, [closeContextMenu, navigateToFolder, openFileById]) const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current @@ -1486,17 +1526,14 @@ export function Files() { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { const parsed = parseRowId(rowId) if (parsed.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${parsed.id}`) + navigateToFolder(parsed.id) return } - router.push( - currentFolderId - ? `/workspace/${workspaceId}/files/${parsed.id}?folderId=${currentFolderId}` - : `/workspace/${workspaceId}/files/${parsed.id}` - ) + const file = filesRef.current.find((f) => f.id === parsed.id) + openFileById(parsed.id, file?.name ?? '') } }, - [router, workspaceId, currentFolderId] + [navigateToFolder, openFileById] ) const handleUploadClick = useCallback(() => { @@ -1555,8 +1592,8 @@ export function Files() { ) const handleNavigateToFiles = useCallback(() => { - router.push(`/workspace/${workspaceId}/files`) - }, [router, workspaceId]) + navigateToFolder(null) + }, [navigateToFolder]) const loadingBreadcrumbs = useMemo( () => [{ label: 'Files', onClick: handleNavigateToFiles }, { label: '...' }], @@ -1581,9 +1618,7 @@ export function Files() { const isCurrentFolder = folder.id === currentFolderId breadcrumbs.push({ label: folder.name, - onClick: isCurrentFolder - ? undefined - : () => router.push(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + onClick: isCurrentFolder ? undefined : () => navigateToFolder(folder.id), editing: isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id ? { @@ -1613,8 +1648,7 @@ export function Files() { currentFolderId, folders, handleNavigateToFiles, - router, - workspaceId, + navigateToFolder, canEdit, userPermissions.isLoading, breadcrumbRename.editingId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx new file mode 100644 index 00000000000..e4ccde60b2e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { differenceInCalendarDays, isToday, isYesterday } from 'date-fns' +import { useParams } from 'next/navigation' +import { Skeleton } from '@/components/emcn' +import { Search } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import { + type MothershipChatMetadata, + useMothershipChats, + usePrefetchChatHistory, +} from '@/hooks/queries/mothership-chats' + +const CONFIG = { + LIST_MAX_HEIGHT: 320, + SKELETON_ROWS: 5, +} as const + +/** A recency bucket of chats rendered as one section in the history list. */ +interface ChatBucket { + key: string + label: string + tasks: MothershipChatMetadata[] +} + +/** + * Buckets chats into Codex-style recency sections. Pinned chats are lifted out + * of their date bucket into a dedicated section at the top; everything else is + * grouped by how recently it was last updated. The server already returns the + * list ordered (pinned first, then desc by `updatedAt`), so per-bucket order is + * preserved by simply appending as we iterate. + */ +function bucketChats(tasks: readonly MothershipChatMetadata[]): ChatBucket[] { + const now = new Date() + const pinned: MothershipChatMetadata[] = [] + const today: MothershipChatMetadata[] = [] + const yesterday: MothershipChatMetadata[] = [] + const last7: MothershipChatMetadata[] = [] + const last30: MothershipChatMetadata[] = [] + const older: MothershipChatMetadata[] = [] + + for (const task of tasks) { + if (task.isPinned) { + pinned.push(task) + continue + } + const date = task.updatedAt + if (isToday(date)) { + today.push(task) + } else if (isYesterday(date)) { + yesterday.push(task) + } else { + const days = differenceInCalendarDays(now, date) + if (days <= 7) last7.push(task) + else if (days <= 30) last30.push(task) + else older.push(task) + } + } + + return ( + [ + { key: 'pinned', label: 'Pinned', tasks: pinned }, + { key: 'today', label: 'Today', tasks: today }, + { key: 'yesterday', label: 'Yesterday', tasks: yesterday }, + { key: 'last7', label: 'Previous 7 Days', tasks: last7 }, + { key: 'last30', label: 'Previous 30 Days', tasks: last30 }, + { key: 'older', label: 'Older', tasks: older }, + ] as const + ).filter((bucket) => bucket.tasks.length > 0) +} + +/** + * A small status dot mirroring the sidebar's semantics: yellow while a chat is + * actively streaming, brand accent when it has unread activity. Rendered only + * when one of those states applies. + */ +function StatusDot({ task }: { task: MothershipChatMetadata }) { + if (!task.isActive && !task.isUnread) return null + return ( +