Leon4gr45/builder
0
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.