mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement: sidebar, chat
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AgentGroup } from './agent-group'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Subagent } from './subagent'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ToolCall } from './tool-calls'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]')} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user