Skip to content

Commit f9b34f4

Browse files
andresdjassoclaude
andcommitted
feat(workspace): hidden-sidebar chrome, chat-everywhere title bars, panel design pass
Sidebar / chrome: - Remove the sidebar's Chats section and the collapsed icon rail entirely; collapsing now hides the menu to zero width and the content goes full-bleed - Single SidebarToggle lives at the top-left of page title bars (both states); hovering it while collapsed opens the menu as a popover-styled, content-fit flyout anchored below the bar; clicking pins the sidebar open - Flyout pins open while popups launched from it (workspace menu) are showing - "New chat" nav item swaps the house icon for the new MessageCircle Chat view: - ChatTitleBar always renders (incl. new-chat view) with a "Chats / {title}" breadcrumb switcher; close (x) hides the chat pane while the resource panel takes full width and the switcher moves inline into the tabs bar - ChatSwitcher promoted to a workspace-level control rendered on every page header (Tables/Files/Knowledge/Scheduled/Logs via ResourceHeader) and floating on the workflow canvas, so chats are reachable from anywhere - Guardrails: chat pane restores when the resource panel closes; embedded pages suppress duplicate chrome via SidebarToggleHidden context Design pass: - Resource tabs become ghost chips (30px, rounded-lg, active surface) in a 44px bar matching the chat title bar; icon buttons unified at 30px with surface-active hover; 7px equal-distance edge insets for all pill controls - Chat dropdown matches the flyout anchor rhythm (6px below bar, 8px inset); emcn PopoverContent gains an alignOffset passthrough Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a247900 commit f9b34f4

28 files changed

Lines changed: 700 additions & 1796 deletions

File tree

