diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
index 4f962ccce10..700cc8fa944 100644
--- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
+++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx
@@ -2,8 +2,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { RefreshCw } from 'lucide-react'
import { useRouter } from 'next/navigation'
+import { RefreshCw } from '@/components/emcn/icons'
const logger = createLogger('ResumePage')
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..493fc88783a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx
@@ -0,0 +1,200 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { useParams, usePathname, useRouter } from 'next/navigation'
+import {
+ 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 { useMothershipTabsStore } from '@/stores/mothership-tabs/store'
+
+const FALLBACK_TITLE = 'New chat'
+
+/**
+ * Resolves the resource the current page represents, so opening a chat keeps
+ * that page on screen as the focused panel tab instead of teleporting away.
+ * Titles are placeholders — the tab strip 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 === 'w' && detail) return { type: 'workflow', id: detail, title: 'Workflow' }
+ if (segment === 'tables' && detail) return { type: 'table', id: detail, title: 'Table' }
+ 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
+ /**
+ * Compact icon-only chip for non-chat pages, where the page title owns the
+ * bar — a chat name beside it would read as a breadcrumb segment.
+ */
+ iconOnly?: 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
+ /**
+ * 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,
+ iconOnly = false,
+ onSelectChat,
+ isWorking = false,
+}: ChatSwitcherProps) {
+ const isHidden = useSidebarToggleHidden()
+ const { workspaceId } = useParams<{ workspaceId?: string }>()
+ const router = useRouter()
+ const pathname = usePathname()
+ const openTabs = useMothershipTabsStore((state) => state.openTabs)
+ 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 (selectedChatId === chatId) return
+ // Opening a chat never takes away what you're looking at: the current
+ // page becomes the focused panel tab, and the chat slides in beside it.
+ const pageResource = derivePageResource(pathname, workspaceId)
+ if (pageResource) {
+ openTabs(workspaceId, [pageResource], { focusId: pageResource.id })
+ router.push(`/workspace/${workspaceId}/chat/${selectedChatId}?resource=${pageResource.id}`)
+ return
+ }
+ router.push(`/workspace/${workspaceId}/chat/${selectedChatId}`)
+ }
+
+ const chipIcon = isWorking ? (
+
+ ) : (
+
+ )
+
+ const trigger = iconOnly ? (
+
+ ) : (
+
+ )
+
+ return (
+
+
+ {iconOnly ? (
+
+
+ {trigger}
+
+
Recents
+
+
+
+ ) : (
+ 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/error/error.tsx b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
index f6279b6f9f3..a9f74247ca9 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
@@ -2,8 +2,8 @@
import { type ReactNode, useEffect } from 'react'
import { createLogger } from '@sim/logger'
-import { TriangleAlert } from 'lucide-react'
import { Button } from '@/components/emcn'
+import { TriangleAlert } from '@/components/emcn/icons'
/** Props shape required by Next.js error boundary files (`error.tsx`). */
export interface ErrorBoundaryProps {
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/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index 74cf5990e2a..b6b648b52b4 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -1,7 +1,6 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
-import { GitBranch } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Check,
@@ -16,6 +15,7 @@ import {
Tooltip,
toast,
} from '@/components/emcn'
+import { GitBranch } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
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..83cb1a568cf 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
@@ -8,7 +8,6 @@ import {
useRef,
useState,
} from 'react'
-import { ArrowUpLeft } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Chip,
@@ -29,9 +28,12 @@ import {
useFloatingTooltip,
useIsOverflowing,
} from '@/components/emcn'
+import { ArrowUpLeft } from '@/components/emcn/icons'
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 { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
+import { SidebarToggle } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
export interface DropdownOption {
label: string
@@ -128,8 +130,19 @@ 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. */}
+
+
+
+
+
{hasBreadcrumbs ? (
breadcrumbs.map((crumb, i) => {
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index f6cadd0a3a1..83b92896933 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -9,7 +9,6 @@ import {
useRef,
useState,
} from 'react'
-import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
ArrowDown,
ArrowUp,
@@ -20,6 +19,7 @@ import {
chipContentLabelClass,
Loader,
} from '@/components/emcn'
+import { ChevronLeft, ChevronRight } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
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..d60f3d9de43
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts
@@ -0,0 +1 @@
+export { SidebarToggle, SidebarToggleHidden, 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..d126377d264
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx
@@ -0,0 +1,67 @@
+'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}
+
+ )
+}
+
+/** 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/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
index 27ed6d8464c..a2009b9af10 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
@@ -1,6 +1,5 @@
'use client'
-import { Scissors } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,7 +8,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/emcn'
-import { Clipboard, Duplicate, Search, SelectAll } from '@/components/emcn/icons'
+import { Clipboard, Duplicate, Scissors, Search, SelectAll } from '@/components/emcn/icons'
interface EditorContextMenuProps {
isOpen: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
index b161d2f8bce..f0d358464f5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx
@@ -1,5 +1,5 @@
-import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/emcn'
+import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
interface PreviewNavigationControls {
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..2e6bd9f226c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx
@@ -0,0 +1,204 @@
+'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 (
+
+ )
+}
+
+interface ChatHistoryListProps {
+ /** Invoked with the chat id when a row is chosen. */
+ onSelect: (chatId: string) => void
+ /** The currently-open chat, highlighted in the list. */
+ activeChatId?: string
+ /** Focus the search field (and reset the query when it goes false). */
+ autoFocus?: boolean
+}
+
+/**
+ * The searchable, recency-grouped list of a workspace's Mothership chats. Shared
+ * by the home "All Chats" tray and the open-chat title-bar switcher; the host
+ * supplies `onSelect` (inline open, route push, etc.). Hovering a row warms its
+ * history cache so opening it is instant.
+ */
+export function ChatHistoryList({
+ onSelect,
+ activeChatId,
+ autoFocus = false,
+}: ChatHistoryListProps) {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const prefetchChatHistory = usePrefetchChatHistory()
+ const { data: tasks = [], isLoading } = useMothershipChats(workspaceId)
+ const [query, setQuery] = useState('')
+ const inputRef = useRef(null)
+
+ const buckets = useMemo(() => {
+ const trimmed = query.trim().toLowerCase()
+ const filtered = trimmed
+ ? tasks.filter((task) => task.name.toLowerCase().includes(trimmed))
+ : tasks
+ return bucketChats(filtered)
+ }, [tasks, query])
+
+ const hasChats = tasks.length > 0
+ const hasResults = buckets.length > 0
+
+ /** Focus search when activated; clear a stale query when deactivated. */
+ useEffect(() => {
+ if (autoFocus) inputRef.current?.focus()
+ else setQuery('')
+ }, [autoFocus])
+
+ return (
+
+ {/* The scroller bleeds to the dropdown's edge (-mx-2) so the scrollbar
+ hugs it instead of floating mid-panel; the thumb is clipped to a 4px
+ pill inset 2px from the edge. Right padding is just 2px — the
+ scrollbar gutter supplies the rest of the row's visual inset. */}
+
)
}
@@ -442,7 +427,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
Suggested follow-ups
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
index 0e5bcd3c98d..7547a5f5a53 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
@@ -88,7 +88,7 @@ export function ThinkingBlock({
Mothership
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/chat-title-bar.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/chat-title-bar.tsx
new file mode 100644
index 00000000000..b6e658f7595
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/chat-title-bar.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { Tooltip } from '@/components/emcn'
+import { X } from '@/components/emcn/icons'
+import { ChatSwitcher } from '@/app/workspace/[workspaceId]/components/chat-switcher'
+import { SidebarToggle } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
+
+interface ChatTitleBarProps {
+ /** The open chat's id — resolves the title and highlights the active row. */
+ chatId?: string
+ /**
+ * 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
+ /** Renders a close (×) control at the bar's right edge that hides the chat pane. */
+ onClose?: () => void
+ /** The chat is generating a response — the switcher's recents icon becomes a spinner. */
+ isWorking?: boolean
+}
+
+/**
+ * A Codex-style title bar for an open Mothership chat. The title is a chip with
+ * a chevron that opens the workspace's chat list inline, letting the user jump
+ * straight between chats without returning to the new-chat view. Selecting a
+ * chat navigates to it.
+ */
+export function ChatTitleBar({ chatId, onSelectChat, onClose, isWorking }: ChatTitleBarProps) {
+ return (
+
+ {/* Edge controls pull out by 9px so their 30px hover pills sit 7px from
+ the panel edge — matching the pill's 7px top/bottom gap in the bar. */}
+
+ {/* The title bar only renders on chat surfaces, so no chat id means the
+ new-chat empty state — never fall back to the most recent chat. */}
+
+ {onClose && (
+
+
+
+
+
+
Close chat
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/index.ts
new file mode 100644
index 00000000000..a2b94654960
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/chat-title-bar/index.ts
@@ -0,0 +1 @@
+export { ChatTitleBar } from './chat-title-bar'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 6bd66067600..ded9fadbbcd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -26,8 +26,10 @@ import type {
QueuedMessage,
} from '@/app/workspace/[workspaceId]/home/types'
import { useAutoScroll } from '@/hooks/use-auto-scroll'
+import { useAutoHideScrollbar } from '@/hooks/use-autohide-scrollbar'
import { useProgressiveList } from '@/hooks/use-progressive-list'
import type { ChatContext } from '@/stores/panel'
+import { ChatTitleBar } from './components/chat-title-bar'
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
interface MothershipChatProps {
@@ -58,6 +60,8 @@ interface MothershipChatProps {
initialScrollBlocked?: boolean
animateInput?: boolean
onInputAnimationEnd?: () => void
+ /** Shows the title bar's close (×) control that hides the chat pane. */
+ onCloseChat?: () => void
className?: string
}
@@ -87,6 +91,17 @@ const LAYOUT_STYLES = {
const EMPTY_BLOCKS: ContentBlock[] = []
+/**
+ * Hides the scroll thumb by default and reveals it (color fade) only while the
+ * container carries `data-scrolling="true"` — toggled by {@link useAutoHideScrollbar}.
+ * Local override of the always-visible global scrollbar; covers WebKit + Firefox.
+ */
+const SCROLLBAR_AUTOHIDE = cn(
+ '[&::-webkit-scrollbar-thumb]:bg-transparent [&::-webkit-scrollbar-thumb]:transition-colors [&::-webkit-scrollbar-thumb]:duration-300',
+ 'data-[scrolling=true]:[&::-webkit-scrollbar-thumb]:bg-[var(--scrollbar-thumb-color)]',
+ '[scrollbar-color:transparent_transparent] data-[scrolling=true]:[scrollbar-color:var(--scrollbar-thumb-color)_transparent]'
+)
+
interface UserMessageRowProps {
content: string
contexts?: ChatMessageContext[]
@@ -197,6 +212,7 @@ export function MothershipChat({
initialScrollBlocked = false,
animateInput = false,
onInputAnimationEnd,
+ onCloseChat,
className,
}: MothershipChatProps) {
const styles = LAYOUT_STYLES[layout]
@@ -204,6 +220,14 @@ export function MothershipChat({
const { ref: scrollContainerRef, scrollToBottom } = useAutoScroll(isStreamActive, {
scrollOnMount: true,
})
+ const attachAutoHideScrollbar = useAutoHideScrollbar()
+ const setScrollContainer = useCallback(
+ (el: HTMLDivElement | null) => {
+ scrollContainerRef(el)
+ attachAutoHideScrollbar(el)
+ },
+ [scrollContainerRef, attachAutoHideScrollbar]
+ )
const hasMessages = messages.length > 0
const stagingKey = chatId ?? 'pending-chat'
const { staged: stagedMessages, isStaging } = useProgressiveList(messages, stagingKey)
@@ -284,41 +308,53 @@ export function MothershipChat({
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
>
+ {/* Messages dissolve into the input instead of hard-clipping: a bg
+ fade plus the faintest backdrop blur, both masked so they ramp in
+ toward the bottom edge. */}
+
+ )
+})
+
+interface ChatActivityProps {
+ model: ActivityModel
+}
+
+/**
+ * Inline-in-chat agent activity: the deployed agents and their tools, rendered
+ * within an assistant message (chat width). Evolves the existing AgentGroup
+ * with per-agent color identity + deploy-in motion. No artifact preview — the
+ * artifact materializes in the right panel, not here.
+ */
+export const ChatActivity = memo(function ChatActivity({ model }: ChatActivityProps) {
+ const lanes = model.actorOrder.map((id) => model.actors[id]).filter((a): a is Actor => !!a)
+ const showThinking = model.phase === 'thinking' && model.activities.length === 0
+
+ return (
+
+ {showThinking && (
+
+
+ Thinking…
+
+ )}
+ {lanes.map((actor) => (
+
+ ))}
+
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/parallel-agents.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/parallel-agents.tsx
new file mode 100644
index 00000000000..61dc409f637
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/parallel-agents.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import { ShimmerStatus } from './shimmer-status'
+
+interface ParallelAgent {
+ label: string
+ phrase: string
+}
+
+interface ParallelAgentsProps {
+ /** Compact summary line, e.g. "Profile scan · 2 agents". */
+ header: string
+ agents: ParallelAgent[]
+ /** Animate the per-agent shimmer (true while concurrently working). */
+ active?: boolean
+}
+
+/**
+ * The ONLY sanctioned breakout: shown solely while ≥2 agents run concurrently,
+ * because a single line can't express concurrency. Each agent still gets just
+ * one shimmering line (the atom) under a shared header. Collapses back to a
+ * single line the instant agents stop running in parallel.
+ */
+export function ParallelAgents({ header, agents, active = true }: ParallelAgentsProps) {
+ return (
+
+ {header}
+
+ {agents.map((a) => (
+
+ {a.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.module.css b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.module.css
new file mode 100644
index 00000000000..983e063897a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.module.css
@@ -0,0 +1,41 @@
+.shimmer {
+ background-image: linear-gradient(
+ 90deg,
+ var(--text-primary) 0%,
+ var(--text-secondary) 45%,
+ var(--text-muted) 75%
+ );
+ background-clip: text;
+ -webkit-background-clip: text;
+ color: transparent;
+}
+
+.active {
+ background-image: linear-gradient(
+ 90deg,
+ var(--text-muted) 0%,
+ var(--text-muted) 35%,
+ var(--text-primary) 50%,
+ var(--text-muted) 65%,
+ var(--text-muted) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer-sweep 2.4s linear infinite;
+}
+
+@keyframes shimmer-sweep {
+ from {
+ background-position: 150% 0;
+ }
+ to {
+ background-position: -50% 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .active {
+ animation: none;
+ background-image: linear-gradient(90deg, var(--text-primary), var(--text-muted));
+ background-size: 100% 100%;
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.tsx
new file mode 100644
index 00000000000..90a8f1fa558
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view/shimmer-status.tsx
@@ -0,0 +1,34 @@
+'use client'
+
+import { cn } from '@/lib/core/utils/cn'
+import styles from './shimmer-status.module.css'
+
+interface ShimmerStatusProps {
+ /** The single status line. Swapping it crossfades to the new text. */
+ text: string
+ /** Animate the shimmer sweep (true while working; false when paused/idle). */
+ active?: boolean
+ className?: string
+}
+
+/**
+ * A single shimmering status line that changes as work progresses — the entire
+ * in-progress chat surface. Deliberately replaces the broken-out agent lanes /
+ * tool rows: one line, no bullets, no stacking. Re-keys on `text` so each new
+ * phrase fades in.
+ */
+export function ShimmerStatus({ text, active = true, className }: ShimmerStatusProps) {
+ return (
+
+ {text}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
index 889ac5ada6b..2050d4d5c2f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
@@ -14,6 +14,7 @@ import {
Tooltip,
} from '@/components/emcn'
import { Folder, Plus, Workflow } from '@/components/emcn/icons'
+import { MOTHERSHIP_PAGES, type MothershipPageId } from '@/lib/copilot/resources/types'
import { cn } from '@/lib/core/utils/cn'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
@@ -83,6 +84,14 @@ export function useAvailableResources(
return useMemo(() => {
const excluded = new Set(excludeTypes ?? [])
const groups: AvailableItemsByType[] = [
+ {
+ type: 'page' as const,
+ items: (Object.keys(MOTHERSHIP_PAGES) as MothershipPageId[]).map((id) => ({
+ id,
+ name: MOTHERSHIP_PAGES[id],
+ isOpen: existingKeys.has(`page:${id}`),
+ })),
+ },
{
type: 'workflow' as const,
items: workflows.map((w) => ({
@@ -379,7 +388,10 @@ export function AddResourceDropdown({
const [activeIndex, setActiveIndex] = useState(0)
const available = useAvailableResources(workspaceId, existingKeys, [
...(excludeTypes ?? []),
+ // Never offered as tabs: integrations attach via @-mention and chats have
+ // no embedded tab view — ResourceContent cannot render either type.
'integration',
+ 'task',
])
const handleOpenChange = (next: boolean) => {
setOpen(next)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 965987807b8..0f6c439fd24 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -2,18 +2,19 @@
import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
-import { useRouter } from 'next/navigation'
+import { stripVersionSuffix } from '@sim/utils/string'
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
import {
+ Connections,
Download,
FileX,
Folder as FolderIcon,
Library,
Square,
- SquareArrowUpRight,
Workflow as WorkflowIcon,
WorkflowX,
} from '@/components/emcn/icons'
+import { getDocumentIcon } from '@/components/icons/document-icons'
import { isApiClientError } from '@/lib/api/client/errors'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import {
@@ -22,6 +23,7 @@ import {
reportManualRunToolStop,
} from '@/lib/copilot/tools/client/run-tool-execution'
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
+import { INTEGRATIONS, type Integration } from '@/lib/integrations'
import { triggerFileDownload } from '@/lib/uploads/client/download'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
import {
@@ -30,6 +32,7 @@ import {
resolveFileCategory,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content'
+import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
@@ -52,6 +55,7 @@ import { useFolders } from '@/hooks/queries/folders'
import { useLogDetail } from '@/hooks/queries/logs'
import { downloadTableExport } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
+import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useExecutionStore } from '@/stores/execution/store'
@@ -59,6 +63,37 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
+const Tables = lazy(() =>
+ import('@/app/workspace/[workspaceId]/tables/tables').then((m) => ({ default: m.Tables }))
+)
+const Knowledge = lazy(() =>
+ import('@/app/workspace/[workspaceId]/knowledge/knowledge').then((m) => ({
+ default: m.Knowledge,
+ }))
+)
+const Logs = lazy(() => import('@/app/workspace/[workspaceId]/logs/logs'))
+const ScheduledTasks = lazy(() =>
+ import('@/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks').then((m) => ({
+ default: m.ScheduledTasks,
+ }))
+)
+const IntegrationBlockDetail = lazy(() =>
+ import('@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail').then(
+ (m) => ({ default: m.IntegrationBlockDetail })
+ )
+)
+
+/**
+ * Resolves an integration catalog entry from a resource tab id (the block's
+ * registry type). Catalog types may carry version suffixes (`gmail_v2`) while
+ * tab ids may use base types, so both forms are matched.
+ */
+function findIntegrationByBlockType(blockType: string): Integration | undefined {
+ return INTEGRATIONS.find(
+ (i) => i.type === blockType || stripVersionSuffix(i.type) === stripVersionSuffix(blockType)
+ )
+}
+
const LOADING_SKELETON = (
@@ -75,12 +110,15 @@ interface ResourceContentProps {
genericResourceData?: GenericResourceData
previewContextKey?: string
onNotFound?: (resourceId: string) => void
+ /** Opens another resource as a tab (used by embedded pages to open details in-panel). */
+ onAddResource?: (resource: MothershipResource) => void
}
/**
* Renders the content for the currently active mothership resource.
- * Handles table, file, and workflow resource types with appropriate
- * embedded rendering for each.
+ * Each persistable resource type gets an embedded view (table, file,
+ * workflow, knowledge base, folder, file folder, log, integration, page);
+ * types without one fall back to an explanatory placeholder panel.
*/
const STREAMING_EPOCH = new Date(0)
@@ -92,6 +130,7 @@ export const ResourceContent = memo(function ResourceContent({
genericResourceData,
previewContextKey,
onNotFound,
+ onAddResource,
}: ResourceContentProps) {
const streamFileName = previewSession?.fileName || 'file.md'
const syntheticFile = useMemo(() => {
@@ -182,6 +221,21 @@ export const ResourceContent = memo(function ResourceContent({
case 'folder':
return
+ case 'filefolder':
+ return (
+
+ )
+
+ case 'integration':
+ return (
+
+ )
+
case 'log':
return (
)
+ case 'page':
+ return
+
case 'generic':
return (
)
default:
- return null
+ return
}
})
+interface UnsupportedResourceContentProps {
+ resource: MothershipResource
+}
+
+/**
+ * Fallback for persisted tabs whose type has no embedded view (e.g. legacy
+ * `task` tabs). Shown instead of a blank panel so the tab stays explainable
+ * and removable.
+ */
+function UnsupportedResourceContent({ resource }: UnsupportedResourceContentProps) {
+ const Icon = getResourceConfig(resource.type).icon
+ return (
+
+
+
+
{resource.title}
+
+ This resource doesn't have an embedded view
+
+
+
+ )
+}
+
+interface EmbeddedPageProps {
+ pageId: string
+ onAddResource?: (resource: MothershipResource) => void
+}
+
+/**
+ * Renders a workspace area page (Tables, Knowledge Base, Logs, Scheduled
+ * Tasks) inside the chat's resource panel. Detail navigation is intercepted
+ * where the standalone page would route away: opening a table or knowledge
+ * base adds it as a sibling resource tab instead.
+ */
+function EmbeddedPage({ pageId, onAddResource }: EmbeddedPageProps) {
+ const content = (() => {
+ switch (pageId) {
+ case 'tables':
+ return (
+
+ onAddResource?.({ type: 'table', id: tableId, title: tableName })
+ }
+ />
+ )
+ case 'knowledge':
+ return (
+
+ onAddResource?.({
+ type: 'knowledgebase',
+ id: knowledgeBaseId,
+ title: knowledgeBaseName,
+ })
+ }
+ />
+ )
+ case 'logs':
+ return
+ case 'scheduled-tasks':
+ return
+ default:
+ return null
+ }
+ })()
+
+ if (!content) return null
+
+ return (
+
+ )
+}
+
+/**
+ * The resource switcher dropdown's contents: every open tab, grouped by
+ * provenance ("From this chat" vs the rest) when the active chat has surfaced
+ * any of them. Rows select on click and expose a hover close control.
+ * Spacing mirrors the workspace dropdown: rows 30px/rounded-lg/13px with a
+ * 2px gap, inside the popover's canonical 6px inset.
+ */
+export function ResourceSwitcherList({ items, onSelect, onClose }: ResourceSwitcherListProps) {
+ const chatItems = items.filter((item) => item.isChatArtifact)
+ const otherItems = items.filter((item) => !item.isChatArtifact)
+ const showSections = chatItems.length > 0 && otherItems.length > 0
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
index 3397ffdf15f..0adea97cd34 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
@@ -1,5 +1,11 @@
export const RESOURCE_TAB_GAP_CLASS = 'gap-1.5'
-export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'shrink-0 bg-transparent px-2 py-[5px] text-caption'
+/**
+ * Icon-button chrome for the resource panel's header controls, matching the
+ * chip-era canon used by the title-bar controls on the chat side: 30px square,
+ * rounded-lg, `--surface-active` hover.
+ */
+export const RESOURCE_TAB_ICON_BUTTON_CLASS =
+ 'size-[30px] shrink-0 rounded-lg bg-transparent p-0 hover-hover:bg-[var(--surface-active)]'
export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index dfab22cc4fd..945306adca5 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -10,15 +10,24 @@ import {
useRef,
useState,
} from 'react'
-import { Button, Tooltip } from '@/components/emcn'
-import { Columns3, Eye, PanelLeft, Pencil } from '@/components/emcn/icons'
+import {
+ Button,
+ chipVariants,
+ POPOVER_ANIMATION_CLASSES,
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+ Tooltip,
+} from '@/components/emcn'
+import { ChevronDown, Columns3, Eye, Pencil, X } from '@/components/emcn/icons'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
-import { isEphemeralResource } from '@/lib/copilot/resources/types'
import { cn } from '@/lib/core/utils/cn'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
+import { ResourcePanelToggle } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-panel-toggle'
+import { ResourceSwitcherList } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-switcher-list'
import {
RESOURCE_TAB_GAP_CLASS,
RESOURCE_TAB_ICON_BUTTON_CLASS,
@@ -30,17 +39,21 @@ import type {
} from '@/app/workspace/[workspaceId]/home/types'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
-import {
- useAddChatResource,
- useRemoveChatResource,
- useReorderChatResources,
-} from '@/hooks/queries/mothership-chats'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
-const EDGE_ZONE = 40
-const SCROLL_SPEED = 8
+/**
+ * Hard ceiling on a single tab's width — guards against pathological names.
+ * Matches the inline tabs' max-w so measurement and render agree.
+ */
+const TAB_MAX_WIDTH = 240
+/** Reserved width for the +N overflow chip when it renders. */
+const OVERFLOW_CHIP_WIDTH = 56
+/** Reserved width for the add-resource (+) button. */
+const ADD_BUTTON_WIDTH = 30
+/** Gap between strip items (gap-1.5). */
+const STRIP_GAP = 6
const ADD_RESOURCE_EXCLUDED_TYPES: readonly MothershipResourceType[] = ['folder', 'task'] as const
@@ -69,10 +82,10 @@ function findNearestId(
* snapshotted it.
*/
function buildMultiDragImage(
- scrollNode: HTMLElement | null,
+ stripNode: HTMLElement | null,
selected: MothershipResource[]
): HTMLElement | null {
- if (!scrollNode || selected.length === 0) return null
+ if (!stripNode || selected.length === 0) return null
const container = document.createElement('div')
Object.assign(container.style, {
position: 'fixed',
@@ -86,7 +99,7 @@ function buildMultiDragImage(
} satisfies Partial)
let appendedAny = false
for (const r of selected) {
- const original = scrollNode.querySelector(
+ const original = stripNode.querySelector(
`[data-resource-tab-id="${CSS.escape(r.id)}"]`
)
if (!original) continue
@@ -144,7 +157,6 @@ interface ResourceTabItemProps {
showGapBefore: boolean
showGapAfter: boolean
displayName: string
- chatId?: string
onDragStart: (e: React.DragEvent, idx: number) => void
onDragOver: (e: React.DragEvent, idx: number) => void
onDragLeave: () => void
@@ -154,6 +166,11 @@ interface ResourceTabItemProps {
onRemove: (e: React.SyntheticEvent, resource: MothershipResource) => void
}
+/**
+ * A tab at its natural width — labels never truncate (beyond the
+ * pathological-name ceiling); tabs that don't fit whole collapse into the +N
+ * dropdown instead. The active tab is highlighted in place, never moved.
+ */
const ResourceTabItem = memo(function ResourceTabItem({
resource,
idx,
@@ -164,7 +181,6 @@ const ResourceTabItem = memo(function ResourceTabItem({
showGapBefore,
showGapAfter,
displayName,
- chatId,
onDragStart,
onDragOver,
onDragLeave,
@@ -175,12 +191,12 @@ const ResourceTabItem = memo(function ResourceTabItem({
}: ResourceTabItemProps) {
const config = getResourceConfig(resource.type)
return (
-
+
{showGapBefore && (
)}
-
+
{showGapAfter && (
)}
@@ -245,6 +255,16 @@ interface ResourceTabsProps {
previewMode?: PreviewMode
onCyclePreviewMode?: () => void
actions?: ReactNode
+ /**
+ * Controls rendered before the tab strip (e.g. the sidebar toggle and
+ * compact chat switcher while the chat pane is hidden).
+ */
+ leading?: ReactNode
+ /**
+ * `type:id` keys of the artifacts the active chat has surfaced — used to
+ * group the switcher dropdown by provenance.
+ */
+ chatArtifactKeys?: ReadonlySet
}
export function ResourceTabs({
@@ -255,6 +275,8 @@ export function ResourceTabs({
previewMode,
onCyclePreviewMode,
actions,
+ leading,
+ chatArtifactKeys,
}: ResourceTabsProps) {
const PreviewModeIcon = PREVIEW_MODE_ICONS[previewMode ?? 'split']
const nameLookup = useResourceNameLookup(workspaceId)
@@ -263,53 +285,24 @@ export function ResourceTabs({
addResource: onAddResource,
removeResource: onRemoveResource,
reorderResources: onReorderResources,
- collapseResource,
} = useMothershipResources()
- const scrollNodeRef = useRef(null)
-
- useEffect(() => {
- const node = scrollNodeRef.current
- if (!node) return
- const handler = (e: WheelEvent) => {
- if (e.deltaY !== 0) {
- node.scrollLeft += e.deltaY
- e.preventDefault()
- }
- }
- node.addEventListener('wheel', handler, { passive: false })
- return () => node.removeEventListener('wheel', handler)
+ // Callback ref held in state so the capacity effect re-runs exactly when
+ // the strip node attaches — a plain ref can be null on the effect's first
+ // pass (hydration/transition commits), which would leave no observer behind
+ // and freeze capacity at zero.
+ const stripRef = useRef(null)
+ const [stripNode, setStripNode] = useState(null)
+ const attachStrip = useCallback((node: HTMLDivElement | null) => {
+ stripRef.current = node
+ setStripNode(node)
}, [])
- useEffect(() => {
- const node = scrollNodeRef.current
- if (!node || !activeId) return
- const tab = node.querySelector(`[data-resource-tab-id="${CSS.escape(activeId)}"]`)
- if (!tab) return
- // Use bounding rects because the tab's offsetParent is a `position: relative`
- // wrapper, so `offsetLeft` is relative to that wrapper rather than `node`.
- const tabRect = tab.getBoundingClientRect()
- const nodeRect = node.getBoundingClientRect()
- const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft
- const tabRight = tabLeft + tabRect.width
- const viewLeft = node.scrollLeft
- const viewRight = viewLeft + node.clientWidth
- if (tabLeft < viewLeft) {
- node.scrollTo({ left: tabLeft, behavior: 'smooth' })
- } else if (tabRight > viewRight) {
- node.scrollTo({ left: tabRight - node.clientWidth, behavior: 'smooth' })
- }
- }, [activeId])
-
- const addResource = useAddChatResource(chatId)
- const removeResource = useRemoveChatResource(chatId)
- const reorderResources = useReorderChatResources(chatId)
-
+ const [switcherOpen, setSwitcherOpen] = useState(false)
const [hoveredTabId, setHoveredTabId] = useState(null)
const [draggedIdx, setDraggedIdx] = useState(null)
const [dropGapIdx, setDropGapIdx] = useState(null)
const [selectedIds, setSelectedIds] = useState>(new Set())
const dragStartIdx = useRef(null)
- const autoScrollRaf = useRef(null)
const anchorIdRef = useRef(null)
const prevChatIdRef = useRef(chatId)
@@ -321,6 +314,118 @@ export function ResourceTabs({
anchorIdRef.current = null
}
+ // Tabs render in strip order and the active one is highlighted in place —
+ // selecting never repositions a visible tab. Only a tab surfacing from the
+ // +N dropdown joins the row (at the end), since it has no inline position.
+ const activeResource = useMemo(
+ () => resources.find((r) => r.id === activeId) ?? resources[0] ?? null,
+ [resources, activeId]
+ )
+
+ // Width-aware capacity: a hidden measuring row holds every tab at natural
+ // width; whole tabs are fitted in order. The inline renders don't affect the
+ // measured nodes, so there's no layout feedback loop.
+ const measureRef = useRef(null)
+ // Permissive until the first trustworthy measurement: all tabs inline (the
+ // panel's overflow-hidden clips any excess during the expand animation).
+ const [stripLayout, setStripLayout] = useState({
+ prefix: Number.MAX_SAFE_INTEGER,
+ appendActive: false,
+ })
+ const resourceCount = resources.length
+ const activeIdx = activeResource ? resources.indexOf(activeResource) : -1
+ // Names participate in fitting (tabs render at natural width), so capacity
+ // must recompute when any label changes — not just when counts do.
+ const namesKey = resources
+ .map((r) => nameLookup.get(`${r.type}:${r.id}`) ?? r.title)
+ .join('\u0000')
+ useEffect(() => {
+ if (!stripNode) return
+ const compute = () => {
+ // Zero width means the strip isn't laid out yet (panel collapsed or
+ // mid-animation) — keep the previous layout rather than trusting it.
+ if (stripNode.clientWidth === 0) return
+ const measureNode = measureRef.current
+ if (!measureNode) return
+ const tabWidths = Array.from(measureNode.children).map((child) =>
+ Math.min((child as HTMLElement).offsetWidth, TAB_MAX_WIDTH)
+ )
+ const addWidth = chatId ? ADD_BUTTON_WIDTH + STRIP_GAP : 0
+ const available = stripNode.clientWidth - addWidth
+ const apply = (next: { prefix: number; appendActive: boolean }) =>
+ setStripLayout((prev) =>
+ prev.prefix === next.prefix && prev.appendActive === next.appendActive ? prev : next
+ )
+ const totalAll = tabWidths.reduce((sum, w) => sum + w + STRIP_GAP, 0)
+ if (totalAll <= available + STRIP_GAP) {
+ apply({ prefix: resourceCount, appendActive: false })
+ return
+ }
+ const fitPrefix = (budget: number) => {
+ let used = 0
+ let fit = 0
+ for (const width of tabWidths) {
+ if (used + width > budget) break
+ used += width + STRIP_GAP
+ fit += 1
+ }
+ return fit
+ }
+ const budget = available - (OVERFLOW_CHIP_WIDTH + STRIP_GAP)
+ const prefix = fitPrefix(budget)
+ // The active tab must stay visible: when it lands beyond the fit, append
+ // it after the prefix, reserving its width.
+ if (activeIdx >= prefix && activeIdx >= 0) {
+ apply({
+ prefix: fitPrefix(budget - (tabWidths[activeIdx] + STRIP_GAP)),
+ appendActive: true,
+ })
+ return
+ }
+ apply({ prefix, appendActive: false })
+ }
+ compute()
+ // The strip resizes with every panel/sidebar/window change, so observing
+ // it covers all reflow sources; the window listener is a fallback for the
+ // first paint after the expand animation.
+ const observer = new ResizeObserver(compute)
+ observer.observe(stripNode)
+ window.addEventListener('resize', compute)
+ return () => {
+ observer.disconnect()
+ window.removeEventListener('resize', compute)
+ }
+ }, [stripNode, resourceCount, chatId, activeIdx, namesKey])
+
+ const prefixTabs = resources.slice(0, Math.min(stripLayout.prefix, resources.length))
+ const inlineTabs =
+ stripLayout.appendActive && activeResource && !prefixTabs.includes(activeResource)
+ ? [...prefixTabs, activeResource]
+ : prefixTabs
+ const overflowTabs = resources.filter((r) => !inlineTabs.includes(r))
+
+ const resolveName = useCallback(
+ (resource: MothershipResource) =>
+ nameLookup.get(`${resource.type}:${resource.id}`) ?? resource.title,
+ [nameLookup]
+ )
+
+ // Only the tabs that didn't fit inline — the dropdown never duplicates
+ // what the strip already shows.
+ const overflowItems = useMemo(
+ () =>
+ overflowTabs.map((resource) => {
+ const key = `${resource.type}:${resource.id}`
+ return {
+ resource,
+ name: resolveName(resource),
+ isActive: false,
+ isChatArtifact: chatArtifactKeys?.has(key) ?? false,
+ }
+ }),
+ [overflowTabs, resolveName, chatArtifactKeys]
+ )
+
const existingKeys = useMemo(
() => new Set(resources.map((r) => `${r.type}:${r.id}`)),
[resources]
@@ -328,29 +433,40 @@ export function ResourceTabs({
const handleAdd = useCallback(
(resource: MothershipResource) => {
- if (!chatId) return
- addResource.mutate({ chatId, resource })
onAddResource(resource)
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [chatId, onAddResource]
+ [onAddResource]
+ )
+
+ const handleSwitcherSelect = useCallback(
+ (id: string) => {
+ setSwitcherOpen(false)
+ selectResource(id)
+ },
+ [selectResource]
+ )
+
+ const handleSwitcherClose = useCallback(
+ (resource: MothershipResource) => {
+ onRemoveResource(resource.type, resource.id)
+ },
+ [onRemoveResource]
)
const handleTabClick = useCallback(
(e: React.MouseEvent, idx: number) => {
- const resource = resources[idx]
+ const resource = inlineTabs[idx]
if (!resource) return
- // Shift+click: contiguous range from anchor
+ // Shift+click: contiguous range from anchor (within the visible strip)
if (e.shiftKey) {
- // Fall back to activeId when no explicit anchor exists (e.g. tab opened via sidebar)
- const anchorId = anchorIdRef.current ?? activeId
- const anchorIdx = anchorId ? resources.findIndex((r) => r.id === anchorId) : -1
+ const anchorId = anchorIdRef.current
+ const anchorIdx = anchorId ? inlineTabs.findIndex((r) => r.id === anchorId) : -1
if (anchorIdx !== -1) {
const start = Math.min(anchorIdx, idx)
const end = Math.max(anchorIdx, idx)
const next = new Set()
- for (let i = start; i <= end; i++) next.add(resources[i].id)
+ for (let i = start; i <= end; i++) next.add(inlineTabs[i].id)
setSelectedIds(next)
selectResource(resource.id)
return
@@ -364,10 +480,9 @@ export function ResourceTabs({
const next = new Set(selectedIds)
next.delete(resource.id)
setSelectedIds(next)
- // Only switch active if we just deselected the currently-active tab
if (activeId === resource.id) {
const fallback =
- findNearestId(resources, idx, next) ?? findNearestId(resources, idx, null)
+ findNearestId(inlineTabs, idx, next) ?? findNearestId(inlineTabs, idx, null)
if (fallback) selectResource(fallback)
}
} else {
@@ -383,16 +498,16 @@ export function ResourceTabs({
setSelectedIds(new Set([resource.id]))
selectResource(resource.id)
},
- [resources, selectResource, selectedIds, activeId]
+ [inlineTabs, selectResource, selectedIds, activeId]
)
const handleRemove = useCallback(
(e: React.SyntheticEvent, resource: MothershipResource) => {
e.stopPropagation()
- if (!chatId) return
const isMulti = selectedIds.has(resource.id) && selectedIds.size > 1
const targets = isMulti ? resources.filter((r) => selectedIds.has(r.id)) : [resource]
- // Update parent state immediately for all targets
+ // Closing tabs is a session action — it never detaches the artifact from
+ // the chat that surfaced it.
for (const r of targets) {
onRemoveResource(r.type, r.id)
}
@@ -406,25 +521,20 @@ export function ResourceTabs({
if (anchorIdRef.current && removedIds.has(anchorIdRef.current)) {
anchorIdRef.current = null
}
- for (const r of targets) {
- if (isEphemeralResource(r)) continue
- removeResource.mutate({ chatId, resourceType: r.type, resourceId: r.id })
- }
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [chatId, onRemoveResource, resources, selectedIds]
+ [onRemoveResource, resources, selectedIds]
)
const handleDragStart = useCallback(
(e: React.DragEvent, idx: number) => {
- const resource = resources[idx]
+ const resource = inlineTabs[idx]
if (!resource) return
- const selected = resources.filter((r) => selectedIds.has(r.id))
+ const selected = inlineTabs.filter((r) => selectedIds.has(r.id))
const isMultiDrag = selected.length > 1 && selectedIds.has(resource.id)
if (isMultiDrag) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(selected))
- const dragImage = buildMultiDragImage(scrollNodeRef.current, selected)
+ const dragImage = buildMultiDragImage(stripRef.current, selected)
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 16, 16)
setTimeout(() => dragImage.remove(), 0)
@@ -443,179 +553,161 @@ export function ResourceTabs({
JSON.stringify({ type: resource.type, id: resource.id, title: resource.title })
)
},
- [resources, selectedIds]
+ [inlineTabs, selectedIds]
)
- const stopAutoScroll = useCallback(() => {
- if (autoScrollRaf.current) {
- cancelAnimationFrame(autoScrollRaf.current)
- autoScrollRaf.current = null
- }
+ const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ const rect = e.currentTarget.getBoundingClientRect()
+ const midpoint = rect.left + rect.width / 2
+ const gap = e.clientX < midpoint ? idx : idx + 1
+ setDropGapIdx(gap)
}, [])
- const startEdgeScroll = useCallback(
- (clientX: number) => {
- const container = scrollNodeRef.current
- if (!container) return
- const cRect = container.getBoundingClientRect()
- if (autoScrollRaf.current) cancelAnimationFrame(autoScrollRaf.current)
- if (clientX < cRect.left + EDGE_ZONE) {
- const tick = () => {
- container.scrollLeft -= SCROLL_SPEED
- autoScrollRaf.current = requestAnimationFrame(tick)
- }
- autoScrollRaf.current = requestAnimationFrame(tick)
- } else if (clientX > cRect.right - EDGE_ZONE) {
- const tick = () => {
- container.scrollLeft += SCROLL_SPEED
- autoScrollRaf.current = requestAnimationFrame(tick)
- }
- autoScrollRaf.current = requestAnimationFrame(tick)
- } else {
- stopAutoScroll()
- }
- },
- [stopAutoScroll]
- )
-
- const handleDragOver = useCallback(
- (e: React.DragEvent, idx: number) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- const rect = e.currentTarget.getBoundingClientRect()
- const midpoint = rect.left + rect.width / 2
- const gap = e.clientX < midpoint ? idx : idx + 1
- setDropGapIdx(gap)
- startEdgeScroll(e.clientX)
- },
- [startEdgeScroll]
- )
-
const handleDragLeave = useCallback(() => {
setDropGapIdx(null)
- stopAutoScroll()
- }, [stopAutoScroll])
+ }, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
- stopAutoScroll()
const fromIdx = dragStartIdx.current
const gapIdx = dropGapIdx
- if (fromIdx === null || gapIdx === null) {
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
- return
- }
- const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx
- if (insertAt === fromIdx) {
- setDraggedIdx(null)
- setDropGapIdx(null)
- dragStartIdx.current = null
- return
- }
- const reordered = [...resources]
- const [moved] = reordered.splice(fromIdx, 1)
- reordered.splice(insertAt, 0, moved)
- onReorderResources(reordered)
- if (chatId) {
- const persistable = reordered.filter((r) => !isEphemeralResource(r))
- if (persistable.length > 0) {
- reorderResources.mutate({ chatId, resources: persistable })
- }
- }
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
+ if (fromIdx === null || gapIdx === null) return
+ const insertAt = gapIdx > fromIdx ? gapIdx - 1 : gapIdx
+ if (insertAt === fromIdx) return
+ const reorderedInline = [...inlineTabs]
+ const [moved] = reorderedInline.splice(fromIdx, 1)
+ reorderedInline.splice(insertAt, 0, moved)
+ // The strip's visual order is [inline..., overflow...] — persist exactly
+ // that so the view and store never disagree.
+ onReorderResources([...reorderedInline, ...overflowTabs])
},
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [chatId, resources, onReorderResources, dropGapIdx, stopAutoScroll]
+ [inlineTabs, overflowTabs, onReorderResources, dropGapIdx]
)
const handleDragEnd = useCallback(() => {
- stopAutoScroll()
setDraggedIdx(null)
setDropGapIdx(null)
dragStartIdx.current = null
- }, [stopAutoScroll])
+ }, [])
return (
-
-
-
-
-
-
Collapse
-
-
-
+ {leading}
+ {/* Without leading controls, the strip starts at the bar's left edge —
+ pull it out 9px so the first tab sits 7px from the edge, matching
+ the edge icon buttons' equal-distance rhythm. */}
+
e.preventDefault()}
+ onDrop={handleDrop}
+ >
+ {/* Hidden measuring row: every tab at natural width, so the capacity
+ pass fits whole tabs instead of guessing slot sizes. */}
+
+
+ )}
+ {/* Inert spacer reserving the toggle's exact footprint at the far right.
+ The real, interactive toggle is rendered absolutely in home.tsx and
+ overlays this spot, so it never moves when the panel collapses. Pulled
+ out 9px so the hover pill sits 7px from the edge (equal to its 7px
+ top/bottom gap in the bar). */}
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
index ae6857a1044..8f538991d52 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx
@@ -1,9 +1,10 @@
'use client'
-import { forwardRef, memo, useState } from 'react'
+import { forwardRef, memo, type ReactNode, useState } from 'react'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { cn } from '@/lib/core/utils/cn'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
+import { SidebarToggleHidden } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { RICH_PREVIEWABLE_EXTENSIONS } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { useMothershipResources } from '@/app/workspace/[workspaceId]/home/components/mothership-resources-context'
@@ -48,6 +49,13 @@ interface MothershipViewProps {
className?: string
previewSession?: FilePreviewSession | null
genericResourceData?: GenericResourceData
+ /** Controls rendered before the tab strip (see {@link ResourceTabs}). */
+ tabsLeading?: ReactNode
+ /**
+ * `type:id` keys of the artifacts the active chat has surfaced — used to
+ * group the resource switcher dropdown by provenance.
+ */
+ chatArtifactKeys?: ReadonlySet
}
export const MothershipView = memo(
@@ -61,12 +69,14 @@ export const MothershipView = memo(
className,
previewSession,
genericResourceData,
+ tabsLeading,
+ chatArtifactKeys,
}: MothershipViewProps,
ref
) {
const active = resources.find((r) => r.id === activeResourceId) ?? resources[0] ?? null
const { canEdit } = useUserPermissionsContext()
- const { removeResource } = useMothershipResources()
+ const { addResource, removeResource } = useMothershipResources()
const previewForActive =
previewSession && active && shouldShowStreamingFilePanel(previewSession, active)
@@ -100,6 +110,8 @@ export const MothershipView = memo(
Click "+" above to add a resource
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
index 95be7793ae5..72844427ef3 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx
@@ -1,8 +1,16 @@
'use client'
import { useCallback, useRef, useState } from 'react'
-import { ArrowUp, ChevronDown, ChevronRight, Paperclip, Pencil, Trash2, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
+import {
+ ArrowUp,
+ ChevronDown,
+ ChevronRight,
+ Paperclip,
+ Pencil,
+ Trash2,
+ X,
+} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
index e71c4ed5fae..1ca03c86ae0 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -4,14 +4,8 @@ import { type ComponentType, type CSSProperties, useMemo, useState } from 'react
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
-import {
- ArrowRight,
- ChevronDown,
- chipVariants,
- Expandable,
- ExpandableContent,
-} from '@/components/emcn'
-import { Shuffle, Table } from '@/components/emcn/icons'
+import { ChevronDown, chipVariants, Expandable, ExpandableContent } from '@/components/emcn'
+import { ArrowRight2, Shuffle, Table } from '@/components/emcn/icons'
import { GmailIcon, SlackIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import {
@@ -370,7 +364,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
Suggested actions
@@ -413,7 +407,7 @@ export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
{action.label}
-
+
)
})}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
index 36690900b35..b0e83793c53 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
@@ -1,8 +1,8 @@
'use client'
import React from 'react'
-import { X } from 'lucide-react'
import { Loader, Tooltip } from '@/components/emcn'
+import { X } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
index 76cb3079af3..fb8ad2ccc45 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
@@ -109,6 +109,11 @@ const RESOURCE_TO_CONTEXT: Record<
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
+ page: (r) => {
+ if (r.id === 'logs') return { kind: 'logs', label: r.title }
+ if (r.id === 'knowledge') return { kind: 'knowledge', label: r.title }
+ return { kind: 'docs', label: r.title }
+ },
generic: (r) => ({ kind: 'docs', label: r.title }),
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
index 3eedfd22d3f..f6ad81a75dd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
@@ -1,7 +1,8 @@
'use client'
import React from 'react'
-import { ArrowUp, Button } from '@/components/emcn'
+import { Button } from '@/components/emcn'
+import { ArrowUp2 } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import {
SEND_BUTTON_ACTIVE,
@@ -47,7 +48,7 @@ export const SendButton = React.memo(function SendButton({
disabled={!canSubmit}
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
>
-
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index aaaa2a0659d..7ccd0baf17f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -1330,7 +1330,7 @@ export const UserInput = forwardRef(function Us
{
+ clearWidth()
+ setIsChatCollapsed(true)
+ }, [clearWidth])
+
+ const reopenChatPane = useCallback(() => setIsChatCollapsed(false), [])
+
+ // The tab strip is user-owned per workspace (browser-tab semantics): chats
+ // merge their artifacts in additively; only the user closes/reorders tabs.
+ const workspaceTabs = useMothershipTabsStore((s) => s.byWorkspace[workspaceId])
+ const openTabs = useMothershipTabsStore((s) => s.openTabs)
+ const closeTab = useMothershipTabsStore((s) => s.closeTab)
+ const reorderTabs = useMothershipTabsStore((s) => s.reorderTabs)
+ const setActiveTab = useMothershipTabsStore((s) => s.setActiveTab)
+ const storeTabs = workspaceTabs?.tabs
+ const storeActiveTabId = workspaceTabs?.activeTabId ?? null
+
function handleResourceEvent() {
if (isResourceCollapsedRef.current) {
setIsResourceCollapsed(false)
@@ -158,12 +183,10 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
sendMessage,
stopGeneration,
resolvedChatId,
+ adoptResolvedChatId,
resources,
- activeResourceId,
- setActiveResourceId,
addResource,
removeResource,
- reorderResources,
messageQueue,
removeFromQueue,
sendNow,
@@ -179,6 +202,12 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
chatId,
getMothershipUseChatOptions({
onResourceEvent: handleResourceEvent,
+ // The panel follows the conversation: any resource the agent touches —
+ // even one that's already an open tab — surfaces and takes focus, so
+ // "switch to X" in chat actually switches the strip.
+ onResourceTouched: (resource) => {
+ openTabs(workspaceId, [resource], { focusId: resource.id })
+ },
initialActiveResourceId: initialResourceId,
onRequestStarted: ({ requestId, userMessageId }) => {
captureEvent(posthogRef.current, 'task_request_started', {
@@ -191,16 +220,86 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
})
)
+ // The panel renders the workspace tab strip plus the active chat's ephemeral
+ // resources (in-flight streaming previews stay chat-scoped, never persisted).
+ const ephemeralResources = useMemo(() => resources.filter(isEphemeralResource), [resources])
+ const panelTabs = useMemo(
+ () => [...(storeTabs ?? []), ...ephemeralResources],
+ [storeTabs, ephemeralResources]
+ )
+ const chatArtifactKeys = useMemo(
+ () => new Set(resources.filter((r) => !isEphemeralResource(r)).map((r) => `${r.type}:${r.id}`)),
+ [resources]
+ )
+
+ // Merge the active chat's artifacts into the strip. Tracking merged keys per
+ // chat means a tab the user closed mid-chat isn't resurrected by the next
+ // render, while re-entering the chat later re-opens its artifacts. The last
+ // fresh artifact gets focus (on switch that's the chat's most recent one; on
+ // a live stream it's the resource the agent just touched).
+ const mergedChatKeyRef = useRef(null)
+ const mergedKeysRef = useRef>(new Set())
+ const initialResourceIdRef = useRef(initialResourceId)
+ useEffect(() => {
+ const chatKey = resolvedChatId ?? chatId ?? 'new'
+ if (mergedChatKeyRef.current !== chatKey) {
+ mergedChatKeyRef.current = chatKey
+ mergedKeysRef.current = new Set()
+ }
+ const fresh = resources.filter(
+ (r) => !isEphemeralResource(r) && !mergedKeysRef.current.has(`${r.type}:${r.id}`)
+ )
+ if (fresh.length === 0) return
+ for (const r of fresh) mergedKeysRef.current.add(`${r.type}:${r.id}`)
+ const urlFocus = initialResourceIdRef.current
+ initialResourceIdRef.current = null
+ // A URL-pinned resource wins outright: if it's one of this chat's fresh
+ // artifacts, focus it; otherwise it's already focused in the strip (the
+ // page the user opened the chat from), so the merge must not steal focus.
+ const focusId = urlFocus
+ ? fresh.some((r) => r.id === urlFocus)
+ ? urlFocus
+ : undefined
+ : fresh[fresh.length - 1].id
+ openTabs(workspaceId, fresh, focusId ? { focusId } : undefined)
+ }, [resources, resolvedChatId, chatId, workspaceId, openTabs])
+
+ const handleSelectTab = useCallback(
+ (id: string) => {
+ setActiveTab(workspaceId, id)
+ },
+ [setActiveTab, workspaceId]
+ )
+
+ const handleCloseTab = useCallback(
+ (resourceType: MothershipResourceType, resourceId: string) => {
+ closeTab(workspaceId, resourceType, resourceId)
+ },
+ [closeTab, workspaceId]
+ )
+
+ // Focus newly-appearing ephemeral resources (e.g. a streaming file preview),
+ // mirroring how the chat focuses artifacts it touches.
+ const prevEphemeralKeysRef = useRef>(new Set())
+ useEffect(() => {
+ const keys = new Set(ephemeralResources.map((r) => `${r.type}:${r.id}`))
+ const fresh = ephemeralResources.find(
+ (r) => !prevEphemeralKeysRef.current.has(`${r.type}:${r.id}`)
+ )
+ prevEphemeralKeysRef.current = keys
+ if (fresh) setActiveTab(workspaceId, fresh.id)
+ }, [ephemeralResources, setActiveTab, workspaceId])
+
useEffect(() => {
const url = new URL(window.location.href)
- if (activeResourceId) {
- url.searchParams.set('resource', activeResourceId)
+ if (storeActiveTabId) {
+ url.searchParams.set('resource', storeActiveTabId)
} else {
url.searchParams.delete('resource')
}
url.hash = ''
window.history.replaceState(null, '', url.toString())
- }, [activeResourceId])
+ }, [storeActiveTabId])
useEffect(() => {
wasSendingRef.current = false
@@ -220,18 +319,18 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
}, [isSending, resolvedChatId, markRead])
useEffect(() => {
- if (!(resources.length > 0 && isResourceCollapsedRef.current)) return
+ if (!(panelTabs.length > 0 && isResourceCollapsedRef.current)) return
setIsResourceCollapsed(false)
setSkipResourceTransition(true)
const id = requestAnimationFrame(() => setSkipResourceTransition(false))
return () => cancelAnimationFrame(id)
- }, [resources])
+ }, [panelTabs])
useEffect(() => {
- if (resources.length === 0 && !isResourceCollapsedRef.current) {
+ if (panelTabs.length === 0 && !isResourceCollapsedRef.current) {
collapseResource()
}
- }, [resources, collapseResource])
+ }, [panelTabs, collapseResource])
function handleStopGeneration() {
captureEvent(posthogRef.current, 'task_generation_aborted', {
@@ -264,6 +363,24 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
}
+ /**
+ * Opens an existing chat from the All Chats list WITHOUT navigating. Adopting
+ * the chat id repoints `useChat` at its history (so messages hydrate in place)
+ * and rewrites the URL to /task/[id] via replaceState; flipping
+ * `isInputEntering` plays the same slide-in morph as sending a new message.
+ */
+ const handleOpenExistingChat = useCallback(
+ (selectedChatId: string) => {
+ captureEvent(posthogRef.current, 'task_opened_from_history', {
+ workspace_id: workspaceId,
+ chat_id: selectedChatId,
+ })
+ setIsInputEntering(true)
+ adoptResolvedChatId(selectedChatId, { replaceHomeHistory: true })
+ },
+ [adoptResolvedChatId, workspaceId]
+ )
+
useEffect(() => {
const handler = (e: Event) => {
const message = (e as CustomEvent).detail?.message
@@ -291,17 +408,30 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
}
}
+ /**
+ * Manually attaching a resource opens its tab (session) AND records it on
+ * the chat (provenance + agent context) via {@link addResource}, which keeps
+ * the existing persistence machinery.
+ */
+ function openResourceTab(resource: MothershipResource) {
+ openTabs(workspaceId, [resource], { focusId: resource.id })
+ addResource(resource)
+ handleResourceEvent()
+ }
+
function handleContextAdd(context: ChatContext) {
const resolved = resolveResourceFromContext(context)
if (resolved) {
- addResource({ ...resolved, title: context.label })
- handleResourceEvent()
+ openResourceTab({ ...resolved, title: context.label })
}
}
function handleInitialContextRemove(context: ChatContext) {
const resolved = resolveResourceFromContext(context)
if (!resolved) return
+ // Symmetric un-attach: the chip was just added by the same flow, so this
+ // also detaches it from the chat rather than only closing the tab.
+ closeTab(workspaceId, resolved.type, resolved.id)
removeResource(resolved.type, resolved.id)
}
@@ -356,46 +486,69 @@ export function Home({ chatId, userName, userId, initialResourceId = null }: Hom
)
function handleWorkspaceResourceSelect(resource: MothershipResource) {
- const resolvedResource = resolveFileResource(resource)
- const wasAdded = addResource(resolvedResource)
- if (!wasAdded) {
- setActiveResourceId(resolvedResource.id)
- }
- handleResourceEvent()
+ openResourceTab(resolveFileResource(resource))
}
+ // `resolvedChatId` is the chat actually in view — the prop on direct nav, or
+ // the id adopted when opening a chat inline from the All Chats list. Gating on
+ // it (not just the prop) lets an inline-opened chat render its skeleton + view
+ // before its history finishes loading.
+ const activeChatId = resolvedChatId ?? chatId
+ const { isPending: isActiveChatHistoryPending } = useMothershipChatHistory(activeChatId)
const hasMessages = messages.length > 0
- const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending
+ const showChatSkeleton = Boolean(activeChatId) && !hasMessages && isActiveChatHistoryPending
+ const showChatView = hasMessages || showChatSkeleton || Boolean(resolvedChatId)
const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}`
+ const canCloseChat = panelTabs.length > 0 && !isResourceCollapsed
+
+ // The chat pane can only hide while the resource panel is visible; restore it
+ // when the panel collapses or the last tab closes so the view never blanks.
+ useEffect(() => {
+ if (isChatCollapsed && (panelTabs.length === 0 || isResourceCollapsed)) {
+ setIsChatCollapsed(false)
+ }
+ }, [isChatCollapsed, panelTabs, isResourceCollapsed])
+
+ // Opening a different chat from anywhere (title-bar dropdown, search, deep
+ // link) is an explicit "open this chat" — always show its conversation pane.
+ useEffect(() => {
+ setIsChatCollapsed(false)
+ }, [activeChatId])
- if (!hasMessages && !showChatSkeleton) {
+ if (!showChatView) {
return (
-
+
+
- {/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
-
-
- What should we get done{firstName ? `, ${firstName}` : ''}?
-
-
-
-
-
- {/* Anchored out of flow so expanding/collapsing never shifts the centered input */}
-
+
+
+
+ What should we get done{firstName ? `, ${firstName}` : ''}?
+
+
+ {/* Stacked card (Figma node 1-3): grey tray sits behind the input
+ with a 1px frame on top/sides. The docked "All Chats" launcher
+ lives in the shelf below and animates the chat list open inside
+ the grey tray — growing downward as the input rides up. */}
+
- )}
+ {/* Single, stationary collapse/expand toggle. Lives OUTSIDE the animating
+ panel and is always rendered at the fixed top-right corner, overlaying
+ the header's spacer when open — so it never moves as the panel slides. */}
+ (isResourceCollapsed ? setIsResourceCollapsed(false) : collapseResource())}
+ className='absolute top-[7px] right-[7px] z-30'
+ />
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
index 53b75ebe0f6..29769dd3441 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
@@ -101,6 +101,10 @@ export interface UseChatReturn {
isReconnecting: boolean
error: string | null
resolvedChatId: string | undefined
+ adoptResolvedChatId: (
+ chatId: string,
+ options?: { replaceHomeHistory?: boolean; invalidateList?: boolean }
+ ) => void
sendMessage: (
message: string,
fileAttachments?: FileAttachmentForApi[],
@@ -977,6 +981,13 @@ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId
export interface UseChatOptions {
onResourceEvent?: () => void
+ /**
+ * Fired when the agent touches a resource — created, updated, or read —
+ * whether or not it was already an artifact. Lets the host surface and
+ * focus the resource's tab so the panel follows the conversation
+ * ("switch to Telecom_Leads_CRM" actually switches).
+ */
+ onResourceTouched?: (resource: MothershipResource) => void
apiPath?: string
stopPath?: string
workflowId?: string
@@ -1003,7 +1014,11 @@ interface StopGenerationOptions {
export function getMothershipUseChatOptions(
options: Pick<
UseChatOptions,
- 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId' | 'onRequestStarted'
+ | 'onResourceEvent'
+ | 'onResourceTouched'
+ | 'onStreamEnd'
+ | 'initialActiveResourceId'
+ | 'onRequestStarted'
> = {}
): UseChatOptions {
return {
@@ -1048,6 +1063,8 @@ export function useChat(
const onResourceEventRef = useRef(options?.onResourceEvent)
const revealedSimKeysRef = useRef(new Map())
onResourceEventRef.current = options?.onResourceEvent
+ const onResourceTouchedRef = useRef(options?.onResourceTouched)
+ onResourceTouchedRef.current = options?.onResourceTouched
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
apiPathRef.current = options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH
const stopPathRef = useRef(options?.stopPath ?? '/api/mothership/chat/stop')
@@ -1549,15 +1566,17 @@ export function useChat(
}
const meta = getWorkflowById(workspaceId, targetWorkflowId)
- const wasAdded = addResource({
+ const workflowResource: MothershipResource = {
type: 'workflow',
id: targetWorkflowId,
title: meta?.name ?? 'Workflow',
- })
+ }
+ const wasAdded = addResource(workflowResource)
if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) {
setActiveResourceId(targetWorkflowId)
}
onResourceEventRef.current?.()
+ onResourceTouchedRef.current?.(workflowResource)
return targetWorkflowId
},
@@ -1913,7 +1932,17 @@ export function useChat(
setResolvedChatId,
setResources,
setActiveResourceId,
- addResource,
+ /**
+ * The stream handlers call `addResource` for every resource the agent
+ * touches (read-tool results, resource upserts) — piggyback the
+ * touched hook here so the host can surface/focus the tab even when
+ * the resource is already attached (`addResource` returns false).
+ */
+ addResource: (resource) => {
+ const wasAdded = addResource(resource)
+ onResourceTouchedRef.current?.(resource)
+ return wasAdded
+ },
removeResource,
startClientWorkflowTool,
upsertMothershipChatHistory: upsertChatHistory,
@@ -4158,6 +4187,7 @@ export function useChat(
isReconnecting,
error,
resolvedChatId,
+ adoptResolvedChatId,
sendMessage,
stopGeneration,
resources,
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
index 2868326b928..7e720170849 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx
@@ -1,10 +1,10 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { Chip, ChipDropdown, ChipLink } from '@/components/emcn'
+import { ArrowLeft, ArrowRight, Plus } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import {
blockTypeToIconMap,
@@ -42,9 +42,15 @@ const TEMPLATE_TILE_Z = ['z-30', 'z-20', 'z-10'] as const
interface IntegrationBlockDetailProps {
integration: Integration
workspaceId: string
+ /** Hides full-page chrome (the back link) when rendered inside the chat resource panel. */
+ embedded?: boolean
}
-export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
+export function IntegrationBlockDetail({
+ integration,
+ workspaceId,
+ embedded = false,
+}: IntegrationBlockDetailProps) {
useOAuthReturnRouter()
const router = useRouter()
const pathname = usePathname()
@@ -124,9 +130,11 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
return (
{oauthService ? (
hasServiceAccount ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx
index 8b40a489734..66118bc7359 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx
@@ -1,9 +1,9 @@
'use client'
import { useMemo, useRef, useState } from 'react'
-import { Check, Plus } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { Chip, toast } from '@/components/emcn'
+import { Check, Plus } from '@/components/emcn/icons'
import { captureEvent } from '@/lib/posthog/client'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import type { SuggestedSkill } from '@/blocks/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx
index 95ef5a0ccd7..951310df5d9 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
import { ChipLink } from '@/components/emcn'
+import { ChatSwitcher } from '@/app/workspace/[workspaceId]/components/chat-switcher'
+import { SidebarToggle } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
@@ -18,7 +20,16 @@ export function IntegrationTabsHeader({
rightSlot,
}: IntegrationTabsHeaderProps) {
return (
-
+
+ {/* Chrome controls match ResourceHeader's toggle+switcher cluster (9px
+ pull-out, gap-1 rhythm) inside the canonical 44px bar, so the pair
+ lands on the same 7px/7px spot as every other page and never shifts
+ during navigation. The 44px bar keeps the 27px chips at the same
+ 8.5px inset the old padding produced. */}
+
+
+
+
Integrations
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx
index 73666025dec..bc2653e7405 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Chip } from '@/components/emcn'
+import { ArrowRight } from '@/components/emcn/icons'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
index 6c077e94dd6..13811e7b907 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
@@ -271,7 +271,7 @@ export function Integrations() {
? selectedCategory
: formatIntegrationType(selectedCategory)}
-
+
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index 3ccdc46f638..de6791838ab 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -2,10 +2,18 @@
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
-import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@/components/emcn'
-import { Database } from '@/components/emcn/icons'
+import { Badge, ChipCombobox, ChipConfirmModal } from '@/components/emcn'
+import {
+ ChevronDown,
+ ChevronUp,
+ Database,
+ FileText,
+ Pencil,
+ Plus,
+ TagIcon,
+ Trash,
+} from '@/components/emcn/icons'
import { SearchHighlight } from '@/components/ui/search-highlight'
import type { ChunkData } from '@/lib/knowledge/types'
import { formatTokenCount } from '@/lib/tokenization'
@@ -479,7 +487,7 @@ export function Document({
...(userPermissions.canEdit
? [
{ label: 'Rename', icon: Pencil, onClick: handleStartDocRename },
- { label: 'Tags', icon: Tag, onClick: handleShowTags },
+ { label: 'Tags', icon: TagIcon, onClick: handleShowTags },
{ label: 'Delete', icon: Trash, onClick: handleShowDeleteDoc },
]
: []),
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
index 0bdb64ee9a0..866b2f39d8f 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
@@ -1,8 +1,7 @@
'use client'
-import { FileText } from 'lucide-react'
import { Plus } from '@/components/emcn'
-import { Database } from '@/components/emcn/icons'
+import { Database, FileText } from '@/components/emcn/icons'
import {
type BreadcrumbItem,
type ChromeActionSpec,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index f0bbc811cec..2a8c68d1420 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { format } from 'date-fns'
-import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react'
import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import {
@@ -27,7 +26,7 @@ import {
Tooltip,
Trash,
} from '@/components/emcn'
-import { Database, DatabaseX } from '@/components/emcn/icons'
+import { CircleAlert, Database, DatabaseX, Pencil, Plus, TagIcon, X } from '@/components/emcn/icons'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { cn } from '@/lib/core/utils/cn'
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
@@ -131,7 +130,7 @@ const getStatusBadge = (doc: DocumentData) => {
)
case 'failed':
return doc.processingError ? (
-
+
Failed
) : (
@@ -826,7 +825,7 @@ export function KnowledgeBase({
},
{
label: 'Tags',
- icon: Tag,
+ icon: TagIcon,
disabled: !userPermissions.canEdit,
onClick: () => setShowTagsModal(true),
},
@@ -1542,7 +1541,7 @@ function TagFilterValueControl({ entry, onChange }: TagFilterValueControlProps)
}
/**
- * Tag filter section rendered inside the combined filter popover.
+ * TagIcon filter section rendered inside the combined filter popover.
*/
function TagFilterSection({ tagDefinitions, entries, onChange }: TagFilterSectionProps) {
const activeCount = entries.filter((f) => f.tagSlot && f.value.trim()).length
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
index f82e10a7ea8..56ebce0548c 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/action-bar/action-bar.tsx
@@ -1,6 +1,6 @@
import { domAnimation, LazyMotion, m } from 'framer-motion'
-import { Circle, CircleOff } from 'lucide-react'
import { Button, Tooltip, Trash2 } from '@/components/emcn'
+import { Circle, CircleOff } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
index d0d6d21d783..f3bf79af6b2 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx
@@ -1,7 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
-import { ArrowLeft, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
ArrowRight,
@@ -20,6 +19,7 @@ import {
type ComboboxOption,
Search,
} from '@/components/emcn'
+import { ArrowLeft, Plus } from '@/components/emcn/icons'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { cn } from '@/lib/core/utils/cn'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
index 4b6967a3552..a8ea23ad835 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-documents-modal/add-documents-modal.tsx
@@ -2,7 +2,6 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { RotateCcw, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -14,6 +13,7 @@ import {
ChipModalHeader,
Loader,
} from '@/components/emcn'
+import { RotateCcw, X } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
index 43383449ac6..69205941132 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx
@@ -1,7 +1,7 @@
'use client'
-import { ArrowLeftRight, Info } from 'lucide-react'
import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@/components/emcn'
+import { ArrowLeftRight, Info } from '@/components/emcn/icons'
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field'
import type {
ConfigFieldMap,
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 3e2b9fea59b..c01964c3b8f 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -3,19 +3,19 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { format, formatDistanceToNow, isPast } from 'date-fns'
+import { Badge, Button, Checkbox, ChipConfirmModal, Loader, Tooltip } from '@/components/emcn'
import {
- AlertCircle,
- AlertTriangle,
- CheckCircle2,
ChevronDown,
+ CircleAlert,
+ CircleCheck,
Pause,
Play,
RefreshCw,
Settings,
Trash,
+ TriangleAlert,
XCircle,
-} from 'lucide-react'
-import { Badge, Button, Checkbox, ChipConfirmModal, Loader, Tooltip } from '@/components/emcn'
+} from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getProviderIdFromServiceId } from '@/lib/oauth'
@@ -352,7 +352,7 @@ function ConnectorCard({
)}
{connector.status === 'disabled' && (
-
+
)}
@@ -391,7 +391,7 @@ function ConnectorCard({
{connector.lastSyncError && (
-
+ {connector.lastSyncError}
@@ -499,7 +499,7 @@ function ConnectorCard({
-
+
Connector disabled after repeated sync failures