Skip to content

feat: Web app with Tailscale network access#58

Merged
jcanizalez merged 7 commits intomainfrom
feat/web-app
Mar 18, 2026
Merged

feat: Web app with Tailscale network access#58
jcanizalez merged 7 commits intomainfrom
feat/web-app

Conversation

@jcanizalez
Copy link
Copy Markdown
Owner

@jcanizalez jcanizalez commented Mar 18, 2026

Summary

  • Web app accessible via browser with WebSocket RPC API shim and Fastify static file serving
  • Tailscale Network Access settings: toggle, QR code for mobile, device list with online/offline status
  • Responsive mobile UI with hamburger menu, single-column grid, collapsible sidebar
  • Platform detection to hide Electron-only UI elements in web mode
  • PWA manifest, web file dialogs, task image HTTP serving

Test plan

  • Run npm run dev and open http://localhost:<port>/app/ in browser
  • Verify Settings > Network detects Tailscale correctly
  • Toggle network access ON, confirm QR code + device list appear
  • Open from another Tailscale device using the displayed URL
  • Test responsive layout at mobile (390px) and tablet (1024px) viewports
  • Verify Electron desktop app still works normally

Create packages/web/ that runs the existing Electron renderer
components in a standard browser via a WebSocket JSON-RPC shim.
The shim implements the full window.api surface, translating
IPC calls to RPC over the existing Fastify WebSocket server.

- New packages/web/ with Vite + React 19 + Tailwind
- api-shim.ts: 66-method WebSocket RPC bridge matching preload API
- Server: @fastify/static serves web dist at /app/ with SPA fallback
- Server: --host CLI arg for network binding
- Config: add webAccessEnabled/mobileAccessEnabled to defaults
- Fix: database.ts loadDefaults now reads web/mobile access flags
Add platform detection (isWeb/isElectron) and conditionally remove
Electron-specific UI: traffic light padding (pl-[78px]), WindowControls.
Grid cards use min(320px, 100%) for mobile viewport support.
…on settings (Phase 3)

- Replace dialog no-ops with HTML5 file inputs and prompt() for paths
- Add /api/task-images/:taskId/:filename HTTP route for image serving
- Add task:imageUpload RPC for base64 image upload from browser
- Fix file:// image URLs to use HTTP paths in web mode
- Hide Floating Widget and Update Channel settings on web (isElectron)
- Add PWA manifest, icons (192/512), theme-color, apple-mobile-web-app
- Add /api proxy to Vite dev server config
Add Network Access settings panel that detects Tailscale, shows QR code
for mobile connection, lists tailnet devices with online/offline status,
and binds server to 0.0.0.0 when enabled. Fix loadDefaults whitelist to
persist networkAccessEnabled config. Add responsive mobile layout with
hamburger menu, single-column grid, and collapsible sidebar.
Copilot AI review requested due to automatic review settings March 18, 2026 00:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an initial “web + mobile-friendly” mode for the renderer, backed by a WebSocket JSON-RPC shim and server-side static hosting under /app/, plus a new Network settings section for Tailscale-based remote access.

Changes:

  • Introduces a new packages/web Vite app that mounts a window.api shim and renders the existing renderer UI in the browser.
  • Extends the server to serve the web build at /app/, serve task images over HTTP, and expose Tailscale status for “Network Access”.
  • Improves mobile responsiveness (sidebar drawer, single-column grid, condensed top bar) and adds a new “Network” settings panel with QR code + device list.

Reviewed changes

