Repository Public
1'use client';2import { useEffect, useRef } from 'react';3import dynamic from 'next/dynamic';4import { useRouter, useSearchParams } from 'next/navigation';5import { Group, Panel, Separator } from 'react-resizable-panels';6import { useSessions } from '@/lib/queries';7import { useTerminals } from '@/stores/terminals';8import { useUi } from '@/stores/ui';9import { useCurrentProject, sortSessions } from '@/hooks/use-current';10import { useNow } from '@/hooks/use-viewport';11import { Button } from '@/components/ui/button';12import { EmptyState, Spinner } from '@/components/ui/misc';13import { SessionCard } from '@/components/home/sections';14import { StartView } from '@/components/home/start-view';15import { IllusTerminal } from '@/components/ui/illustrations';16import { EditorPane } from '@/components/editor/editor-pane';17import { RightPanel } from '@/components/shell/right-panel';18import { useBrand } from '@/stores/branding';1920export const TerminalView = dynamic(() => import('./terminal-view'), { ssr: false, loading: () => <div className="flex h-full items-center justify-center"><Spinner /></div> });2122/** Keeps `/terminal/?s=<id>` and the active tab in sync (both directions, without loops). */23export function useSessionUrlSync(): void {24 const params = useSearchParams();25 const router = useRouter();26 const param = params.get('s');27 const activeId = useTerminals((s) => s.activeId);28 const lastParam = useRef<string | null>(null);29 useEffect(() => {30 if (param && param !== lastParam.current && param !== useTerminals.getState().activeId) useTerminals.getState().open(param, true);31 lastParam.current = param;32 }, [param]);33 useEffect(() => {34 if (activeId && activeId !== lastParam.current) {35 lastParam.current = activeId;36 router.replace(`/terminal/?s=${activeId}`);37 } else if (!activeId && lastParam.current) {38 lastParam.current = null;39 router.replace('/terminal/');40 }41 }, [activeId, router]);42}4344/** Nothing open: the desktop gets the start view; phones get a compact prompt (Home is their dashboard). */45export function NoSession({ mobile }: { mobile?: boolean }) {46 const brand = useBrand();47 const sessions = useSessions();48 const project = useCurrentProject();49 const openNew = useUi((s) => s.openNewSession);50 const now = useNow(15_000);51 const list = sortSessions(sessions.data ?? []);52 if (!mobile) return <StartView />;53 return (54 <div className="flex h-full flex-col items-center overflow-y-auto px-5 py-8" data-testid="no-session">55 <EmptyState56 illustration={<IllusTerminal />}57 title={list.length ? 'No terminal open' : 'No sessions yet'}58 description={list.length ? 'Pick a session to attach to, or start a new one.' : `Start a ${brand.agentName} or shell session. It runs on the server and survives closing this app.`}59 action={<Button variant="brand" size="lg" onClick={() => openNew(project?.id ?? null)} data-testid="empty-new-session">New session</Button>}60 className="py-4"61 />62 {list.length > 0 && (63 <div className="mt-4 flex w-full max-w-md flex-col gap-2.5">64 {list.slice(0, 8).map((s) => (65 <SessionCard key={s.id} session={s} now={now} onOpen={() => useTerminals.getState().open(s.id, true)} />66 ))}67 </div>68 )}69 </div>70 );71}7273const sep = 'w-px bg-line transition-colors data-[separator=hover]:bg-accent data-[separator=active]:bg-accent focus-visible:bg-accent focus-visible:outline-none';7475export function DesktopWorkspace() {76 useSessionUrlSync();77 const { activeId, splitId, activate } = useTerminals();78 const { rightOpen, editorOpen, sizes, setSizes } = useUi();79 const groupKey = `ws:${editorOpen ? 'e' : ''}${rightOpen ? 'r' : ''}`;80 // Nothing open: the start view takes the whole workspace (no side panels around a dashboard).81 if (!activeId) return <StartView />;82 return (83 <Group84 key={groupKey}85 id={groupKey}86 orientation="horizontal"87 defaultLayout={sizes[groupKey]}88 onLayoutChanged={(layout) => setSizes(groupKey, layout)}89 className="h-full"90 >91 <Panel id="term" minSize="20%" defaultSize={editorOpen && rightOpen ? '45%' : editorOpen ? '50%' : rightOpen ? '74%' : '100%'}>92 {!activeId ? (93 <NoSession />94 ) : splitId ? (95 <Group id="split" orientation="horizontal" className="h-full">96 <Panel id="a" minSize="20%"><TerminalView key={activeId} sessionId={activeId} focused /></Panel>97 <Separator className={sep} />98 <Panel id="b" minSize="20%"><TerminalView key={splitId} sessionId={splitId} focused={false} /></Panel>99 </Group>100 ) : (101 <TerminalView key={activeId} sessionId={activeId} focused onFocusWithin={() => activate(activeId)} />102 )}103 </Panel>104 {editorOpen && (105 <>106 <Separator className={sep} />107 <Panel id="editor" minSize="15%" defaultSize={rightOpen ? '33%' : '50%'}><EditorPane /></Panel>108 </>109 )}110 {rightOpen && (111 <>112 <Separator className={sep} />113 <Panel id="right" minSize="14%" maxSize="50%" defaultSize={editorOpen ? '22%' : '26%'}><RightPanel /></Panel>114 </>115 )}116 </Group>117 );118}119