CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
index.tsx2258 linesDownload Raw Back to workspace
1'use client';2 3import React, { useCallback, useEffect, useRef, useMemo } from 'react';4import { Project, VirtualFile } from '@/lib/vfs/types';5import { vfs } from '@/lib/vfs';6import { logger } from '@/lib/utils';7import { FileExplorer } from '@/components/file-explorer';8import { MultiTabEditor, openFileInEditor } from '@/components/editor/multi-tab-editor';9import { MultipagePreview, MultipagePreviewHandle } from '@/components/preview/multipage-preview';10import { Button } from '@/components/ui/button';11import { ArrowLeft, MessageSquare, FolderTree, Code2, Eye, Settings, Save, Bug, RotateCcw, History, Settings2, Terminal as TerminalIcon, Sparkles, ChevronDown, ChevronUp, EllipsisVertical } from 'lucide-react';12import { AppHeader, HeaderAction } from '@/components/ui/app-header';13import { PendingImage, PendingAudio, PendingFile } from '@/lib/llm/multi-agent-orchestrator';14import { configManager, migrateBackendKey } from '@/lib/config/storage';15import { useWorkspaceStore } from '@/lib/stores/workspace';16import type { InterviewTemplate, InterviewHandoff } from '@/lib/interview/types';17import { track } from '@/lib/telemetry';18import { bucketInterviewTemplateId } from '@/lib/telemetry/tool-analytics';19import { PANEL_MAP } from '@/lib/stores/slices/layout';20import { useCostSettings } from '@/lib/hooks/use-cost-settings';21import { getModelInputModalities } from '@/lib/llm/providers/registry';22import { isProjectProviderReady } from '@/lib/llm/models/project-assignment';23import { resolveActiveAssignment } from '@/lib/llm/models/template-store';24import { toast } from 'sonner';25import { debugEventsState } from '@/lib/llm/debug-events-state';26import {27  ResizableHandle,28  ResizablePanel,29  ResizablePanelGroup,30} from '@/components/ui/resizable';31import {32  Tooltip,33  TooltipContent,34  TooltipProvider,35  TooltipTrigger,36} from '@/components/ui/tooltip';37import { checkpointManager } from '@/lib/vfs/checkpoint';38import { saveManager } from '@/lib/vfs/save-manager';39import {40  Popover,41  PopoverContent,42  PopoverTrigger,43} from '@/components/ui/popover';44import { SettingsPanel } from '@/components/settings';45import { GuidedTourOverlay } from '@/components/guided-tour/overlay';46import { useGuidedTour } from '@/components/guided-tour/context';47import { GuidedTourTranscriptEvent } from '@/components/guided-tour/types';48import { FocusContextPayload } from '@/lib/preview/types';49import type { PlacedBlock } from '@/lib/semantic-blocks/types';50import type { PlacementResult } from '@/lib/preview/types';51import { getBlockById } from '@/lib/semantic-blocks/registry';52import { DebugPanel } from '@/components/debug-panel';53import { ChatPanel } from '@/components/chat-panel';54import { DeploymentSelector } from '@/components/workspace/deployment-selector';55import { CheckpointPanel } from '@/components/checkpoint-panel';56import { ProjectSettingsModal } from '@/components/project-backend';57import { SkillsPanel } from '@/components/workspace/skills-panel';58import { PanelDragProvider } from '@/components/ui/panel';59import { ConsolePanel } from '@/components/console';60import { drainRuntimeErrors, peekRuntimeErrors, formatRuntimeErrors } from '@/lib/preview/runtime-errors';61 62interface WorkspaceProps {63  project: Project;64  onBack: () => void;65  workspaceId?: string;66}67 68type FocusTarget = FocusContextPayload & { timestamp: number };69 70export function Workspace({ project, onBack, workspaceId }: WorkspaceProps) {71  const refreshTrigger = useWorkspaceStore(s => s.refreshTrigger);72  const generating = useWorkspaceStore(s => s.generating);73  const debugEvents = useWorkspaceStore(s => s.debugEvents);74  const currentModel = useWorkspaceStore(s => s.currentModel);75  const modelConfigVersion = useWorkspaceStore(s => s.modelConfigVersion);76  const projectCost = useWorkspaceStore(s => s.projectCost);77  const addDebugEvent = useWorkspaceStore(s => s.addDebugEvent);78  const isDirty = useWorkspaceStore(s => s.isDirty);79  const saveInProgress = useWorkspaceStore(s => s.saveInProgress);80  const entryPoint = useWorkspaceStore(s => s.entryPoint);81  const projectRuntime = useWorkspaceStore(s => s.projectRuntime);82  const focusContext = useWorkspaceStore(s => s.focusContext);83  const mode = useWorkspaceStore(s => s.mode);84  const activeInterview = useWorkspaceStore(s => s.activeInterview);85  const runtimeErrors = useWorkspaceStore(s => s.runtimeErrors);86  const initialCheckpointId = useWorkspaceStore(s => s.initialCheckpointId);87  const checkpointRefreshKey = useWorkspaceStore(s => s.checkpointRefreshKey);88  const backendEnabled = useWorkspaceStore(s => s.backendEnabled);89  const selectedDeploymentId = useWorkspaceStore(s => s.selectedDeploymentId);90  const activeMobilePanel = useWorkspaceStore(s => s.activeMobilePanel);91  const mobileOverflowOpen = useWorkspaceStore(s => s.mobileOverflowOpen);92  const placedBlocks = useWorkspaceStore(s => s.placedBlocks);93  const paletteOpen = useWorkspaceStore(s => s.paletteOpen);94  const lastFocusSignatureRef = useRef<{ signature: string; timestamp: number } | null>(null);95  const previewRef = useRef<MultipagePreviewHandle>(null);96  const generatingRef = useRef(false);97  const handleGenerateRef = useRef<((promptText?: string, images?: PendingImage[], audio?: PendingAudio[], files?: PendingFile[]) => Promise<void>) | null>(null);98  const storeSetMode = useWorkspaceStore(s => s.setMode);99  const storeSetActiveInterview = useWorkspaceStore(s => s.setActiveInterview);100  const storeFocusContext = useWorkspaceStore(s => s.setFocusContext);101  const { state: tourState, setWorkspaceHandler } = useGuidedTour();102  const tourStep = tourState.currentStep?.id;103  const tourRunning = tourState.status === 'running';104  const isTourLockingInput = tourRunning && tourStep !== 'wrap-up';105 106  // Keep generatingRef in sync for runtime error listener107  useEffect(() => { generatingRef.current = generating; }, [generating]);108 109  // Guard against accidental navigation away with unsaved changes110  // During generation, let the user leave freely — the generation shelf handles status111  const guardedBack = useCallback(() => {112    if (!generating && isDirty) {113      if (!window.confirm('You have unsaved changes. Leave anyway?')) return;114    }115    onBack();116  }, [generating, isDirty, onBack]);117 118  // Reattach to any server-side tasks that were running before page reload119  useEffect(() => {120    useWorkspaceStore.getState().reattachServerTasks();121  }, []);122 123  // Browser beforeunload — warn when dirty or generating124  useEffect(() => {125    const handler = (e: BeforeUnloadEvent) => {126      if (useWorkspaceStore.getState().isAnyGenerating() || isDirty) {127        e.preventDefault();128      }129    };130    window.addEventListener('beforeunload', handler);131    return () => window.removeEventListener('beforeunload', handler);132  }, [isDirty]);133 134  // Subscribe to runtime errors that arrive after generation completes135  useEffect(() => {136    const handler = () => {137      if (!generatingRef.current) {138        useWorkspaceStore.getState().setRuntimeErrors(peekRuntimeErrors());139      }140    };141    window.addEventListener('runtimeErrorsChanged', handler);142    return () => window.removeEventListener('runtimeErrorsChanged', handler);143  }, []);144 145  // The reactive model-config signal bump (apiKeyUpdated + modelConfigChanged -> bumpModelConfig)146  // lives in the useModelConfigSignal hook, mounted at the always-present mode roots (StudioInner147  // and PageWrapperInner). Mounting it there (not here) makes modelConfigVersion bump globally so148  // ChatPanels rendered outside the Workspace subtree (describe-mode, project-manager) also react.149 150  // Listen for runtime changes from the CLI (e.g., LLM runs `runtime handlebars`)151  useEffect(() => {152    const handler = (e: Event) => {153      const runtime = (e as CustomEvent).detail?.runtime;154      if (runtime) useWorkspaceStore.getState().updateProjectSettings({ runtime });155    };156    window.addEventListener('runtimeChanged', handler);157    return () => window.removeEventListener('runtimeChanged', handler);158  }, []);159 160  // The GLOBAL model auto-assign on provider connect (apiKeyUpdated -> activateProviderAsGlobalDefault)161  // lives in the useProviderAutoAssign hook, mounted at the always-present mode roots (StudioInner and162  // PageWrapperInner). It cannot live here: the Connections UI is reachable outside a workspace163  // (dashboard -> Settings -> Connections), where Workspace is not mounted.164 165  // Get cost settings for conditional display166  const { shouldShowCosts } = useCostSettings();167 168  // Input modalities of the active agent model (drives image/voice affordances).169  // Resolve from the globally-resolved active assignment, falling back to the170  // global selectedProvider/currentModel.171  const inputModalities = useMemo(() => {172    const agent = resolveActiveAssignment().agent;173    const currentProvider = agent?.provider ?? configManager.getSelectedProvider();174    const modelId = agent?.model || currentModel || configManager.getDefaultModel();175 176    // Check cached discovered models first (has accurate modality data from API)177    const cached = configManager.getCachedModels(currentProvider);178    if (cached) {179      const model = (cached.models as import('@/lib/llm/providers/types').ProviderModel[])180        .find(m => m.id === modelId);181      if (model?.inputModalities) return model.inputModalities;182      if (model?.supportsVision !== undefined) {183        return model.supportsVision ? ['text' as const, 'image' as const] : ['text' as const];184      }185    }186 187    return getModelInputModalities(currentProvider, modelId);188  }, [modelConfigVersion, currentModel]);189 190  const supportsVision = inputModalities.includes('image');191 192  // Readiness keys off the globally-resolved active agent provider193  // (see isProjectProviderReady), recomputed on modelConfigVersion bumps.194  const providerReady = useMemo(195    () => isProjectProviderReady(),196    [modelConfigVersion],197  );198  199  // Console panel — visible by default for terminal-mode runtimes (Python, Lua), togglable for all200 201  const showChat = useWorkspaceStore(s => s.showChat);202  const showFiles = useWorkspaceStore(s => s.showFiles);203  const showEditor = useWorkspaceStore(s => s.showEditor);204  const showPreview = useWorkspaceStore(s => s.showPreview);205  const showCheckpoints = useWorkspaceStore(s => s.showCheckpoints);206  const showDebugPanel = useWorkspaceStore(s => s.showDebugPanel);207  const showProjectSettingsModal = useWorkspaceStore(s => s.showProjectSettingsModal);208  const showSkillsPanel = useWorkspaceStore(s => s.showSkillsPanel);209  const showConsole = useWorkspaceStore(s => s.showConsole);210  const fullscreenPreview = useWorkspaceStore(s => s.fullscreenPreview);211  const panelReplacePreview = useWorkspaceStore(s => s.panelReplacePreview);212  const panelInsertPreview = useWorkspaceStore(s => s.panelInsertPreview);213  const hasUnreadConsole = useWorkspaceStore(s => s.hasUnreadConsole);214  // Ref to imperatively reset panel sizes after reorder215  const panelGroupRef = useRef<import('react-resizable-panels').ImperativePanelGroupHandle | null>(null);216 217  const panelOrder = useWorkspaceStore(s => s.panelOrder);218  const draggingPanel = useWorkspaceStore(s => s.draggingPanel);219  const dropTarget = useWorkspaceStore(s => s.dropTarget);220 221  const handlePanelDragStart = useCallback((panelKey: string) => {222    // Capture the dragged panel's center X for "stay put" distance comparison223    const container = panelContainerRef.current;224    if (container) {225      const panelEl = container.querySelector(`[data-panel-id="${panelKey}"]`);226      if (panelEl) {227        const rect = panelEl.getBoundingClientRect();228        draggedPanelCenter.current = rect.left + rect.width / 2;229      }230    }231    document.body.style.cursor = 'grabbing';232    useWorkspaceStore.getState().startDrag(panelKey);233  }, []);234 235  const handlePanelDragEnd = useCallback(() => {236    if (draggingPanel && dropTarget !== null) {237      // Capture current sizes keyed by panel identity before reordering238      const visibleBefore = panelOrder.filter(k => {239        if (k === 'chat') return showChat;240        if (k === 'files') return showFiles;241        if (k === 'editor') return showEditor;242        if (k === 'console') return showConsole;243        if (k === 'preview') return showPreview;244        if (k === 'checkpoints') return showCheckpoints;245        if (k === 'debug') return showDebugPanel;246        if (k === 'skills') return showSkillsPanel;247        return false;248      });249      const currentLayout = panelGroupRef.current?.getLayout() || [];250      const sizeByKey: Record<string, number> = {};251      visibleBefore.forEach((key, i) => {252        if (i < currentLayout.length) sizeByKey[key] = currentLayout[i];253      });254 255      {256        const prevOrder = panelOrder;257        const newOrder = prevOrder.filter(k => k !== draggingPanel);258        const targetKey = visibleBefore[dropTarget];259        if (targetKey) {260          const insertIdx = newOrder.indexOf(targetKey);261          newOrder.splice(insertIdx, 0, draggingPanel);262        } else {263          newOrder.push(draggingPanel);264        }265        useWorkspaceStore.getState().setPanelOrder(newOrder);266      }267 268      // Restore sizes in the new order after React re-renders269      requestAnimationFrame(() => {270        if (panelGroupRef.current && visibleBefore.length > 0) {271          // Compute new visible order272          const newVisible = [...visibleBefore];273          const dragIdx = newVisible.indexOf(draggingPanel);274          if (dragIdx >= 0) newVisible.splice(dragIdx, 1);275          const targetKey = visibleBefore[dropTarget];276          const insertIdx = targetKey ? newVisible.indexOf(targetKey) : newVisible.length;277          newVisible.splice(insertIdx >= 0 ? insertIdx : newVisible.length, 0, draggingPanel);278 279          const sizes = newVisible.map(k => sizeByKey[k] || Math.floor(100 / newVisible.length));280          // Normalize to exactly 100281          const total = sizes.reduce((a, b) => a + b, 0);282          if (total !== 100 && sizes.length > 0) {283            sizes[sizes.length - 1] += 100 - total;284          }285          panelGroupRef.current.setLayout(sizes);286        }287      });288    }289    useWorkspaceStore.getState().endDrag();290    draggedPanelCenter.current = null;291    document.body.style.cursor = '';292  }, [draggingPanel, dropTarget, panelOrder, showChat, showFiles, showEditor, showConsole, showPreview, showCheckpoints, showDebugPanel, showSkillsPanel]);293 294  // Document-level mouseup listener — ends drag whether inside or outside the container.295  // If mouseUp is inside the container, the container's own onMouseUp handles it (with drop logic).296  // If mouseUp is outside, this fires and cancels the drag.297  useEffect(() => {298    if (!draggingPanel) return;299    const handleDocumentMouseUp = () => {300      // Only cancel if still dragging — the container's onMouseUp may have already handled it301      if (useWorkspaceStore.getState().draggingPanel) {302        useWorkspaceStore.getState().endDrag();303        draggedPanelCenter.current = null;304        document.body.style.cursor = '';305      }306    };307    document.addEventListener('mouseup', handleDocumentMouseUp);308    return () => document.removeEventListener('mouseup', handleDocumentMouseUp);309  }, [draggingPanel]);310 311  // During drag: track mouse X and find closest drop zone312  const panelContainerRef = useRef<HTMLDivElement>(null);313  const dropZonePositions = useRef<Map<number, number>>(new Map()); // position index → X center314  const draggedPanelCenter = useRef<number | null>(null); // X center of the panel being dragged315 316  const registerDropZone = useCallback((position: number, el: HTMLDivElement | null) => {317    if (el) {318      const rect = el.getBoundingClientRect();319      dropZonePositions.current.set(position, rect.left + rect.width / 2);320    } else {321      dropZonePositions.current.delete(position);322    }323  }, []);324 325  const handleDragMouseMove = useCallback((e: React.MouseEvent) => {326    if (!draggingPanel) return;327    const positions = dropZonePositions.current;328    if (positions.size === 0) return;329 330    // Check distance to each drop zone331    let closest: number | null = null;332    let closestDist = Infinity;333    for (const [pos, x] of positions) {334      const dist = Math.abs(e.clientX - x);335      if (dist < closestDist) {336        closestDist = dist;337        closest = pos;338      }339    }340 341    // If the mouse is closer to the dragged panel's own center, stay put (no move)342    if (draggedPanelCenter.current !== null) {343      const distToSelf = Math.abs(e.clientX - draggedPanelCenter.current);344      if (distToSelf <= closestDist) {345        useWorkspaceStore.getState().setDropTarget(null);346        return;347      }348    }349 350    useWorkspaceStore.getState().setDropTarget(closest);351  }, [draggingPanel]);352 353  const togglePanel = useWorkspaceStore(s => s.togglePanel);354 355  const handleSidebarHover = useCallback((key: string | null) => {356    const store = useWorkspaceStore.getState();357    if (!key) { store.setPanelReplacePreview(null); store.setPanelInsertPreview(null); return; }358    const panelStateKey = PANEL_MAP[key];359    if (!panelStateKey || store[panelStateKey]) { store.setPanelReplacePreview(null); store.setPanelInsertPreview(null); return; }360    const allPanels = store.panelOrder361      .filter(k => PANEL_MAP[k] !== undefined)362      .map(k => ({ key: k, open: !!store[PANEL_MAP[k]] }));363    const visibleCount = allPanels.filter(p => p.open).length;364    const MAX_VISIBLE_PANELS = 3;365    if (visibleCount < MAX_VISIBLE_PANELS) {366      store.setPanelInsertPreview(visibleCount);367      store.setPanelReplacePreview(null);368      return;369    }370    store.setPanelInsertPreview(null);371    for (let i = allPanels.length - 1; i >= 0; i--) {372      if (allPanels[i].open && allPanels[i].key !== key) {373        store.setPanelReplacePreview(allPanels[i].key);374        return;375      }376    }377    store.setPanelReplacePreview(null);378  }, []);379 380  const consoleBufferRef = useRef<{ level: string; text: string }[]>([]);381  const showConsoleRef = useRef(showConsole);382 383  // Keep showConsoleRef in sync for buffering logic (tracks both desktop and mobile)384  useEffect(() => {385    showConsoleRef.current = showConsole || activeMobilePanel === 'console';386  }, [showConsole, activeMobilePanel]);387 388  // Buffer previewConsole events when console is hidden389  useEffect(() => {390    const handler = (e: Event) => {391      if (!showConsoleRef.current) {392        const { level, args } = (e as CustomEvent<{ level: string; args: string[] }>).detail;393        consoleBufferRef.current.push({ level, text: args.join(' ') });394        useWorkspaceStore.getState().setHasUnreadConsole(true);395      }396    };397    window.addEventListener('previewConsole', handler);398    return () => window.removeEventListener('previewConsole', handler);399  }, []);400 401  // Clear unread flag when console opens (desktop or mobile)402  useEffect(() => {403    if (showConsole || activeMobilePanel === 'console') {404      useWorkspaceStore.getState().setHasUnreadConsole(false);405    }406  }, [showConsole, activeMobilePanel]);407 408  const clearDebugEvents = useCallback(async () => {409    await useWorkspaceStore.getState().clearChat(project.id);410    // Clear auto-checkpoints when conversation is cleared (keep manual saves)411    await checkpointManager.clearAutoCheckpoints(project.id);412    // Clearing the chat also ends any active interview (returns to the picker).413    // Note: this counts as abandoned even if the interview had already completed414    // and the user is just clearing up afterwards; acceptable minor over-count.415    const endingInterview = useWorkspaceStore.getState().activeInterview;416    if (endingInterview) {417      track('interview_abandoned', { template: bucketInterviewTemplateId(endingInterview.templateId) });418    }419    useWorkspaceStore.getState().setActiveInterview(null);420  }, [project.id]);421  422  const visiblePanelCount = [showChat, showFiles, showEditor, showConsole, showPreview, showCheckpoints, showDebugPanel, showSkillsPanel].filter(Boolean).length;423  const baseSize = visiblePanelCount > 0 ? Math.floor(100 / visiblePanelCount) : 100;424 425  const getModelDisplayName = (modelId: string) => {426    if (!modelId) return 'Select Model';427    const parts = modelId.split('/');428    const modelName = parts[parts.length - 1];429    return modelName430      .split('-')431      .map(part => part.charAt(0).toUpperCase() + part.slice(1))432      .join(' ');433  };434 435  const truncateHtmlSnippet = useCallback((html: string, maxLength: number = 1200) => {436    if (!html) {437      return '';438    }439    if (html.length <= maxLength) {440      return html;441    }442    const headLength = Math.max(0, Math.floor(maxLength * 0.6));443    const tailLength = Math.max(0, Math.floor(maxLength * 0.3));444    const head = html.slice(0, headLength);445    const tail = tailLength > 0 ? html.slice(-tailLength) : '';446    return `${head}\n  (...truncated...)\n${tail}`;447  }, []);448 449  // Truncate an HTML snippet so the given marker comment stays visible with450  // surrounding context on both sides. Used for semantic block drops where the451  // marker's position is the whole point of the snippet — head/tail truncation452  // would drop the marker whenever it falls in the middle of a large parent.453  const truncateHtmlAroundMarker = useCallback((html: string, marker: string, maxLength: number = 1200) => {454    if (!html) return '';455    if (html.length <= maxLength) return html;456    const markerIdx = html.indexOf(marker);457    if (markerIdx === -1) {458      // Fall back to head/tail when the marker isn't present459      const headLength = Math.max(0, Math.floor(maxLength * 0.6));460      const tailLength = Math.max(0, Math.floor(maxLength * 0.3));461      const head = html.slice(0, headLength);462      const tail = tailLength > 0 ? html.slice(-tailLength) : '';463      return `${head}\n  (...truncated...)\n${tail}`;464    }465    const half = Math.max(0, Math.floor((maxLength - marker.length) / 2));466    const start = Math.max(0, markerIdx - half);467    const end = Math.min(html.length, markerIdx + marker.length + half);468    const prefix = start > 0 ? '(...truncated...)\n' : '';469    const suffix = end < html.length ? '\n(...truncated...)' : '';470    return `${prefix}${html.slice(start, end)}${suffix}`;471  }, []);472 473  const describeFocusTarget = useCallback((target: FocusTarget) => {474    const attributeEntries = Object.entries(target.attributes || {}).slice(0, 6);475    if (attributeEntries.length === 0) {476      return `<${target.tagName}>`;477    }478    const summary = attributeEntries479      .map(([key, value]) => {480        const trimmed = value.length > 40 ? `${value.slice(0, 37)}…` : value;481        return `${key}="${trimmed}"`;482      })483      .join(' ');484    return `<${target.tagName} ${summary}>`;485  }, []);486 487  const formatFocusContextBlock = useCallback((target: FocusTarget) => {488    const descriptor = describeFocusTarget(target);489    const snippet = truncateHtmlSnippet(target.outerHTML, 1200);490    const domPath = target.domPath || '(unknown path)';491    return [492      'Focus context:',493      `- Target: ${descriptor}`,494      `- DOM path: ${domPath}`,495      '- HTML snippet:',496      '```html',497      snippet,498      '```'499    ].join('\n');500  }, [describeFocusTarget, truncateHtmlSnippet]);501 502  const formatPlacedBlocksContext = useCallback((blocks: PlacedBlock[]) => {503    if (blocks.length === 0) return '';504    const lines = [505      'Semantic blocks to implement:',506      'The user has placed the following semantic blocks at specific positions in the preview. Each block\'s HTML context below contains an HTML comment marker of the form `<!-- INSERT <block name> HERE -->` at the exact drop position — implement the block at that location. The user chose this position intentionally, so honor it precisely; adapt the block\'s layout and content to fit the surrounding context, and match the existing project\'s styling, colors, fonts, and conventions. Use placeholder/sample content where needed.',507      '',508    ];509    blocks.forEach((placed, index) => {510      const block = getBlockById(placed.blockId);511      if (!block) return;512      lines.push(`[${index + 1}] ${block.name} (page: ${placed.page})`);513      lines.push(`    Description: ${block.description}`);514      if (placed.htmlContext) {515        const marker = `<!-- INSERT ${block.name} HERE -->`;516        const snippet = truncateHtmlAroundMarker(placed.htmlContext, marker, 1200);517        lines.push(`    Insert position in context (look for \`${marker}\`):`);518        lines.push('    ```html');519        lines.push(`    ${snippet}`);520        lines.push('    ```');521      } else {522        lines.push(`    Position: insert ${placed.position} ${placed.domPath}`);523      }524      lines.push('');525    });526    return lines.join('\n');527  }, [truncateHtmlAroundMarker]);528 529  const handleFocusSelection = useCallback((selection: FocusContextPayload | null) => {530    if (!selection) {531      useWorkspaceStore.getState().setFocusContext(null);532      lastFocusSignatureRef.current = null;533      return;534    }535    const signature = `${selection.domPath || ''}::${selection.tagName || ''}::${selection.outerHTML ? selection.outerHTML.length : 0}`;536    const now = Date.now();537    if (lastFocusSignatureRef.current && lastFocusSignatureRef.current.signature === signature && (now - lastFocusSignatureRef.current.timestamp) < 400) {538      return;539    }540    const nextTarget: FocusTarget = {541      ...selection,542      timestamp: now543    };544    useWorkspaceStore.getState().setFocusContext(nextTarget);545    toast.info('Focus context set', {546      description: describeFocusTarget(nextTarget)547    });548    lastFocusSignatureRef.current = { signature, timestamp: now };549  }, [describeFocusTarget]);550 551  const handlePlacementToggle = useCallback(() => {552    useWorkspaceStore.getState().setPaletteOpen(!useWorkspaceStore.getState().paletteOpen);553  }, []);554 555  const handlePlacementComplete = useCallback((payload: PlacementResult) => {556    const currentPage = previewRef.current?.getActivePath?.() || '/';557    useWorkspaceStore.setState(s => ({ placedBlocks: [...s.placedBlocks, {558      blockId: payload.blockId,559      placementId: payload.placementId,560      domPath: payload.domPath,561      position: payload.position,562      page: currentPage,563      htmlContext: payload.htmlContext,564    }] }));565  }, []);566 567  const handleRemovePlacedBlock = useCallback((placementId: string) => {568    useWorkspaceStore.setState(s => ({ placedBlocks: s.placedBlocks.filter(b => b.placementId !== placementId) }));569    previewRef.current?.removePlaceholder(placementId);570  }, []);571 572  const handleClearPlacedBlocks = useCallback(() => {573    placedBlocks.forEach(b => previewRef.current?.removePlaceholder(b.placementId));574    useWorkspaceStore.setState({ placedBlocks: [] });575  }, [placedBlocks]);576 577  const handleClosePreview = useCallback(() => {578    useWorkspaceStore.getState().togglePanel('preview');579  }, []);580 581  const handleEnterFullscreen = useCallback(() => {582    useWorkspaceStore.getState().setFullscreenPreview(true);583  }, []);584 585  const handleExitFullscreen = useCallback(() => {586    useWorkspaceStore.getState().setFullscreenPreview(false);587  }, []);588 589  // Listen for showPreview event (dispatched by AI preview command).590  // Only opens the panel — never toggles it closed if already open.591  const showPreviewRef = useRef(showPreview);592  showPreviewRef.current = showPreview;593  useEffect(() => {594    const handler = () => {595      if (!showPreviewRef.current) togglePanel('preview');596    };597    window.addEventListener('showPreview', handler);598    return () => window.removeEventListener('showPreview', handler);599  }, [togglePanel]);600 601  const handleSetEntryPoint = useCallback(async (path: string) => {602    try {603      const proj = await vfs.getProject(project.id);604      proj.settings = { ...proj.settings, previewEntryPoint: path };605      await vfs.updateProject(proj);606      useWorkspaceStore.getState().updateProjectSettings({ previewEntryPoint: path });607      toast.success(`Entry point set to ${path}`);608    } catch (err) {609      logger.error('Failed to set entry point:', err);610      toast.error('Failed to set entry point');611    }612  }, [project.id]);613 614  const handleAddPromptFile = useCallback(async () => {615    try {616      const { getDomainPrompt } = await import('@/lib/llm/prompts');617      const runtime = projectRuntime || 'handlebars';618      await vfs.createFile(project.id, '/.PROMPT.md', getDomainPrompt(runtime));619      window.dispatchEvent(new CustomEvent('filesChanged', { detail: { projectId: project.id } }));620      toast.success('.PROMPT.md added to project');621    } catch (err) {622      logger.error('Failed to add .PROMPT.md:', err);623      toast.error('Failed to add .PROMPT.md');624    }625  }, [project.id, projectRuntime]);626 627  const focusPreviewSnippet = focusContext ? truncateHtmlSnippet(focusContext.outerHTML, 240) : '';628 629  useEffect(() => {630    const dirty = saveManager.isDirty(project.id);631    if (dirty) useWorkspaceStore.getState().markDirty();632    else useWorkspaceStore.getState().markClean();633    const unsubscribe = saveManager.subscribe(({ projectId, dirty: d }) => {634      if (projectId === project.id) {635        if (d) useWorkspaceStore.getState().markDirty();636        else useWorkspaceStore.getState().markClean();637      }638    });639    return () => unsubscribe();640  }, [project.id]);641 642  useEffect(() => {643    let isMounted = true;644 645    const initializeWorkspace = async () => {646      // Dismiss immediately so the generation shelf clears even if async init is slow647      useWorkspaceStore.getState().dismissGenerationResult(project.id);648 649      try {650        // In server mode, check if server has newer version before loading651        if (process.env.NEXT_PUBLIC_SERVER_MODE === 'true') {652          try {653            const { checkServerUpdates, pullServerUpdates, setAutoSyncWorkspaceId } = await import('@/lib/vfs/auto-sync');654            if (workspaceId) {655              setAutoSyncWorkspaceId(workspaceId);656            }657            const hasUpdates = await checkServerUpdates(project.id);658            if (hasUpdates) {659              await pullServerUpdates(project.id, false);660              logger.debug(`[Workspace] Pulled server updates for project ${project.id}`);661            }662          } catch (syncErr) {663            logger.warn('[Workspace] Server check failed, using local state:', syncErr);664          }665        }666 667        // Skip checkpoint restore if an orchestrator session is active668        // (generation ran or is running while the workspace was unmounted)669        if (!useWorkspaceStore.getState().isProjectGenerating(project.id)) {670          await saveManager.syncProjectSaveState(project.id);671          const savedCheckpointId = saveManager.getSavedCheckpointId(project.id);672 673          if (savedCheckpointId) {674            const exists = await checkpointManager.checkpointExists(savedCheckpointId);675            if (exists) {676              const restored = await saveManager.restoreLastSaved(project.id);677              if (restored) {678                if (!isMounted) return;679                useWorkspaceStore.setState({ initialCheckpointId: savedCheckpointId });680              }681            } else {682              // Stale reference — checkpoint was pruned or deleted683              const proj = await vfs.getProject(project.id);684              proj.lastSavedCheckpointId = null;685              await vfs.updateProject(proj);686            }687          }688 689          if (!isMounted) return;690        }691 692        const latestProject = await vfs.getProject(project.id);693        if (!isMounted) return;694        useWorkspaceStore.getState().initProject(latestProject);695        if (saveManager.isDirty(project.id)) useWorkspaceStore.getState().markDirty();696 697        // Ensure a starting-point checkpoint exists so the user can always roll back698        if (!latestProject.lastSavedCheckpointId) {699          try {700            const cp = await checkpointManager.createCheckpoint(project.id, 'Project opened', { kind: 'auto' });701            latestProject.lastSavedCheckpointId = cp.id;702            await vfs.updateProject(latestProject);703            useWorkspaceStore.setState({ initialCheckpointId: cp.id });704          } catch { /* non-fatal */ }705        }706        // Initialize mode from localStorage (migrating the legacy chat-mode key)707        if (typeof window !== 'undefined') {708          const storedMode = localStorage.getItem('osw-studio-mode');709          if (storedMode === 'chat' || storedMode === 'interview' || storedMode === 'code') {710            useWorkspaceStore.setState({ mode: storedMode });711          } else if (localStorage.getItem('osw-studio-chat-mode') === 'true') {712            useWorkspaceStore.setState({ mode: 'chat' });713            localStorage.setItem('osw-studio-mode', 'chat');714          }715        }716        // Initialize the active interview from localStorage (per project)717        if (typeof window !== 'undefined') {718          let restored = null;719          const stored = localStorage.getItem(`osw-interview-${project.id}`);720          if (stored) {721            try {722              const parsed = JSON.parse(stored);723              if (parsed && typeof parsed.templateId === 'string' && typeof parsed.title === 'string') {724                restored = { templateId: parsed.templateId, title: parsed.title };725              }726            } catch { /* ignore malformed */ }727          }728          useWorkspaceStore.setState({ activeInterview: restored });729        }730        // Initialize backendEnabled from localStorage731        if (migrateBackendKey(project.id)) {732          useWorkspaceStore.getState().setBackendEnabled(true);733        }734 735        logger.debug(`[Workspace] Initializing workspace for project: ${project.id}`);736 737        // Initialize store persistence and load debug events738        useWorkspaceStore.getState().initPersistence(project.id);739        useWorkspaceStore.getState().initLayout();740        useWorkspaceStore.getState().setCurrentModel(configManager.getDefaultModel());741        try {742          await useWorkspaceStore.getState().loadDebugEvents(project.id);743          if (!isMounted) return;744        } catch (error) {745          if (!isMounted) return;746          logger.error('Failed to load debug events:', error);747        }748        useWorkspaceStore.setState({ workspaceReady: true });749      } catch (error) {750        if (!isMounted) return;751        logger.error('Failed to initialize workspace:', error);752        useWorkspaceStore.setState({ workspaceReady: true });753      }754    };755 756    initializeWorkspace();757 758    const updateProjectCost = async () => {759      try {760        const currentProject = await vfs.getProject(project.id);761        if (!isMounted) return;762        if (currentProject?.costTracking?.totalCost) {763          useWorkspaceStore.getState().setProjectCost(currentProject.costTracking.totalCost);764        } else {765          useWorkspaceStore.getState().setProjectCost(0);766        }767      } catch {768        if (!isMounted) return;769        useWorkspaceStore.getState().setProjectCost(0);770      }771    };772 773    updateProjectCost();774    const interval = setInterval(updateProjectCost, 2000);775 776    return () => {777      isMounted = false;778      clearInterval(interval);779    };780  }, [project.id]);781 782  useEffect(() => {783    if (!tourRunning) return;784 785    if (tourStep === 'provider-settings') {786      if (typeof window !== 'undefined') {787        window.dispatchEvent(new CustomEvent('tour-open-provider-settings'));788      }789    }790  }, [tourRunning, tourStep]);791 792  useEffect(() => {793    if (!tourRunning) {794      setWorkspaceHandler(null);795      return;796    }797 798    const handler = async (event: GuidedTourTranscriptEvent) => {799      // Convert tour events to debug events for ChatPanel display800      if (event.role === 'clear' && event.action === 'conversation') {801        await clearDebugEvents();802        return;803      }804 805      if (event.role === 'user') {806        // User message → conversation_message event807        await addDebugEvent('conversation_message', {808          message: {809            role: 'user',810            content: event.content811          }812        });813      } else if (event.role === 'assistant') {814        // Assistant message → conversation_message event815        const message: any = {816          role: 'assistant',817          content: event.content818        };819 820        // Store checkpoint ID in UI metadata if present821        if (event.checkpointId) {822          message.ui_metadata = {823            checkpointId: event.checkpointId824          };825        }826 827        await addDebugEvent('conversation_message', { message });828 829        // Emit checkpoint_created event if checkpoint ID is present830        if (event.checkpointId) {831          await addDebugEvent('checkpoint_created', {832            checkpointId: event.checkpointId,833            description: `Tour checkpoint: ${event.content.substring(0, 60)}`834          });835        }836      } else if (event.role === 'tool') {837        // Tool call → simulate tool execution sequence838        // 1. Tool call initiated839        const toolCall = {840          id: `tour-tool-${Date.now()}`,841          function: {842            name: event.name,843            arguments: JSON.stringify({ command: event.command })844          }845        };846 847        await addDebugEvent('toolCalls', {848          toolCalls: [toolCall]849        });850 851        // 2. Tool status (executing)852        await addDebugEvent('tool_status', {853          toolId: toolCall.id,854          name: event.name,855          status: 'executing'856        });857 858        // 3. Tool result859        await addDebugEvent('tool_result', {860          toolId: toolCall.id,861          name: event.name,862          result: event.output,863          status: 'completed'864        });865 866        // 4. Tool message in conversation867        await addDebugEvent('conversation_message', {868          message: {869            role: 'tool',870            content: event.output,871            tool_call_id: toolCall.id872          }873        });874      }875    };876 877    setWorkspaceHandler(handler);878 879    return () => {880      setWorkspaceHandler(null);881    };882  }, [tourRunning, tourStep, tourState.isBusy, setWorkspaceHandler, clearDebugEvents, addDebugEvent]);883 884  // Clear orchestrator when project changes (chatMode changes handled inside setChatMode action)885  useEffect(() => {886    if (!useWorkspaceStore.getState().generating) {887      useWorkspaceStore.getState().resetOrchestrator();888    }889  }, [project.id]);890 891  // Auto-mount/unmount project backend context based on enabled toggle892  useEffect(() => {893    let cancelled = false;894    if (process.env.NEXT_PUBLIC_SERVER_MODE === 'true' && backendEnabled) {895      vfs.mountProjectBackendContext(project.id).then(() => {896        if (!cancelled) {897          useWorkspaceStore.getState().bumpRefreshTrigger();898        }899      });900    } else {901      vfs.unmountBackendContext();902      useWorkspaceStore.getState().bumpRefreshTrigger();903    }904    return () => { cancelled = true; };905  }, [project.id, backendEnabled]);906 907  // MEMORY CLEANUP: Unload project data from singletons when leaving the workspace908  // This prevents memory accumulation across project switches909  useEffect(() => {910    const projectId = project.id;911 912    return () => {913      const thisProjectGenerating = useWorkspaceStore.getState().isProjectGenerating(projectId);914 915      // Stash foreground events to background buffer before clearing, so the916      // generation shelf and re-entry can access them.917      if (thisProjectGenerating) {918        useWorkspaceStore.getState().stashForegroundEvents(projectId);919      }920 921      // Clear caches only if not generating (data still needed for active generation)922      if (!thisProjectGenerating) {923        checkpointManager.unloadProject(projectId);924        debugEventsState.unloadProject(projectId);925      }926 927      // Clean up store persistence only if not generating (debounce timer still needed)928      if (!thisProjectGenerating) {929        useWorkspaceStore.getState().cleanupPersistence();930        useWorkspaceStore.getState().resetOrchestrator();931        useWorkspaceStore.getState().clearDebugEvents();932      }933      useWorkspaceStore.getState().resetLayout();934 935      // Flush any pending sync for this project before leaving936      vfs.flushSyncTimeout(projectId);937 938      // Unmount backend context when leaving workspace939      vfs.unmountBackendContext();940 941      // Reset project slice state942      if (!thisProjectGenerating) {943        useWorkspaceStore.getState().resetProject();944      } else {945        // During generation, still reset workspaceReady so the next project shows loading spinners946        useWorkspaceStore.setState({ workspaceReady: false });947      }948 949      logger.debug(`[Workspace] Cleaned up memory for project ${projectId}`);950    };951  }, [project.id]);952 953  // Handle deployment selection change - mount/unmount backend context954  const handleDeploymentChange = useCallback(async (deploymentId: string | null, deploymentName: string | null) => {955    useWorkspaceStore.getState().setDeployment(deploymentId);956 957    // Reset orchestrator so it picks up new backend context on next message958    useWorkspaceStore.getState().resetOrchestrator();959 960    if (deploymentId && deploymentName) {961      await vfs.mountDeploymentRuntimeContext(deploymentId);962      logger.info(`[Workspace] Connected deployment runtime: ${deploymentName}`);963    } else {964      vfs.unmountDeploymentRuntimeContext();965      logger.info('[Workspace] Disconnected deployment runtime');966    }967 968    // Refresh file tree969    useWorkspaceStore.getState().bumpRefreshTrigger();970  }, []);971 972  // Handle backend toggle973  const handleBackendToggle = useCallback((enabled: boolean) => {974    useWorkspaceStore.getState().setBackendEnabled(enabled);975  }, []);976 977  // Handle project settings updates (runtime, entry point)978  const handleProjectSettingsUpdate = useCallback((updated: Project) => {979    useWorkspaceStore.getState().updateProjectSettings({980      runtime: updated.settings?.runtime,981      previewEntryPoint: updated.settings?.previewEntryPoint,982    });983  }, []);984 985  const handleFileSelect = useCallback((file: VirtualFile) => {986    // Check if we're on mobile (matches Tailwind's md breakpoint)987    const isMobile = window.innerWidth < 768;988 989    if (isMobile) {990      // On mobile, switch to editor panel and open file991      useWorkspaceStore.getState().setActiveMobilePanel('editor');992      setTimeout(() => {993        openFileInEditor(file);994      }, 0);995    } else {996      // Desktop behavior remains the same997      if (!useWorkspaceStore.getState().showEditor) {998        useWorkspaceStore.getState().togglePanel('editor');999        setTimeout(() => {1000          openFileInEditor(file);1001        }, 0);1002      } else {1003        openFileInEditor(file);1004      }1005    }1006  }, []);1007 1008  const handleFilesChange = useCallback(() => {1009    useWorkspaceStore.getState().bumpRefreshTrigger();1010  }, []);1011 1012  const handleSave = useCallback(async () => {1013    if (useWorkspaceStore.getState().saveInProgress) {1014      return;1015    }1016 1017    useWorkspaceStore.setState({ saveInProgress: true });1018    try {1019      const checkpoint = await saveManager.save(project.id);1020      const latestProject = await vfs.getProject(project.id);1021 1022      useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? new Date(checkpoint.timestamp) });1023      useWorkspaceStore.getState().incrementCheckpointRefresh();1024      toast.success('Project saved');1025    } catch (error) {1026      logger.error('Failed to save project', error);1027      toast.error('Failed to save project');1028    } finally {1029      useWorkspaceStore.setState({ saveInProgress: false });1030    }1031 1032  }, [project.id]);1033 1034  const handleCaptureScreenshot = useCallback(async (screenshot: string) => {1035    try {1036      const proj = await vfs.getProject(project.id);1037      proj.previewImage = screenshot;1038      proj.previewUpdatedAt = new Date();1039      await vfs.updateProject(proj);1040      toast.success('Thumbnail updated');1041    } catch (err) {1042      logger.error('Failed to save screenshot:', err);1043      toast.error('Failed to save thumbnail');1044    }1045  }, [project.id]);1046 1047  useEffect(() => {1048    const handleKeyDown = (event: KeyboardEvent) => {1049      const isMac = navigator.platform?.toLowerCase().includes('mac');1050      const modifierPressed = isMac ? event.metaKey : event.ctrlKey;1051      if (!modifierPressed) return;1052 1053      if (event.key.toLowerCase() === 's') {1054        // Check if Monaco editor has focus - if so, let Monaco handle the save1055        const activeElement = document.activeElement;1056        const isMonacoFocused = activeElement?.closest('.monaco-editor') !== null;1057 1058        if (isMonacoFocused) {1059          // Monaco editor will handle file save1060          return;1061        }1062 1063        // Otherwise, save the project1064        event.preventDefault();1065        handleSave();1066      }1067    };1068 1069    window.addEventListener('keydown', handleKeyDown);1070    return () => window.removeEventListener('keydown', handleKeyDown);1071  }, [handleSave]);1072 1073  const handleRestoreCheckpoint = useCallback(async (checkpointId: string, description?: string, options?: { isDiscard?: boolean }) => {1074    try {1075      // First check if checkpoint exists1076      const exists = await checkpointManager.checkpointExists(checkpointId);1077      if (!exists) {1078        toast.error('Checkpoint no longer exists - it may have been cleaned up');1079        logger.warn(`[Workspace] Checkpoint ${checkpointId} no longer exists`);1080        return;1081      }1082 1083      const success = await saveManager.runWithSuppressedDirty(project.id, () =>1084        checkpointManager.restoreCheckpoint(checkpointId)1085      );1086      if (success) {1087        toast.success(`Restored to: ${description || 'checkpoint'}`);1088        track(options?.isDiscard ? 'changes_discarded' : 'checkpoint_restore');1089        handleFilesChange();1090 1091        const savedId = saveManager.getSavedCheckpointId(project.id);1092        if (savedId && savedId === checkpointId) {1093          saveManager.markClean(project.id);1094          const latestProject = await vfs.getProject(project.id);1095          useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? null });1096        } else {1097          saveManager.markDirty(project.id);1098        }1099      } else {1100        toast.error('Failed to restore checkpoint');1101      }1102    } catch (error) {1103      logger.error('Error restoring checkpoint:', error);1104      toast.error('Failed to restore checkpoint');1105    }1106  }, [handleFilesChange, project.id]);1107 1108  const handleScrollToCheckpoint = useCallback((checkpointId: string) => {1109    if (!useWorkspaceStore.getState().showChat) useWorkspaceStore.getState().togglePanel('chat');1110    requestAnimationFrame(() => {1111      const el = document.querySelector(`[data-checkpoint-id="${checkpointId}"]`);1112      if (el) {1113        el.scrollIntoView({ behavior: 'smooth', block: 'center' });1114        el.classList.add('ring-2', 'ring-primary/50');1115        setTimeout(() => el.classList.remove('ring-2', 'ring-primary/50'), 2000);1116      }1117    });1118  }, []);1119 1120  const handleRetry = useCallback(async (checkpointId: string) => {1121    try {1122      // First check if checkpoint exists1123      const exists = await checkpointManager.checkpointExists(checkpointId);1124      if (!exists) {1125        toast.error('Checkpoint no longer exists - cannot retry');1126        logger.warn(`[Workspace] Checkpoint ${checkpointId} no longer exists`);1127        return;1128      }1129 1130      // Find the user message associated with this checkpoint1131      // Search backwards from the checkpoint to find the most recent user message1132      let userMessageContent = null;1133      const checkpointEventIndex = debugEvents.findIndex(1134        e => e.event === 'checkpoint_created' && e.data?.checkpointId === checkpointId1135      );1136 1137      if (checkpointEventIndex >= 0) {1138        // Search backwards from checkpoint event to find user message (in conversation_message events)1139        for (let i = checkpointEventIndex - 1; i >= 0; i--) {1140          if (debugEvents[i].event === 'conversation_message' &&1141              debugEvents[i].data?.message?.role === 'user') {1142            userMessageContent = debugEvents[i].data.message.content;1143            break;1144          }1145        }1146      }1147 1148      if (!userMessageContent) {1149        toast.error('Cannot find original user message to retry');1150        logger.warn('[Workspace] No user message found before checkpoint');1151        return;1152      }1153 1154      // Find the user message event index to truncate debug events1155      let userMessageIndex = -1;1156      for (let i = checkpointEventIndex - 1; i >= 0; i--) {1157        if (debugEvents[i].event === 'conversation_message' &&1158            debugEvents[i].data?.message?.role === 'user' &&1159            debugEvents[i].data.message.content === userMessageContent) {1160          userMessageIndex = i;1161          break;1162        }1163      }1164 1165      if (userMessageIndex === -1) {1166        toast.error('Cannot find user message event to truncate');1167        logger.warn('[Workspace] User message event not found in debug events');1168        return;1169      }1170 1171      // Restore the checkpoint1172      const success = await saveManager.runWithSuppressedDirty(project.id, () =>1173        checkpointManager.restoreCheckpoint(checkpointId)1174      );1175      if (!success) {1176        toast.error('Failed to restore checkpoint');1177        return;1178      }1179 1180      const savedId = saveManager.getSavedCheckpointId(project.id);1181      if (savedId && savedId === checkpointId) {1182        saveManager.markClean(project.id);1183        const latestProject = await vfs.getProject(project.id);1184        useWorkspaceStore.setState({ lastSavedAt: latestProject.lastSavedAt ?? null });1185      } else {1186        saveManager.markDirty(project.id);1187      }1188 1189      // Truncate debug events to remove the user message and all subsequent events1190      // The user message will be re-added by the orchestrator when generation runs1191      const truncatedEvents = debugEvents.slice(0, userMessageIndex);1192      useWorkspaceStore.setState({ debugEvents: truncatedEvents });1193      useWorkspaceStore.getState().resetOrchestrator();1194      await debugEventsState.truncateEvents(project.id, truncatedEvents);1195 1196      toast.success('Restored checkpoint and retrying...');1197      handleFilesChange();1198 1199      // Retry generation with the original user message.1200      // Use setTimeout so handleGenerate (declared below) is available.

Showing the first 1,200 of 2258 lines. Download the file for the rest.