improvement: sidebar, chat

This commit is contained in:
Emir Karabeg
2026-03-10 19:29:30 -07:00
parent 7151e81fff
commit 80830f5311
24 changed files with 732 additions and 492 deletions

View File

@@ -19,6 +19,22 @@
.sidebar-container {
width: var(--sidebar-width);
transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1);
}
.sidebar-container span,
.sidebar-container .text-small {
transition: opacity 120ms ease;
white-space: nowrap;
}
.sidebar-container[data-collapsed] span,
.sidebar-container[data-collapsed] .text-small {
opacity: 0;
}
.sidebar-container.is-resizing {
transition: none;
}
.panel-container {

View File

@@ -110,13 +110,19 @@ export default function RootLayout({ children }: { children: React.ReactNode })
if (stored) {
var parsed = JSON.parse(stored);
var state = parsed && parsed.state;
var width = state && state.sidebarWidth;
var maxSidebarWidth = window.innerWidth * 0.3;
var isCollapsed = state && state.isCollapsed;
if (width >= 248 && width <= maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
if (isCollapsed) {
document.documentElement.style.setProperty('--sidebar-width', '51px');
} else {
var width = state && state.sidebarWidth;
var maxSidebarWidth = window.innerWidth * 0.3;
if (width >= 248 && width <= maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', width + 'px');
} else if (width > maxSidebarWidth) {
document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
}
}
}
} catch (e) {

View File

@@ -0,0 +1,64 @@
'use client'
import { useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallStatus } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { ToolCallItem } from './tool-call-item'
interface ToolCallData {
id: string
toolName: string
displayTitle: string
status: ToolCallStatus
}
interface AgentGroupProps {
agentName: string
agentLabel: string
tools: ToolCallData[]
}
export function AgentGroup({ agentName, agentLabel, tools }: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const [expanded, setExpanded] = useState(true)
const hasTools = tools.length > 0
return (
<div className='flex flex-col gap-1.5'>
<button
type='button'
onClick={hasTools ? () => setExpanded((prev) => !prev) : undefined}
className={cn('flex items-center gap-[8px]', hasTools && 'cursor-pointer')}
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<AgentIcon className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</div>
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
{agentLabel}
</span>
{hasTools && (
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
!expanded && '-rotate-90'
)}
/>
)}
</button>
{hasTools && expanded && (
<div className='flex flex-col gap-1.5'>
{tools.map((tool) => (
<ToolCallItem
key={tool.id}
toolName={tool.toolName}
displayTitle={tool.displayTitle}
status={tool.status}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export { AgentGroup } from './agent-group'

View File

@@ -0,0 +1,52 @@
import { Loader } from '@/components/emcn'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon } from '../../utils'
function CircleCheck({ className }: { className?: string }) {
return (
<svg
width='16'
height='16'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<circle cx='8' cy='8' r='6.5' stroke='currentColor' strokeWidth='1.25' />
<path
d='M5.5 8.5L7 10L10.5 6.5'
stroke='currentColor'
strokeWidth='1.25'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
interface ToolCallItemProps {
toolName: string
displayTitle: string
status: ToolCallStatus
}
export function ToolCallItem({ toolName, displayTitle, status }: ToolCallItemProps) {
const Icon = getToolIcon(toolName)
return (
<div className='flex items-center gap-[8px] pl-[24px]'>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
{status === 'executing' ? (
<Loader className='h-[16px] w-[16px] text-[var(--text-icon)]' animate />
) : Icon ? (
<Icon className='h-[16px] w-[16px] text-[var(--text-icon)]' />
) : (
<CircleCheck className='h-[16px] w-[16px] text-[var(--text-icon)]' />
)}
</div>
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
{displayTitle}
</span>
</div>
)
}

View File

@@ -39,12 +39,12 @@ function extractTextContent(node: React.ReactNode): string {
const PROSE_CLASSES = cn(
'prose prose-base dark:prose-invert max-w-none',
'font-[family-name:var(--font-inter)] antialiased break-words font-[420] tracking-[0]',
'font-[family-name:var(--font-inter)] antialiased break-words font-[430] tracking-[0]',
'prose-headings:font-[600] prose-headings:tracking-[0] prose-headings:text-[var(--text-primary)]',
'prose-headings:mb-3 prose-headings:mt-6 first:prose-headings:mt-0',
'prose-p:text-[16px] prose-p:leading-7 prose-p:text-[var(--text-primary)]',
'prose-p:text-[15px] prose-p:leading-[25px] prose-p:text-[var(--text-primary)]',
'first:prose-p:mt-0 last:prose-p:mb-0',
'prose-li:text-[16px] prose-li:leading-7 prose-li:text-[var(--text-primary)]',
'prose-li:text-[15px] prose-li:leading-[25px] prose-li:text-[var(--text-primary)]',
'prose-li:my-1',
'prose-ul:my-4 prose-ol:my-4',
'prose-strong:font-[600] prose-strong:text-[var(--text-primary)]',
@@ -106,7 +106,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
if (!codeString) {
return (
<pre className='not-prose my-6 overflow-x-auto rounded-lg bg-[var(--surface-5)] p-4 font-[420] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:bg-[#1F1F1F]'>
<pre className='not-prose my-6 overflow-x-auto rounded-lg bg-[var(--surface-5)] p-4 font-[430] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:bg-[#1F1F1F]'>
{children}
</pre>
)
@@ -125,7 +125,7 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
)}
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[#1F1F1F]'>
<pre
className='m-0 overflow-x-auto whitespace-pre p-4 font-[420] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
className='m-0 overflow-x-auto whitespace-pre p-4 font-[430] font-mono text-[13px] text-[var(--text-primary)] leading-[21px] dark:text-[#eeeeee]'
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
@@ -156,13 +156,13 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>['component
li({ children, className }) {
if (className?.includes('task-list-item')) {
return (
<li className='flex list-none items-start gap-2 text-[16px] text-[var(--text-primary)] leading-7'>
<li className='flex list-none items-start gap-2 text-[15px] text-[var(--text-primary)] leading-[25px]'>
{children}
</li>
)
}
return (
<li className='my-1 text-[16px] text-[var(--text-primary)] leading-7 marker:text-[var(--text-primary)]'>
<li className='my-1 text-[15px] text-[var(--text-primary)] leading-[25px] marker:text-[var(--text-primary)]'>
{children}
</li>
)

View File

@@ -1,4 +1,3 @@
export { AgentGroup } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
export { Subagent } from './subagent'
export { ToolCall } from './tool-calls'

View File

@@ -15,7 +15,7 @@ export function Options({ items, onSelect }: OptionsProps) {
key={item.id}
type='button'
onClick={() => onSelect?.(item.id)}
className='rounded-full border border-[var(--divider)] bg-[var(--bg)] px-3.5 py-1.5 font-[420] font-[family-name:var(--font-inter)] text-[14px] text-[var(--text-primary)] leading-5 transition-colors hover:bg-[var(--surface-5)]'
className='rounded-full border border-[var(--divider)] bg-[var(--bg)] px-3.5 py-1.5 font-[430] font-[family-name:var(--font-inter)] text-[14px] text-[var(--text-primary)] leading-5 transition-colors hover:bg-[var(--surface-5)]'
>
{item.label}
</button>

View File

@@ -1 +0,0 @@
export { Subagent } from './subagent'

View File

@@ -1,28 +0,0 @@
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallStatus } from '../../../../types'
import { getToolIcon } from '../../utils'
const STATUS_STYLES: Record<ToolCallStatus, string> = {
executing: 'bg-[var(--text-tertiary)] animate-pulse',
success: 'bg-[var(--text-tertiary)]',
error: 'bg-red-500',
}
interface SubagentProps {
id: string
name: string
label: string
status: ToolCallStatus
}
export function Subagent({ name, label, status }: SubagentProps) {
const Icon = getToolIcon(name)
return (
<div className='flex items-center gap-[6px]'>
<div className={cn('h-[5px] w-[5px] shrink-0 rounded-full', STATUS_STYLES[status])} />
{Icon && <Icon className='h-3.5 w-3.5 shrink-0 text-[var(--text-tertiary)]' />}
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>{label}</span>
</div>
)
}

View File

@@ -1 +0,0 @@
export { ToolCall } from './tool-calls'

View File

@@ -1,58 +0,0 @@
import { cn } from '@/lib/core/utils/cn'
import type { MothershipToolName, SubagentName, ToolCallStatus, ToolPhase } from '../../../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../../../types'
import { getToolIcon } from '../../utils'
const STATUS_STYLES: Record<ToolCallStatus, string> = {
executing: 'bg-[var(--text-tertiary)] animate-pulse',
success: 'bg-[var(--text-tertiary)]',
error: 'bg-red-500',
}
const PHASE_COLORS: Record<ToolPhase, string> = {
workspace: 'text-blue-500',
search: 'text-emerald-500',
management: 'text-amber-500',
execution: 'text-purple-500',
resource: 'text-cyan-500',
subagent: 'text-orange-500',
}
interface ToolCallProps {
id: string
toolName: string
displayTitle?: string
status: ToolCallStatus
phaseLabel?: string
calledBy?: string
}
export function ToolCall({ toolName, displayTitle, status, phaseLabel, calledBy }: ToolCallProps) {
const metadata = TOOL_UI_METADATA[toolName as MothershipToolName]
const resolvedTitle = displayTitle || metadata?.title || toolName
const resolvedPhase = phaseLabel || metadata?.phaseLabel
const resolvedPhaseType = metadata?.phase
const Icon = getToolIcon(toolName)
const callerLabel = calledBy
? (SUBAGENT_LABELS[calledBy as SubagentName] ?? calledBy)
: 'Mothership'
return (
<div className='flex items-center gap-[6px]'>
<div className={cn('h-[5px] w-[5px] shrink-0 rounded-full', STATUS_STYLES[status])} />
{Icon && <Icon className='h-3.5 w-3.5 shrink-0 text-[var(--text-tertiary)]' />}
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>{resolvedTitle}</span>
{resolvedPhase && (
<span
className={cn(
'rounded bg-[var(--surface-5)] px-1.5 py-0.5 font-[500] text-[10px]',
resolvedPhaseType ? PHASE_COLORS[resolvedPhaseType] : 'text-[var(--text-tertiary)]'
)}
>
{resolvedPhase}
</span>
)}
<span className='text-[11px] text-[var(--text-quaternary)]'>via {callerLabel}</span>
</div>
)
}

View File

@@ -2,29 +2,26 @@
import type { ContentBlock, OptionItem, SubagentName, ToolCallStatus } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
import { ChatContent, Options, Subagent, ToolCall } from './components'
import { AgentGroup, ChatContent, Options } from './components'
interface TextSegment {
type: 'text'
content: string
}
interface ToolCallSegment {
type: 'tool_call'
interface ToolCallData {
id: string
toolName: string
displayTitle?: string
displayTitle: string
status: ToolCallStatus
phaseLabel?: string
calledBy?: string
}
interface SubagentSegment {
type: 'subagent'
interface AgentGroupSegment {
type: 'agent_group'
id: string
name: string
label: string
status: ToolCallStatus
agentName: string
agentLabel: string
tools: ToolCallData[]
}
interface OptionsSegment {
@@ -32,7 +29,9 @@ interface OptionsSegment {
items: OptionItem[]
}
type MessageSegment = TextSegment | ToolCallSegment | SubagentSegment | OptionsSegment
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment
const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
function formatToolName(name: string): string {
return name
@@ -42,71 +41,134 @@ function formatToolName(name: string): string {
.join(' ')
}
/**
* Flattens raw content blocks into typed segments for rendering.
* Each content type maps to its own segment with all available data preserved.
*/
function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegment[] {
const segments: MessageSegment[] = []
let lastSubagentIdx = -1
for (let j = blocks.length - 1; j >= 0; j--) {
if (blocks[j].type === 'subagent') {
lastSubagentIdx = j
break
}
function resolveAgentLabel(key: string): string {
return SUBAGENT_LABELS[key as SubagentName] ?? formatToolName(key)
}
function toToolData(tc: NonNullable<ContentBlock['toolCall']>): ToolCallData {
return {
id: tc.id,
toolName: tc.name,
displayTitle: tc.displayTitle || formatToolName(tc.name),
status: tc.status,
}
}
/**
* Groups content blocks into agent-scoped segments.
* Dispatch tool_calls (name matches a subagent key, no calledBy) are absorbed
* into the agent header. Inner tool_calls are nested underneath their agent.
* Orphan tool_calls (no calledBy, not a dispatch) group under "Mothership".
*/
function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
let group: AgentGroupSegment | null = null
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
switch (block.type) {
case 'text': {
if (block.content?.trim()) {
const last = segments[segments.length - 1]
if (last?.type === 'text') {
last.content += block.content
} else {
segments.push({ type: 'text', content: block.content })
if (block.type === 'text') {
if (!block.content?.trim()) continue
if (group) {
segments.push(group)
group = null
}
const last = segments[segments.length - 1]
if (last?.type === 'text') {
last.content += block.content
} else {
segments.push({ type: 'text', content: block.content })
}
continue
}
if (block.type === 'subagent') {
if (!block.content) continue
const key = block.content
if (group && group.agentName === key) continue
if (group) {
segments.push(group)
group = null
}
group = {
type: 'agent_group',
id: `agent-${key}-${i}`,
agentName: key,
agentLabel: resolveAgentLabel(key),
tools: [],
}
continue
}
if (block.type === 'tool_call') {
if (!block.toolCall) continue
const tc = block.toolCall
const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy
if (isDispatch) {
if (!group || group.agentName !== tc.name) {
if (group) {
segments.push(group)
group = null
}
group = {
type: 'agent_group',
id: `agent-${tc.name}-${i}`,
agentName: tc.name,
agentLabel: resolveAgentLabel(tc.name),
tools: [],
}
}
break
continue
}
case 'subagent': {
if (block.content) {
const key = block.content
segments.push({
type: 'subagent',
id: `subagent-${i}`,
name: key,
label: SUBAGENT_LABELS[key as SubagentName] ?? key,
status: isStreaming && i === lastSubagentIdx ? 'executing' : 'success',
})
const tool = toToolData(tc)
if (tc.calledBy && group && group.agentName === tc.calledBy) {
group.tools.push(tool)
} else if (tc.calledBy) {
if (group) {
segments.push(group)
group = null
}
break
}
case 'tool_call': {
if (block.toolCall) {
segments.push({
type: 'tool_call',
id: block.toolCall.id,
toolName: block.toolCall.name,
displayTitle: block.toolCall.displayTitle || formatToolName(block.toolCall.name),
status: block.toolCall.status,
phaseLabel: block.toolCall.phaseLabel,
calledBy: block.toolCall.calledBy,
})
group = {
type: 'agent_group',
id: `agent-${tc.calledBy}-${i}`,
agentName: tc.calledBy,
agentLabel: resolveAgentLabel(tc.calledBy),
tools: [tool],
}
break
}
case 'options': {
if (block.options?.length) {
segments.push({ type: 'options', items: block.options })
} else {
if (group && group.agentName === 'mothership') {
group.tools.push(tool)
} else {
if (group) {
segments.push(group)
group = null
}
group = {
type: 'agent_group',
id: `agent-mothership-${i}`,
agentName: 'mothership',
agentLabel: 'Mothership',
tools: [tool],
}
}
break
}
continue
}
if (block.type === 'options') {
if (!block.options?.length) continue
if (group) {
segments.push(group)
group = null
}
segments.push({ type: 'options', items: block.options })
}
}
if (group) segments.push(group)
return segments
}
@@ -117,13 +179,8 @@ interface MessageContentProps {
onOptionSelect?: (id: string) => void
}
export function MessageContent({
blocks,
fallbackContent,
isStreaming,
onOptionSelect,
}: MessageContentProps) {
const parsed = blocks.length > 0 ? parseBlocks(blocks, isStreaming) : []
export function MessageContent({ blocks, fallbackContent, onOptionSelect }: MessageContentProps) {
const parsed = blocks.length > 0 ? parseBlocks(blocks) : []
const segments: MessageSegment[] =
parsed.length > 0
@@ -140,26 +197,13 @@ export function MessageContent({
switch (segment.type) {
case 'text':
return <ChatContent key={`text-${i}`} content={segment.content} />
case 'tool_call':
case 'agent_group':
return (
<ToolCall
<AgentGroup
key={segment.id}
id={segment.id}
toolName={segment.toolName}
displayTitle={segment.displayTitle}
status={segment.status}
phaseLabel={segment.phaseLabel}
calledBy={segment.calledBy}
/>
)
case 'subagent':
return (
<Subagent
key={segment.id}
id={segment.id}
name={segment.name}
label={segment.label}
status={segment.status}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
tools={segment.tools}
/>
)
case 'options':

View File

@@ -1,6 +1,7 @@
import type { ComponentType, SVGProps } from 'react'
import {
Asterisk,
Blimp,
BubbleChatPreview,
Connections,
Database,
@@ -14,16 +15,17 @@ import {
Pencil,
Play,
Rocket,
Rows3,
Search,
Settings,
TerminalWindow,
} from '@/components/emcn'
import { Table as TableIcon } from '@/components/emcn/icons'
import type { MothershipToolName, SubagentName } from '../../types'
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
export type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
const TOOL_ICONS: Record<MothershipToolName | SubagentName, IconComponent> = {
const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconComponent> = {
mothership: Blimp,
glob: FolderCode,
grep: Search,
read: File,
@@ -36,7 +38,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName, IconComponent> = {
user_memory: Database,
function_execute: TerminalWindow,
superagent: Play,
user_table: Rows3,
user_table: TableIcon,
workspace_file: File,
create_workflow: Connections,
edit_workflow: Pencil,
@@ -45,7 +47,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName, IconComponent> = {
deploy: Rocket,
auth: Key,
knowledge: Database,
table: Rows3,
table: TableIcon,
job: Loader,
agent: BubbleChatPreview,
custom_tool: Settings,
@@ -55,6 +57,11 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName, IconComponent> = {
edit: Pencil,
}
export function getToolIcon(name: string): IconComponent | undefined {
return TOOL_ICONS[name as MothershipToolName | SubagentName]
export function getAgentIcon(name: string): IconComponent {
return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp
}
export function getToolIcon(name: string): IconComponent | undefined {
const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS]
return icon === Blimp ? undefined : icon
}

View File

@@ -46,8 +46,8 @@ export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProp
variant='subtle'
onClick={() => onSelect(resource.id)}
className={cn(
'shrink-0 border border-transparent bg-transparent px-[8px] py-[4px] text-[12px]',
isActive && 'border-[var(--border)] bg-[var(--surface-4)]'
'shrink-0 bg-transparent px-[8px] py-[4px] text-[12px]',
isActive && 'bg-[var(--surface-4)]'
)}
>
<Icon className={cn('mr-[6px] h-[14px] w-[14px] text-[var(--text-icon)]')} />

View File

@@ -22,7 +22,7 @@ const SEND_BUTTON_ACTIVE =
'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
const MAX_CHAT_TEXTAREA_HEIGHT = 104 // 4 lines × 24px line-height + 8px padding
const MAX_CHAT_TEXTAREA_HEIGHT = 200 // 8 lines × 24px line-height + 8px padding
function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight: number) {
const target = e.target as HTMLTextAreaElement
@@ -248,7 +248,7 @@ export function UserInput({
onInput={handleInput}
placeholder={files.isDragging ? 'Drop files here...' : placeholder}
rows={1}
className={cn(TEXTAREA_BASE_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[104px]')}
className={cn(TEXTAREA_BASE_CLASSES, isInitialView ? 'max-h-[30vh]' : 'max-h-[200px]')}
/>
<div className='flex items-center justify-between'>
<button

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Loader } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import {
LandingPromptStorage,
@@ -149,8 +150,8 @@ export function Home({ chatId }: HomeProps = {}) {
if (msg.role === 'user') {
return (
<div key={msg.id} className='flex justify-end pt-3'>
<div className='max-w-[70%] rounded-[22px] bg-[var(--surface-5)] px-4 py-2.5'>
<p className='whitespace-pre-wrap font-[420] font-[family-name:var(--font-inter)] text-[16px] text-[var(--text-primary)] leading-6 tracking-[0] antialiased'>
<div className='max-w-[70%] rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2'>
<p className='whitespace-pre-wrap font-[430] font-[family-name:var(--font-inter)] text-[15px] text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'>
{msg.content}
</p>
</div>
@@ -164,9 +165,9 @@ export function Home({ chatId }: HomeProps = {}) {
if (!hasBlocks && !msg.content && isThisStreaming) {
return (
<div key={msg.id} className='flex items-center gap-[6px] py-[8px]'>
<div className='h-[6px] w-[6px] animate-pulse rounded-full bg-[var(--text-tertiary)]' />
<span className='font-base text-[13px] text-[var(--text-tertiary)]'>
<div key={msg.id} className='flex items-center gap-[8px] py-[8px]'>
<Loader className='h-[16px] w-[16px] text-[var(--text-icon)]' animate />
<span className='font-[var(--sidebar-font-weight)] text-[14px] text-[var(--text-body)]'>
Thinking
</span>
</div>

View File

@@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { ChevronDown, Skeleton } from '@/components/emcn'
import { ChevronDown, Skeleton, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionStatus } from '@/lib/billing/client'
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -24,7 +24,15 @@ import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/s
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
import { usePermissionConfig } from '@/hooks/use-permission-config'
export function SettingsSidebar() {
interface SettingsSidebarProps {
isCollapsed?: boolean
showCollapsedContent?: boolean
}
export function SettingsSidebar({
isCollapsed = false,
showCollapsedContent = false,
}: SettingsSidebarProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const pathname = usePathname()
@@ -163,18 +171,27 @@ export function SettingsSidebar() {
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Back button */}
<div className='mt-[10px] flex flex-shrink-0 flex-col gap-[2px] px-[8px]'>
<button
type='button'
onClick={handleBack}
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
>
<span className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
</span>
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
Back
</span>
</button>
<Tooltip.Root key={`back-${isCollapsed}`}>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={handleBack}
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='h-[10px] w-[10px] rotate-90' />
</div>
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
Back
</span>
</button>
</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>Back</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
{/* Settings sections */}
@@ -207,7 +224,7 @@ export function SettingsSidebar() {
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const className = cn(
const itemClassName = cn(
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
active && 'bg-[var(--surface-active)]'
)
@@ -220,31 +237,36 @@ export function SettingsSidebar() {
</>
)
if (item.externalUrl) {
return (
<a
key={item.id}
href={item.externalUrl}
target='_blank'
rel='noopener noreferrer'
className={className}
>
{content}
</a>
)
}
return (
const element = item.externalUrl ? (
<a
href={item.externalUrl}
target='_blank'
rel='noopener noreferrer'
className={itemClassName}
>
{content}
</a>
) : (
<Link
key={item.id}
href={`/workspace/${workspaceId}/settings/${item.id}`}
className={className}
className={itemClassName}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
>
{content}
</Link>
)
return (
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
})}
</div>
</div>

View File

@@ -316,7 +316,7 @@ export function WorkspaceHeader({
<button
type='button'
aria-label='Switch workspace'
className='group flex h-[32px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px] transition-colors hover:bg-[var(--surface-5)]'
className='group flex h-[32px] w-full min-w-0 cursor-pointer items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)] pr-[8px] pl-[6.5px] transition-colors hover:bg-[var(--surface-5)]'
title={activeWorkspace?.name || 'Loading...'}
onContextMenu={(e) => {
if (activeWorkspaceFull) {
@@ -496,7 +496,7 @@ export function WorkspaceHeader({
<button
type='button'
aria-label='Switch workspace'
className='flex h-[32px] w-full min-w-0 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px]'
className='flex h-[32px] w-full min-w-0 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)] pr-[8px] pl-[6.5px]'
title={activeWorkspace?.name || 'Loading...'}
disabled
>

View File

@@ -25,6 +25,7 @@ import {
Database,
File,
HelpCircle,
PanelLeft,
Plus,
Search,
Settings,
@@ -112,8 +113,20 @@ export const Sidebar = memo(function Sidebar() {
}, [initializeSearchData, filterBlocks])
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
const isOnWorkflowPage = !!workflowId
const [showCollapsedContent, setShowCollapsedContent] = useState(isCollapsed)
useEffect(() => {
if (isCollapsed) {
const timer = setTimeout(() => setShowCollapsedContent(true), 200)
return () => clearTimeout(timer)
}
setShowCollapsedContent(false)
}, [isCollapsed])
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
@@ -184,7 +197,7 @@ export const Sidebar = memo(function Sidebar() {
sessionUserId: sessionData?.user?.id,
})
const { handleMouseDown } = useSidebarResize()
const { handleMouseDown, isResizing } = useSidebarResize()
const {
regularWorkflows,
@@ -537,10 +550,10 @@ export const Sidebar = memo(function Sidebar() {
}, [workflowId, workflowsLoading])
useEffect(() => {
if (!isOnWorkflowPage) {
if (!isOnWorkflowPage && !isCollapsed) {
setSidebarWidth(SIDEBAR_WIDTH.MIN)
}
}, [isOnWorkflowPage, setSidebarWidth])
}, [isOnWorkflowPage, isCollapsed, setSidebarWidth])
const handleCreateWorkflow = useCallback(async () => {
const workflowId = await createWorkflow()
@@ -718,12 +731,67 @@ export const Sidebar = memo(function Sidebar() {
<>
<aside
ref={sidebarRef}
className='sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)]'
className={cn(
'sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)]',
isResizing && 'is-resizing'
)}
data-collapsed={isCollapsed || undefined}
aria-label='Workspace sidebar'
onClick={handleSidebarClick}
>
<div className='flex h-full flex-col pt-[12px]'>
{/* Header */}
{/* Top bar: Logo + Collapse toggle */}
<div className='flex flex-shrink-0 items-center pr-[8px] pb-[8px] pl-[10px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{showCollapsedContent ? (
<button
type='button'
onClick={toggleCollapsed}
className='group flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
aria-label='Expand sidebar'
>
<Blimp className='h-[18px] w-[18px] text-[var(--text-icon)] group-hover:hidden' />
<PanelLeft className='hidden h-[16px] w-[16px] rotate-180 text-[var(--text-icon)] group-hover:block' />
</button>
) : (
<Link
href={`/workspace/${workspaceId}/home`}
className='flex h-[30px] w-[30px] items-center justify-center rounded-[8px] hover:bg-[var(--surface-active)]'
>
<Blimp className='h-[18px] w-[18px] text-[var(--text-icon)]' />
</Link>
)}
</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>Expand sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={toggleCollapsed}
className={cn(
'ml-auto flex h-[30px] items-center justify-center overflow-hidden rounded-[8px] transition-all duration-200 hover:bg-[var(--surface-active)]',
isCollapsed ? 'w-0 opacity-0' : 'w-[30px] opacity-100'
)}
aria-label='Collapse sidebar'
>
<PanelLeft className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
</button>
</Tooltip.Trigger>
{!isCollapsed && (
<Tooltip.Content side='bottom'>
<p>Collapse sidebar</p>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
{/* Workspace Header */}
<div className='flex-shrink-0 px-[8px]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
@@ -747,10 +815,10 @@ export const Sidebar = memo(function Sidebar() {
</div>
{isOnSettingsPage ? (
<>
{/* Settings sidebar navigation */}
<SettingsSidebar />
</>
<SettingsSidebar
isCollapsed={isCollapsed}
showCollapsedContent={showCollapsedContent}
/>
) : (
<>
{/* Top Navigation: Home, Search */}
@@ -762,26 +830,20 @@ export const Sidebar = memo(function Sidebar() {
'group flex h-[30px] items-center gap-[8px] rounded-[8px] mx-[2px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
const activeClasses = active ? 'bg-[var(--surface-active)]' : ''
if (item.onClick) {
return (
<button
key={item.id}
type='button'
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
onClick={item.onClick}
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</button>
)
}
return (
const element = item.onClick ? (
<button
type='button'
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
onClick={item.onClick}
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</button>
) : (
<Link
key={item.id}
href={item.href!}
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
@@ -793,13 +855,28 @@ export const Sidebar = memo(function Sidebar() {
</span>
</Link>
)
return (
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
})}
</div>
{/* Workspace */}
<div className='mt-[14px] flex flex-shrink-0 flex-col pb-[8px]'>
<div className='px-[16px] pb-[6px]'>
<div className='font-base text-[var(--text-icon)] text-small'>Workspace</div>
<div
className={`font-base text-[var(--text-icon)] text-small${isCollapsed ? ' opacity-0' : ''}`}
>
Workspace
</div>
</div>
<div className='flex flex-col gap-[2px] px-[8px]'>
{workspaceNavItems.map((item) => {
@@ -810,209 +887,225 @@ export const Sidebar = memo(function Sidebar() {
const activeClasses = active ? 'bg-[var(--surface-active)]' : ''
return (
<Link
key={item.id}
href={item.href!}
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
onContextMenu={(e) => handleNavItemContextMenu(e, item.href!)}
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</Link>
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>
<Link
href={item.href!}
data-item-id={item.id}
className={`${baseClasses} ${activeClasses}`}
onContextMenu={(e) => handleNavItemContextMenu(e, item.href!)}
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</Link>
</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
})}
</div>
</div>
{/* Scrollable Tasks + Workflows */}
<div
ref={scrollContainerRef}
className={cn(
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-[9px] transition-colors duration-150',
!hasOverflowTop && 'border-transparent'
)}
>
{/* Tasks */}
<div className='flex flex-shrink-0 flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const active = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<Link
key={task.id}
href={task.href}
className={cn(
'mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
(active || isSelected) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
if (e.shiftKey || e.metaKey || e.ctrlKey) {
e.preventDefault()
handleTaskClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
} else {
handleTaskClick(task.id, false, false)
}
}}
onContextMenu={
task.id !== 'new'
? (e) => handleTaskContextMenu(e, task.id)
: undefined
}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{task.name}
</div>
</Link>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-[var(--sidebar-font-weight)]'>See more</span>
</button>
)}
</>
)}
</div>
</div>
{/* Workflows */}
<div className='workflows-section relative mt-[14px] flex flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
<div className='flex items-center justify-center gap-[8px]'>
<Popover>
{/* Scrollable Tasks + Workflows — hidden when collapsed */}
{!isCollapsed && (
<div
ref={scrollContainerRef}
className={cn(
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-[9px] transition-colors duration-150',
!hasOverflowTop && 'border-transparent'
)}
>
{/* Tasks */}
<div className='flex flex-shrink-0 flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>
All tasks
</div>
<div className='flex items-center justify-center gap-[8px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</PopoverTrigger>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
<p>New task</p>
</Tooltip.Content>
</Tooltip.Root>
<PopoverContent align='end' sideOffset={8} minWidth={160}>
<PopoverItem
onClick={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download className='h-[16px] w-[16px]' />
<span>{isImporting ? 'Importing...' : 'Import workflow'}</span>
</PopoverItem>
<PopoverItem
onClick={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus className='h-[16px] w-[16px]' />
<span>
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</span>
</PopoverItem>
</PopoverContent>
</Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'New workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
{tasksLoading ? (
<SidebarItemSkeleton />
) : (
<>
{tasks.slice(0, visibleTaskCount).map((task) => {
const active = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
if (isRenaming) {
return (
<div
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleSaveTaskRename}
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
/>
</div>
)
}
return (
<Link
key={task.id}
href={task.href}
className={cn(
'mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
(active || isSelected) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
if (task.id === 'new') return
if (e.shiftKey || e.metaKey || e.ctrlKey) {
e.preventDefault()
handleTaskClick(task.id, e.shiftKey, e.metaKey || e.ctrlKey)
} else {
handleTaskClick(task.id, false, false)
}
}}
onContextMenu={
task.id !== 'new'
? (e) => handleTaskContextMenu(e, task.id)
: undefined
}
>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{task.name}
</div>
</Link>
)
})}
{tasks.length > visibleTaskCount && (
<button
type='button'
onClick={() => setVisibleTaskCount((prev) => prev + 5)}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
<span className='font-[var(--sidebar-font-weight)]'>See more</span>
</button>
)}
</>
)}
</div>
</div>
<div className='mt-[6px] px-[8px]'>
{workflowsLoading && regularWorkflows.length === 0 && <SidebarItemSkeleton />}
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
{/* Workflows */}
<div className='workflows-section relative mt-[14px] flex flex-col'>
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
<div className='flex items-center justify-between'>
<div className='font-base text-[var(--text-icon)] text-small'>
Workflows
</div>
<div className='flex items-center justify-center gap-[8px]'>
<Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<PopoverTrigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
disabled={!canEdit}
>
{isImporting || isCreatingFolder ? (
<Loader className='h-[16px] w-[16px]' animate />
) : (
<MoreHorizontal className='h-[16px] w-[16px]' />
)}
</Button>
</PopoverTrigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p>More actions</p>
</Tooltip.Content>
</Tooltip.Root>
<PopoverContent align='end' sideOffset={8} minWidth={160}>
<PopoverItem
onClick={handleImportWorkflow}
disabled={!canEdit || isImporting}
>
<Download className='h-[16px] w-[16px]' />
<span>{isImporting ? 'Importing...' : 'Import workflow'}</span>
</PopoverItem>
<PopoverItem
onClick={handleCreateFolder}
disabled={!canEdit || isCreatingFolder}
>
<FolderPlus className='h-[16px] w-[16px]' />
<span>
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
</span>
</PopoverItem>
</PopoverContent>
</Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
onClick={handleCreateWorkflow}
disabled={isCreatingWorkflow || !canEdit}
>
<Plus className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'New workflow'}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</div>
<div className='mt-[6px] px-[8px]'>
{workflowsLoading && regularWorkflows.length === 0 && <SidebarItemSkeleton />}
<WorkflowList
regularWorkflows={regularWorkflows}
isLoading={isLoading}
canReorder={canEdit}
handleFileChange={handleImportFileChange}
fileInputRef={fileInputRef}
scrollContainerRef={scrollContainerRef}
onCreateWorkflow={handleCreateWorkflow}
onCreateFolder={handleCreateFolder}
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
/>
</div>
</div>
</div>
</div>
)}
{isCollapsed && <div className='flex-1' />}
{/* Footer */}
<div
@@ -1023,29 +1116,21 @@ export const Sidebar = memo(function Sidebar() {
>
{footerItems.map((item) => {
const Icon = item.icon
const footerClasses =
'group flex h-[30px] items-center gap-[8px] rounded-[8px] mx-[2px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
if (item.href) {
return (
<Link
key={item.id}
href={item.href}
data-item-id={item.id}
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</Link>
)
}
return (
const element = item.href ? (
<Link href={item.href} data-item-id={item.id} className={footerClasses}>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate font-[var(--sidebar-font-weight)] text-[var(--text-body)]'>
{item.label}
</span>
</Link>
) : (
<button
key={item.id}
type='button'
data-item-id={item.id}
className='group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]'
className={footerClasses}
onClick={item.onClick}
>
<Icon className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
@@ -1054,10 +1139,21 @@ export const Sidebar = memo(function Sidebar() {
</span>
</button>
)
return (
<Tooltip.Root key={`${item.id}-${isCollapsed}`}>
<Tooltip.Trigger asChild>{element}</Tooltip.Trigger>
{showCollapsedContent && (
<Tooltip.Content side='right'>
<p>{item.label}</p>
</Tooltip.Content>
)}
</Tooltip.Root>
)
})}
</div>
{/* Nav Item Context Menu (Home, Tables, Files, etc.) */}
{/* Nav Item Context Menu */}
<NavItemContextMenu
isOpen={isNavContextMenuOpen}
position={navContextMenuPosition}
@@ -1098,7 +1194,7 @@ export const Sidebar = memo(function Sidebar() {
</div>
{/* Resize Handle */}
{isOnWorkflowPage && (
{isOnWorkflowPage && !isCollapsed && (
<div
className='absolute top-0 right-[-4px] bottom-0 z-20 w-[8px] cursor-ew-resize'
onMouseDown={handleMouseDown}

View File

@@ -1,23 +1,25 @@
import type { SVGProps } from 'react'
/**
* PanelLeft icon component
* PanelLeft icon component - sidebar panel layout
* @param props - SVG properties including className, fill, etc.
*/
export function PanelLeft(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='16'
height='14'
viewBox='0 0 16 14'
width='24'
height='24'
viewBox='-1 -2 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.75'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M9.33301 0.5C10.4138 0.5 11.2687 0.499523 11.9482 0.580078C12.6403 0.662154 13.209 0.833996 13.6963 1.21777C14.0303 1.48085 14.3188 1.80524 14.5488 2.1748C14.8787 2.70489 15.0256 3.3188 15.0967 4.07715C15.1672 4.82972 15.167 5.77992 15.167 7C15.167 8.22008 15.1672 9.17028 15.0967 9.92285C15.0256 10.6812 14.8787 11.2951 14.5488 11.8252C14.3188 12.1948 14.0303 12.5191 13.6963 12.7822C13.209 13.166 12.6403 13.3378 11.9482 13.4199C11.2687 13.5005 10.4138 13.5 9.33301 13.5H6.66699C5.58622 13.5 4.73134 13.5005 4.05176 13.4199C3.35971 13.3378 2.79101 13.166 2.30371 12.7822C1.96974 12.5191 1.68123 12.1948 1.45117 11.8252C1.1213 11.2951 0.974405 10.6812 0.90332 9.92285C0.832808 9.17028 0.833008 8.22008 0.833008 7C0.833008 5.77992 0.832808 4.82972 0.90332 4.07715C0.974405 3.3188 1.1213 2.70489 1.45117 2.1748C1.68123 1.80524 1.96974 1.48085 2.30371 1.21777C2.79101 0.833996 3.35971 0.662154 4.05176 0.580078C4.73134 0.499523 5.58622 0.5 6.66699 0.5H9.33301ZM6.83301 12.5H9.33301C10.4382 12.5 11.2239 12.4988 11.8311 12.4268C12.4253 12.3563 12.7908 12.2234 13.0781 11.9971C13.3173 11.8086 13.5289 11.5721 13.7002 11.2969C13.9126 10.9555 14.036 10.5178 14.1006 9.8291C14.1657 9.13409 14.167 8.23916 14.167 7C14.167 5.76084 14.1657 4.86591 14.1006 4.1709C14.036 3.48215 13.9126 3.04452 13.7002 2.70312C13.5289 2.42788 13.3173 2.19139 13.0781 2.00293C12.7908 1.77661 12.4253 1.64375 11.8311 1.57324C11.2239 1.50125 10.4382 1.5 9.33301 1.5H6.83301V12.5ZM5.83301 1.50098C5.14755 1.50512 4.61095 1.52083 4.16895 1.57324C3.57469 1.64375 3.20925 1.77661 2.92188 2.00293C2.68266 2.19139 2.47113 2.42788 2.2998 2.70312C2.08736 3.04452 1.96397 3.48215 1.89941 4.1709C1.83432 4.86591 1.83301 5.76084 1.83301 7C1.83301 8.23916 1.83432 9.13409 1.89941 9.8291C1.96397 10.5178 2.08736 10.9555 2.2998 11.2969C2.47113 11.5721 2.68266 11.8086 2.92188 11.9971C3.20925 12.2234 3.57469 12.3563 4.16895 12.4268C4.61095 12.4792 5.14755 12.4939 5.83301 12.498V1.50098ZM4 5.16699C4.27614 5.16699 4.5 5.39085 4.5 5.66699C4.49982 5.94298 4.27603 6.16699 4 6.16699H3.33301C3.05712 6.16682 2.83318 5.94288 2.83301 5.66699C2.83301 5.39096 3.05702 5.16717 3.33301 5.16699H4ZM4 3.16699C4.27614 3.16699 4.5 3.39085 4.5 3.66699C4.49982 3.94298 4.27603 4.16699 4 4.16699H3.33301C3.05712 4.16682 2.83318 3.94288 2.83301 3.66699C2.83301 3.39096 3.05702 3.16717 3.33301 3.16699H4Z'
fill='currentColor'
/>
<path d='M0.75 3.25C0.75 1.86929 1.86929 0.75 3.25 0.75H17.25C18.6307 0.75 19.75 1.86929 19.75 3.25V16.25C19.75 17.6307 18.6307 18.75 17.25 18.75H3.25C1.86929 18.75 0.75 17.6307 0.75 16.25V3.25Z' />
<path d='M8.25 0.75V18.75' />
</svg>
)
}

View File

@@ -22,6 +22,8 @@ export const API_ENDPOINTS = {
export const SIDEBAR_WIDTH = {
DEFAULT: 248,
MIN: 248,
/** Width when sidebar is collapsed to icon-only mode */
COLLAPSED: 51,
/** Maximum is 30% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.3,
} as const

View File

@@ -3,20 +3,32 @@ import { persist } from 'zustand/middleware'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import type { SidebarState } from './types'
function applySidebarWidth(width: number) {
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
}
}
export const useSidebarStore = create<SidebarState>()(
persist(
(set) => ({
(set, get) => ({
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: false,
isResizing: false,
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
if (get().isCollapsed) return
const clampedWidth = Math.max(SIDEBAR_WIDTH.MIN, width)
set({ sidebarWidth: clampedWidth })
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
}
applySidebarWidth(clampedWidth)
},
toggleCollapsed: () => {
const { isCollapsed, sidebarWidth } = get()
const nextCollapsed = !isCollapsed
set({ isCollapsed: nextCollapsed })
applySidebarWidth(nextCollapsed ? SIDEBAR_WIDTH.COLLAPSED : sidebarWidth)
},
setIsResizing: (isResizing) => {
set({ isResizing })
@@ -28,13 +40,13 @@ export const useSidebarStore = create<SidebarState>()(
onRehydrateStorage: () => (state) => {
if (state) {
state.setHasHydrated(true)
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`)
}
const width = state.isCollapsed ? SIDEBAR_WIDTH.COLLAPSED : state.sidebarWidth
applySidebarWidth(width)
}
},
partialize: (state) => ({
sidebarWidth: state.sidebarWidth,
isCollapsed: state.isCollapsed,
}),
}
)

View File

@@ -4,11 +4,15 @@
export interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
/** Whether the sidebar is collapsed to icon-only mode */
isCollapsed: boolean
/** Whether the sidebar is currently being resized */
isResizing: boolean
_hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
/** Toggles sidebar between collapsed and expanded states */
toggleCollapsed: () => void
/** Updates the sidebar resize state */
setIsResizing: (isResizing: boolean) => void
setHasHydrated: (hasHydrated: boolean) => void