SPB Git

Repository Public

workspace.tsx 5.1 KB 119 lines tsx

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
Files main
Menu

Theme