Copilot reviewed 32 out of 36 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
yarn.lock Adds dependencies for web build, QR code, and Fastify static serving.
src/renderer/stores/types.ts Adds network settings category.
src/renderer/lib/platform.ts Adds isWeb / isElectron helpers for conditional rendering.
src/renderer/hooks/useIsMobile.ts Adds a viewport-based mobile detection hook.
src/renderer/components/TaskDetailPanel.tsx Uses file:// only in Electron for task images.
src/renderer/components/SettingsPage.tsx Adds “Network” section and adjusts titlebar padding for web vs Electron.
src/renderer/components/settings/NetworkSettings.tsx New Network Access UI (Tailscale status, toggle, URL copy, QR code, device list).
src/renderer/components/settings/GeneralSettings.tsx Hides Electron-only settings in web mode.
src/renderer/components/PromptLauncher.tsx Improves layout on smaller screens.
src/renderer/components/ProjectSidebar.tsx Adds mobile drawer behavior and hides resize handle on mobile.
src/renderer/components/GridView.tsx Switches to single-column layout on mobile.
src/renderer/components/FocusedTerminal.tsx Adjusts titlebar padding for web vs Electron.
src/renderer/components/AddTaskDialog.tsx Uses file:// only in Electron for images.
src/renderer/App.tsx Adds mobile UI tweaks (hamburger/menu behavior, condensed buttons) and web window-control handling.
packages/web/vite.config.ts New Vite config for web build (aliases, /app/ base, dev proxies).
packages/web/tsconfig.json New TS config for the web package.
packages/web/tailwind.config.ts Tailwind scan paths include shared renderer components.
packages/web/src/main.tsx Mounts the API shim, waits for WS readiness, then loads the renderer App.
packages/web/src/global.css Web-specific global styles (scrollbar, focus rings, titlebar no-ops).
packages/web/src/env.ts Determines WS URL based on current host/protocol.
packages/web/src/api-shim.ts Implements window.api surface over WebSocket JSON-RPC + web file/image handling.
packages/web/public/manifest.webmanifest Adds PWA manifest for the web app.
packages/web/public/icon-192.png Adds PWA icon asset.
packages/web/postcss.config.cjs Adds Tailwind + Autoprefixer config for web.
packages/web/package.json Adds new @vibegrid/web workspace package with Vite/Tailwind toolchain.
packages/web/index.html New web entry HTML for Vite app and initial loading message.
packages/shared/src/types.ts Adds Tailscale types and new default flags + IPC constants.
packages/server/src/task-images.ts Adds base64 image upload support for web mode.
packages/server/src/tailscale.ts Adds Tailscale binary discovery and status retrieval.
packages/server/src/register-methods.ts Registers tailscale:status and task:imageUpload JSON-RPC methods.
packages/server/src/index.ts Serves /app/ static build, adds HTTP task-image serving, and updates bind behavior for network access.
packages/server/src/database.ts Persists newly added default flags from DB.
packages/server/package.json Adds @fastify/static dependency.
package.json Adds build:web script and adds qrcode + @types/qrcode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +121 to +123
host = '0.0.0.0' // Bind to all interfaces so both localhost and Tailscale IP work
log.info(
`[server] network access enabled, binding to 0.0.0.0 (tailscale IP: ${tsStatus.selfIP})`
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept 0.0.0.0 binding — it's needed so both localhost (Electron desktop app) and the Tailscale IP work simultaneously. The Tailscale network itself provides authentication (only devices signed into the same tailnet can connect). Binding to just the Tailscale IP would break the Electron app which connects via localhost. Added a clearer comment explaining this trade-off.

}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream')
reply.header('Cache-Control', 'public, max-age=86400')
return reply.send(fs.readFileSync(filePath))
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: switched to fs.createReadStream() for streaming instead of buffering, and added X-Content-Type-Options: nosniff header.

Comment on lines +15 to +21
function exec(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout: 10_000 }, (err, stdout) => {
if (err) reject(err)
else resolve(stdout.trim())
})
})
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added env: getSafeEnv() to the execFile call, consistent with the rest of the server's subprocess hardening pattern.

Comment on lines +120 to +128
// Queue until connected
this._ready.then(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg)
} else {
this.pending.delete(id)
reject(new Error('WebSocket not connected'))
}
})
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: invoke() now captures readyAtCall at call time and checks if the pending request still exists before rejecting. If the socket closes and _ready is replaced, queued calls will see the socket isn't open and reject properly instead of hanging.