apps/sim/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
8585
var isCollapsed = state && state.isCollapsed;
8686
8787
if (isCollapsed) {
88-
document.documentElement.style.setProperty('--sidebar-width', '51px');
88+
document.documentElement.style.setProperty('--sidebar-width', '0px');
8989
document.documentElement.setAttribute('data-sidebar-collapsed', '');
9090
} else {
9191
var width = state && state.sidebarWidth;
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use client'
2+
3+
import { useMemo, useState } from 'react'
4+
import { useParams, useRouter } from 'next/navigation'
5+
import {
6+
POPOVER_ANIMATION_CLASSES,
7+
Popover,
8+
PopoverAnchor,
9+
PopoverContent,
10+
} from '@/components/emcn'
11+
import { ChevronDown, MessageCircle } from '@/components/emcn/icons'
12+
import { cn } from '@/lib/core/utils/cn'
13+
import { useSidebarToggleHidden } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
14+
import { ChatHistoryList } from '@/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list'
15+
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
16+
17+
const FALLBACK_TITLE = 'New chat'
18+
19+
interface ChatSwitcherProps {
20+
/**
21+
* The chat shown in the breadcrumb and highlighted in the list. Omitted on
22+
* non-chat pages, where the most recently updated chat is shown instead.
23+
*/
24+
chatId?: string
25+
/**
26+
* Called with the picked chat id before navigation. The chat view uses this
27+
* to reopen a hidden chat pane (including re-picking the current chat).
28+
*/
29+
onSelectChat?: (chatId: string) => void
30+
}
31+
32+
/**
33+
* The chat-switcher chip — a "Chats / {title}" breadcrumb that lives at the
34+
* top-left of every page's title bar. Clicking it opens the workspace's chat
35+
* list inline; selecting a chat navigates to it from anywhere.
36+
*/
37+
export function ChatSwitcher({ chatId, onSelectChat }: ChatSwitcherProps) {
38+
const isHidden = useSidebarToggleHidden()
39+
const { workspaceId } = useParams<{ workspaceId?: string }>()
40+
const router = useRouter()
41+
const { data: tasks = [] } = useMothershipChats(workspaceId)
42+
const [open, setOpen] = useState(false)
43+
44+
const mostRecent = useMemo(
45+
() =>
46+
tasks.reduce<(typeof tasks)[number] | null>(
47+
(latest, task) => (!latest || task.updatedAt > latest.updatedAt ? task : latest),
48+
null
49+
),
50+
[tasks]
51+
)
52+
53+
if (isHidden || !workspaceId) return null
54+
55+
const title = chatId
56+
? (tasks.find((task) => task.id === chatId)?.name ?? FALLBACK_TITLE)
57+
: (mostRecent?.name ?? FALLBACK_TITLE)
58+
59+
const handleSelect = (selectedChatId: string) => {
60+
setOpen(false)
61+
onSelectChat?.(selectedChatId)
62+
if (selectedChatId === chatId) return
63+
router.push(`/workspace/${workspaceId}/chat/${selectedChatId}`)
64+
}
65+
66+
return (
67+
<Popover size='md' open={open} onOpenChange={setOpen}>
68+
<PopoverAnchor asChild>
69+
<button
70+
type='button'
71+
aria-label='Switch chat'
72+
onClick={() => setOpen((prev) => !prev)}
73+
className={cn(
74+
'flex h-[30px] min-w-0 items-center gap-1.5 rounded-lg px-2 transition-colors',
75+
'hover-hover:bg-[var(--surface-active)]',
76+
open && 'bg-[var(--surface-active)]'
77+
)}
78+
>
79+
<MessageCircle className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
80+
<span className='flex-shrink-0 text-[14px] text-[var(--text-muted)]'>Chats</span>
81+
<span
82+
aria-hidden='true'
83+
className='flex-shrink-0 select-none text-[14px] text-[var(--text-icon)]'
84+
>
85+
/
86+
</span>
87+
<span className='min-w-0 truncate font-medium text-[14px] text-[var(--text-primary)]'>
88+
{title}
89+
</span>
90+
<ChevronDown className='ml-0.5 h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
91+
</button>
92+
</PopoverAnchor>
93+
{/* Mirrors the sidebar flyout's anchor rhythm: the chip sits at y 7..37 in
94+
the 44px bar, so offset 13 lands the panel 6px below the bar, and the
95+
-33 align offset walks back from the chip to 8px off the panel edge. */}
96+
<PopoverContent
97+
side='bottom'
98+
align='start'
99+
sideOffset={13}
100+
alignOffset={-33}
101+
minWidth={280}
102+
maxWidth={360}
103+
border
104+
className={cn(POPOVER_ANIMATION_CLASSES, 'bg-[var(--bg)] p-0 dark:bg-[var(--bg)]')}
105+
>
106+
<ChatHistoryList onSelect={handleSelect} activeChatId={chatId} autoFocus={open} />
107+
</PopoverContent>
108+
</Popover>
109+
)
110+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ChatSwitcher } from './chat-switcher'

apps/sim/app/workspace/[workspaceId]/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { ChatSwitcher } from './chat-switcher'
12
export { ConversationListItem } from './conversation-list-item'
23
export type { ErrorBoundaryProps, ErrorStateProps } from './error'
34
export { ErrorShell, ErrorState } from './error'
@@ -27,3 +28,4 @@ export type {
2728
SelectableConfig,
2829
} from './resource/resource'
2930
export { EMPTY_CELL_PLACEHOLDER, Resource, ResourceTable } from './resource/resource'
31+
export { SidebarToggle } from './sidebar-toggle'

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import {
2121
useIsOverflowing,
2222
} from '@/components/emcn'
2323
import { cn } from '@/lib/core/utils/cn'
24+
import { ChatSwitcher } from '@/app/workspace/[workspaceId]/components/chat-switcher'
2425
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
2526
import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
27+
import { SidebarToggle } from '@/app/workspace/[workspaceId]/components/sidebar-toggle'
2628

2729
const HEADER_PLUS_ICON = <Plus className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
2830

@@ -117,6 +119,8 @@ export const ResourceHeader = memo(function ResourceHeader({
117119
>
118120
<div className='flex min-w-0 items-center justify-between gap-3'>
119121
<div className='flex min-w-0 flex-1 items-center gap-2 overflow-hidden'>
122+
<SidebarToggle />
123+
<ChatSwitcher />
120124
{hasBreadcrumbs ? (
121125
breadcrumbs.map((crumb, i) => {
122126
const segmentClassName = getBreadcrumbSegmentClassName(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SidebarToggle, SidebarToggleHidden, useSidebarToggleHidden } from './sidebar-toggle'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client'
2+
3+
import { createContext, useContext } from 'react'
4+
import { PanelLeft } from '@/components/emcn/icons'
5+
import { cn } from '@/lib/core/utils/cn'
6+
import { useSidebarStore } from '@/stores/sidebar/store'
7+
8+
const SidebarToggleHiddenContext = createContext(false)
9+
10+
interface SidebarToggleHiddenProps {
11+
children: React.ReactNode
12+
}
13+
14+
/**
15+
* Suppresses every {@link SidebarToggle} (and other title-bar chrome controls,
16+
* e.g. the chat switcher) in the subtree. Wrap surfaces that embed full pages
17+
* (e.g. the chat resource panel) so their headers don't duplicate the chrome.
18+
*/
19+
export function SidebarToggleHidden({ children }: SidebarToggleHiddenProps) {
20+
return (
21+
<SidebarToggleHiddenContext.Provider value={true}>
22+
{children}
23+
</SidebarToggleHiddenContext.Provider>
24+
)
25+
}
26+
27+
/** Whether title-bar chrome controls are suppressed by {@link SidebarToggleHidden}. */
28+
export function useSidebarToggleHidden(): boolean {
29+
return useContext(SidebarToggleHiddenContext)
30+
}
31+
32+
interface SidebarToggleProps {
33+
/** Layout-only positioning for the host surface (margins, absolute placement). */
34+
className?: string
35+
}
36+
37+
/**
38+
* The single sidebar control, living at the top-left of a page's title bar in
39+
* both states. Clicking toggles the sidebar open/closed. While the sidebar is
40+
* hidden, hovering reveals the floating menu panel (rendered by the workspace
41+
* chrome) anchored underneath this toggle.
42+
*/
43+
export function SidebarToggle({ className }: SidebarToggleProps) {
44+
const isHidden = useContext(SidebarToggleHiddenContext)
45+
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
46+
const toggleCollapsed = useSidebarStore((s) => s.toggleCollapsed)
47+
const openFlyout = useSidebarStore((s) => s.openFlyout)
48+
const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose)
49+
50+
if (isHidden) return null
51+
52+
return (
53+
<button
54+
type='button'
55+
onClick={toggleCollapsed}
56+
onMouseEnter={isCollapsed ? openFlyout : undefined}
57+
onMouseLeave={isCollapsed ? scheduleFlyoutClose : undefined}
58+
className={cn(
59+
'flex size-[30px] flex-shrink-0 items-center justify-center rounded-lg transition-colors hover-hover:bg-[var(--surface-active)]',
60+
className
61+
)}
62+
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
63+
>
64+
<PanelLeft className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
65+
</button>
66+
)
67+
}

apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
44
import { usePathname } from 'next/navigation'
55
import { cn } from '@/lib/core/utils/cn'
66
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
7+
import { SIDEBAR_WIDTH } from '@/stores/constants'
78
import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
89
import { useSidebarStore } from '@/stores/sidebar/store'
910

@@ -40,6 +41,12 @@ function isFullscreenPath(pathname: string | null): boolean {
4041
*
4142
* On a direct load of a fullscreen route the wrapper mounts already collapsed,
4243
* so no slide plays (CSS transitions don't run on mount).
44+
*
45+
* The sidebar's single control is the `SidebarToggle` at the top-left of page
46+
* title bars. While the sidebar is hidden, hovering that toggle opens the
47+
* floating menu panel this chrome owns, anchored underneath the title bar so
48+
* the toggle stays visible; an invisible left-edge hover zone opens it on
49+
* pages without a title-bar toggle. Clicking the toggle pins the sidebar open.
4350
*/
4451
export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
4552
const pathname = usePathname()
@@ -49,6 +56,16 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
4956

5057
const hasHydrated = useSidebarStore((s) => s._hasHydrated)
5158
const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
59+
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
60+
const isFlyoutOpen = useSidebarStore((s) => s.isFlyoutOpen)
61+
const openFlyout = useSidebarStore((s) => s.openFlyout)
62+
const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose)
63+
const closeFlyout = useSidebarStore((s) => s.closeFlyout)
64+
65+
// Hide the flyout after navigating from it.
66+
useEffect(() => {
67+
closeFlyout()
68+
}, [pathname, closeFlyout])
5269

5370
// Remember the last non-fullscreen page so a fullscreen route's Back control
5471
// can return there, deterministically and for any trigger.
@@ -86,14 +103,14 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
86103
}, [syncSidebarWidth])
87104

88105
return (
89-
<div className='flex min-h-0 flex-1'>
106+
<div className='relative flex min-h-0 flex-1'>
90107
<div
91108
className={cn(
92109
'sidebar-shell-outer shrink-0 overflow-hidden transition-[width]',
93110
SLIDE_TRANSITION,
94111
isFullscreen ? 'w-0' : 'w-[var(--sidebar-width)]'
95112
)}
96-
aria-hidden={isFullscreen || undefined}
113+
aria-hidden={isFullscreen || isCollapsed || undefined}
97114
suppressHydrationWarning
98115
>
99116
<div
@@ -103,20 +120,58 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
103120
isFullscreen && '-translate-x-full'
104121
)}
105122
>
106-
<Sidebar />
123+
{!isCollapsed && <Sidebar />}
107124
</div>
108125
</div>
126+
{/* Sidebar hidden → content goes full-bleed to the browser edge; sidebar
127+
visible (or fullscreen route) → framed card with the 8px gutter. */}
109128
<div
110129
className={cn(
111-
'flex min-w-0 flex-1 flex-col p-[8px] transition-[padding]',
130+
'flex min-w-0 flex-1 flex-col transition-[padding]',
112131
SLIDE_TRANSITION,
113-
!isFullscreen && 'pl-0'
132+
isFullscreen ? 'p-[8px]' : isCollapsed ? 'p-0' : 'p-[8px] pl-0'
114133
)}
115134
>
116-
<div className='flex-1 overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)]'>
135+
<div
136+
className={cn(
137+
'flex-1 overflow-hidden bg-[var(--bg)]',
138+
(isFullscreen || !isCollapsed) && 'rounded-[8px] border border-[var(--border)]'
139+
)}
140+
>
117141
{children}
118142
</div>
119143
</div>
144+
{isCollapsed && !isFullscreen && (
145+
<>
146+
{/* Invisible hover zone so the flyout is reachable from the screen
147+
edge on pages whose header has no SidebarFlyoutTrigger. */}
148+
<div
149+
className='absolute top-0 bottom-0 left-0 z-40 w-[10px]'
150+
onMouseEnter={openFlyout}
151+
onMouseLeave={scheduleFlyoutClose}
152+
/>
153+
{/* Anchored below the page title bar so the SidebarToggle that opened
154+
it stays visible above the panel. Chrome mirrors the canonical
155+
popover surface (rounded-xl, --border-1, --bg, shadow-sm) and enter
156+
motion (fade + zoom + slide from top, 150ms ease-out). Content-fit
157+
like a dropdown: height tracks the menu, capped so it scrolls
158+
internally instead of overflowing the viewport. */}
159+
<div
160+
onMouseEnter={openFlyout}
161+
onMouseLeave={scheduleFlyoutClose}
162+
className={cn(
163+
'absolute top-[50px] left-[8px] z-50 flex max-h-[calc(100%-58px)] w-[var(--sidebar-width)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] shadow-sm',
164+
'origin-top-left transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none',
165+
isFlyoutOpen
166+
? 'translate-y-0 scale-100 opacity-100'
167+
: '-translate-y-2 pointer-events-none scale-95 opacity-0'
168+
)}
169+
style={{ '--sidebar-width': `${SIDEBAR_WIDTH.DEFAULT}px` } as React.CSSProperties}
170+
>
171+
<Sidebar variant='flyout' />
172+
</div>
173+
</>
174+
)}
120175
</div>
121176
)
122177
}

0 commit comments

Comments
 (0)