diff --git a/apps/web/app/task/[id]/[sessionId]/page.tsx b/apps/web/app/task/[id]/[sessionId]/page.tsx index fca7128f7c..1766b44e73 100644 --- a/apps/web/app/task/[id]/[sessionId]/page.tsx +++ b/apps/web/app/task/[id]/[sessionId]/page.tsx @@ -1,7 +1,14 @@ import { StateHydrator } from '@/components/state-hydrator'; -import { fetchTask, listTaskSessionMessages } from '@/lib/http'; +import { + fetchBoardSnapshot, + fetchTask, + listAgents, + listRepositories, + listTaskSessionMessages, + listTaskSessions, +} from '@/lib/http'; import type { Task } from '@/lib/types/http'; -import { taskToState } from '@/lib/ssr/mapper'; +import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; import TaskPageClient from '../page-client'; export default async function TaskSessionPage({ @@ -12,35 +19,95 @@ export default async function TaskSessionPage({ let initialState: ReturnType | null = null; let task: Task | null = null; let sessionId: string | null = null; + let sessionsByTask: Record>['sessions']> = {}; try { const { id, sessionId: paramSessionId } = await params; sessionId = paramSessionId; task = await fetchTask(id, { cache: 'no-store' }); - // Load messages for SSR + const [snapshot, agents, repositories] = await Promise.all([ + fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), + listAgents({ cache: 'no-store' }), + listRepositories(task.workspace_id, { cache: 'no-store' }), + ]); + + const sessionResults = await Promise.all( + snapshot.tasks.map((taskItem) => + listTaskSessions(taskItem.id, { cache: 'no-store' }).then((result) => [taskItem.id, result.sessions] as const) + ) + ); + sessionsByTask = Object.fromEntries(sessionResults); + + let taskState = taskToState(task); try { - const messagesResponse = await listTaskSessionMessages(sessionId, { limit: 50, sort: 'asc' }, { cache: 'no-store' }); - initialState = taskToState(task, { + const messagesResponse = await listTaskSessionMessages( + sessionId, + { limit: 50, sort: 'asc' }, + { cache: 'no-store' } + ); + taskState = taskToState(task, { items: messagesResponse.messages ?? [], hasMore: messagesResponse.has_more ?? false, oldestCursor: messagesResponse.cursor ?? (messagesResponse.messages?.[0]?.id ?? null), }); } catch (error) { - // SSR failed - client will load messages via WebSocket - console.warn('Could not SSR messages (client will load via WebSocket):', error instanceof Error ? error.message : String(error)); - initialState = taskToState(task); + console.warn( + 'Could not SSR messages (client will load via WebSocket):', + error instanceof Error ? error.message : String(error) + ); } + + initialState = { + ...snapshotToState(snapshot), + ...taskState, + repositories: { + itemsByWorkspaceId: { + [task.workspace_id]: repositories.repositories, + }, + loadingByWorkspaceId: { + [task.workspace_id]: false, + }, + loadedByWorkspaceId: { + [task.workspace_id]: true, + }, + }, + agentProfiles: { + items: agents.agents.flatMap((agent) => + agent.profiles.map((profile) => ({ + id: profile.id, + label: `${agent.name} • ${profile.name}`, + agent_id: agent.id, + })) + ), + version: 0, + }, + settingsAgents: { + items: agents.agents, + }, + settingsData: { + agentsLoaded: true, + executorsLoaded: false, + environmentsLoaded: false, + }, + }; } catch { initialState = null; task = null; sessionId = null; + sessionsByTask = {}; } return ( <> {initialState ? : null} - + ); } diff --git a/apps/web/app/task/[id]/page-client.tsx b/apps/web/app/task/[id]/page-client.tsx index 17e5358e6d..51033de4e9 100644 --- a/apps/web/app/task/[id]/page-client.tsx +++ b/apps/web/app/task/[id]/page-client.tsx @@ -7,37 +7,57 @@ import { TaskTopBar } from '@/components/task/task-top-bar'; import { TaskLayout } from '@/components/task/task-layout'; import { CommandApprovalDialog } from '@/components/task/command-approval-dialog'; import { DebugOverlay } from '@/components/debug-overlay'; -import type { Task } from '@/lib/types/http'; +import type { Repository, Task, TaskSession } from '@/lib/types/http'; import { DEBUG_UI } from '@/lib/config'; import { getWebSocketClient } from '@/lib/ws/connection'; import { useRepositories } from '@/hooks/use-repositories'; import { useTaskAgent } from '@/hooks/use-task-agent'; +import { useTaskMessages } from '@/hooks/use-task-messages'; import { useSessionResumption } from '@/hooks/use-session-resumption'; import { useAppStore } from '@/components/state-provider'; +import { fetchTask, listTaskSessions } from '@/lib/http'; +import { linkToTaskSession } from '@/lib/links'; type TaskPageClientProps = { task: Task | null; sessionId?: string | null; + initialSessionsByTask?: Record; + initialRepositories?: Repository[]; + initialAgentProfiles?: Array<{ id: string; label: string; agent_id: string }>; }; -export default function TaskPage({ task: initialTask, sessionId = null }: TaskPageClientProps) { +export default function TaskPage({ + task: initialTask, + sessionId = null, + initialSessionsByTask = {}, + initialRepositories = [], + initialAgentProfiles = [], +}: TaskPageClientProps) { + const [activeTaskOverride, setActiveTaskOverride] = useState(null); + const [activeSessionOverride, setActiveSessionOverride] = useState(null); const [isMounted, setIsMounted] = useState(false); + const effectiveTaskId = activeTaskOverride?.id ?? initialTask?.id ?? null; const kanbanTask = useAppStore((state) => - initialTask?.id ? state.kanban.tasks.find((item) => item.id === initialTask.id) ?? null : null + effectiveTaskId ? state.kanban.tasks.find((item) => item.id === effectiveTaskId) ?? null : null ); + const boardTasks = useAppStore((state) => state.kanban.tasks); + const boardColumns = useAppStore((state) => state.kanban.columns); + const workspaces = useAppStore((state) => state.workspaces.items); + const agentProfiles = useAppStore((state) => state.agentProfiles.items); const task = useMemo(() => { - if (!initialTask) return null; - if (!kanbanTask) return initialTask; + const baseTask = activeTaskOverride ?? initialTask; + if (!baseTask) return null; + if (!kanbanTask) return baseTask; return { - ...initialTask, - title: kanbanTask.title ?? initialTask.title, - description: kanbanTask.description ?? initialTask.description, - column_id: (kanbanTask.columnId as string | undefined) ?? initialTask.column_id, - position: kanbanTask.position ?? initialTask.position, - state: (kanbanTask.state as Task['state'] | undefined) ?? initialTask.state, - repositories: initialTask.repositories, + ...baseTask, + title: kanbanTask.title ?? baseTask.title, + description: kanbanTask.description ?? baseTask.description, + column_id: (kanbanTask.columnId as string | undefined) ?? baseTask.column_id, + position: kanbanTask.position ?? baseTask.position, + state: (kanbanTask.state as Task['state'] | undefined) ?? baseTask.state, + repositories: baseTask.repositories, }; - }, [initialTask, kanbanTask]); + }, [activeTaskOverride, initialTask, kanbanTask]); const connectionStatus = useAppStore((state) => state.connection.status); // Session resumption hook - handles auto-resume on page reload @@ -62,7 +82,7 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa } = useTaskAgent(task); // Merge state from resumption and regular agent hooks - const activeSessionId = sessionId ?? taskSessionId; + const activeSessionId = activeSessionOverride ?? sessionId ?? taskSessionId; const isResuming = resumptionState === 'checking' || resumptionState === 'resuming'; const isResumed = resumptionState === 'resumed' || resumptionState === 'running'; @@ -77,16 +97,23 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa taskSessionState !== null ? taskSessionState === 'STARTING' || taskSessionState === 'RUNNING' : isAgentRunning && (task?.state === 'IN_PROGRESS' || task?.state === 'SCHEDULING'); + useTaskMessages(task?.id ?? null, activeSessionId); + const [sessionsByTask, setSessionsByTask] = useState>( + initialSessionsByTask + ); useEffect(() => { queueMicrotask(() => setIsMounted(true)); }, []); - - const { repositories } = useRepositories(task?.workspace_id ?? null, Boolean(task?.workspace_id)); + const effectiveRepositories = repositories.length ? repositories : initialRepositories; + const repositoryPathsById = useMemo( + () => Object.fromEntries(effectiveRepositories.map((repo) => [repo.id, repo.local_path])), + [effectiveRepositories] + ); const repository = useMemo( - () => repositories.find((item) => item.id === task?.repositories?.[0]?.repository_id) ?? null, - [repositories, task?.repositories] + () => effectiveRepositories.find((item) => item.id === task?.repositories?.[0]?.repository_id) ?? null, + [effectiveRepositories, task?.repositories] ); const handleSendMessage = useCallback(async (content: string) => { @@ -111,6 +138,171 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa } }, [activeSessionId, task]); + const updateUrl = useCallback((taskId: string, sessionIdToOpen: string) => { + if (typeof window === 'undefined') return; + window.history.replaceState({}, '', linkToTaskSession(taskId, sessionIdToOpen)); + }, []); + + const handleSelectSession = useCallback( + (taskId: string, sessionIdToOpen: string) => { + setActiveSessionOverride(sessionIdToOpen); + updateUrl(taskId, sessionIdToOpen); + }, + [updateUrl] + ); + + const handleLoadTaskSessions = useCallback(async (taskId: string) => { + try { + const response = await listTaskSessions(taskId, { cache: 'no-store' }); + setSessionsByTask((prev) => ({ ...prev, [taskId]: response.sessions })); + } catch (error) { + console.error('Failed to load task sessions:', error); + } + }, []); + + const handleSelectTask = useCallback( + async (taskId: string, sessionId: string | null) => { + try { + const nextTask = await fetchTask(taskId, { cache: 'no-store' }); + setActiveTaskOverride(nextTask); + } catch (error) { + console.error('Failed to load task details:', error); + } + + let targetSessionId = sessionId; + if (!targetSessionId) { + try { + const response = await listTaskSessions(taskId, { cache: 'no-store' }); + targetSessionId = response.sessions[0]?.id ?? null; + } catch (error) { + console.error('Failed to load task sessions for navigation:', error); + } + } + + if (!targetSessionId) { + setActiveSessionOverride(null); + return; + } + + setActiveSessionOverride(targetSessionId); + updateUrl(taskId, targetSessionId); + }, + [updateUrl] + ); + + const handleCreateSessionForTask = useCallback( + async ( + taskId: string, + data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string } + ) => { + const client = getWebSocketClient(); + if (!client) return; + + try { + const response = await client.request<{ + success: boolean; + task_id: string; + agent_instance_id: string; + task_session_id?: string; + state: string; + }>( + 'orchestrator.start', + { task_id: taskId, agent_profile_id: data.agentProfileId }, + 15000 + ); + + if (response?.task_session_id && data.prompt.trim()) { + await client.request( + 'message.add', + { task_id: taskId, task_session_id: response.task_session_id, content: data.prompt.trim() }, + 10000 + ); + } + + await handleLoadTaskSessions(taskId); + + if (response?.task_session_id) { + setActiveSessionOverride(response.task_session_id); + updateUrl(taskId, response.task_session_id); + } + } catch (error) { + console.error('Failed to create task session:', error); + } + }, + [handleLoadTaskSessions, updateUrl] + ); + + const workspaceName = useMemo(() => { + if (!task?.workspace_id) return 'Workspace'; + return workspaces.find((workspace) => workspace.id === task.workspace_id)?.name ?? 'Workspace'; + }, [task, workspaces]); + + const effectiveAgentProfiles = agentProfiles.length ? agentProfiles : initialAgentProfiles; + const agentLabelsById = useMemo(() => { + return Object.fromEntries(effectiveAgentProfiles.map((profile) => [profile.id, profile.label])); + }, [effectiveAgentProfiles]); + + const tasksWithRepositories = useMemo( + () => + boardTasks.map((taskItem) => ({ + ...taskItem, + repositoryPath: taskItem.repositoryId ? repositoryPathsById[taskItem.repositoryId] : undefined, + })), + [boardTasks, repositoryPathsById] + ); + + useEffect(() => { + if (!task?.id) return; + if (sessionsByTask[task.id]) return; + // eslint-disable-next-line react-hooks/set-state-in-effect + handleLoadTaskSessions(task.id); + }, [handleLoadTaskSessions, sessionsByTask, task?.id]); + + useEffect(() => { + const client = getWebSocketClient(); + if (!client) return; + + const taskIds = boardTasks.map((item) => item.id); + taskIds.forEach((taskId) => client.subscribe(taskId)); + + return () => { + taskIds.forEach((taskId) => client.unsubscribe(taskId)); + }; + }, [boardTasks]); + + useEffect(() => { + const client = getWebSocketClient(); + if (!client) return; + + const pending = new Set(); + const unsubscribe = client.on('task_session.state_changed', (message) => { + const taskId = message.payload?.task_id; + const sessionId = message.payload?.task_session_id; + const payload = message.payload as Record | undefined; + const nextState = (payload?.new_state ?? payload?.state) as TaskSession['state'] | undefined; + if (taskId && sessionId && nextState) { + setSessionsByTask((prev) => { + const sessions = prev[taskId]; + if (!sessions) return prev; + const nextSessions = sessions.map((session) => + session.id === sessionId ? { ...session, state: nextState } : session + ); + return { ...prev, [taskId]: nextSessions }; + }); + } + if (!taskId || pending.has(taskId)) return; + pending.add(taskId); + setTimeout(() => { + pending.delete(taskId); + handleLoadTaskSessions(taskId); + }, 250); + }); + + return () => { + unsubscribe(); + }; + }, [handleLoadTaskSessions]); + if (!isMounted) { return
; } @@ -153,6 +345,18 @@ export default function TaskPage({ task: initialTask, sessionId = null }: TaskPa taskId={task?.id ?? null} sessionId={activeSessionId ?? null} onSendMessage={handleSendMessage} + tasks={tasksWithRepositories} + columns={boardColumns.map((column) => ({ id: column.id, title: column.title }))} + workspaceId={task?.workspace_id ?? null} + boardId={task?.board_id ?? null} + workspaceName={workspaceName} + agentLabelsById={agentLabelsById} + activeSessionId={activeSessionId ?? null} + sessionsByTask={sessionsByTask} + onSelectSession={handleSelectSession} + onLoadTaskSessions={handleLoadTaskSessions} + onSelectTask={handleSelectTask} + onCreateSession={handleCreateSessionForTask} /> {/* Fallback dialog for permissions without matching tool call (e.g., workspace indexing) */} diff --git a/apps/web/app/task/[id]/page.tsx b/apps/web/app/task/[id]/page.tsx index b453dedf26..e8a3acd800 100644 --- a/apps/web/app/task/[id]/page.tsx +++ b/apps/web/app/task/[id]/page.tsx @@ -1,31 +1,18 @@ -import { StateHydrator } from '@/components/state-hydrator'; import { fetchTask, listTaskSessions } from '@/lib/http'; -import type { Task } from '@/lib/types/http'; -import { taskToState } from '@/lib/ssr/mapper'; -import TaskPageClient from './page-client'; import { redirect } from 'next/navigation'; +import { linkToTaskSession } from '@/lib/links'; export default async function TaskPage({ params }: { params: Promise<{ id: string }> }) { - let initialState: ReturnType | null = null; - let task: Task | null = null; - try { const { id } = await params; - task = await fetchTask(id, { cache: 'no-store' }); + const task = await fetchTask(id, { cache: 'no-store' }); const sessions = await listTaskSessions(id, { cache: 'no-store' }); if (sessions.sessions.length > 0) { - redirect(`/task/${id}/${sessions.sessions[0].id}`); + redirect(linkToTaskSession(id, sessions.sessions[0].id)); } - initialState = taskToState(task); + redirect(`/?boardId=${task.board_id}`); } catch { - initialState = null; - task = null; + redirect('/'); } - - return ( - <> - {initialState ? : null} - - - ); + return null; } diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 417f3e9dfb..dfadf14abb 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -16,6 +16,7 @@ import { useTaskActions } from '@/hooks/use-task-actions'; import { KanbanBoardHeader } from './kanban-board-header'; import { KanbanBoardGrid } from './kanban-board-grid'; import { getWebSocketClient } from '@/lib/ws/connection'; +import { linkToTaskSession } from '@/lib/links'; interface KanbanBoardProps { onPreviewTask?: (task: Task) => void; @@ -235,7 +236,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} if (onOpenTask) { onOpenTask(task, latestSessionId); } else { - router.push(`/task/${task.id}/${latestSessionId}`); + router.push(linkToTaskSession(task.id, latestSessionId)); } }; diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 0aa1e15ac8..d1c5530b5c 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -9,6 +9,7 @@ import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; import { getWebSocketClient } from "@/lib/ws/connection"; +import { linkToTaskSession } from "@/lib/links"; type KanbanWithPreviewProps = { initialTaskId?: string; @@ -138,7 +139,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit const handleNavigateToTask = useCallback( (task: Task, sessionId: string) => { close(); - router.push(`/task/${task.id}/${sessionId}`); + router.push(linkToTaskSession(task.id, sessionId)); }, [close, router] ); diff --git a/apps/web/components/settings/notifications-settings.tsx b/apps/web/components/settings/notifications-settings.tsx index c7d010d267..1d3cad78e5 100644 --- a/apps/web/components/settings/notifications-settings.tsx +++ b/apps/web/components/settings/notifications-settings.tsx @@ -9,7 +9,7 @@ import { Input } from '@kandev/ui/input'; import { Separator } from '@kandev/ui/separator'; import { Textarea } from '@kandev/ui/textarea'; import { SettingsPageTemplate } from '@/components/settings/settings-page-template'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip'; import { createNotificationProvider, deleteNotificationProvider, diff --git a/apps/web/components/task/command-approval-dialog.tsx b/apps/web/components/task/command-approval-dialog.tsx index 498b6fa059..946739603c 100644 --- a/apps/web/components/task/command-approval-dialog.tsx +++ b/apps/web/components/task/command-approval-dialog.tsx @@ -11,7 +11,7 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, -} from '@/components/ui/alert-dialog'; +} from '@kandev/ui/alert-dialog'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; @@ -175,4 +175,3 @@ export function CommandApprovalDialog({ taskId, standaloneOnly = false }: Comman ); } - diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index efb65592e3..20938e6b83 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -23,6 +23,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { TaskCreateDialog } from '../task-create-dialog'; import { useAppStore } from '@/components/state-provider'; import { getWebSocketClient } from '@/lib/ws/connection'; +import { linkToTaskSession } from '@/lib/links'; type SessionStatus = 'running' | 'waiting_input' | 'complete' | 'failed' | 'cancelled'; @@ -186,7 +187,7 @@ export function SessionsDropdown({ taskId, activeSessionId = null, taskTitle = ' task_session_id: sessionId, }, 15000); await fetchSessions(taskId); - router.push(`/task/${taskId}/${sessionId}`); + router.push(linkToTaskSession(taskId, sessionId)); } catch (error) { console.error('Failed to resume task session:', error); } diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 6eecd2f37a..add0b0fc83 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -18,9 +18,18 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { const fitAddonRef = useRef(null); const lastOutputLengthRef = useRef(0); const subscriptionIdRef = useRef(0); + const onDataDisposableRef = useRef<{ dispose: () => void } | null>(null); const storeApi = useAppStoreApi(); const shellOutput = useAppStore((state) => state.shell.outputs[taskId] || ''); + const sessionState = useAppStore((state) => { + if (sessionId) { + return state.taskSessions.items[sessionId]?.state ?? null; + } + return state.taskSessionStatesByTaskId[taskId] ?? null; + }); + const canSubscribe = + Boolean(sessionId) && (sessionState === 'RUNNING' || sessionState === 'WAITING_FOR_INPUT'); const send = useCallback( (action: string, payload: Record) => { @@ -73,11 +82,6 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { xtermRef.current = terminal; fitAddonRef.current = fitAddon; - // Handle user input - terminal.onData((data) => { - send('shell.input', { task_id: taskId, session_id: sessionId, data }); - }); - // Handle resize const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); @@ -92,6 +96,23 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { }; }, [taskId, sessionId, send]); + useEffect(() => { + if (!xtermRef.current) return; + onDataDisposableRef.current?.dispose(); + onDataDisposableRef.current = null; + + if (!sessionId) return; + + onDataDisposableRef.current = xtermRef.current.onData((data) => { + send('shell.input', { task_id: taskId, session_id: sessionId, data }); + }); + + return () => { + onDataDisposableRef.current?.dispose(); + onDataDisposableRef.current = null; + }; + }, [taskId, sessionId, send]); + // Write new output to terminal useEffect(() => { if (!xtermRef.current) return; @@ -107,6 +128,8 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { // Subscribe to shell on mount to start the shell stream // Uses retry logic to handle cases where agent isn't ready yet (e.g., session resumption) useEffect(() => { + if (!canSubscribe) return; + // Increment subscription ID to invalidate any pending requests const currentSubscriptionId = ++subscriptionIdRef.current; let retryCount = 0; @@ -162,7 +185,7 @@ export function ShellTerminal({ taskId, sessionId }: ShellTerminalProps) { clearTimeout(retryTimeout); } }; - }, [taskId, sessionId, storeApi]); + }, [taskId, sessionId, storeApi, canSubscribe]); return (
); } - diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index e17e33d4a9..d6e45c0f92 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -1,32 +1,78 @@ 'use client'; -import { memo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable'; import { getLocalStorage, setLocalStorage } from '@/lib/local-storage'; import { TaskLeftPanel } from './task-left-panel'; import { TaskRightPanel } from './task-right-panel'; import { TaskFilesPanel } from './task-files-panel'; +import { TaskSessionSidebar } from './task-session-sidebar'; import type { OpenFileTab } from '@/lib/types/backend'; +import type { TaskSession, TaskState } from '@/lib/types/http'; -const DEFAULT_HORIZONTAL_LAYOUT: [number, number] = [75, 25]; +const DEFAULT_HORIZONTAL_LAYOUT: [number, number, number] = [18, 57, 25]; type TaskLayoutProps = { taskId: string | null; sessionId: string | null; onSendMessage: (content: string) => Promise; + tasks: Array<{ + id: string; + title: string; + state?: TaskState; + description?: string; + columnId?: string; + repositoryPath?: string; + }>; + columns: Array<{ id: string; title: string }>; + workspaceId: string | null; + boardId: string | null; + workspaceName: string; + agentLabelsById: Record; + activeSessionId: string | null; + sessionsByTask: Record; + onSelectSession: (taskId: string, sessionId: string) => void; + onLoadTaskSessions: (taskId: string) => void; + onSelectTask: (taskId: string, sessionId: string | null) => void; + onCreateSession: (taskId: string, data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => void; }; export const TaskLayout = memo(function TaskLayout({ taskId, sessionId, onSendMessage, + tasks, + columns, + workspaceId, + boardId, + workspaceName, + agentLabelsById, + activeSessionId, + sessionsByTask, + onSelectSession, + onLoadTaskSessions, + onSelectTask, + onCreateSession, }: TaskLayoutProps) { - const [horizontalLayout, setHorizontalLayout] = useState<[number, number]>( - getLocalStorage('task-layout-horizontal', DEFAULT_HORIZONTAL_LAYOUT) + const [horizontalLayout, setHorizontalLayout] = useState<[number, number, number]>( + getLocalStorage('task-layout-horizontal-v2', DEFAULT_HORIZONTAL_LAYOUT) ); const [selectedDiffPath, setSelectedDiffPath] = useState(null); const [openFileRequest, setOpenFileRequest] = useState(null); + const switcherTasks = useMemo( + () => + tasks.map((task) => ({ + id: task.id, + title: task.title, + state: task.state, + description: task.description, + columnId: task.columnId, + repositoryPath: task.repositoryPath, + })), + [tasks] + ); + const handleSelectDiffPath = (path: string) => { setSelectedDiffPath(path); }; @@ -47,13 +93,31 @@ export const TaskLayout = memo(function TaskLayout({
{ - setHorizontalLayout(sizes as [number, number]); - setLocalStorage('task-layout-horizontal', sizes); + setHorizontalLayout(sizes as [number, number, number]); + setLocalStorage('task-layout-horizontal-v2', sizes); }} > - + + + + + - + diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx new file mode 100644 index 0000000000..86c6f1da27 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import type { TaskSession, TaskState } from '@/lib/types/http'; +import { TaskSessionSwitcher } from './task-session-switcher'; +import { TaskSwitcher } from './task-switcher'; +import { Button } from '@kandev/ui/button'; +import { IconPlus } from '@tabler/icons-react'; +import { TaskCreateDialog } from '@/components/task-create-dialog'; +import { useAppStoreApi } from '@/components/state-provider'; + +type TaskSummary = { + id: string; + title: string; + state?: TaskState; + description?: string; + columnId?: string; + repositoryPath?: string; +}; + +type TaskSessionSidebarProps = { + workspaceName: string; + tasks: TaskSummary[]; + columns: Array<{ id: string; title: string }>; + workspaceId: string | null; + boardId: string | null; + activeTaskId: string | null; + activeSessionId: string | null; + sessionsByTask: Record; + agentLabelsById: Record; + onSelectSession: (taskId: string, sessionId: string) => void; + onLoadTaskSessions: (taskId: string) => void; + onSelectTask: (taskId: string, sessionId: string | null) => void; + onCreateSession: (taskId: string, data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => void; +}; + +export function TaskSessionSidebar({ + workspaceName, + tasks, + columns, + workspaceId, + boardId, + activeTaskId, + activeSessionId, + sessionsByTask, + agentLabelsById, + onSelectSession, + onLoadTaskSessions, + onSelectTask, + onCreateSession, +}: TaskSessionSidebarProps) { + const [selectedTaskId, setSelectedTaskId] = useState(activeTaskId); + const selectedSessions = selectedTaskId ? sessionsByTask[selectedTaskId] ?? [] : []; + const [taskDialogOpen, setTaskDialogOpen] = useState(false); + const [sessionDialogOpen, setSessionDialogOpen] = useState(false); + const [sessionTask, setSessionTask] = useState(null); + const store = useAppStoreApi(); + + useEffect(() => { + setSelectedTaskId(activeTaskId); + }, [activeTaskId]); + + return ( + <> +
+
+ {workspaceName || 'Workspace'} + +
+
+ { + if (!selectedTaskId) return; + const task = tasks.find((item) => item.id === selectedTaskId) ?? null; + setSessionTask(task); + setSessionDialogOpen(true); + }} + /> + { + setSelectedTaskId(taskId); + onSelectTask(taskId, sessionId); + }} + onLoadTaskSessions={onLoadTaskSessions} + /> +
+
+ ({ id: column.id, title: column.title }))} + onSuccess={(task) => { + store.setState((state) => { + if (state.kanban.boardId !== task.board_id) return state; + const nextTask = { + id: task.id, + columnId: task.column_id, + title: task.title, + description: task.description, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + }; + return { + ...state, + kanban: { + ...state.kanban, + tasks: state.kanban.tasks.some((item) => item.id === task.id) + ? state.kanban.tasks.map((item) => (item.id === task.id ? nextTask : item)) + : [...state.kanban.tasks, nextTask], + }, + }; + }); + setSelectedTaskId(task.id); + onSelectTask(task.id, null); + }} + /> + ({ id: column.id, title: column.title }))} + initialValues={{ + title: sessionTask?.title ?? '', + description: sessionTask?.description ?? '', + }} + onCreateSession={(data) => { + if (!sessionTask) return; + onCreateSession(sessionTask.id, data); + }} + /> + + ); +} diff --git a/apps/web/components/task/task-session-switcher.tsx b/apps/web/components/task/task-session-switcher.tsx new file mode 100644 index 0000000000..d2d6d7ce42 --- /dev/null +++ b/apps/web/components/task/task-session-switcher.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { IconCheck, IconLoader2, IconAlertCircle, IconPlayerStopFilled, IconPlus, IconX } from '@tabler/icons-react'; +import type { TaskSession, TaskSessionState } from '@/lib/types/http'; +import { Badge } from '@kandev/ui/badge'; +import { Command, CommandGroup, CommandItem, CommandList } from '@kandev/ui/command'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; + +type SessionSwitcherProps = { + taskId: string | null; + activeSessionId: string | null; + sessions: TaskSession[]; + agentLabelsById: Record; + onSelectSession: (taskId: string, sessionId: string) => void; + showHeader?: boolean; + onCreateSession?: () => void; +}; + +const STATUS_ORDER: Record = { + RUNNING: 1, + STARTING: 1, + WAITING_FOR_INPUT: 2, + CREATED: 3, + COMPLETED: 4, + FAILED: 5, + CANCELLED: 6, +}; + +type SessionStatus = 'running' | 'waiting_input' | 'complete' | 'failed' | 'cancelled' | 'starting'; + +function getSessionStatus(state: TaskSessionState): SessionStatus { + switch (state) { + case 'RUNNING': + return 'running'; + case 'STARTING': + return 'starting'; + case 'WAITING_FOR_INPUT': + return 'waiting_input'; + case 'COMPLETED': + return 'complete'; + case 'FAILED': + return 'failed'; + case 'CANCELLED': + return 'cancelled'; + default: + return 'waiting_input'; + } +} + +function getSessionIcon(status: SessionStatus) { + switch (status) { + case 'running': + case 'starting': + return ; + case 'waiting_input': + return ; + case 'complete': + return ; + case 'failed': + case 'cancelled': + return ; + default: + return null; + } +} + +function formatSessionLabel(index: number, total: number) { + const number = total - index; + return `#${number}`; +} + +function formatDuration(startedAt: string, isRunning: boolean, now: number) { + const start = new Date(startedAt).getTime(); + const diff = Math.floor(((isRunning ? now : start) - start) / 1000); + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + const seconds = diff % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +export function TaskSessionSwitcher({ + taskId, + activeSessionId, + sessions, + agentLabelsById, + onSelectSession, + showHeader = true, + onCreateSession, +}: SessionSwitcherProps) { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + const sortedSessions = useMemo(() => { + return [...sessions].sort((a, b) => { + const orderDelta = (STATUS_ORDER[a.state] ?? 99) - (STATUS_ORDER[b.state] ?? 99); + if (orderDelta !== 0) return orderDelta; + return new Date(b.started_at).getTime() - new Date(a.started_at).getTime(); + }); + }, [sessions]); + + const resolveAgentLabel = (session: TaskSession) => { + if (session.agent_profile_id && agentLabelsById[session.agent_profile_id]) { + return agentLabelsById[session.agent_profile_id]; + } + return 'Unknown agent'; + }; + + useEffect(() => { + const hasRunning = sortedSessions.some((session) => + ['RUNNING', 'STARTING'].includes(session.state) + ); + if (!hasRunning) return; + const interval = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + return () => clearInterval(interval); + }, [sortedSessions]); + + return ( +
+ {showHeader ? ( +
+ Sessions +
+ {onCreateSession ? ( + + + + + New session + + ) : null} + + {sortedSessions.length} + +
+
+ ) : null} + {sortedSessions.length === 0 ? ( +
No sessions yet.
+ ) : ( + + + + {sortedSessions.map((session, index) => { + const isActive = session.id === activeSessionId; + const agentLabel = resolveAgentLabel(session); + const status = getSessionStatus(session.state); + const duration = formatDuration( + session.started_at, + status === 'running' || status === 'starting', + currentTime + ); + return ( + { + if (!taskId) return; + onSelectSession(taskId, session.id); + }} + className="group flex w-full cursor-pointer items-center gap-3 px-2 py-1.5 rounded-sm text-left transition-colors data-[selected=true]:bg-muted" + > + + {formatSessionLabel(index, sortedSessions.length)} + + + {agentLabel} + + + {duration} + +
+
+ {getSessionIcon(status)} +
+ {(status === 'running' || status === 'starting') && ( + + + + + Stop session + + )} +
+ +
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/components/task/task-state-actions.tsx b/apps/web/components/task/task-state-actions.tsx new file mode 100644 index 0000000000..5f77bc7e2a --- /dev/null +++ b/apps/web/components/task/task-state-actions.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { IconAlertCircle, IconCheck, IconEye, IconLoader2, IconX } from '@tabler/icons-react'; +import type { TaskState } from '@/lib/types/http'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; + +type TaskStateActionsProps = { + state?: TaskState; + onMarkDone?: () => void; +}; + +function getStateIcon(state?: TaskState) { + switch (state) { + case 'CREATED': + case 'SCHEDULING': + case 'IN_PROGRESS': + return ; + case 'REVIEW': + return ; + case 'WAITING_FOR_INPUT': + case 'BLOCKED': + return ; + case 'COMPLETED': + return ; + case 'FAILED': + case 'CANCELLED': + return ; + default: + return ; + } +} + +export function TaskStateActions({ state, onMarkDone }: TaskStateActionsProps) { + return ( +
+
+ {getStateIcon(state)} +
+
+ + + + + Mark as done + +
+
+ ); +} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx new file mode 100644 index 0000000000..cc3711cf50 --- /dev/null +++ b/apps/web/components/task/task-switcher.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { IconChevronRight } from '@tabler/icons-react'; +import type { TaskSession, TaskState } from '@/lib/types/http'; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from '@kandev/ui/item'; +import { truncateRepoPath } from '@/lib/utils'; +import { TaskStateActions } from './task-state-actions'; + +type TaskSwitcherItem = { + id: string; + title: string; + state?: TaskState; + description?: string; + columnId?: string; + repositoryPath?: string; +}; + +type TaskSwitcherProps = { + tasks: TaskSwitcherItem[]; + columns: Array<{ id: string; title: string }>; + activeTaskId: string | null; + selectedTaskId: string | null; + sessionsByTask: Record; + onLoadTaskSessions: (taskId: string) => void; + onSelectTask: (taskId: string, sessionId: string | null) => void; +}; + +export function TaskSwitcher({ + tasks, + columns, + activeTaskId, + selectedTaskId, + sessionsByTask, + onLoadTaskSessions, + onSelectTask, +}: TaskSwitcherProps) { + const [collapsedColumnIds, setCollapsedColumnIds] = useState>(() => new Set()); + + const sortedTasks = useMemo(() => { + return [...tasks].sort((a, b) => a.title.localeCompare(b.title)); + }, [tasks]); + + const tasksByColumn = useMemo(() => { + const grouped: Record = {}; + for (const task of sortedTasks) { + const key = task.columnId ?? 'unknown'; + if (!grouped[key]) grouped[key] = []; + grouped[key].push(task); + } + return grouped; + }, [sortedTasks]); + + const columnsWithFallback = useMemo(() => { + const seen = new Set(columns.map((column) => column.id)); + const extra: Array<{ id: string; title: string }> = []; + if (tasksByColumn.unknown?.length) { + extra.push({ id: 'unknown', title: 'Other' }); + } + return [...columns, ...extra].filter((column) => { + if (seen.has(column.id)) return true; + seen.add(column.id); + return true; + }); + }, [columns, tasksByColumn]); + + const handleToggleColumn = (columnId: string) => { + setCollapsedColumnIds((prev) => { + const next = new Set(prev); + if (next.has(columnId)) next.delete(columnId); + else next.add(columnId); + return next; + }); + }; + + return ( +
+ Tasks + {sortedTasks.length === 0 ? ( +
No tasks on this board.
+ ) : ( +
+ {columnsWithFallback.map((column) => { + const columnTasks = tasksByColumn[column.id] ?? []; + if (columnTasks.length === 0) return null; + const isColumnOpen = !collapsedColumnIds.has(column.id); + return ( +
+ + {isColumnOpen && ( +
+ + {columnTasks.map((task) => { + const isActive = task.id === activeTaskId; + const sessions = sessionsByTask[task.id] ?? []; + const isSelected = task.id === selectedTaskId || isActive; + const repoLabel = task.repositoryPath ? truncateRepoPath(task.repositoryPath) : 'No repository'; + return ( + + { + event.preventDefault(); + const firstSessionId = sessions[0]?.id ?? null; + onSelectTask(task.id, firstSessionId); + onLoadTaskSessions(task.id); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + const firstSessionId = sessions[0]?.id ?? null; + onSelectTask(task.id, firstSessionId); + onLoadTaskSessions(task.id); + } + }} + className="flex w-full items-center gap-2" + > + + {task.title} + {repoLabel} + + + + + + + ); + })} + +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/ui/accordion.tsx b/apps/web/components/ui/accordion.tsx deleted file mode 100644 index afede0fa89..0000000000 --- a/apps/web/components/ui/accordion.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client" - -import * as React from "react" -import { Accordion as AccordionPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" -import { IconChevronDown, IconChevronUp } from "@tabler/icons-react" - -function Accordion({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AccordionItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AccordionTrigger({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - - - - ) -} - -function AccordionContent({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - -
- {children} -
-
- ) -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx deleted file mode 100644 index 30e1e5430c..0000000000 --- a/apps/web/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"use client" - -import * as React from "react" -import { AlertDialog as AlertDialogPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -function AlertDialog({ - ...props -}: React.ComponentProps) { - return -} - -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogContent({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm" -}) { - return ( - - - - - ) -} - -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDialogMedia({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AlertDialogAction({ - className, - variant = "default", - size = "default", - ...props -}: React.ComponentProps & - Pick, "variant" | "size">) { - return ( - - ) -} - -function AlertDialogCancel({ - className, - variant = "outline", - size = "default", - ...props -}: React.ComponentProps & - Pick, "variant" | "size">) { - return ( - - ) -} - -export { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogMedia, - AlertDialogOverlay, - AlertDialogPortal, - AlertDialogTitle, - AlertDialogTrigger, -} diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx deleted file mode 100644 index 8a6575458c..0000000000 --- a/apps/web/components/ui/alert.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva("grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert", { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - }, - }, - defaultVariants: { - variant: "default", - }, -}) - -function Alert({ - className, - variant, - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3", - className - )} - {...props} - /> - ) -} - -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AlertAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/apps/web/components/ui/aspect-ratio.tsx b/apps/web/components/ui/aspect-ratio.tsx deleted file mode 100644 index 57e38fa96f..0000000000 --- a/apps/web/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client" - -import { AspectRatio as AspectRatioPrimitive } from "radix-ui" - -function AspectRatio({ - ...props -}: React.ComponentProps) { - return -} - -export { AspectRatio } diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx deleted file mode 100644 index 5ef47cd7c2..0000000000 --- a/apps/web/components/ui/avatar.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client" - -import * as React from "react" -import { Avatar as AvatarPrimitive } from "radix-ui" - -import { cn } from "@/lib/utils" - -function Avatar({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm" | "lg" -}) { - return ( - - ) -} - -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AvatarFallback({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { - return ( - svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", - className - )} - {...props} - /> - ) -} - -function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)} - {...props} - /> - ) -} - -export { - Avatar, - AvatarImage, - AvatarFallback, - AvatarGroup, - AvatarGroupCount, - AvatarBadge, -} diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx deleted file mode 100644 index 2e5b2cca4e..0000000000 --- a/apps/web/components/ui/badge.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-colors overflow-hidden group/badge", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", - destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", - outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", - ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", - link: "text-primary underline-offset-4 hover:underline", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -function Badge({ - className, - variant = "default", - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot.Root : "span" - - return ( - - ) -} - -export { Badge, badgeVariants } diff --git a/apps/web/components/ui/breadcrumb.tsx b/apps/web/components/ui/breadcrumb.tsx deleted file mode 100644 index 71bad761c8..0000000000 --- a/apps/web/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import * as React from "react" -import { Slot } from "radix-ui" - -import { cn } from "@/lib/utils" -import { IconChevronRight, IconDots } from "@tabler/icons-react" - -function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { - return ( -