diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c5efcfb6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,138 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + id: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + continue-on-error: true + + - name: Setup Node + id: setup_node + if: always() + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + cache-dependency-path: | + package-lock.json + webview-ui/package-lock.json + continue-on-error: true + + - name: Install Root Dependencies + id: install_root + if: always() + run: npm ci + continue-on-error: true + + - name: Install Webview Dependencies + id: install_webview + if: always() + working-directory: webview-ui + run: npm ci + continue-on-error: true + + - name: Root Lint + id: root_lint + if: always() + run: npm run lint + continue-on-error: true + + - name: Webview Lint + id: webview_lint + if: always() + working-directory: webview-ui + run: npm run lint + continue-on-error: true + + - name: Build + id: build + if: always() + run: npm run build + continue-on-error: true + + - name: Contributors Policy + id: contributors_policy + if: always() + run: | + if [ -f scripts/check-contributors-rules.mjs ]; then + node scripts/check-contributors-rules.mjs + else + echo "Missing contributors policy script: scripts/check-contributors-rules.mjs" >&2 + exit 1 + fi + continue-on-error: true + + - name: Write Step Summary + if: always() + env: + CHECKOUT: ${{ steps.checkout.outcome }} + SETUP_NODE: ${{ steps.setup_node.outcome }} + INSTALL_ROOT: ${{ steps.install_root.outcome }} + INSTALL_WEBVIEW: ${{ steps.install_webview.outcome }} + ROOT_LINT: ${{ steps.root_lint.outcome }} + WEBVIEW_LINT: ${{ steps.webview_lint.outcome }} + BUILD: ${{ steps.build.outcome }} + CONTRIBUTORS_POLICY: ${{ steps.contributors_policy.outcome }} + run: | + status() { + if [ "$1" = "success" ]; then + echo "PASS" + else + echo "FAIL" + fi + } + + { + echo "## CI Results" + echo + echo "| Check | Result |" + echo "| --- | --- |" + echo "| Checkout | $(status "$CHECKOUT") |" + echo "| Setup Node | $(status "$SETUP_NODE") |" + echo "| Install root dependencies | $(status "$INSTALL_ROOT") |" + echo "| Install webview dependencies | $(status "$INSTALL_WEBVIEW") |" + echo "| Root lint | $(status "$ROOT_LINT") |" + echo "| Webview lint | $(status "$WEBVIEW_LINT") |" + echo "| Build | $(status "$BUILD") |" + echo "| Contributors policy | $(status "$CONTRIBUTORS_POLICY") |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Fail If Any Blocking Check Failed + if: always() + env: + CHECKOUT: ${{ steps.checkout.outcome }} + SETUP_NODE: ${{ steps.setup_node.outcome }} + INSTALL_ROOT: ${{ steps.install_root.outcome }} + INSTALL_WEBVIEW: ${{ steps.install_webview.outcome }} + ROOT_LINT: ${{ steps.root_lint.outcome }} + WEBVIEW_LINT: ${{ steps.webview_lint.outcome }} + BUILD: ${{ steps.build.outcome }} + CONTRIBUTORS_POLICY: ${{ steps.contributors_policy.outcome }} + run: | + failed=0 + + [ "$CHECKOUT" = "success" ] || failed=1 + [ "$SETUP_NODE" = "success" ] || failed=1 + [ "$INSTALL_ROOT" = "success" ] || failed=1 + [ "$INSTALL_WEBVIEW" = "success" ] || failed=1 + [ "$ROOT_LINT" = "success" ] || failed=1 + [ "$WEBVIEW_LINT" = "success" ] || failed=1 + [ "$BUILD" = "success" ] || failed=1 + [ "$CONTRIBUTORS_POLICY" = "success" ] || failed=1 + + exit "$failed" diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c51b0c0..e29b3377 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,9 +19,9 @@ export default [{ format: ["camelCase", "PascalCase"], }], - curly: "warn", + curly: "off", eqeqeq: "warn", "no-throw-literal": "warn", - semi: "warn", + semi: "off", }, }]; \ No newline at end of file diff --git a/scripts/check-contributors-rules.mjs b/scripts/check-contributors-rules.mjs new file mode 100644 index 00000000..45020fec --- /dev/null +++ b/scripts/check-contributors-rules.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +const repoRoot = process.cwd(); +const summaryPath = process.env.GITHUB_STEP_SUMMARY || null; + +const ALLOWED_FILES = new Set([ + 'src/constants.ts', + 'webview-ui/src/constants.ts', + 'webview-ui/src/index.css', +]); + +const EXCLUDED_PREFIXES = [ + 'webview-ui/src/fonts/', + 'webview-ui/src/office/sprites/', +]; + +const COLOR_LITERAL_PATTERNS = [ + /#[0-9a-fA-F]{3,8}\b/, + /\brgba?\s*\(/, + /\bhsla?\s*\(/, +]; + +function runGit(args) { + return execFileSync('git', args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +function writeSummary(markdown) { + if (!summaryPath) return; + fs.appendFileSync(summaryPath, `${markdown}\n`, 'utf8'); +} + +function isRelevantFile(filePath) { + if (!filePath) return false; + if (ALLOWED_FILES.has(filePath)) return false; + if (EXCLUDED_PREFIXES.some((prefix) => filePath.startsWith(prefix))) return false; + + if (filePath.startsWith('src/') && filePath.endsWith('.ts')) return true; + if (filePath.startsWith('webview-ui/src/') && /\.(ts|tsx|css)$/.test(filePath)) return true; + + return false; +} + +function detectMode() { + const eventName = process.env.GITHUB_EVENT_NAME; + const baseRef = process.env.GITHUB_BASE_REF; + const beforeSha = process.env.GITHUB_EVENT_BEFORE; + + if (eventName === 'pull_request' && baseRef) { + const remoteBaseRef = `origin/${baseRef}`; + try { + runGit(['rev-parse', '--verify', remoteBaseRef]); + return { + mode: 'diff', + label: `changed lines against ${remoteBaseRef}`, + args: ['diff', '--unified=0', '--no-color', `${remoteBaseRef}...HEAD`], + }; + } catch { + // Fall through to other modes. + } + } + + if (beforeSha && !/^0+$/.test(beforeSha)) { + try { + runGit(['rev-parse', '--verify', beforeSha]); + return { + mode: 'diff', + label: `changed lines against ${beforeSha.slice(0, 12)}`, + args: ['diff', '--unified=0', '--no-color', `${beforeSha}...HEAD`], + }; + } catch { + // Fall through to other modes. + } + } + + return { + mode: 'full', + label: 'full repository scan', + }; +} + +function parseDiff(diffText) { + const fileMap = new Map(); + let currentFile = null; + let currentLine = 0; + + for (const rawLine of diffText.split('\n')) { + if (rawLine.startsWith('+++ b/')) { + currentFile = rawLine.slice('+++ b/'.length); + if (!fileMap.has(currentFile)) { + fileMap.set(currentFile, []); + } + continue; + } + + const hunkMatch = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + if (hunkMatch) { + currentLine = Number(hunkMatch[1]); + continue; + } + + if (!currentFile || rawLine.startsWith('diff --git') || rawLine.startsWith('--- ')) { + continue; + } + + if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) { + fileMap.get(currentFile).push({ + lineNumber: currentLine, + text: rawLine.slice(1), + }); + currentLine += 1; + continue; + } + + if (rawLine.startsWith('-')) { + continue; + } + + currentLine += 1; + } + + return fileMap; +} + +function collectFullTree() { + const trackedFiles = runGit(['ls-files']).split('\n').filter(Boolean); + const fileMap = new Map(); + + for (const filePath of trackedFiles) { + if (!isRelevantFile(filePath)) continue; + + const absolutePath = path.join(repoRoot, filePath); + if (!fs.existsSync(absolutePath)) continue; + + const lines = fs.readFileSync(absolutePath, 'utf8').split('\n').map((text, index) => ({ + lineNumber: index + 1, + text, + })); + + fileMap.set(filePath, lines); + } + + return fileMap; +} + +function isCommentOnlyLine(line) { + const trimmed = line.trim(); + return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*'); +} + +function checkLine(filePath, lineNumber, text) { + const violations = []; + const trimmed = text.trim(); + + if (!trimmed || isCommentOnlyLine(trimmed)) { + return violations; + } + + const isWebviewSource = filePath.startsWith('webview-ui/src/'); + + if (isWebviewSource) { + for (const pattern of COLOR_LITERAL_PATTERNS) { + const match = text.match(pattern); + if (match) { + violations.push({ + rule: 'centralize-colors', + message: 'Use shared constants or `--pixel-*` tokens instead of inline color literals.', + filePath, + lineNumber, + text: trimmed, + }); + break; + } + } + + const hasBoxShadow = /\bboxShadow\b|\bbox-shadow\b/.test(text); + if (hasBoxShadow && !text.includes('var(--pixel-shadow)') && !text.includes('2px 2px 0px')) { + violations.push({ + rule: 'pixel-shadow', + message: 'Use `var(--pixel-shadow)` or a hard offset `2px 2px 0px` shadow.', + filePath, + lineNumber, + text: trimmed, + }); + } + + const hasFontFamily = /\bfontFamily\b|\bfont-family\b/.test(text); + if (hasFontFamily && !text.includes('FS Pixel Sans')) { + violations.push({ + rule: 'pixel-font', + message: 'Use the FS Pixel Sans font for UI styling.', + filePath, + lineNumber, + text: trimmed, + }); + } + } + + if (filePath.startsWith('src/')) { + for (const pattern of COLOR_LITERAL_PATTERNS) { + if (pattern.test(text)) { + violations.push({ + rule: 'centralize-colors', + message: 'Keep color literals out of backend source files unless they are defined in shared constants.', + filePath, + lineNumber, + text: trimmed, + }); + break; + } + } + } + + return violations; +} + +function collectViolations(scanMode) { + const sources = + scanMode.mode === 'diff' + ? parseDiff(runGit(scanMode.args)) + : collectFullTree(); + + const relevantEntries = [...sources.entries()].filter(([filePath]) => isRelevantFile(filePath)); + const violations = []; + + for (const [filePath, lines] of relevantEntries) { + for (const { lineNumber, text } of lines) { + violations.push(...checkLine(filePath, lineNumber, text)); + } + } + + return { + filesScanned: relevantEntries.length, + violations, + }; +} + +function formatViolations(violations) { + return violations + .map(({ rule, filePath, lineNumber, message, text }) => `- \`${rule}\` [${filePath}:${lineNumber}] ${message}\n \`${text}\``) + .join('\n'); +} + +function main() { + const scanMode = detectMode(); + const { filesScanned, violations } = collectViolations(scanMode); + + let output = `Contributors policy scan: ${scanMode.label}\n`; + output += `Files scanned: ${filesScanned}\n`; + + if (violations.length === 0) { + output += 'Result: PASS\n'; + console.log(output.trimEnd()); + + writeSummary([ + '## Contributors Policy', + '', + `Mode: ${scanMode.label}`, + '', + `Files scanned: ${filesScanned}`, + '', + 'Result: PASS', + ].join('\n')); + + return; + } + + output += `Result: FAIL (${violations.length} violation${violations.length === 1 ? '' : 's'})\n\n`; + output += formatViolations(violations); + console.error(output.trimEnd()); + + writeSummary([ + '## Contributors Policy', + '', + `Mode: ${scanMode.label}`, + '', + `Files scanned: ${filesScanned}`, + '', + `Result: FAIL (${violations.length} violation${violations.length === 1 ? '' : 's'})`, + '', + formatViolations(violations), + ].join('\n')); + + process.exitCode = 1; +} + +main(); diff --git a/webview-ui/src/components/AgentLabels.tsx b/webview-ui/src/components/AgentLabels.tsx index 3d84bfa9..c290bfde 100644 --- a/webview-ui/src/components/AgentLabels.tsx +++ b/webview-ui/src/components/AgentLabels.tsx @@ -13,6 +13,12 @@ interface AgentLabelsProps { subagentCharacters: SubagentCharacter[] } +interface LabelViewportMetrics { + dpr: number + deviceOffsetX: number + deviceOffsetY: number +} + export function AgentLabels({ officeState, agents, @@ -22,29 +28,42 @@ export function AgentLabels({ panRef, subagentCharacters, }: AgentLabelsProps) { - const [, setTick] = useState(0) + const [viewportMetrics, setViewportMetrics] = useState(null) + useEffect(() => { let rafId = 0 - const tick = () => { - setTick((n) => n + 1) - rafId = requestAnimationFrame(tick) + + const updateMetrics = () => { + const el = containerRef.current + if (el) { + const rect = el.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + const canvasW = Math.round(rect.width * dpr) + const canvasH = Math.round(rect.height * dpr) + const layout = officeState.getLayout() + const mapW = layout.cols * TILE_SIZE * zoom + const mapH = layout.rows * TILE_SIZE * zoom + const pan = panRef.current + + setViewportMetrics({ + dpr, + deviceOffsetX: Math.floor((canvasW - mapW) / 2) + Math.round(pan.x), + deviceOffsetY: Math.floor((canvasH - mapH) / 2) + Math.round(pan.y), + }) + } else { + setViewportMetrics(null) + } + + rafId = requestAnimationFrame(updateMetrics) } - rafId = requestAnimationFrame(tick) + + rafId = requestAnimationFrame(updateMetrics) return () => cancelAnimationFrame(rafId) - }, []) - - const el = containerRef.current - if (!el) return null - const rect = el.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - // Compute device pixel offset (same math as renderFrame, including pan) - const canvasW = Math.round(rect.width * dpr) - const canvasH = Math.round(rect.height * dpr) - const layout = officeState.getLayout() - const mapW = layout.cols * TILE_SIZE * zoom - const mapH = layout.rows * TILE_SIZE * zoom - const deviceOffsetX = Math.floor((canvasW - mapW) / 2) + Math.round(panRef.current.x) - const deviceOffsetY = Math.floor((canvasH - mapH) / 2) + Math.round(panRef.current.y) + }, [containerRef, officeState, panRef, zoom]) + + if (!viewportMetrics) return null + + const { dpr, deviceOffsetX, deviceOffsetY } = viewportMetrics // Build sub-agent label lookup const subLabelMap = new Map() diff --git a/webview-ui/src/components/ZoomControls.tsx b/webview-ui/src/components/ZoomControls.tsx index a04d8ad3..e599763e 100644 --- a/webview-ui/src/components/ZoomControls.tsx +++ b/webview-ui/src/components/ZoomControls.tsx @@ -31,6 +31,7 @@ export function ZoomControls({ zoom, onZoomChange }: ZoomControlsProps) { const [hovered, setHovered] = useState<'minus' | 'plus' | null>(null) const [showLevel, setShowLevel] = useState(false) const [fadeOut, setFadeOut] = useState(false) + const showTimerRef = useRef | null>(null) const timerRef = useRef | null>(null) const fadeTimerRef = useRef | null>(null) const prevZoomRef = useRef(zoom) @@ -44,11 +45,14 @@ export function ZoomControls({ zoom, onZoomChange }: ZoomControlsProps) { prevZoomRef.current = zoom // Clear existing timers + if (showTimerRef.current) clearTimeout(showTimerRef.current) if (timerRef.current) clearTimeout(timerRef.current) if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) - setShowLevel(true) - setFadeOut(false) + showTimerRef.current = setTimeout(() => { + setShowLevel(true) + setFadeOut(false) + }, 0) // Start fade after delay fadeTimerRef.current = setTimeout(() => { @@ -62,6 +66,7 @@ export function ZoomControls({ zoom, onZoomChange }: ZoomControlsProps) { }, ZOOM_LEVEL_HIDE_DELAY_MS) return () => { + if (showTimerRef.current) clearTimeout(showTimerRef.current) if (timerRef.current) clearTimeout(timerRef.current) if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current) } diff --git a/webview-ui/src/hooks/useEditorActions.ts b/webview-ui/src/hooks/useEditorActions.ts index 02116632..b1b5e1a5 100644 --- a/webview-ui/src/hooks/useEditorActions.ts +++ b/webview-ui/src/hooks/useEditorActions.ts @@ -11,6 +11,22 @@ import { defaultZoom } from '../office/toolUtils.js' import { vscode } from '../vscodeApi.js' import { LAYOUT_SAVE_DEBOUNCE_MS, ZOOM_MIN, ZOOM_MAX } from '../constants.js' +class MutableValue { + private value: T + + constructor(value: T) { + this.value = value + } + + get(): T { + return this.value + } + + set(nextValue: T): void { + this.value = nextValue + } +} + export interface EditorActions { isEditMode: boolean editorTick: number @@ -71,7 +87,7 @@ export function useEditorActions( const os = getOfficeState() editorState.pushUndo(os.getLayout()) editorState.clearRedo() - editorState.isDirty = true + editorState.setDirty(true) setIsDirty(true) os.rebuildFromLayout(newLayout) saveLayout(newLayout) @@ -85,7 +101,7 @@ export function useEditorActions( const handleToggleEditMode = useCallback(() => { setIsEditMode((prev) => { const next = !prev - editorState.isEditMode = next + editorState.setEditMode(next) if (next) { // Initialize wallColor from existing wall tiles so new walls match const os = getOfficeState() @@ -93,7 +109,7 @@ export function useEditorActions( if (layout.tileColors) { for (let i = 0; i < layout.tiles.length; i++) { if (layout.tiles[i] === TileType.WALL && layout.tileColors[i]) { - editorState.wallColor = { ...layout.tileColors[i]! } + editorState.setWallColor({ ...layout.tileColors[i]! }) break } } @@ -102,7 +118,7 @@ export function useEditorActions( editorState.clearSelection() editorState.clearGhost() editorState.clearDrag() - wallColorEditActiveRef.current = false + wallColorEditActiveRef.current.set(false) } return next }) @@ -111,33 +127,33 @@ export function useEditorActions( // Tool toggle: clicking already-active tool deselects it (returns to SELECT) const handleToolChange = useCallback((tool: EditToolType) => { if (editorState.activeTool === tool) { - editorState.activeTool = EditTool.SELECT + editorState.setActiveTool(EditTool.SELECT) } else { - editorState.activeTool = tool + editorState.setActiveTool(tool) } editorState.clearSelection() editorState.clearGhost() editorState.clearDrag() - colorEditUidRef.current = null - wallColorEditActiveRef.current = false + colorEditUidRef.current.set(null) + wallColorEditActiveRef.current.set(false) setEditorTick((n) => n + 1) }, [editorState]) const handleTileTypeChange = useCallback((type: TileTypeVal) => { - editorState.selectedTileType = type + editorState.setSelectedTileType(type) setEditorTick((n) => n + 1) }, [editorState]) const handleFloorColorChange = useCallback((color: FloorColor) => { - editorState.floorColor = color + editorState.setFloorColor(color) setEditorTick((n) => n + 1) }, [editorState]) // Track whether we've already pushed undo for the current wall color editing session - const wallColorEditActiveRef = useRef(false) + const wallColorEditActiveRef = useRef(new MutableValue(false)) const handleWallColorChange = useCallback((color: FloorColor) => { - editorState.wallColor = color + editorState.setWallColor(color) // Update all existing wall tiles to the new color const os = getOfficeState() @@ -153,13 +169,13 @@ export function useEditorActions( } if (changed) { // Push undo only once per editing session (first slider touch) - if (!wallColorEditActiveRef.current) { + if (!wallColorEditActiveRef.current.get()) { editorState.pushUndo(layout) editorState.clearRedo() - wallColorEditActiveRef.current = true + wallColorEditActiveRef.current.set(true) } const newLayout = { ...layout, tileColors: newColors } - editorState.isDirty = true + editorState.setDirty(true) setIsDirty(true) os.rebuildFromLayout(newLayout) saveLayout(newLayout) @@ -169,7 +185,7 @@ export function useEditorActions( // Track which uid we've already pushed undo for during color editing // so dragging sliders doesn't create N undo entries - const colorEditUidRef = useRef(null) + const colorEditUidRef = useRef(new MutableValue(null)) const handleSelectedFurnitureColorChange = useCallback((color: FloorColor | null) => { const uid = editorState.selectedFurnitureUid @@ -178,10 +194,10 @@ export function useEditorActions( const layout = os.getLayout() // Push undo only once per selection (first slider touch) - if (colorEditUidRef.current !== uid) { + if (colorEditUidRef.current.get() !== uid) { editorState.pushUndo(layout) editorState.clearRedo() - colorEditUidRef.current = uid + colorEditUidRef.current.set(uid) } // Update color on the placed furniture item (null removes color) @@ -190,7 +206,7 @@ export function useEditorActions( ) const newLayout = { ...layout, furniture: newFurniture } - editorState.isDirty = true + editorState.setDirty(true) setIsDirty(true) os.rebuildFromLayout(newLayout) saveLayout(newLayout) @@ -200,10 +216,10 @@ export function useEditorActions( const handleFurnitureTypeChange = useCallback((type: string) => { // Clicking the same item deselects it (no ghost), stays in furniture mode if (editorState.selectedFurnitureType === type) { - editorState.selectedFurnitureType = '' + editorState.setSelectedFurnitureType('') editorState.clearGhost() } else { - editorState.selectedFurnitureType = type + editorState.setSelectedFurnitureType(type) } setEditorTick((n) => n + 1) }, [editorState]) @@ -216,7 +232,7 @@ export function useEditorActions( if (newLayout !== os.getLayout()) { applyEdit(newLayout) editorState.clearSelection() - colorEditUidRef.current = null + colorEditUidRef.current.set(null) } }, [getOfficeState, editorState, applyEdit]) @@ -225,7 +241,7 @@ export function useEditorActions( if (editorState.activeTool === EditTool.FURNITURE_PLACE) { const rotated = getRotatedType(editorState.selectedFurnitureType, 'cw') if (rotated) { - editorState.selectedFurnitureType = rotated + editorState.setSelectedFurnitureType(rotated) setEditorTick((n) => n + 1) } return @@ -245,7 +261,7 @@ export function useEditorActions( if (editorState.activeTool === EditTool.FURNITURE_PLACE) { const toggled = getToggledType(editorState.selectedFurnitureType) if (toggled) { - editorState.selectedFurnitureType = toggled + editorState.setSelectedFurnitureType(toggled) setEditorTick((n) => n + 1) } return @@ -268,7 +284,7 @@ export function useEditorActions( editorState.pushRedo(os.getLayout()) os.rebuildFromLayout(prev) saveLayout(prev) - editorState.isDirty = true + editorState.setDirty(true) setIsDirty(true) setEditorTick((n) => n + 1) }, [getOfficeState, editorState, saveLayout]) @@ -281,7 +297,7 @@ export function useEditorActions( editorState.pushUndo(os.getLayout()) os.rebuildFromLayout(next) saveLayout(next) - editorState.isDirty = true + editorState.setDirty(true) setIsDirty(true) setEditorTick((n) => n + 1) }, [getOfficeState, editorState, saveLayout]) @@ -304,13 +320,13 @@ export function useEditorActions( const layout = os.getLayout() lastSavedLayoutRef.current = structuredClone(layout) vscode.postMessage({ type: 'saveLayout', layout }) - editorState.isDirty = false + editorState.setDirty(false) setIsDirty(false) }, [getOfficeState, editorState]) // Notify React that imperative editor selection changed (e.g., from OfficeCanvas mouseUp) const handleEditorSelectionChange = useCallback(() => { - colorEditUidRef.current = null + colorEditUidRef.current.set(null) setEditorTick((n) => n + 1) }, []) @@ -389,7 +405,7 @@ export function useEditorActions( // First tile of drag sets direction if (editorState.wallDragAdding === null) { - editorState.wallDragAdding = !isWall + editorState.setWallDragAdding(!isWall) } if (editorState.wallDragAdding) { @@ -424,7 +440,7 @@ export function useEditorActions( if (!entry) return false return col >= f.col && col < f.col + entry.footprintW && row >= f.row && row < f.row + entry.footprintH }) - editorState.selectedFurnitureUid = hit ? hit.uid : null + editorState.setSelectedFurnitureUid(hit ? hit.uid : null) setEditorTick((n) => n + 1) } else { const placementRow = getWallPlacementRow(type, row) @@ -447,28 +463,28 @@ export function useEditorActions( return col >= f.col && col < f.col + entry.footprintW && row >= f.row && row < f.row + entry.footprintH }) if (hit) { - editorState.selectedFurnitureType = hit.type - editorState.pickedFurnitureColor = hit.color ? { ...hit.color } : null - editorState.activeTool = EditTool.FURNITURE_PLACE + editorState.setSelectedFurnitureType(hit.type) + editorState.setPickedFurnitureColor(hit.color ? { ...hit.color } : null) + editorState.setActiveTool(EditTool.FURNITURE_PLACE) } setEditorTick((n) => n + 1) } else if (editorState.activeTool === EditTool.EYEDROPPER) { const idx = row * layout.cols + col const tile = layout.tiles[idx] if (tile !== undefined && tile !== TileType.WALL && tile !== TileType.VOID) { - editorState.selectedTileType = tile + editorState.setSelectedTileType(tile) const color = layout.tileColors?.[idx] if (color) { - editorState.floorColor = { ...color } + editorState.setFloorColor({ ...color }) } - editorState.activeTool = EditTool.TILE_PAINT + editorState.setActiveTool(EditTool.TILE_PAINT) } else if (tile === TileType.WALL) { // Pick wall color and switch to wall tool const color = layout.tileColors?.[idx] if (color) { - editorState.wallColor = { ...color } + editorState.setWallColor({ ...color }) } - editorState.activeTool = EditTool.WALL_PAINT + editorState.setActiveTool(EditTool.WALL_PAINT) } setEditorTick((n) => n + 1) } else if (editorState.activeTool === EditTool.SELECT) { @@ -477,7 +493,7 @@ export function useEditorActions( if (!entry) return false return col >= f.col && col < f.col + entry.footprintW && row >= f.row && row < f.row + entry.footprintH }) - editorState.selectedFurnitureUid = hit ? hit.uid : null + editorState.setSelectedFurnitureUid(hit ? hit.uid : null) setEditorTick((n) => n + 1) } }, [getOfficeState, editorState, applyEdit, maybeExpand]) diff --git a/webview-ui/src/hooks/useExtensionMessages.ts b/webview-ui/src/hooks/useExtensionMessages.ts index 961b0c4c..bea93c22 100644 --- a/webview-ui/src/hooks/useExtensionMessages.ts +++ b/webview-ui/src/hooks/useExtensionMessages.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useEffectEvent, useRef } from 'react' import type { OfficeState } from '../office/engine/officeState.js' import type { OfficeLayout, ToolActivity } from '../office/types.js' import { extractToolName } from '../office/toolUtils.js' @@ -78,6 +78,10 @@ export function useExtensionMessages( // Track whether initial layout has been loaded (ref to avoid re-render) const layoutReadyRef = useRef(false) + const notifyLayoutLoaded = useEffectEvent((layout: OfficeLayout) => { + onLayoutLoaded?.(layout) + }) + const getEditDirty = useEffectEvent(() => isEditDirty?.() ?? false) useEffect(() => { // Buffer agents from existingAgents until layout is loaded @@ -89,7 +93,7 @@ export function useExtensionMessages( if (msg.type === 'layoutLoaded') { // Skip external layout updates while editor has unsaved changes - if (layoutReadyRef.current && isEditDirty?.()) { + if (layoutReadyRef.current && getEditDirty()) { console.log('[Webview] Skipping external layout update — editor has unsaved changes') return } @@ -97,10 +101,10 @@ export function useExtensionMessages( const layout = rawLayout && rawLayout.version === 1 ? migrateLayoutColors(rawLayout) : null if (layout) { os.rebuildFromLayout(layout) - onLayoutLoaded?.(layout) + notifyLayoutLoaded(layout) } else { // Default layout — snapshot whatever OfficeState built - onLayoutLoaded?.(os.getLayout()) + notifyLayoutLoaded(os.getLayout()) } // Add buffered agents now that layout (and seats) are correct for (const p of pendingAgents) { diff --git a/webview-ui/src/office/components/OfficeCanvas.tsx b/webview-ui/src/office/components/OfficeCanvas.tsx index 4ddcab97..04dde48d 100644 --- a/webview-ui/src/office/components/OfficeCanvas.tsx +++ b/webview-ui/src/office/components/OfficeCanvas.tsx @@ -307,13 +307,12 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on if (isEditMode) { const tile = screenToTile(e.clientX, e.clientY) if (tile) { - editorState.ghostCol = tile.col - editorState.ghostRow = tile.row + editorState.setGhostPosition(tile.col, tile.row) // Drag-to-move: check if cursor moved to different tile if (editorState.dragUid && !editorState.isDragMoving) { if (tile.col !== editorState.dragStartCol || tile.row !== editorState.dragStartRow) { - editorState.isDragMoving = true + editorState.setDragMoving(true) } } @@ -329,8 +328,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on } } } else { - editorState.ghostCol = -1 - editorState.ghostRow = -1 + editorState.setGhostPosition(-1, -1) } // Cursor: show grab during drag, pointer over delete button, crosshair otherwise @@ -372,7 +370,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on if (!pos) return const hitId = officeState.getCharacterAt(pos.worldX, pos.worldY) const tile = screenToTile(e.clientX, e.clientY) - officeState.hoveredTile = tile + officeState.setHoveredTile(tile) const canvas = canvasRef.current if (canvas) { let cursor = 'default' @@ -393,7 +391,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on } canvas.style.cursor = cursor } - officeState.hoveredAgentId = hitId + officeState.setHoveredAgentId(hitId) }, [officeState, screenToWorld, screenToTile, isEditMode, editorState, onEditorTileAction, onEditorEraseAction, panRef, hitTestDeleteButton, hitTestRotateButton, clampPan], ) @@ -405,7 +403,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on if (e.button === 1) { e.preventDefault() // Break camera follow on manual pan - officeState.cameraFollowId = null + officeState.setCameraFollowId(null) isPanningRef.current = true panStartRef.current = { mouseX: e.clientX, @@ -478,7 +476,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on } // Non-select tools: start paint drag - editorState.isDragging = true + editorState.setDragging(true) if (tile) { onEditorTileAction(tile.col, tile.row) } @@ -524,7 +522,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on if (editorState.selectedFurnitureUid === editorState.dragUid) { editorState.clearSelection() } else { - editorState.selectedFurnitureUid = editorState.dragUid + editorState.setSelectedFurnitureUid(editorState.dragUid) } } editorState.clearDrag() @@ -534,8 +532,8 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on return } - editorState.isDragging = false - editorState.wallDragAdding = null + editorState.setDragging(false) + editorState.setWallDragAdding(null) }, [editorState, isEditMode, officeState, onDragMove, onEditorSelectionChange], ) @@ -552,11 +550,11 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on officeState.dismissBubble(hitId) // Toggle selection: click same agent deselects, different agent selects if (officeState.selectedAgentId === hitId) { - officeState.selectedAgentId = null - officeState.cameraFollowId = null + officeState.setSelectedAgentId(null) + officeState.setCameraFollowId(null) } else { - officeState.selectedAgentId = hitId - officeState.cameraFollowId = hitId + officeState.setSelectedAgentId(hitId) + officeState.setCameraFollowId(hitId) } onClick(hitId) // still focus terminal return @@ -576,14 +574,14 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on if (selectedCh.seatId === seatId) { // Clicked own seat — send agent back to it officeState.sendToSeat(officeState.selectedAgentId) - officeState.selectedAgentId = null - officeState.cameraFollowId = null + officeState.setSelectedAgentId(null) + officeState.setCameraFollowId(null) return } else if (!seat.assigned) { // Clicked available seat — reassign officeState.reassignSeat(officeState.selectedAgentId, seatId) - officeState.selectedAgentId = null - officeState.cameraFollowId = null + officeState.setSelectedAgentId(null) + officeState.setCameraFollowId(null) // Persist seat assignments (exclude sub-agents) const seats: Record = {} for (const ch of officeState.characters.values()) { @@ -598,8 +596,8 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on } } // Clicked empty space — deselect - officeState.selectedAgentId = null - officeState.cameraFollowId = null + officeState.setSelectedAgentId(null) + officeState.setCameraFollowId(null) } }, [officeState, onClick, screenToWorld, screenToTile, isEditMode], @@ -608,13 +606,12 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on const handleMouseLeave = useCallback(() => { isPanningRef.current = false isEraseDraggingRef.current = false - editorState.isDragging = false - editorState.wallDragAdding = null + editorState.setDragging(false) + editorState.setWallDragAdding(null) editorState.clearDrag() - editorState.ghostCol = -1 - editorState.ghostRow = -1 - officeState.hoveredAgentId = null - officeState.hoveredTile = null + editorState.setGhostPosition(-1, -1) + officeState.setHoveredAgentId(null) + officeState.setHoveredTile(null) }, [officeState, editorState]) const handleContextMenu = useCallback((e: React.MouseEvent) => { @@ -647,7 +644,7 @@ export function OfficeCanvas({ officeState, onClick, isEditMode, editorState, on } else { // Pan via trackpad two-finger scroll or mouse wheel const dpr = window.devicePixelRatio || 1 - officeState.cameraFollowId = null + officeState.setCameraFollowId(null) panRef.current = clampPan( panRef.current.x - e.deltaX * dpr, panRef.current.y - e.deltaY * dpr, diff --git a/webview-ui/src/office/components/ToolOverlay.tsx b/webview-ui/src/office/components/ToolOverlay.tsx index ecf69ff0..859a1ac3 100644 --- a/webview-ui/src/office/components/ToolOverlay.tsx +++ b/webview-ui/src/office/components/ToolOverlay.tsx @@ -16,6 +16,12 @@ interface ToolOverlayProps { onCloseAgent: (id: number) => void } +interface OverlayViewportMetrics { + dpr: number + deviceOffsetX: number + deviceOffsetY: number +} + /** Derive a short human-readable activity string from tools/status */ function getActivityText( agentId: number, @@ -50,28 +56,42 @@ export function ToolOverlay({ panRef, onCloseAgent, }: ToolOverlayProps) { - const [, setTick] = useState(0) + const [viewportMetrics, setViewportMetrics] = useState(null) + useEffect(() => { let rafId = 0 - const tick = () => { - setTick((n) => n + 1) - rafId = requestAnimationFrame(tick) + + const updateMetrics = () => { + const el = containerRef.current + if (el) { + const rect = el.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + const canvasW = Math.round(rect.width * dpr) + const canvasH = Math.round(rect.height * dpr) + const layout = officeState.getLayout() + const mapW = layout.cols * TILE_SIZE * zoom + const mapH = layout.rows * TILE_SIZE * zoom + const pan = panRef.current + + setViewportMetrics({ + dpr, + deviceOffsetX: Math.floor((canvasW - mapW) / 2) + Math.round(pan.x), + deviceOffsetY: Math.floor((canvasH - mapH) / 2) + Math.round(pan.y), + }) + } else { + setViewportMetrics(null) + } + + rafId = requestAnimationFrame(updateMetrics) } - rafId = requestAnimationFrame(tick) + + rafId = requestAnimationFrame(updateMetrics) return () => cancelAnimationFrame(rafId) - }, []) - - const el = containerRef.current - if (!el) return null - const rect = el.getBoundingClientRect() - const dpr = window.devicePixelRatio || 1 - const canvasW = Math.round(rect.width * dpr) - const canvasH = Math.round(rect.height * dpr) - const layout = officeState.getLayout() - const mapW = layout.cols * TILE_SIZE * zoom - const mapH = layout.rows * TILE_SIZE * zoom - const deviceOffsetX = Math.floor((canvasW - mapW) / 2) + Math.round(panRef.current.x) - const deviceOffsetY = Math.floor((canvasH - mapH) / 2) + Math.round(panRef.current.y) + }, [containerRef, officeState, panRef, zoom]) + + if (!viewportMetrics) return null + + const { dpr, deviceOffsetX, deviceOffsetY } = viewportMetrics const selectedId = officeState.selectedAgentId const hoveredId = officeState.hoveredAgentId diff --git a/webview-ui/src/office/editor/EditorToolbar.tsx b/webview-ui/src/office/editor/EditorToolbar.tsx index 793c8896..e6ba71f5 100644 --- a/webview-ui/src/office/editor/EditorToolbar.tsx +++ b/webview-ui/src/office/editor/EditorToolbar.tsx @@ -168,15 +168,6 @@ export function EditorToolbar({ const success = buildDynamicCatalog(loadedAssets) console.log(`[EditorToolbar] Catalog build result: ${success}`) - // Reset to first available category if current doesn't exist - const activeCategories = getActiveCategories() - if (activeCategories.length > 0) { - const firstCat = activeCategories[0]?.id - if (firstCat) { - console.log(`[EditorToolbar] Setting active category to: ${firstCat}`) - setActiveCategory(firstCat) - } - } } catch (err) { console.error(`[EditorToolbar] Error building dynamic catalog:`, err) } @@ -197,7 +188,11 @@ export function EditorToolbar({ onSelectedFurnitureColorChange({ ...effectiveColor, [key]: value }) }, [effectiveColor, onSelectedFurnitureColorChange]) - const categoryItems = getCatalogByCategory(activeCategory) + const activeCategories = getActiveCategories() + const resolvedActiveCategory = activeCategories.some((category) => category.id === activeCategory) + ? activeCategory + : (activeCategories[0]?.id ?? activeCategory) + const categoryItems = getCatalogByCategory(resolvedActiveCategory) const patternCount = getFloorPatternCount() // Wall is TileType 0, floor patterns are 1..patternCount @@ -357,7 +352,7 @@ export function EditorToolbar({ {getActiveCategories().map((cat) => (