improvement: notifications, terminal, globals

This commit is contained in:
Emir Karabeg
2026-03-12 21:03:06 -07:00
parent 3613a3aef6
commit 0ba69d5992
24 changed files with 524 additions and 308 deletions

View File

@@ -667,16 +667,36 @@ input[type="search"]::-ms-clear {
}
/**
* Notification toast enter animation
* Notification toast enter animation — pop-open with stack offset
*/
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(-16px);
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px));
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
}
@keyframes notification-countdown {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 34.56;
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
to {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
}
}

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import type { ChatResource, ResourceType } from '@/lib/copilot/resources'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
@@ -12,6 +11,7 @@ import {
createNotFoundResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import type { ChatResource, ResourceType } from '@/lib/copilot/resources'
const logger = createLogger('CopilotChatResourcesAPI')
@@ -75,7 +75,9 @@ export async function POST(req: NextRequest) {
let merged: ChatResource[]
if (prev) {
if (GENERIC_TITLES.has(prev.title) && !GENERIC_TITLES.has(resource.title)) {
merged = existing.map((r) => (`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r))
merged = existing.map((r) =>
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
)
} else {
merged = existing
}

View File

@@ -1,11 +1,7 @@
'use client'
import {
type RefCallback,
useCallback,
useMemo,
useState,
} from 'react'
import { type RefCallback, useCallback, useMemo, useState } from 'react'
import { ChevronRight, Folder } from 'lucide-react'
import {
Button,
DropdownMenu,
@@ -19,20 +15,19 @@ import {
Tooltip,
} from '@/components/emcn'
import { Plus, Search } from '@/components/emcn/icons'
import { ChevronRight, Folder } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
export interface AddResourceDropdownProps {
workspaceId: string
@@ -54,34 +49,54 @@ const EMPTY_SUBMENU = (
</DropdownMenuItem>
)
export function useAvailableResources(workspaceId: string, existingKeys: Set<string>): AvailableItemsByType[] {
export function useAvailableResources(
workspaceId: string,
existingKeys: Set<string>
): AvailableItemsByType[] {
const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false })
const { data: tables = [] } = useTablesList(workspaceId)
const { data: files = [] } = useWorkspaceFiles(workspaceId)
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)
return useMemo(() => [
{
type: 'workflow' as const,
items: workflows
.map((w) => ({ id: w.id, name: w.name, color: w.color, folderId: w.folderId, isOpen: existingKeys.has(`workflow:${w.id}`) })),
},
{
type: 'table' as const,
items: tables
.map((t) => ({ id: t.id, name: t.name, isOpen: existingKeys.has(`table:${t.id}`) })),
},
{
type: 'file' as const,
items: files
.map((f) => ({ id: f.id, name: f.name, isOpen: existingKeys.has(`file:${f.id}`) })),
},
{
type: 'knowledgebase' as const,
items: (knowledgeBases ?? [])
.map((kb) => ({ id: kb.id, name: kb.name, isOpen: existingKeys.has(`knowledgebase:${kb.id}`) })),
},
], [workflows, tables, files, knowledgeBases, existingKeys])
return useMemo(
() => [
{
type: 'workflow' as const,
items: workflows.map((w) => ({
id: w.id,
name: w.name,
color: w.color,
folderId: w.folderId,
isOpen: existingKeys.has(`workflow:${w.id}`),
})),
},
{
type: 'table' as const,
items: tables.map((t) => ({
id: t.id,
name: t.name,
isOpen: existingKeys.has(`table:${t.id}`),
})),
},
{
type: 'file' as const,
items: files.map((f) => ({
id: f.id,
name: f.name,
isOpen: existingKeys.has(`file:${f.id}`),
})),
},
{
type: 'knowledgebase' as const,
items: (knowledgeBases ?? []).map((kb) => ({
id: kb.id,
name: kb.name,
isOpen: existingKeys.has(`knowledgebase:${kb.id}`),
})),
},
],
[workflows, tables, files, knowledgeBases, existingKeys]
)
}
function CollapsibleFolder({
@@ -101,9 +116,7 @@ function CollapsibleFolder({
config: ReturnType<typeof getResourceConfig>
level: number
}) {
const folderWorkflows = workflows.filter(
(w) => (w.folderId as string | null) === folder.id
)
const folderWorkflows = workflows.filter((w) => (w.folderId as string | null) === folder.id)
const isExpanded = expanded.has(folder.id)
const indent = level * 12
@@ -112,8 +125,13 @@ function CollapsibleFolder({
<div
role='button'
tabIndex={0}
onClick={(e) => { e.preventDefault(); onToggle(folder.id) }}
onKeyDown={(e) => { if (e.key === 'Enter') onToggle(folder.id) }}
onClick={(e) => {
e.preventDefault()
onToggle(folder.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') onToggle(folder.id)
}}
className='flex cursor-pointer items-center gap-[6px] rounded-sm px-[8px] py-[6px] text-[13px] hover:bg-[var(--surface-active)]'
style={{ paddingLeft: `${8 + indent}px` }}
>
@@ -169,7 +187,10 @@ function WorkflowSubmenuContent({
useFolders(workspaceId)
const folders = useFolderStore((state) => state.folders)
const getFolderTree = useFolderStore((state) => state.getFolderTree)
const folderTree = useMemo(() => getFolderTree(workspaceId), [folders, getFolderTree, workspaceId])
const folderTree = useMemo(
() => getFolderTree(workspaceId),
[folders, getFolderTree, workspaceId]
)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const toggleFolder = useCallback((id: string) => {
@@ -231,7 +252,12 @@ function WorkflowSubmenuContent({
)
}
export function AddResourceDropdown({ workspaceId, existingKeys, onAdd, onSwitch }: AddResourceDropdownProps) {
export function AddResourceDropdown({
workspaceId,
existingKeys,
onAdd,
onSwitch,
}: AddResourceDropdownProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const available = useAvailableResources(workspaceId, existingKeys)
@@ -338,7 +364,9 @@ export function AddResourceDropdown({ workspaceId, existingKeys, onAdd, onSwitch
workspaceId={workspaceId}
items={items}
config={config}
onSelect={(item) => select({ type, id: item.id, title: item.name }, item.isOpen)}
onSelect={(item) =>
select({ type, id: item.id, title: item.name }, item.isOpen)
}
/>
) : items.length > 0 ? (
items.map((item) => (

View File

@@ -1,3 +1,2 @@
export { AddResourceDropdown } from './add-resource-dropdown'
export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown'
export { useAvailableResources } from './add-resource-dropdown'
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'

View File

@@ -1,5 +1,4 @@
export { AddResourceDropdown } from './add-resource-dropdown'
export type { AddResourceDropdownProps, AvailableItem } from './add-resource-dropdown'
export { useAvailableResources } from './add-resource-dropdown'
export { AddResourceDropdown, useAvailableResources } from './add-resource-dropdown'
export { ResourceActions, ResourceContent } from './resource-content'
export { ResourceTabs } from './resource-tabs'

View File

@@ -92,7 +92,9 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
case 'workflow':
return <EmbeddedWorkflowActions workspaceId={workspaceId} workflowId={resource.id} />
case 'knowledgebase':
return <EmbeddedKnowledgeBaseActions workspaceId={workspaceId} knowledgeBaseId={resource.id} />
return (
<EmbeddedKnowledgeBaseActions workspaceId={workspaceId} knowledgeBaseId={resource.id} />
)
default:
return null
}

View File

@@ -1,19 +1,19 @@
'use client'
import { type ElementType, type ReactNode } from 'react'
import type { ElementType, ReactNode } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { Database, File as FileIcon, Table as TableIcon } from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { workflowKeys } from '@/hooks/queries/workflows'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { cn } from '@/lib/core/utils/cn'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { tableKeys } from '@/hooks/queries/tables'
import { workflowKeys } from '@/hooks/queries/workflows'
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DropdownItemRenderProps {
@@ -87,7 +87,9 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
type: 'table',
label: 'Tables',
icon: TableIcon,
renderTabIcon: (_resource, className) => <TableIcon className={cn(className, 'text-[var(--text-icon)]')} />,
renderTabIcon: (_resource, className) => (
<TableIcon className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
file: {
@@ -104,7 +106,9 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
type: 'knowledgebase',
label: 'Knowledge Bases',
icon: Database,
renderTabIcon: (_resource, className) => <Database className={cn(className, 'text-[var(--text-icon)]')} />,
renderTabIcon: (_resource, className) => (
<Database className={cn(className, 'text-[var(--text-icon)]')} />
),
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
},
} as const

View File

@@ -1,31 +1,31 @@
import {
type ReactNode,
type RefCallback,
type SVGProps,
type SVGProps,
useCallback,
useMemo,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
Button,
Tooltip,
} from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { PanelLeft } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useAddChatResource, useRemoveChatResource, useReorderChatResources } from '@/hooks/queries/tasks'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import { AddResourceDropdown } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import {
useAddChatResource,
useRemoveChatResource,
useReorderChatResources,
} from '@/hooks/queries/tasks'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const LEFT_HALF =
'M10.25 0.75H3.25C1.86929 0.75 0.75 1.86929 0.75 3.25V16.25C0.75 17.6307 1.86929 18.75 3.25 18.75H10.25V0.75Z'
@@ -301,13 +301,21 @@ export function ResourceTabs({
const isActive = activeId === resource.id
const isHovered = hoveredTabId === resource.id
const isDragging = draggedIdx === idx
const showGapBefore = dropGapIdx === idx && draggedIdx !== null && draggedIdx !== idx && draggedIdx !== idx - 1
const showGapAfter = idx === resources.length - 1 && dropGapIdx === resources.length && draggedIdx !== null && draggedIdx !== idx
const showGapBefore =
dropGapIdx === idx &&
draggedIdx !== null &&
draggedIdx !== idx &&
draggedIdx !== idx - 1
const showGapAfter =
idx === resources.length - 1 &&
dropGapIdx === resources.length &&
draggedIdx !== null &&
draggedIdx !== idx
return (
<div key={resource.id} className='relative flex shrink-0 items-center'>
{showGapBefore && (
<div className='pointer-events-none absolute top-1/2 left-0 z-10 h-[16px] w-[2px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--text-subtle)]' />
<div className='-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute top-1/2 left-0 z-10 h-[16px] w-[2px] rounded-full bg-[var(--text-subtle)]' />
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
@@ -340,11 +348,22 @@ export function ResourceTabs({
role='button'
tabIndex={-1}
onClick={(e) => handleRemove(e, resource)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRemove(e as unknown as React.MouseEvent, resource) }}
className='absolute right-[4px] top-1/2 flex -translate-y-1/2 items-center justify-center rounded-[4px] p-[1px] hover:bg-[var(--surface-5)]'
onKeyDown={(e) => {
if (e.key === 'Enter')
handleRemove(e as unknown as React.MouseEvent, resource)
}}
className='-translate-y-1/2 absolute top-1/2 right-[4px] flex items-center justify-center rounded-[4px] p-[1px] hover:bg-[var(--surface-5)]'
aria-label={`Close ${displayName}`}
>
<svg className='h-[10px] w-[10px] text-[var(--text-tertiary)]' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2.5' strokeLinecap='round' strokeLinejoin='round'>
<svg
className='h-[10px] w-[10px] text-[var(--text-tertiary)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2.5'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M18 6 6 18M6 6l12 12' />
</svg>
</span>
@@ -356,7 +375,7 @@ export function ResourceTabs({
</Tooltip.Content>
</Tooltip.Root>
{showGapAfter && (
<div className='pointer-events-none absolute top-1/2 right-0 z-10 h-[16px] w-[2px] translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--text-subtle)]' />
<div className='-translate-y-1/2 pointer-events-none absolute top-1/2 right-0 z-10 h-[16px] w-[2px] translate-x-1/2 rounded-full bg-[var(--text-subtle)]' />
)}
</div>
)
@@ -398,4 +417,3 @@ export function ResourceTabs({
</div>
)
}

View File

@@ -91,9 +91,12 @@ export function MothershipView({
/>
) : (
<div className='flex h-full flex-col items-center justify-center gap-[4px] px-[24px]'>
<h2 className='text-[20px] font-semibold text-[var(--text-secondary)]'>No resources open</h2>
<h2 className='font-semibold text-[20px] text-[var(--text-secondary)]'>
No resources open
</h2>
<p className='text-[12px] text-[var(--text-tertiary)]'>
Click the <span className='font-medium text-[var(--text-secondary)]'>+</span> button above to add a resource to this task
Click the <span className='font-medium text-[var(--text-secondary)]'>+</span> button
above to add a resource to this task
</p>
</div>
)}

View File

@@ -400,7 +400,13 @@ export function Home({ chatId }: HomeProps = {}) {
onReorderResources={reorderResources}
onCollapse={collapseResource}
isCollapsed={isResourceCollapsed}
className={isResourceAnimatingIn ? 'animate-slide-in-right' : skipResourceTransition ? '!transition-none' : undefined}
className={
isResourceAnimatingIn
? 'animate-slide-in-right'
: skipResourceTransition
? '!transition-none'
: undefined
}
/>
{isResourceCollapsed && (

View File

@@ -8,6 +8,7 @@ import {
reportManualRunToolStop,
} from '@/lib/copilot/client-sse/run-tool-execution'
import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
import { getNextWorkflowColor } from '@/lib/workflows/colors'
import {
@@ -25,7 +26,6 @@ import { useExecutionStore } from '@/stores/execution/store'
import { useFolderStore } from '@/stores/folders/store'
import { useTerminalConsoleStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
import { invalidateResourceQueries } from '../components/mothership-view/components/resource-registry'
import type { FileAttachmentForApi } from '../components/user-input/user-input'
import type {
@@ -192,8 +192,7 @@ function extractResourceFromReadResult(
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType || !segments[1]) return null
const obj =
output && typeof output === 'object' ? (output as Record<string, unknown>) : undefined
const obj = output && typeof output === 'object' ? (output as Record<string, unknown>) : undefined
if (!obj) return null
let id = obj.id as string | undefined
@@ -256,12 +255,9 @@ export function useChat(
setActiveResourceId(resource.id)
}, [])
const removeResource = useCallback(
(resourceType: MothershipResourceType, resourceId: string) => {
setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId)))
},
[]
)
const removeResource = useCallback((resourceType: MothershipResourceType, resourceId: string) => {
setResources((prev) => prev.filter((r) => !(r.type === resourceType && r.id === resourceId)))
}, [])
const reorderResources = useCallback((newOrder: MothershipResource[]) => {
setResources(newOrder)
@@ -524,12 +520,7 @@ export function useChat(
)
if (resource) {
addResource(resource)
invalidateResourceQueries(
queryClient,
workspaceId,
resource.type,
resource.id
)
invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id)
onResourceEventRef.current?.()
}
}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
@@ -6,6 +6,7 @@ import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/provide
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
type Notification,
type NotificationAction,
openCopilotWithMessage,
useNotificationStore,
@@ -14,11 +15,71 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
const STACK_OFFSET_PX = 3
const AUTO_DISMISS_MS = 10000
const EXIT_ANIMATION_MS = 200
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ACTION_LABELS: Record<NotificationAction['type'], string> = {
copilot: 'Fix in Copilot',
refresh: 'Refresh',
'unlock-workflow': 'Unlock Workflow',
} as const
function isAutoDismissable(n: Notification): boolean {
return n.level === 'error' && !!n.workflowId
}
function CountdownRing({ onPause }: { onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep notifications visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
}}
/>
</svg>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
/**
* Notifications display component.
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*
* Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
* ring. Clicking the ring pauses all timers until the notification stack clears.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -63,7 +124,6 @@ export const Notifications = memo(function Notifications() {
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
// Dismiss the notification after the action is triggered
removeNotification(notificationId)
} catch (error) {
logger.error('Failed to execute notification action', {
@@ -76,13 +136,6 @@ export const Notifications = memo(function Notifications() {
[removeNotification]
)
/**
* Register global keyboard shortcut for clearing notifications.
*
* - Mod+E: Clear all notifications visible in the current workflow (including global ones).
*
* The command is disabled in editable contexts so it does not interfere with typing.
*/
useRegisterGlobalCommands(() =>
createCommands([
{
@@ -99,19 +152,78 @@ export const Notifications = memo(function Notifications() {
const preventZoomRef = usePreventZoom()
const [isPaused, setIsPaused] = useState(false)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const pauseAll = useCallback(() => {
setIsPaused(true)
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
/**
* Manages per-notification dismiss timers.
* Resets pause state when the notification stack empties so new arrivals get fresh timers.
*/
useEffect(() => {
if (visibleNotifications.length === 0) {
if (isPaused) setIsPaused(false)
return
}
if (isPaused) return
const timers = timersRef.current
const activeIds = new Set<string>()
for (const n of visibleNotifications) {
if (!isAutoDismissable(n) || timers.has(n.id)) continue
activeIds.add(n.id)
timers.set(
n.id,
setTimeout(() => {
timers.delete(n.id)
setExitingIds((prev) => new Set(prev).add(n.id))
setTimeout(() => {
removeNotification(n.id)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(n.id)
return next
})
}, EXIT_ANIMATION_MS)
}, AUTO_DISMISS_MS)
)
}
for (const [id, timer] of timers) {
if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [visibleNotifications, removeNotification, isPaused])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
}, [])
if (visibleNotifications.length === 0) {
return null
}
return (
<div
ref={preventZoomRef}
className='absolute right-[16px] bottom-[16px] z-30 flex flex-col items-start'
>
<div ref={preventZoomRef} className='absolute right-[16px] bottom-[16px] z-30 grid'>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
const xOffset = depth * 3
const xOffset = depth * STACK_OFFSET_PX
const hasAction = Boolean(notification.action)
const showCountdown = !isPaused && isAutoDismissable(notification)
return (
<div
@@ -119,54 +231,48 @@ export const Notifications = memo(function Notifications() {
style={
{
'--stack-offset': `${xOffset}px`,
animation: 'notification-enter 200ms ease-out forwards',
animation: exitingIds.has(notification.id)
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'notification-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
className={`relative h-[80px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] ${
index > 0 ? '-mt-[80px]' : ''
}`}
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
<div className='flex h-full flex-col justify-between px-[8px] pt-[6px] pb-[8px]'>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
<div
className={`min-w-0 flex-1 font-medium text-[12px] leading-[16px] ${
hasAction ? 'line-clamp-2' : 'line-clamp-4'
}`}
>
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{notification.level === 'error' && (
<span className='mr-[6px] mb-[2.75px] inline-block h-[6px] w-[6px] rounded-[2px] bg-[var(--text-error)] align-middle' />
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-1.5 -m-1.5 shrink-0'
>
<X className='h-3 w-3' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && <CountdownRing onPause={pauseAll} />}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{hasAction && (
<Button
variant='active'
onClick={() => executeAction(notification.id, notification.action!)}
className='w-full px-[8px] py-[4px] font-medium text-[12px]'
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{notification.action!.type === 'copilot'
? 'Fix in Copilot'
: notification.action!.type === 'refresh'
? 'Refresh'
: notification.action!.type === 'unlock-workflow'
? 'Unlock Workflow'
: 'Take action'}
{ACTION_LABELS[notification.action!.type] ?? 'Take action'}
</Button>
)}
</div>

View File

@@ -38,7 +38,7 @@ const SearchContext = createContext<SearchContextValue | null>(null)
* Configuration for virtualized rendering.
*/
const CONFIG = {
ROW_HEIGHT: 22,
ROW_HEIGHT: 30,
INDENT_PER_LEVEL: 12,
BASE_PADDING: 20,
MAX_SEARCH_DEPTH: 100,
@@ -60,17 +60,16 @@ const BADGE_VARIANTS: Record<ValueType, BadgeVariant> = {
* Styling constants matching the original non-virtualized implementation.
*/
const STYLES = {
row: 'group flex min-h-[22px] cursor-pointer items-center gap-[6px] rounded-[8px] px-[6px] -mx-[6px] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
row: 'group flex min-h-[30px] cursor-pointer items-center gap-[8px] rounded-[8px] px-[8px] -mx-[8px] hover:bg-[var(--surface-active)]',
chevron:
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
keyName:
'font-medium text-[13px] text-[var(--text-primary)] group-hover:text-[var(--text-primary)]',
'h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
keyName: 'font-base text-[14px] text-[var(--text-primary)]',
badge: 'rounded-[4px] px-[4px] py-[0px] text-[11px]',
summary: 'text-[12px] text-[var(--text-tertiary)]',
summary: 'text-[14px] text-[var(--text-secondary)]',
indent:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
value: 'min-w-0 py-[2px] text-[13px] text-[var(--text-primary)]',
emptyValue: 'py-[2px] text-[13px] text-[var(--text-tertiary)]',
value: 'min-w-0 py-[2px] text-[14px] text-[var(--text-primary)]',
emptyValue: 'py-[2px] text-[14px] text-[var(--text-secondary)]',
matchHighlight: 'bg-yellow-200/60 dark:bg-yellow-500/40',
currentMatchHighlight: 'bg-orange-400',
} as const
@@ -87,6 +86,7 @@ function getTypeLabel(value: unknown): ValueType {
function formatPrimitive(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string') return JSON.stringify(value)
return String(value)
}

View File

@@ -316,8 +316,8 @@ export const OutputPanel = React.memo(function OutputPanel({
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
'px-[8px] py-[6px] text-small',
!showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-icon)]'
)}
onClick={handleOutputButtonClick}
aria-label='Show output'
@@ -328,8 +328,8 @@ export const OutputPanel = React.memo(function OutputPanel({
<Button
variant='ghost'
className={clsx(
'px-[8px] py-[6px] text-[12px]',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]'
'px-[8px] py-[6px] text-small',
showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-icon)]'
)}
onClick={handleInputButtonClick}
aria-label='Show input'
@@ -361,7 +361,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
<X className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -377,7 +377,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Search in output'
className='!p-1.5 -m-1.5'
>
<Search className='h-[12px] w-[12px]' />
<Search className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -395,7 +395,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-[12px] w-[12px]' />
<Palette className='h-3.5 w-3.5' />
</Button>
</Link>
</Tooltip.Trigger>
@@ -418,9 +418,9 @@ export const OutputPanel = React.memo(function OutputPanel({
)}
>
{isTraining ? (
<Pause className='h-[12px] w-[12px]' />
<Pause className='h-3.5 w-3.5' />
) : (
<Database className='h-[12px] w-[12px]' />
<Database className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
@@ -439,9 +439,9 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
>
{showCopySuccess ? (
<Check className='h-[12px] w-[12px]' />
<Check className='h-3.5 w-3.5' />
) : (
<Clipboard className='h-[12px] w-[12px]' />
<Clipboard className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
@@ -459,7 +459,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Download console CSV'
className='!p-1.5 -m-1.5'
>
<ArrowDownToLine className='h-3 w-3' />
<ArrowDownToLine className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -474,7 +474,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
<Trash2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -557,7 +557,7 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowUp className='h-[12px] w-[12px]' />
<ArrowUp className='h-3.5 w-3.5' />
</Button>
<Button
variant='ghost'
@@ -566,7 +566,7 @@ export const OutputPanel = React.memo(function OutputPanel({
className='!p-1.5 -m-1.5'
disabled={matchCount === 0}
>
<ArrowDown className='h-[12px] w-[12px]' />
<ArrowDown className='h-3.5 w-3.5' />
</Button>
<Button
variant='ghost'
@@ -574,7 +574,7 @@ export const OutputPanel = React.memo(function OutputPanel({
aria-label='Close search'
className='!p-1.5 -m-1.5'
>
<X className='h-[12px] w-[12px]' />
<X className='h-3.5 w-3.5' />
</Button>
</div>
)}

View File

@@ -108,7 +108,7 @@ const BlockRow = memo(function BlockRow({
data-entry-id={entry.id}
className={clsx(
ROW_STYLES.base,
'h-[26px]',
'h-[30px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
onClick={(e) => {
@@ -118,19 +118,15 @@ const BlockRow = memo(function BlockRow({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
{BlockIcon && <BlockIcon className='h-[10px] w-[10px] text-white' />}
</div>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: isSelected
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
'min-w-0 truncate font-base text-[14px]',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
{entry.blockName}
@@ -138,9 +134,8 @@ const BlockRow = memo(function BlockRow({
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!isRunning &&
(isCanceled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
'flex-shrink-0 font-base text-[14px]',
!isRunning && 'text-[var(--text-secondary)]'
)}
>
<StatusDisplay
@@ -187,7 +182,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
<div className='flex min-w-0 flex-col'>
{/* Iteration Header */}
<div
className={clsx(ROW_STYLES.base, 'h-[26px]', ROW_STYLES.hover)}
className={clsx(ROW_STYLES.base, 'h-[30px]', ROW_STYLES.hover)}
onClick={(e) => {
e.stopPropagation()
onToggle()
@@ -196,10 +191,8 @@ const IterationNodeRow = memo(function IterationNodeRow({
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
'min-w-0 truncate font-base text-[14px]',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
{iterationLabel}
@@ -207,7 +200,7 @@ const IterationNodeRow = memo(function IterationNodeRow({
{hasChildren && (
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
'h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
@@ -215,9 +208,8 @@ const IterationNodeRow = memo(function IterationNodeRow({
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!hasRunningChild &&
(hasCanceledChild ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]')
'flex-shrink-0 font-base text-[14px]',
!hasRunningChild && 'text-[var(--text-secondary)]'
)}
>
<StatusDisplay
@@ -285,7 +277,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
<div className='flex min-w-0 flex-col'>
{/* Subflow Header */}
<div
className={clsx(ROW_STYLES.base, 'h-[26px]', ROW_STYLES.hover)}
className={clsx(ROW_STYLES.base, 'h-[30px]', ROW_STYLES.hover)}
onClick={(e) => {
e.stopPropagation()
onToggleNode(nodeId)
@@ -293,19 +285,15 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
{BlockIcon && <BlockIcon className='h-[10px] w-[10px] text-white' />}
</div>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: isExpanded
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
'min-w-0 truncate font-base text-[14px]',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
{displayName}
@@ -313,7 +301,7 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
{hasChildren && (
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
'h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
@@ -321,11 +309,8 @@ const SubflowNodeRow = memo(function SubflowNodeRow({
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!hasRunningDescendant &&
(hasCanceledDescendant
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-tertiary)]')
'flex-shrink-0 font-base text-[14px]',
!hasRunningDescendant && 'text-[var(--text-secondary)]'
)}
>
<StatusDisplay
@@ -400,7 +385,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
<div
className={clsx(
ROW_STYLES.base,
'h-[26px]',
'h-[30px]',
isSelected ? ROW_STYLES.selected : ROW_STYLES.hover
)}
onClick={(e) => {
@@ -411,19 +396,15 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<div
className='flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center rounded-[4px]'
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
{BlockIcon && <BlockIcon className='h-[10px] w-[10px] text-white' />}
</div>
<span
className={clsx(
'min-w-0 truncate font-medium text-[13px]',
hasError
? 'text-[var(--text-error)]'
: isSelected || isExpanded
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
'min-w-0 truncate font-base text-[14px]',
hasError ? 'text-[var(--text-error)]' : 'text-[var(--text-primary)]'
)}
>
{entry.blockName}
@@ -431,7 +412,7 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
{hasChildren && (
<ChevronDown
className={clsx(
'h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]',
'h-[7px] w-[9px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100',
!isExpanded && '-rotate-90'
)}
/>
@@ -439,11 +420,8 @@ const WorkflowNodeRow = memo(function WorkflowNodeRow({
</div>
<span
className={clsx(
'flex-shrink-0 font-medium text-[13px]',
!hasRunningDescendant &&
(hasCanceledDescendant
? 'text-[var(--text-secondary)]'
: 'text-[var(--text-tertiary)]')
'flex-shrink-0 font-base text-[14px]',
!hasRunningDescendant && 'text-[var(--text-secondary)]'
)}
>
<StatusDisplay
@@ -1341,9 +1319,9 @@ export const Terminal = memo(function Terminal() {
className='!p-1.5 -m-1.5'
>
{sortConfig.direction === 'desc' ? (
<ArrowDown className='h-3 w-3' />
<ArrowDown className='h-3.5 w-3.5' />
) : (
<ArrowUp className='h-3 w-3' />
<ArrowUp className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
@@ -1362,7 +1340,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Component Playground'
className='!p-1.5 -m-1.5'
>
<Palette className='h-3 w-3' />
<Palette className='h-3.5 w-3.5' />
</Button>
</Link>
</Tooltip.Trigger>
@@ -1385,9 +1363,9 @@ export const Terminal = memo(function Terminal() {
)}
>
{isTraining ? (
<Pause className='h-3 w-3' />
<Pause className='h-3.5 w-3.5' />
) : (
<Database className='h-3 w-3' />
<Database className='h-3.5 w-3.5' />
)}
</Button>
</Tooltip.Trigger>
@@ -1407,7 +1385,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Download console CSV'
className='!p-1.5 -m-1.5'
>
<ArrowDownToLine className='h-3 w-3' />
<ArrowDownToLine className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
@@ -1422,7 +1400,7 @@ export const Terminal = memo(function Terminal() {
aria-label='Clear console'
className='!p-1.5 -m-1.5'
>
<Trash2 className='h-3 w-3' />
<Trash2 className='h-3.5 w-3.5' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>

View File

@@ -50,9 +50,9 @@ export interface BlockInfo {
* Common row styling classes for terminal components
*/
export const ROW_STYLES = {
base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[6px]',
selected: 'bg-[var(--surface-6)] dark:bg-[var(--surface-5)]',
hover: 'hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
base: 'group flex cursor-pointer items-center justify-between gap-[8px] rounded-[8px] px-[8px]',
selected: 'bg-[var(--surface-active)]',
hover: 'hover:bg-[var(--surface-active)]',
nested:
'mt-[2px] ml-[3px] flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[9px]',
iconButton: '!p-1.5 -m-1.5',

View File

@@ -694,5 +694,5 @@ export function flattenBlockEntriesOnly(
export const TERMINAL_CONFIG = {
NEAR_MIN_THRESHOLD: 40,
BLOCK_COLUMN_WIDTH_PX: TERMINAL_BLOCK_COLUMN_WIDTH,
HEADER_TEXT_CLASS: 'font-medium text-[var(--text-tertiary)] text-[12px]',
HEADER_TEXT_CLASS: 'font-base text-[var(--text-icon)] text-small',
} as const

View File

@@ -355,11 +355,7 @@ export function WorkspaceHeader({
<span className='min-w-0 flex-1 truncate text-left font-base text-[14px] text-[var(--text-primary)]'>
{activeWorkspace?.name || 'Loading...'}
</span>
<ChevronDown
className={`sidebar-collapse-hide h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
<ChevronDown className='sidebar-collapse-hide h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)] group-hover:text-[var(--text-secondary)]' />
</>
)}
</button>

View File

@@ -72,22 +72,6 @@ import { useSidebarStore } from '@/stores/sidebar/store'
const logger = createLogger('Sidebar')
type TaskStatus = 'running' | 'unread' | 'idle'
function TaskStatusIcon({ status }: { status: TaskStatus }) {
return (
<div className='relative h-[16px] w-[16px] flex-shrink-0'>
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
{status === 'running' && (
<span className='-bottom-[1px] -right-[1px] absolute h-[7px] w-[7px] animate-pulse rounded-full border border-[var(--surface-1)] bg-[var(--brand-tertiary-2)]' />
)}
{status === 'unread' && (
<span className='-bottom-[1px] -right-[1px] absolute h-[7px] w-[7px] rounded-full border border-[var(--surface-1)] bg-[var(--brand-tertiary)]' />
)}
</div>
)
}
function SidebarItemSkeleton() {
return (
<div className='mx-[2px] flex h-[30px] items-center px-[8px]'>
@@ -100,18 +84,22 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
task,
isCurrentRoute,
isSelected,
status,
isActive,
showCollapsedContent,
onMultiSelectClick,
onContextMenu,
onMorePointerDown,
onMoreClick,
}: {
task: { id: string; href: string; name: string }
isCurrentRoute: boolean
isSelected: boolean
status: TaskStatus
isActive: boolean
showCollapsedContent: boolean
onMultiSelectClick: (taskId: string, shiftKey: boolean, metaKey: boolean) => void
onContextMenu: (e: React.MouseEvent, taskId: string) => void
onMorePointerDown: () => void
onMoreClick: (e: React.MouseEvent<HTMLButtonElement>, taskId: string) => void
}) {
return (
<Tooltip.Root>
@@ -119,7 +107,7 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
<Link
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)]',
'group mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] px-[8px] text-[14px] hover:bg-[var(--surface-active)]',
(isCurrentRoute || isSelected) && 'bg-[var(--surface-active)]'
)}
onClick={(e) => {
@@ -136,8 +124,30 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
}}
onContextMenu={task.id !== 'new' ? (e) => onContextMenu(e, task.id) : undefined}
>
<TaskStatusIcon status={status} />
<div className='min-w-0 truncate font-base text-[var(--text-body)]'>{task.name}</div>
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>
{task.name}
</div>
{task.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{isActive && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-[#33C482] group-hover:hidden' />
)}
<button
type='button'
aria-label='Task options'
onPointerDown={onMorePointerDown}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onMoreClick(e, task.id)
}}
className='flex h-[18px] w-[18px] items-center justify-center rounded-[4px] opacity-0 hover:bg-[var(--surface-7)] group-hover:opacity-100'
>
<MoreHorizontal className='h-[16px] w-[16px] text-[var(--text-icon)]' />
</button>
</div>
)}
</Link>
</Tooltip.Trigger>
{showCollapsedContent && (
@@ -390,6 +400,7 @@ export const Sidebar = memo(function Sidebar() {
menuRef: taskMenuRef,
handleContextMenu: handleTaskContextMenuBase,
closeMenu: closeTaskContextMenu,
preventDismiss: preventTaskDismiss,
} = useContextMenu()
const contextMenuSelectionRef = useRef<{ taskIds: string[]; names: string[] }>({
@@ -397,21 +408,49 @@ export const Sidebar = memo(function Sidebar() {
names: [],
})
const captureTaskSelection = useCallback((taskId: string) => {
const { selectedTasks, selectTaskOnly } = useFolderStore.getState()
if (selectedTasks.size > 0 && selectedTasks.has(taskId)) {
contextMenuSelectionRef.current = {
taskIds: Array.from(selectedTasks),
names: [],
}
} else {
selectTaskOnly(taskId)
contextMenuSelectionRef.current = { taskIds: [taskId], names: [] }
}
}, [])
const handleTaskContextMenu = useCallback(
(e: React.MouseEvent, taskId: string) => {
const { selectedTasks, selectTaskOnly } = useFolderStore.getState()
if (selectedTasks.size > 0 && selectedTasks.has(taskId)) {
contextMenuSelectionRef.current = {
taskIds: Array.from(selectedTasks),
names: [],
}
} else {
selectTaskOnly(taskId)
contextMenuSelectionRef.current = { taskIds: [taskId], names: [] }
}
captureTaskSelection(taskId)
handleTaskContextMenuBase(e)
},
[handleTaskContextMenuBase]
[captureTaskSelection, handleTaskContextMenuBase]
)
const handleTaskMorePointerDown = useCallback(() => {
if (isTaskContextMenuOpen) {
preventTaskDismiss()
}
}, [isTaskContextMenuOpen, preventTaskDismiss])
const handleTaskMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>, taskId: string) => {
if (isTaskContextMenuOpen) {
closeTaskContextMenu()
return
}
captureTaskSelection(taskId)
const rect = e.currentTarget.getBoundingClientRect()
handleTaskContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isTaskContextMenuOpen, closeTaskContextMenu, captureTaskSelection, handleTaskContextMenuBase]
)
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
@@ -1048,13 +1087,6 @@ export const Sidebar = memo(function Sidebar() {
const isCurrentRoute = task.id !== 'new' && pathname === task.href
const isRenaming = renamingTaskId === task.id
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
const status: TaskStatus = isCurrentRoute
? 'idle'
: task.isActive
? 'running'
: task.isUnread
? 'unread'
: 'idle'
if (!isCollapsed && isRenaming) {
return (
@@ -1062,7 +1094,7 @@ export const Sidebar = memo(function Sidebar() {
key={task.id}
className='mx-[2px] flex h-[30px] items-center gap-[8px] rounded-[8px] bg-[var(--surface-active)] px-[8px] text-[14px]'
>
<TaskStatusIcon status={status} />
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<input
ref={renameInputRef}
value={renameValue}
@@ -1081,10 +1113,12 @@ export const Sidebar = memo(function Sidebar() {
task={task}
isCurrentRoute={isCurrentRoute}
isSelected={isSelected}
status={status}
isActive={!!task.isActive}
showCollapsedContent={showCollapsedContent}
onMultiSelectClick={handleTaskClick}
onContextMenu={handleTaskContextMenu}
onMorePointerDown={handleTaskMorePointerDown}
onMoreClick={handleTaskMoreClick}
/>
)
})}

View File

@@ -61,8 +61,8 @@ export { Search } from './search'
export { Server } from './server'
export { Settings } from './settings'
export { ShieldCheck } from './shield-check'
export { SquareArrowUpRight } from './square-arrow-up-right'
export { Sim } from './sim'
export { SquareArrowUpRight } from './square-arrow-up-right'
export { Table } from './table'
export { Tag } from './tag'
export { TerminalWindow } from './terminal-window'

View File

@@ -242,7 +242,9 @@ export function useAddChatResource(chatId?: string) {
await queryClient.cancelQueries({ queryKey: taskKeys.detail(chatId) })
const previous = queryClient.getQueryData<TaskChatHistory>(taskKeys.detail(chatId))
if (previous) {
const exists = previous.resources.some((r) => r.type === resource.type && r.id === resource.id)
const exists = previous.resources.some(
(r) => r.type === resource.type && r.id === resource.id
)
if (!exists) {
queryClient.setQueryData<TaskChatHistory>(taskKeys.detail(chatId), {
...previous,

View File

@@ -11,10 +11,7 @@ const logger = createLogger('VfsTools')
* Resolves a VFS resource path to its resource descriptor by reading the
* sibling meta.json (already in memory) for the resource ID and name.
*/
function resolveVfsResource(
vfs: WorkspaceVFS,
path: string
): MothershipResource | null {
function resolveVfsResource(vfs: WorkspaceVFS, path: string): MothershipResource | null {
const segments = path.split('/')
const resourceType = VFS_DIR_TO_RESOURCE[segments[0]]
if (!resourceType || !segments[1]) return null

View File

@@ -5,8 +5,10 @@ import { eq, sql } from 'drizzle-orm'
const logger = createLogger('CopilotResources')
export type { MothershipResourceType as ResourceType } from '@/lib/copilot/resource-types'
export type { MothershipResource as ChatResource } from '@/lib/copilot/resource-types'
export type {
MothershipResource as ChatResource,
MothershipResourceType as ResourceType,
} from '@/lib/copilot/resource-types'
const RESOURCE_TOOL_NAMES = new Set([
'user_table',
@@ -45,22 +47,29 @@ export function extractResourcesFromToolResult(
case 'user_table': {
if (result.tableId) {
return [
{ type: 'table', id: result.tableId as string, title: (result.tableName as string) || 'Table' },
{
type: 'table',
id: result.tableId as string,
title: (result.tableName as string) || 'Table',
},
]
}
if (result.fileId) {
return [
{ type: 'file', id: result.fileId as string, title: (result.fileName as string) || 'File' },
{
type: 'file',
id: result.fileId as string,
title: (result.fileName as string) || 'File',
},
]
}
const table = asRecord(data.table)
if (table.id) {
return [
{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' },
]
return [{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }]
}
const args = asRecord(params?.args)
const tableId = (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string)
const tableId =
(data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string)
if (tableId) {
return [
{ type: 'table', id: tableId as string, title: (data.tableName as string) || 'Table' },
@@ -72,9 +81,7 @@ export function extractResourcesFromToolResult(
case 'workspace_file': {
const file = asRecord(data.file)
if (file.id) {
return [
{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' },
]
return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }]
}
const fileId = (data.fileId as string) ?? (data.id as string)
if (fileId) {
@@ -88,12 +95,20 @@ export function extractResourcesFromToolResult(
case 'read': {
if (result.tableId) {
return [
{ type: 'table', id: result.tableId as string, title: (result.tableName as string) || 'Table' },
{
type: 'table',
id: result.tableId as string,
title: (result.tableName as string) || 'Table',
},
]
}
if (result.fileId) {
return [
{ type: 'file', id: result.fileId as string, title: (result.fileName as string) || 'File' },
{
type: 'file',
id: result.fileId as string,
title: (result.fileName as string) || 'File',
},
]
}
return []
@@ -101,10 +116,16 @@ export function extractResourcesFromToolResult(
case 'create_workflow':
case 'edit_workflow': {
const workflowId = (result.workflowId as string) ?? (data.workflowId as string) ?? (params?.workflowId as string)
const workflowId =
(result.workflowId as string) ??
(data.workflowId as string) ??
(params?.workflowId as string)
if (workflowId) {
const workflowName =
(result.workflowName as string) ?? (data.workflowName as string) ?? (params?.workflowName as string) ?? 'Workflow'
(result.workflowName as string) ??
(data.workflowName as string) ??
(params?.workflowName as string) ??
'Workflow'
return [{ type: 'workflow', id: workflowId, title: workflowName }]
}
return []
@@ -112,9 +133,13 @@ export function extractResourcesFromToolResult(
case 'knowledge_base': {
const kbId =
(data.id as string) ?? (result.knowledgeBaseId as string) ?? (data.knowledgeBaseId as string) ?? (params?.knowledgeBaseId as string)
(data.id as string) ??
(result.knowledgeBaseId as string) ??
(data.knowledgeBaseId as string) ??
(params?.knowledgeBaseId as string)
if (kbId) {
const kbName = (data.name as string) ?? (result.knowledgeBaseName as string) ?? 'Knowledge Base'
const kbName =
(data.name as string) ?? (result.knowledgeBaseName as string) ?? 'Knowledge Base'
return [{ type: 'knowledgebase', id: kbId, title: kbName }]
}
return []
@@ -127,7 +152,11 @@ export function extractResourcesFromToolResult(
for (const kb of kbArray) {
const id = kb.id as string | undefined
if (id) {
resources.push({ type: 'knowledgebase', id, title: (kb.name as string) || 'Knowledge Base' })
resources.push({
type: 'knowledgebase',
id,
title: (kb.name as string) || 'Knowledge Base',
})
}
}
return resources
@@ -142,7 +171,10 @@ export function extractResourcesFromToolResult(
* Appends resources to a chat's JSONB resources column, deduplicating by type+id.
* Updates the title of existing resources if the new title is more specific.
*/
export async function persistChatResources(chatId: string, newResources: ChatResource[]): Promise<void> {
export async function persistChatResources(
chatId: string,
newResources: ChatResource[]
): Promise<void> {
if (newResources.length === 0) return
try {

View File

@@ -4,7 +4,6 @@ export const WORKSPACE_COLORS = [
'#22c55e', // Green
'#FFCC02', // Yellow
'#a855f7', // Purple
'#4aea7f', // Mint
'#f97316', // Orange
'#14b8a6', // Teal
'#ff6b6b', // Coral