Comment on lines +168 to +178
input.addEventListener('change', () => {
const files = input.files ? Array.from(input.files) : null
document.body.removeChild(input)
resolve(files && files.length > 0 ? files : null)
})
// Handle cancel (user closes the dialog without selecting)
input.addEventListener('cancel', () => {
document.body.removeChild(input)
resolve(null)
})
input.click()
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added a window.focus fallback (with 300ms debounce) for browsers that don't fire the cancel event on file inputs. Also ensures cleanup always runs via a resolved flag to prevent double-resolve.

Comment on lines +120 to +125
const copy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}, [text])
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added .catch() rejection handler with window.prompt() fallback so users can still copy the URL in non-HTTPS contexts.

Comment on lines +245 to +252
const allDevices = [
{
ip: status.selfIP,
hostname: status.selfDNSName.split('.')[0] || 'This device',
os: 'macOS',
online: true,
isSelf: true
},
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added selfOS field to TailscaleStatus interface, populated from status.Self.OS in the Tailscale JSON response. The DeviceList now uses status.selfOS || 'unknown' instead of the hard-coded 'macOS'.

Comment on lines +302 to +311
useEffect(() => {
QRCode.toDataURL(url, {
width: 200,
margin: 2,
color: {
dark: '#ffffffFF',
light: '#00000000'
}
}).then(setDataUrl)
}, [url])
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added .catch() for error handling and a mounted flag in the effect cleanup to prevent state updates after unmount.

Comment on lines +73 to +89
export function saveTaskImageFromBase64(
taskId: string,
base64Data: string,
originalFilename: string
): string {
if (!isSafeId(taskId)) throw new Error(`Invalid taskId: ${taskId}`)

const taskDir = resolveSafePath(taskId)
ensureDir(taskDir)

const ext = path.extname(originalFilename) || '.png'
const filename = `${randomUUID()}${ext}`
const destPath = resolveSafePath(taskId, filename)

const buffer = Buffer.from(base64Data, 'base64')
fs.writeFileSync(destPath, buffer)
return filename
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: added a 10MB decoded size limit (MAX_IMAGE_SIZE), an allowlist of image extensions (.png, .jpg, .jpeg, .gif, .webp, .bmp, .svg), and validates both before allocating the buffer.

Comment on lines +459 to +460
TAILSCALE_STATUS: 'tailscale:status',
TAILSCALE_ENABLE: 'tailscale:enable'
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e40bae1: removed the unused TAILSCALE_ENABLE constant. The enable/disable toggle works through config:save (updating the networkAccessEnabled default), so no dedicated IPC method is needed.

# Conflicts:
#	.yarn/install-state.gz
#	packages/server/src/register-methods.ts
#	packages/shared/src/types.ts
#	src/renderer/components/GridView.tsx
#	yarn.lock
Security:
- Stream task images instead of buffering (X-Content-Type-Options: nosniff)
- Use getSafeEnv() for Tailscale subprocess execution
- Add 10MB size limit and extension validation for base64 image uploads
- Remove unused TAILSCALE_ENABLE IPC constant

Reliability:
- Fix WebSocket invoke() hang when connection drops mid-queue
- Add focus-based fallback for file picker cancel detection (Safari)
- Clean up pendingImageFiles after each upload to prevent stale references
- Add Safari <14 addListener fallback for matchMedia in useIsMobile
- Handle clipboard API errors with prompt() fallback
- Add unmount guard and error handling for QR code generation

Correctness:
- Fix product name typo: VibGrid → VibeGrid in PWA manifest, HTML, env.ts
- Use dynamic selfOS from Tailscale status instead of hard-coded 'macOS'
- Add **/*.config.cjs to eslint ignores (CJS files lack node globals)
- TriggerNode: use static icon map instead of render-time getIcon() call
- SlashCommandMenu: suppress pre-existing any/refresh warnings, fix deps
- NewAgentDialog: suppress set-state-in-effect for intentional bulk resets
@jcanizalez jcanizalez merged commit 1ac22a6 into main Mar 18, 2026
1 check passed
@jcanizalez jcanizalez deleted the feat/web-app branch March 18, 2026 00:54
This was referenced Mar 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants