diff --git a/apps/sim/.gitignore b/apps/sim/.gitignore index e90bb4dc00d..ccbc3b9426d 100644 --- a/apps/sim/.gitignore +++ b/apps/sim/.gitignore @@ -45,4 +45,4 @@ next-env.d.ts # Uploads /uploads -.trigger \ No newline at end of file +.trigger.env.local.bak* diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 67ac09b9461..86fe4f71e01 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { @@ -17,6 +16,7 @@ import { Label, Loader, } from '@/components/emcn' +import { Eye, EyeOff } from '@/components/emcn/icons' import { requestJson } from '@/lib/api/client/request' import { forgetPasswordContract } from '@/lib/api/contracts' import { client } from '@/lib/auth/auth-client' diff --git a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx index f1627b8397d..9cbb80ee41f 100644 --- a/apps/sim/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/sim/app/(auth)/reset-password/reset-password-form.tsx @@ -1,8 +1,8 @@ 'use client' import { useState } from 'react' -import { Eye, EyeOff } from 'lucide-react' import { Input, Label, Loader } from '@/components/emcn' +import { Eye, EyeOff } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes' diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 90490160dff..4d90631bd26 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -3,11 +3,11 @@ import { Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' -import { Eye, EyeOff } from 'lucide-react' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Input, Label, Loader } from '@/components/emcn' +import { Eye, EyeOff } from '@/components/emcn/icons' import { client, useSession } from '@/lib/auth/auth-client' import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env' import { validateCallbackUrl } from '@/lib/core/security/input-validation' diff --git a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx index 679c6f44c6e..fd974f1e226 100644 --- a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/share-button.tsx @@ -1,14 +1,13 @@ 'use client' import { useState } from 'react' -import { Share2 } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/emcn' -import { Duplicate } from '@/components/emcn/icons' +import { Duplicate, Share2 } from '@/components/emcn/icons' import { LinkedInIcon, xIcon as XIcon } from '@/components/icons' interface ShareButtonProps { diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index f64ee69b34c..3504badc09c 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { X } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/navigation' import { @@ -14,6 +13,7 @@ import { ModalTitle, ModalTrigger, } from '@/components/emcn' +import { X } from '@/components/emcn/icons' import { GithubIcon, GoogleIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib/api/contracts/auth' diff --git a/apps/sim/app/(landing)/components/features/components/features-preview.tsx b/apps/sim/app/(landing)/components/features/components/features-preview.tsx index 1db87f478e2..a865b6036ed 100644 --- a/apps/sim/app/(landing)/components/features/components/features-preview.tsx +++ b/apps/sim/app/(landing)/components/features/components/features-preview.tsx @@ -946,7 +946,7 @@ function MockFullTable({ revealedRows }: { revealedRows: number }) {
{col} - +
))} diff --git a/apps/sim/app/(landing)/components/footer/footer-cta.tsx b/apps/sim/app/(landing)/components/footer/footer-cta.tsx index 31355a75f35..1ee3181ea68 100644 --- a/apps/sim/app/(landing)/components/footer/footer-cta.tsx +++ b/apps/sim/app/(landing)/components/footer/footer-cta.tsx @@ -1,8 +1,8 @@ 'use client' import { useCallback, useRef, useState } from 'react' -import { ArrowUp } from 'lucide-react' import dynamic from 'next/dynamic' +import { ArrowUp } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { handleKeyboardActivation } from '@/lib/core/utils/keyboard' import { captureClientEvent } from '@/lib/posthog/client' diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx index 3f411f608c5..a440d884cd5 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-home/landing-preview-home.tsx @@ -2,9 +2,8 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import { ArrowUp, Table } from 'lucide-react' import { Blimp, Checkbox, ChevronDown } from '@/components/emcn' -import { TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons' +import { ArrowUp, Table, TypeBoolean, TypeNumber, TypeText } from '@/components/emcn/icons' import { captureClientEvent } from '@/lib/posthog/client' import { useLandingSubmit } from '@/app/(landing)/components/landing-preview/components/landing-preview-panel/landing-preview-panel' import { EASE_OUT } from '@/app/(landing)/components/landing-preview/components/landing-preview-workflow/workflow-data' @@ -221,7 +220,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({ Mothership {ModelIcon && } {model} - + )} diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx index 4d9c5bcef30..2fa70c7ea6b 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-tables/landing-preview-tables.tsx @@ -446,7 +446,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) { / {tableName} - + @@ -472,7 +472,7 @@ function SpreadsheetView({ tableId, tableName, onBack }: SpreadsheetViewProps) { {col.label} - + ) diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx index 0e2af73d5da..b4f214432df 100644 --- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx +++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-workflow/preview-block-node.tsx @@ -2,9 +2,9 @@ import { memo } from 'react' import { domAnimation, LazyMotion, m } from 'framer-motion' -import { Database } from 'lucide-react' import { Handle, type NodeProps, Position } from 'reactflow' import { Blimp } from '@/components/emcn' +import { Database } from '@/components/emcn/icons' import { AgentIcon, AnthropicIcon, diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx index 4dd486620e3..b02f18ce105 100644 --- a/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx +++ b/apps/sim/app/academy/(catalog)/[courseSlug]/components/course-progress.tsx @@ -1,9 +1,9 @@ 'use client' import { useMemo, useSyncExternalStore } from 'react' -import { CheckCircle2, Circle, ExternalLink, GraduationCap } from 'lucide-react' import Link from 'next/link' import { Loader } from '@/components/emcn' +import { Circle, CircleCheck, ExternalLink, GraduationCap } from '@/components/emcn/icons' import { getCompletedLessonsFromSnapshot, getCompletedLessonsSnapshot, @@ -77,7 +77,7 @@ export function CourseProgress({ course, courseSlug }: CourseProgressProps) { className='flex items-center gap-3 rounded-[8px] border border-[#2A2A2A] bg-[#222] px-4 py-3 text-[14px] transition-colors hover:border-[#3A3A3A] hover:bg-[#272727]' > {completedIds.has(lesson.id) ? ( - + ) : ( )} diff --git a/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx b/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx index 4f0d96a1feb..c9dd650a846 100644 --- a/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx +++ b/apps/sim/app/academy/(catalog)/[courseSlug]/page.tsx @@ -1,7 +1,7 @@ -import { Clock, GraduationCap } from 'lucide-react' import type { Metadata } from 'next' import Link from 'next/link' import { notFound } from 'next/navigation' +import { Clock, GraduationCap } from '@/components/emcn/icons' import { COURSES, getCourse } from '@/lib/academy/content' import { CourseProgress } from './components/course-progress' diff --git a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx index f7f4a36f479..f6a6a4686e9 100644 --- a/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx +++ b/apps/sim/app/academy/(catalog)/certificate/[certificateNumber]/page.tsx @@ -2,9 +2,9 @@ import { cache } from 'react' import { db } from '@sim/db' import { academyCertificate } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { CheckCircle2, GraduationCap, XCircle } from 'lucide-react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' +import { CircleCheck, GraduationCap, XCircle } from '@/components/emcn/icons' import type { AcademyCertificate } from '@/lib/academy/types' import { academyCertificateMetadataSchema } from '@/lib/api/contracts/academy' @@ -84,7 +84,7 @@ export default async function CertificatePage({ params }: CertificatePageProps) {certificate.status === 'active' ? (
- + Verified
) : ( diff --git a/apps/sim/app/academy/(catalog)/page.tsx b/apps/sim/app/academy/(catalog)/page.tsx index b770d88c047..bd8ae67fe8d 100644 --- a/apps/sim/app/academy/(catalog)/page.tsx +++ b/apps/sim/app/academy/(catalog)/page.tsx @@ -1,5 +1,5 @@ -import { BookOpen, Clock } from 'lucide-react' import Link from 'next/link' +import { BookOpen, Clock } from '@/components/emcn/icons' import { COURSES } from '@/lib/academy/content' export default function AcademyCatalogPage() { diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx index 8978affbc87..a3a9874941d 100644 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx +++ b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/exercise-view.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useState } from 'react' -import { CheckCircle2 } from 'lucide-react' +import { CircleCheck } from '@/components/emcn/icons' import { markLessonComplete } from '@/lib/academy/local-progress' import type { ExerciseBlockState, ExerciseDefinition, ExerciseEdgeState } from '@/lib/academy/types' import { SandboxCanvasProvider } from '@/app/academy/components/sandbox-canvas-provider' @@ -56,7 +56,7 @@ export function ExerciseView({ {completed && (
- + Exercise complete!
diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx index 045993db67a..bf5cced081a 100644 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx +++ b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/components/lesson-quiz.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { CheckCircle2, XCircle } from 'lucide-react' +import { CircleCheck, XCircle } from '@/components/emcn/icons' import { markLessonComplete } from '@/lib/academy/local-progress' import type { QuizDefinition, QuizQuestion } from '@/lib/academy/types' import { cn } from '@/lib/core/utils/cn' @@ -202,7 +202,7 @@ export function LessonQuiz({ lessonId, quizConfig, onPass }: LessonQuizProps) { )} > {isCorrect ? ( - + ) : ( )} diff --git a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx index 798051faa54..15f870ec778 100644 --- a/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx +++ b/apps/sim/app/academy/[courseSlug]/[lessonSlug]/page.tsx @@ -1,9 +1,9 @@ 'use client' import { use, useCallback, useEffect, useMemo, useState } from 'react' -import { ChevronLeft, ChevronRight } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' +import { ChevronLeft, ChevronRight } from '@/components/emcn/icons' import { getCourse } from '@/lib/academy/content' import { markLessonComplete } from '@/lib/academy/local-progress' import type { Lesson } from '@/lib/academy/types' diff --git a/apps/sim/app/academy/components/validation-checklist.tsx b/apps/sim/app/academy/components/validation-checklist.tsx index 977da84afaa..dd6f3186caa 100644 --- a/apps/sim/app/academy/components/validation-checklist.tsx +++ b/apps/sim/app/academy/components/validation-checklist.tsx @@ -1,6 +1,6 @@ 'use client' -import { CheckCircle2, Circle } from 'lucide-react' +import { Circle, CircleCheck } from '@/components/emcn/icons' import type { ValidationRuleResult } from '@/lib/academy/types' import { cn } from '@/lib/core/utils/cn' @@ -30,7 +30,7 @@ export function ValidationChecklist({ results, allPassed }: ValidationChecklistP {results.map((result) => (
  • {result.passed ? ( - + ) : ( )} diff --git a/apps/sim/app/activity-preview/page.tsx b/apps/sim/app/activity-preview/page.tsx new file mode 100644 index 00000000000..78aadcd9427 --- /dev/null +++ b/apps/sim/app/activity-preview/page.tsx @@ -0,0 +1,195 @@ +'use client' + +/** + * TEMPORARY preview harness (delete before merge). Designs the in-chat working + * indicator: ONE shimmering status line by default, escalating to a per-agent + * breakout ONLY while ≥2 agents run concurrently, then collapsing back to a + * single line and the reply. + */ +import { useEffect, useState } from 'react' +import { + ParallelAgents, + ShimmerStatus, +} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/activity-view' + +type Frame = + | { kind: 'line'; text: string } + | { kind: 'parallel'; header: string; agents: { label: string; phrase: string }[] } + +interface Scene { + key: string + label: string + prompt: string + frames: Frame[] + reply: string +} + +const line = (text: string): Frame => ({ kind: 'line', text }) + +const SCENES: Scene[] = [ + { + key: 'crm', + label: 'Build CRM', + prompt: 'build a simple crm page', + frames: [ + line('Reviewing UX requirements and data model'), + line('Exploring CRM page structure and data flow'), + line('Drafting a clean CRM page layout'), + line('Wiring up the contacts table'), + ], + reply: 'Done — your CRM page is ready. Open it on the right.', + }, + { + key: 'parallel', + label: 'Parallel agents', + prompt: 'Polish my profile — refresh my skills and bio', + frames: [ + line('Reviewing your profile'), + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Scanning your experience' }, + { label: 'Biography', phrase: 'Reading your current bio' }, + ], + }, + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Determining relevant changes' }, + { label: 'Biography', phrase: 'Crafting a proposal' }, + ], + }, + { + kind: 'parallel', + header: 'Profile scan · 2 agents', + agents: [ + { label: 'Skills', phrase: 'Finalizing skill updates' }, + { label: 'Biography', phrase: 'Polishing the wording' }, + ], + }, + line('Wrapping up'), + ], + reply: 'Updated your skills and bio — review the changes on the right.', + }, + { + key: 'edit', + label: 'Edit dialog', + prompt: 'Add an edit dialog to update contact details without deleting them', + frames: [ + line('Reviewing edit dialog integration plans'), + line('Adding the edit form'), + line('Saving changes in place'), + ], + reply: 'Added an edit dialog — contacts now update in place.', + }, +] + +const FRAME_MS = 1900 + +export default function ActivityPreviewPage() { + const [sceneKey, setSceneKey] = useState(SCENES[0].key) + const [idx, setIdx] = useState(0) + const [playing, setPlaying] = useState(true) + + const scene = SCENES.find((s) => s.key === sceneKey) ?? SCENES[0] + const total = scene.frames.length + const done = idx >= total + const frame = done ? null : scene.frames[idx] + + useEffect(() => { + setIdx(0) + setPlaying(true) + }, [sceneKey]) + + useEffect(() => { + if (!playing) return + if (idx >= total) { + setPlaying(false) + return + } + const t = setTimeout(() => setIdx((i) => Math.min(i + 1, total)), FRAME_MS) + return () => clearTimeout(t) + }, [playing, idx, total]) + + return ( +
    +
    + {SCENES.map((s) => ( + + ))} +
    + + + + + {Math.min(idx, total)}/{total} + +
    +
    + +
    +
    +
    +
    + {scene.prompt} +
    +
    + + {done ? ( +

    + {scene.reply} +

    + ) : frame?.kind === 'parallel' ? ( + + ) : ( + + )} +
    +
    +
    + ) +} diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 5417fbe4a49..f71cd7ee33d 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -27,10 +27,20 @@ const VALID_RESOURCE_TYPES = new Set([ 'workflow', 'knowledgebase', 'folder', + 'filefolder', 'log', 'integration', + 'page', +]) +const GENERIC_TITLES = new Set([ + 'Table', + 'File', + 'Workflow', + 'Knowledge Base', + 'Folder', + 'File Folder', + 'Log', ]) -const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log']) export const POST = withRouteHandler(async (req: NextRequest) => { try { diff --git a/apps/sim/app/changelog/components/changelog-content.tsx b/apps/sim/app/changelog/components/changelog-content.tsx index 395af926f9b..6449c763ce6 100644 --- a/apps/sim/app/changelog/components/changelog-content.tsx +++ b/apps/sim/app/changelog/components/changelog-content.tsx @@ -1,5 +1,6 @@ -import { BookOpen, Github, Rss } from 'lucide-react' import Link from 'next/link' +import { BookOpen, Rss } from '@/components/emcn/icons' +import { GithubIcon } from '@/components/icons' import ChangelogList from '@/app/changelog/components/timeline-list' export interface ChangelogEntry { @@ -65,7 +66,7 @@ export default async function ChangelogContent() { rel='noopener noreferrer' className='inline-flex items-center gap-2 rounded-[5px] border border-[var(--white)] bg-[var(--white)] px-[9px] py-[5px] text-[13.5px] text-black transition-colors hover:border-[#E0E0E0] hover:bg-[#E0E0E0]' > - + View on GitHub - diff --git a/apps/sim/app/chat/components/input/voice-input.tsx b/apps/sim/app/chat/components/input/voice-input.tsx index 675c2fb4a41..2576f2caa74 100644 --- a/apps/sim/app/chat/components/input/voice-input.tsx +++ b/apps/sim/app/chat/components/input/voice-input.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { domAnimation, LazyMotion, m } from 'framer-motion' -import { Mic } from 'lucide-react' +import { Mic } from '@/components/emcn/icons' interface VoiceInputProps { onVoiceStart: () => void diff --git a/apps/sim/app/chat/components/message-container/message-container.tsx b/apps/sim/app/chat/components/message-container/message-container.tsx index d85be38058e..b1c96d85d41 100644 --- a/apps/sim/app/chat/components/message-container/message-container.tsx +++ b/apps/sim/app/chat/components/message-container/message-container.tsx @@ -1,7 +1,7 @@ 'use client' import { memo, type RefObject } from 'react' -import { ArrowDown } from 'lucide-react' +import { ArrowDown } from '@/components/emcn/icons' import { Button } from '@/components/ui/button' import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message' diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index bd5aa880dcc..ed9d41adc2e 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -3,8 +3,8 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' -import { Music } from 'lucide-react' import { Button, Download, Loader } from '@/components/emcn' +import { Music } from '@/components/emcn/icons' import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons' import type { ChatFile } from '@/app/chat/components/message/message' diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index a005c7257a5..98f905eecb8 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -1,8 +1,8 @@ 'use client' import { memo, useState } from 'react' -import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react' import { Duplicate, Tooltip } from '@/components/emcn' +import { Check, File as FileIcon, FileText, Image as ImageIcon } from '@/components/emcn/icons' import { ChatFileDownload, ChatFileDownloadAll, diff --git a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx index f313b24046e..95eb8884fe1 100644 --- a/apps/sim/app/chat/components/voice-interface/voice-interface.tsx +++ b/apps/sim/app/chat/components/voice-interface/voice-interface.tsx @@ -2,8 +2,8 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Mic, MicOff, Phone } from 'lucide-react' import dynamic from 'next/dynamic' +import { Mic, MicOff, Phone } from '@/components/emcn/icons' import { Button } from '@/components/ui/button' import { requestJson } from '@/lib/api/client/request' import { speechTokenContract } from '@/lib/api/contracts/media/speech' diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx index a9ba15dca2a..d3fbbbbdf32 100644 --- a/apps/sim/app/credential-account/[token]/page.tsx +++ b/apps/sim/app/credential-account/[token]/page.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import { Mail } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { Mail } from '@/components/emcn/icons' import { GmailIcon, OutlookIcon } from '@/components/icons' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 8c20a3e8b41..946cc398265 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -85,7 +85,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) var isCollapsed = state && state.isCollapsed; if (isCollapsed) { - document.documentElement.style.setProperty('--sidebar-width', '51px'); + document.documentElement.style.setProperty('--sidebar-width', '0px'); document.documentElement.setAttribute('data-sidebar-collapsed', ''); } else { var width = state && state.sidebarWidth; diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx index fe041cff403..c43953acbd3 100644 --- a/apps/sim/app/playground/page.tsx +++ b/apps/sim/app/playground/page.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useSyncExternalStore } from 'react' -import { ArrowLeft, Folder, Moon, Sun } from 'lucide-react' import { notFound, useRouter } from 'next/navigation' import { Avatar, @@ -76,6 +75,7 @@ import { TagInput, type TagItem, Textarea, + ThinkingLoader, TimePicker, ToastProvider, Tooltip, @@ -87,6 +87,7 @@ import { ZoomIn, ZoomOut, } from '@/components/emcn' +import { ArrowLeft, Folder, Moon, Sun } from '@/components/emcn/icons' import { env, isTruthy } from '@/lib/core/config/env' function Section({ title, children }: { title: string; children: React.ReactNode }) { @@ -1007,6 +1008,39 @@ export default function PlaygroundPage() { + {/* ThinkingLoader */} +
    + +
    + + + + +
    +
    + {( + [ + 'metaballs', + 'orbit', + 'relay', + 'corners', + 'burst', + 'compass', + 'squeeze', + 'maze', + ] as const + ).map((variant) => ( + +
    + + + + +
    +
    + ))} +
    + {/* Icons */}
    diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx index 4f962ccce10..700cc8fa944 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { RefreshCw } from 'lucide-react' import { useRouter } from 'next/navigation' +import { RefreshCw } from '@/components/emcn/icons' const logger = createLogger('ResumePage') diff --git a/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx new file mode 100644 index 00000000000..493fc88783a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/chat-switcher.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useMemo, useState } from 'react' +import { useParams, usePathname, useRouter } from 'next/navigation' +import { + POPOVER_ANIMATION_CLASSES, + Popover, + PopoverAnchor, + PopoverContent, + ThinkingLoader, + Tooltip, +} from '@/components/emcn' +import { BubbleChatDelay, ChevronDown } from '@/components/emcn/icons' +import { + isMothershipPageId, + MOTHERSHIP_PAGES, + type MothershipResource, +} from '@/lib/copilot/resources/types' +import { cn } from '@/lib/core/utils/cn' +import { useSidebarToggleHidden } from '@/app/workspace/[workspaceId]/components/sidebar-toggle' +import { ChatHistoryList } from '@/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' +import { useMothershipTabsStore } from '@/stores/mothership-tabs/store' + +const FALLBACK_TITLE = 'New chat' + +/** + * Resolves the resource the current page represents, so opening a chat keeps + * that page on screen as the focused panel tab instead of teleporting away. + * Titles are placeholders — the tab strip resolves live names from queries. + */ +function derivePageResource(pathname: string, workspaceId: string): MothershipResource | null { + const prefix = `/workspace/${workspaceId}/` + if (!pathname.startsWith(prefix)) return null + const [segment, detail] = pathname.slice(prefix.length).split('/') + if (segment === 'w' && detail) return { type: 'workflow', id: detail, title: 'Workflow' } + if (segment === 'tables' && detail) return { type: 'table', id: detail, title: 'Table' } + if (segment === 'knowledge' && detail) { + return { type: 'knowledgebase', id: detail, title: 'Knowledge Base' } + } + if (isMothershipPageId(segment)) { + return { type: 'page', id: segment, title: MOTHERSHIP_PAGES[segment] } + } + return null +} + +interface ChatSwitcherProps { + /** + * The chat shown in the breadcrumb and highlighted in the list. Omitted on + * non-chat pages, where the most recently updated chat is shown instead. + */ + chatId?: string + /** + * Marks the new-chat empty state (home with no chat open): the chip reads + * "New chat" instead of falling back to the most recently updated chat. + */ + isNewChat?: boolean + /** + * Compact icon-only chip for non-chat pages, where the page title owns the + * bar — a chat name beside it would read as a breadcrumb segment. + */ + iconOnly?: boolean + /** + * Called with the picked chat id before navigation. The chat view uses this + * to reopen a hidden chat pane (including re-picking the current chat). + */ + onSelectChat?: (chatId: string) => void + /** + * The chat is generating a response — the recents icon becomes a spinner so + * the title bar signals work in progress even when the messages are off + * screen (collapsed pane, scrolled away). + */ + isWorking?: boolean +} + +/** + * The chat-switcher chip — a chat icon + title that lives at the + * top-left of every page's title bar. Clicking it opens the workspace's chat + * list inline; selecting a chat navigates to it from anywhere. + */ +export function ChatSwitcher({ + chatId, + isNewChat = false, + iconOnly = false, + onSelectChat, + isWorking = false, +}: ChatSwitcherProps) { + const isHidden = useSidebarToggleHidden() + const { workspaceId } = useParams<{ workspaceId?: string }>() + const router = useRouter() + const pathname = usePathname() + const openTabs = useMothershipTabsStore((state) => state.openTabs) + const { data: tasks = [] } = useMothershipChats(workspaceId) + const [open, setOpen] = useState(false) + + const mostRecent = useMemo( + () => + tasks.reduce<(typeof tasks)[number] | null>( + (latest, task) => (!latest || task.updatedAt > latest.updatedAt ? task : latest), + null + ), + [tasks] + ) + + if (isHidden || !workspaceId) return null + + const title = chatId + ? (tasks.find((task) => task.id === chatId)?.name ?? FALLBACK_TITLE) + : isNewChat + ? FALLBACK_TITLE + : (mostRecent?.name ?? FALLBACK_TITLE) + + const handleSelect = (selectedChatId: string) => { + setOpen(false) + onSelectChat?.(selectedChatId) + if (selectedChatId === chatId) return + // Opening a chat never takes away what you're looking at: the current + // page becomes the focused panel tab, and the chat slides in beside it. + const pageResource = derivePageResource(pathname, workspaceId) + if (pageResource) { + openTabs(workspaceId, [pageResource], { focusId: pageResource.id }) + router.push(`/workspace/${workspaceId}/chat/${selectedChatId}?resource=${pageResource.id}`) + return + } + router.push(`/workspace/${workspaceId}/chat/${selectedChatId}`) + } + + const chipIcon = isWorking ? ( + + ) : ( + + ) + + const trigger = iconOnly ? ( + + ) : ( + + ) + + return ( + + + {iconOnly ? ( + + + {trigger} + +

    Recents

    +
    +
    +
    + ) : ( + trigger + )} +
    + {/* Mirrors the sidebar flyout's anchor rhythm: the chip sits at y 7..37 in + the 44px bar, so offset 13 lands the panel 6px below the bar, and the + -33 align offset walks back from the chip to 8px off the panel edge. */} + + + +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts new file mode 100644 index 00000000000..345c1d5839b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/chat-switcher/index.ts @@ -0,0 +1 @@ +export { ChatSwitcher } from './chat-switcher' diff --git a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx index f6279b6f9f3..a9f74247ca9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx @@ -2,8 +2,8 @@ import { type ReactNode, useEffect } from 'react' import { createLogger } from '@sim/logger' -import { TriangleAlert } from 'lucide-react' import { Button } from '@/components/emcn' +import { TriangleAlert } from '@/components/emcn/icons' /** Props shape required by Next.js error boundary files (`error.tsx`). */ export interface ErrorBoundaryProps { diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index dd30b61d6d1..bc42ee366ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,3 +1,4 @@ +export { ChatSwitcher } from './chat-switcher' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' @@ -34,4 +35,5 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { SidebarToggle } from './sidebar-toggle' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 74cf5990e2a..b6b648b52b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -1,7 +1,6 @@ 'use client' import { memo, useEffect, useRef, useState } from 'react' -import { GitBranch } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Check, @@ -16,6 +15,7 @@ import { Tooltip, toast, } from '@/components/emcn' +import { GitBranch } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 375e1c1dada..83cb1a568cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -8,7 +8,6 @@ import { useRef, useState, } from 'react' -import { ArrowUpLeft } from 'lucide-react' import { createPortal } from 'react-dom' import { Chip, @@ -29,9 +28,12 @@ import { useFloatingTooltip, useIsOverflowing, } from '@/components/emcn' +import { ArrowUpLeft } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' +import { ChatSwitcher } from '@/app/workspace/[workspaceId]/components/chat-switcher' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' +import { SidebarToggle } from '@/app/workspace/[workspaceId]/components/sidebar-toggle' export interface DropdownOption { label: string @@ -128,8 +130,19 @@ export const ResourceHeader = memo(function ResourceHeader({ : -1 return ( -
    -
    +
    + {/* Chrome controls live outside the overflow-hidden breadcrumb group so + the toggle's 9px pull-out (7px edge inset, matching the chat title + bar) isn't clipped. The gap-1 cluster matches the chat title bar's + toggle+switcher rhythm so the pair never shifts between pages. */} +
    + + +
    +
    {hasBreadcrumbs ? ( breadcrumbs.map((crumb, i) => { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index f6cadd0a3a1..83b92896933 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -9,7 +9,6 @@ import { useRef, useState, } from 'react' -import { ChevronLeft, ChevronRight } from 'lucide-react' import { ArrowDown, ArrowUp, @@ -20,6 +19,7 @@ import { chipContentLabelClass, Loader, } from '@/components/emcn' +import { ChevronLeft, ChevronRight } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' diff --git a/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts new file mode 100644 index 00000000000..d60f3d9de43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/index.ts @@ -0,0 +1 @@ +export { SidebarToggle, SidebarToggleHidden, useSidebarToggleHidden } from './sidebar-toggle' diff --git a/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx new file mode 100644 index 00000000000..d126377d264 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/sidebar-toggle/sidebar-toggle.tsx @@ -0,0 +1,67 @@ +'use client' + +import { createContext, useContext } from 'react' +import { PanelLeft } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import { useSidebarStore } from '@/stores/sidebar/store' + +const SidebarToggleHiddenContext = createContext(false) + +interface SidebarToggleHiddenProps { + children: React.ReactNode +} + +/** + * Suppresses every {@link SidebarToggle} (and other title-bar chrome controls, + * e.g. the chat switcher) in the subtree. Wrap surfaces that embed full pages + * (e.g. the chat resource panel) so their headers don't duplicate the chrome. + */ +export function SidebarToggleHidden({ children }: SidebarToggleHiddenProps) { + return ( + + {children} + + ) +} + +/** Whether title-bar chrome controls are suppressed by {@link SidebarToggleHidden}. */ +export function useSidebarToggleHidden(): boolean { + return useContext(SidebarToggleHiddenContext) +} + +interface SidebarToggleProps { + /** Layout-only positioning for the host surface (margins, absolute placement). */ + className?: string +} + +/** + * The single sidebar control, living at the top-left of a page's title bar in + * both states. Clicking toggles the sidebar open/closed. While the sidebar is + * hidden, hovering reveals the floating menu panel (rendered by the workspace + * chrome) anchored underneath this toggle. + */ +export function SidebarToggle({ className }: SidebarToggleProps) { + const isHidden = useContext(SidebarToggleHiddenContext) + const isCollapsed = useSidebarStore((s) => s.isCollapsed) + const toggleCollapsed = useSidebarStore((s) => s.toggleCollapsed) + const openFlyout = useSidebarStore((s) => s.openFlyout) + const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose) + + if (isHidden) return null + + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 16b51832dbc..93951d468b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { usePathname } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFullscreenOriginStore } from '@/stores/fullscreen-origin' import { useSidebarStore } from '@/stores/sidebar/store' @@ -40,6 +41,12 @@ function isFullscreenPath(pathname: string | null): boolean { * * On a direct load of a fullscreen route the wrapper mounts already collapsed, * so no slide plays (CSS transitions don't run on mount). + * + * The sidebar's single control is the `SidebarToggle` at the top-left of page + * title bars. While the sidebar is hidden, hovering that toggle opens the + * floating menu panel this chrome owns, anchored underneath the title bar so + * the toggle stays visible; an invisible left-edge hover zone opens it on + * pages without a title-bar toggle. Clicking the toggle pins the sidebar open. */ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { const pathname = usePathname() @@ -49,6 +56,16 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { const hasHydrated = useSidebarStore((s) => s._hasHydrated) const syncSidebarWidth = useSidebarStore((s) => s.syncWidth) + const isCollapsed = useSidebarStore((s) => s.isCollapsed) + const isFlyoutOpen = useSidebarStore((s) => s.isFlyoutOpen) + const openFlyout = useSidebarStore((s) => s.openFlyout) + const scheduleFlyoutClose = useSidebarStore((s) => s.scheduleFlyoutClose) + const closeFlyout = useSidebarStore((s) => s.closeFlyout) + + // Hide the flyout after navigating from it. + useEffect(() => { + closeFlyout() + }, [pathname, closeFlyout]) // Remember the last non-fullscreen page so a fullscreen route's Back control // can return there, deterministically and for any trigger. @@ -86,14 +103,14 @@ export function WorkspaceChrome({ children }: WorkspaceChromeProps) { }, [syncSidebarWidth]) return ( -
    +
    - + {!isCollapsed && }
    + {/* Sidebar hidden → content goes full-bleed to the browser edge; sidebar + visible (or fullscreen route) → framed card with the 8px gutter. */}
    -
    +
    {children}
    + {isCollapsed && !isFullscreen && ( + <> + {/* Invisible hover zone so the flyout is reachable from the screen + edge on pages whose header has no SidebarFlyoutTrigger. */} +
    + {/* Anchored below the page title bar so the SidebarToggle that opened + it stays visible above the panel. Chrome mirrors the canonical + popover surface (rounded-xl, --border-1, --bg, shadow-sm) and enter + motion (fade + zoom + slide from top, 150ms ease-out). Content-fit + like a dropdown: height tracks the menu, capped so it scrolls + internally instead of overflowing the viewport. */} +
    + +
    + + )}
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx index 27ed6d8464c..a2009b9af10 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx @@ -1,6 +1,5 @@ 'use client' -import { Scissors } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -9,7 +8,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '@/components/emcn' -import { Clipboard, Duplicate, Search, SelectAll } from '@/components/emcn/icons' +import { Clipboard, Duplicate, Scissors, Search, SelectAll } from '@/components/emcn/icons' interface EditorContextMenuProps { isOpen: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx index b161d2f8bce..f0d358464f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx @@ -1,5 +1,5 @@ -import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react' import { Button } from '@/components/emcn' +import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from '@/components/emcn/icons' import { cn } from '@/lib/core/utils/cn' interface PreviewNavigationControls { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx new file mode 100644 index 00000000000..2e6bd9f226c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-history/chat-history-list.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { differenceInCalendarDays, isToday, isYesterday } from 'date-fns' +import { useParams } from 'next/navigation' +import { Skeleton } from '@/components/emcn' +import { Search } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' +import { + type MothershipChatMetadata, + useMothershipChats, + usePrefetchChatHistory, +} from '@/hooks/queries/mothership-chats' + +const CONFIG = { + LIST_MAX_HEIGHT: 320, + SKELETON_ROWS: 5, +} as const + +/** A recency bucket of chats rendered as one section in the history list. */ +interface ChatBucket { + key: string + label: string + tasks: MothershipChatMetadata[] +} + +/** + * Buckets chats into Codex-style recency sections. Pinned chats are lifted out + * of their date bucket into a dedicated section at the top; everything else is + * grouped by how recently it was last updated. The server already returns the + * list ordered (pinned first, then desc by `updatedAt`), so per-bucket order is + * preserved by simply appending as we iterate. + */ +function bucketChats(tasks: readonly MothershipChatMetadata[]): ChatBucket[] { + const now = new Date() + const pinned: MothershipChatMetadata[] = [] + const today: MothershipChatMetadata[] = [] + const yesterday: MothershipChatMetadata[] = [] + const last7: MothershipChatMetadata[] = [] + const last30: MothershipChatMetadata[] = [] + const older: MothershipChatMetadata[] = [] + + for (const task of tasks) { + if (task.isPinned) { + pinned.push(task) + continue + } + const date = task.updatedAt + if (isToday(date)) { + today.push(task) + } else if (isYesterday(date)) { + yesterday.push(task) + } else { + const days = differenceInCalendarDays(now, date) + if (days <= 7) last7.push(task) + else if (days <= 30) last30.push(task) + else older.push(task) + } + } + + return ( + [ + { key: 'pinned', label: 'Pinned', tasks: pinned }, + { key: 'today', label: 'Today', tasks: today }, + { key: 'yesterday', label: 'Yesterday', tasks: yesterday }, + { key: 'last7', label: 'Previous 7 Days', tasks: last7 }, + { key: 'last30', label: 'Previous 30 Days', tasks: last30 }, + { key: 'older', label: 'Older', tasks: older }, + ] as const + ).filter((bucket) => bucket.tasks.length > 0) +} + +/** + * A small status dot mirroring the sidebar's semantics: yellow while a chat is + * actively streaming, brand accent when it has unread activity. Rendered only + * when one of those states applies. + */ +function StatusDot({ task }: { task: MothershipChatMetadata }) { + if (!task.isActive && !task.isUnread) return null + return ( +