From 1ff9ca8326daca97d9227a77f0ea759e4837ce03 Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 17:07:43 +0530 Subject: [PATCH 1/7] feat(ui): redesign create page and active environments section Overhaul the Spritz UI with a cleaner design system: - Switch font to Inter, color scheme to black & white - Restructure create form with 2-column layout and explicit field rows - Add animated collapsible advanced configuration section (CSS grid) - Redesign active environments list with divider-based items - Add loading states for create, delete, and refresh buttons - Add skeleton shimmer loading for environment list - Add focus/hover/active states and transitions on all inputs and buttons - Make delete button destructive with red styling and trash icon - Add scrollbar-gutter to prevent layout shift - Change default route to conversation page, create page at #create - Add dev server script --- ui/package.json | 1 + ui/public/index.html | 115 ++++++----- ui/public/styles.css | 403 +++++++++++++++++++++++++++++++++----- ui/scripts/dev-server.mjs | 290 +++++++++++++++++++++++++++ ui/src/acp-page.ts | 4 +- ui/src/app.ts | 94 +++++++-- 6 files changed, 788 insertions(+), 119 deletions(-) create mode 100644 ui/scripts/dev-server.mjs diff --git a/ui/package.json b/ui/package.json index 9e66b19..28eb859 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "build": "tsdown && node scripts/copy-static.mjs", + "dev": "node scripts/dev-server.mjs", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "node --test entrypoint.test.mjs nginx-config.test.mjs public/*.test.mjs" }, diff --git a/ui/public/index.html b/ui/public/index.html index 45e9efd..3b72939 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -8,55 +8,68 @@
-
+
+

Create environment

+

Spin up an ephemeral development environment managed by API.

+
-

Create

- - - - - - - - + > + + Provide shared mounts, ttl, repo, env, or resources. JSON is also accepted. + + + + + + +
-
-
-

Active spritzes

- -
+
+

Active environments

+ +
+
diff --git a/ui/public/styles.css b/ui/public/styles.css index b45911b..feabeed 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -1,10 +1,15 @@ -@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +html { + overflow-y: scroll; + scrollbar-gutter: stable; +} :root { color-scheme: light; - font-family: "Space Grotesk", "IBM Plex Sans", system-ui, sans-serif; - background: radial-gradient(circle at top left, #fdf4e3 0%, #f8f7f3 35%, #eef0f3 100%); - color: #1f2a33; + font-family: "Inter", system-ui, sans-serif; + background: #ffffff; + color: #000000; } body { @@ -44,9 +49,9 @@ header p { justify-content: center; padding: 10px 16px; border-radius: 999px; - background: rgba(46, 58, 70, 0.08); - border: 1px solid rgba(46, 58, 70, 0.14); - color: #2e3a46; + background: rgba(0, 0, 0, 0.06); + border: 1px solid rgba(0, 0, 0, 0.12); + color: #000000; text-decoration: none; white-space: nowrap; } @@ -84,9 +89,9 @@ header p { padding: 14px 16px; border-radius: 16px; border: 1px solid rgba(255, 90, 90, 0.28); - background: rgba(29, 37, 46, 0.94); - color: #f5f7fa; - box-shadow: 0 18px 40px rgba(12, 18, 28, 0.24); + background: rgba(0, 0, 0, 0.92); + color: #ffffff; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.2); pointer-events: auto; } @@ -103,37 +108,130 @@ header p { padding: 0; border: none; background: transparent; - color: rgba(245, 247, 250, 0.84); + color: rgba(255, 255, 255, 0.84); font: inherit; cursor: pointer; } .card { - background: white; + background: #ffffff; border-radius: 20px; - padding: 24px; - box-shadow: 0 18px 40px rgba(12, 18, 28, 0.08); + padding: 32px; + border: 1px solid #e5e5e5; } form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.page-title { + font-size: 24px; + font-weight: 600; + margin: 0; +} + +.page-subtitle { + margin: -16px 0 0; + opacity: 0.6; + font-size: 15px; +} + +.form-row { + display: flex; + gap: 24px; +} + +.form-row > label { + flex: 1; +} + +@media (max-width: 540px) { + .form-row { + flex-direction: column; + } +} + +.advanced-config { + border-top: 1px solid #e5e5e5; + padding-top: 24px; +} + +.advanced-config-toggle { + background: none; + border: none; + padding: 0; + color: #000000; + font-size: 14px; + font-weight: 500; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 12px; +} + +.advanced-config-toggle::before { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.25s ease; +} + +.advanced-config.open .advanced-config-toggle::before { + transform: rotate(45deg); +} + +.advanced-config-body { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease; +} + +.advanced-config.open .advanced-config-body { + grid-template-rows: 1fr; +} + +.advanced-config-inner { + overflow: hidden; + padding-top: 0; +} + +.advanced-config-inner::before { + content: ''; + display: block; + height: 16px; } .preset-panel { - grid-column: 1 / -1; display: flex; flex-direction: column; - gap: 8px; + gap: 10px; + padding-bottom: 4px; } .preset-panel select { - padding: 10px 12px; + padding: 12px 14px; border-radius: 10px; - border: 1px solid #d7dbe0; + border: 1px solid #e5e5e5; font-size: 14px; + font-weight: 400; background: white; + transition: border-color 0.15s ease; +} + +.preset-panel select:hover { + border-color: #cccccc; +} + +.preset-panel select:focus-visible { + outline: none; + border-color: #000000; } .preset-help { @@ -144,90 +242,293 @@ form { label { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; font-size: 14px; + font-weight: 500; } input { - padding: 10px 12px; + padding: 12px 14px; border-radius: 10px; - border: 1px solid #d7dbe0; + border: 1px solid #e5e5e5; font-size: 14px; + font-weight: 400; + transition: border-color 0.15s ease; +} + +input:hover { + border-color: #cccccc; +} + +input:focus-visible { + outline: none; + border-color: #000000; +} + +input::placeholder { + color: #999999; } .name-field { display: flex; - gap: 8px; + gap: 4px; align-items: center; } .name-field input { flex: 1; + border-radius: 10px 4px 4px 10px; +} + +.name-field .ghost { + border-radius: 4px 10px 10px 4px; } textarea { - padding: 10px 12px; + padding: 12px 14px; border-radius: 10px; - border: 1px solid #d7dbe0; + border: 1px solid #e5e5e5; font-size: 13px; font-family: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace; resize: vertical; min-height: 140px; + transition: border-color 0.15s ease; } -small.hint { - font-size: 12px; - opacity: 0.7; +textarea:hover { + border-color: #cccccc; +} + +textarea:focus-visible { + outline: none; + border-color: #000000; +} + +textarea::placeholder { + color: #999999; } -.span-all { - grid-column: 1 / -1; +small.hint { + font-size: 12px; + font-weight: 400; + opacity: 0.6; } button { - padding: 10px 16px; + padding: 16px 22px; border: none; border-radius: 999px; - background: #2e3a46; + background: #000000; color: white; cursor: pointer; + font-size: 14px; + font-weight: 400; + transition: opacity 0.15s ease; +} + +button:hover { + opacity: 0.8; +} + +button:active { + opacity: 0.7; +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +form > button[type="submit"] { + align-self: flex-start; + margin-top: 4px; + display: inline-flex; + align-items: center; + gap: 8px; +} + +#name-random{ + padding:12px 20px; } button.ghost { background: white; - border: 1px solid #d7dbe0; - color: #2e3a46; + border: 1px solid #e5e5e5; + color: #000000; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +button.ghost:hover { + border-color: #cccccc; + background: #fafafa; + opacity: 1; +} + +button.ghost:active { + background: #f5f5f5; + opacity: 1; } .list-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; +} + +.list-header .ghost { + padding: 8px 14px; + font-size: 13px; +} + +.icon-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.icon-btn svg { + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +button.loading { + pointer-events: none; + opacity: 0.6; +} + +button.loading svg { + animation: spin 0.8s linear infinite; } #list { - display: grid; - gap: 12px; + display: flex; + flex-direction: column; + gap: 0; +} + +.list-empty { + padding: 32px 0; + text-align: center; +} + +.list-empty p { + margin: 0; + font-size: 14px; + opacity: 0.4; +} + +.list-empty p:first-child { + font-weight: 500; + opacity: 0.6; + margin-bottom: 4px; } .spritz-item { display: flex; justify-content: space-between; align-items: center; - border: 1px solid #e5e8ec; - padding: 12px 16px; - border-radius: 12px; + padding: 16px 0; + border-bottom: 1px solid #e5e5e5; +} + +.spritz-item:last-child { + border-bottom: none; +} + +.spritz-item strong { + font-size: 14px; + font-weight: 500; } .spritz-item small { display: block; - opacity: 0.6; + opacity: 0.5; + font-size: 13px; + margin-top: 2px; } .actions { display: flex; - gap: 8px; + gap: 6px; + flex-shrink: 0; +} + +.actions button { + padding: 8px 14px; + font-size: 13px; + background: white; + border: 1px solid #e5e5e5; + color: #000000; + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.actions button:hover { + border-color: #cccccc; + background: #fafafa; + opacity: 1; +} + +.actions button:active { + background: #f5f5f5; + opacity: 1; +} + +.actions button.destructive { + color: #dc2626; + border-color: rgba(220, 38, 38, 0.25); +} + +.actions button.destructive:hover { + background: rgba(220, 38, 38, 0.06); + border-color: rgba(220, 38, 38, 0.4); +} + +.actions button.destructive:active { + background: rgba(220, 38, 38, 0.1); +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-line { + height: 14px; + border-radius: 6px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease infinite; +} + +.skeleton-name { + width: 120px; + margin-bottom: 6px; +} + +.skeleton-meta { + width: 200px; + height: 12px; +} + +.skeleton-info { + flex: 1; +} + +.skeleton-actions { + display: flex; + gap: 6px; +} + +.skeleton-btn { + width: 64px; + height: 32px; + border-radius: 999px; + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease infinite; } .terminal-card { @@ -270,7 +571,7 @@ button.ghost { .acp-sidebar { display: flex; flex-direction: column; - border-right: 1px solid #e5e8ec; + border-right: 1px solid #e5e5e5; background: rgba(247, 247, 248, 0.96); min-height: 0; overflow: hidden; @@ -281,7 +582,7 @@ button.ghost { flex-direction: column; gap: 16px; padding: 20px; - border-bottom: 1px solid #e5e8ec; + border-bottom: 1px solid #e5e5e5; background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(247, 247, 248, 0.94)); flex-shrink: 0; } @@ -314,9 +615,9 @@ button.ghost { width: 100%; padding: 12px 14px; border-radius: 16px; - border: 1px solid #d7dbe0; + border: 1px solid #e5e5e5; background: white; - color: #1f2a33; + color: #000000; font: inherit; } @@ -358,7 +659,7 @@ button.ghost { display: inline-flex; align-items: center; justify-content: center; - background: #2e3a46; + background: #000000; color: white; font-size: 13px; font-weight: 600; @@ -540,7 +841,7 @@ button.ghost { } .acp-message--user .acp-bubble { - background: #2e3a46; + background: #000000; color: white; border-color: transparent; border-bottom-right-radius: 10px; @@ -766,7 +1067,7 @@ button.ghost { .terminal-back { font-size: 14px; - color: #2e3a46; + color: #000000; text-decoration: none; } @@ -807,7 +1108,7 @@ button.ghost { .acp-sidebar { min-height: 0; border-right: 0; - border-bottom: 1px solid #e5e8ec; + border-bottom: 1px solid #e5e5e5; } .acp-main { diff --git a/ui/scripts/dev-server.mjs b/ui/scripts/dev-server.mjs new file mode 100644 index 0000000..410fa9a --- /dev/null +++ b/ui/scripts/dev-server.mjs @@ -0,0 +1,290 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const uiDir = path.dirname(__dirname); +const distDir = path.join(uiDir, 'dist'); + +const port = Number.parseInt(process.env.PORT || '8081', 10); +const apiOrigin = process.env.SPRITZ_UI_DEV_API_ORIGIN || 'http://127.0.0.1:8090'; +const ownerId = process.env.SPRITZ_UI_OWNER_ID || 'local-dev'; + +let assetVersion = `${Date.now()}`; +let buildInFlight = false; +let buildQueued = false; +let buildOk = false; +let lastBuildError = ''; +let watchTimer = null; +const liveReloadClients = new Set(); + +function runtimeReplacements() { + return { + '__SPRITZ_API_BASE_URL__': '/api', + '__SPRITZ_OWNER_ID__': ownerId, + '__SPRITZ_UI_PRESETS__': process.env.SPRITZ_UI_PRESETS || 'null', + '__SPRITZ_UI_DEFAULT_REPO_URL__': process.env.SPRITZ_UI_DEFAULT_REPO_URL || '', + '__SPRITZ_UI_DEFAULT_REPO_DIR__': process.env.SPRITZ_UI_DEFAULT_REPO_DIR || '', + '__SPRITZ_UI_DEFAULT_REPO_BRANCH__': process.env.SPRITZ_UI_DEFAULT_REPO_BRANCH || '', + '__SPRITZ_UI_HIDE_REPO_INPUTS__': process.env.SPRITZ_UI_HIDE_REPO_INPUTS || '', + '__SPRITZ_UI_LAUNCH_QUERY_PARAMS__': process.env.SPRITZ_UI_LAUNCH_QUERY_PARAMS || '', + '__SPRITZ_UI_AUTH_MODE__': process.env.SPRITZ_UI_AUTH_MODE || '', + '__SPRITZ_UI_AUTH_TOKEN_STORAGE__': process.env.SPRITZ_UI_AUTH_TOKEN_STORAGE || '', + '__SPRITZ_UI_AUTH_TOKEN_STORAGE_KEYS__': process.env.SPRITZ_UI_AUTH_TOKEN_STORAGE_KEYS || '', + '__SPRITZ_UI_AUTH_BEARER_TOKEN_PARAM__': process.env.SPRITZ_UI_AUTH_BEARER_TOKEN_PARAM || '', + '__SPRITZ_UI_AUTH_LOGIN_URL__': process.env.SPRITZ_UI_AUTH_LOGIN_URL || '', + '__SPRITZ_UI_AUTH_RETURN_TO_MODE__': process.env.SPRITZ_UI_AUTH_RETURN_TO_MODE || '', + '__SPRITZ_UI_AUTH_RETURN_TO_PARAM__': process.env.SPRITZ_UI_AUTH_RETURN_TO_PARAM || '', + '__SPRITZ_UI_AUTH_REDIRECT_ON_UNAUTHORIZED__': process.env.SPRITZ_UI_AUTH_REDIRECT_ON_UNAUTHORIZED || '', + '__SPRITZ_UI_AUTH_REFRESH_ENABLED__': process.env.SPRITZ_UI_AUTH_REFRESH_ENABLED || '', + '__SPRITZ_UI_AUTH_REFRESH_URL__': process.env.SPRITZ_UI_AUTH_REFRESH_URL || '', + '__SPRITZ_UI_AUTH_REFRESH_METHOD__': process.env.SPRITZ_UI_AUTH_REFRESH_METHOD || '', + '__SPRITZ_UI_AUTH_REFRESH_CREDENTIALS__': process.env.SPRITZ_UI_AUTH_REFRESH_CREDENTIALS || '', + '__SPRITZ_UI_AUTH_REFRESH_TOKEN_STORAGE_KEYS__': process.env.SPRITZ_UI_AUTH_REFRESH_TOKEN_STORAGE_KEYS || '', + '__SPRITZ_UI_AUTH_REFRESH_TIMEOUT_MS__': process.env.SPRITZ_UI_AUTH_REFRESH_TIMEOUT_MS || '', + '__SPRITZ_UI_AUTH_REFRESH_COOLDOWN_MS__': process.env.SPRITZ_UI_AUTH_REFRESH_COOLDOWN_MS || '', + '__SPRITZ_UI_AUTH_REFRESH_HEADERS__': process.env.SPRITZ_UI_AUTH_REFRESH_HEADERS || '', + '__SPRITZ_UI_ASSET_VERSION__': assetVersion, + }; +} + +function transformRuntimeContent(fileName, content) { + let next = content; + for (const [placeholder, value] of Object.entries(runtimeReplacements())) { + next = next.split(placeholder).join(value); + } + if (fileName === 'index.html') { + next = next.replace( + '', + ``, + ); + } + return next; +} + +function notifyReload() { + for (const client of liveReloadClients) { + client.write('data: reload\n\n'); + } +} + +function runBuild() { + if (buildInFlight) { + buildQueued = true; + return; + } + buildInFlight = true; + const child = spawn('pnpm', ['build'], { + cwd: uiDir, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }); + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + output += text; + process.stderr.write(text); + }); + child.on('close', (code) => { + buildInFlight = false; + if (code === 0) { + buildOk = true; + lastBuildError = ''; + assetVersion = `${Date.now()}`; + notifyReload(); + process.stdout.write('[spritz-ui] rebuild complete\n'); + } else { + buildOk = false; + lastBuildError = output || `build failed with exit code ${code}`; + process.stderr.write('[spritz-ui] rebuild failed\n'); + } + if (buildQueued) { + buildQueued = false; + runBuild(); + } + }); +} + +function queueBuild(reason) { + if (watchTimer) { + clearTimeout(watchTimer); + } + watchTimer = setTimeout(() => { + process.stdout.write(`[spritz-ui] change detected: ${reason}\n`); + runBuild(); + }, 120); +} + +function watchPath(target) { + fs.watch(target, { recursive: true }, (_eventType, fileName) => { + queueBuild(`${path.relative(uiDir, target)}${fileName ? `/${fileName}` : ''}`); + }); +} + +function contentType(filePath) { + switch (path.extname(filePath)) { + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'application/javascript; charset=utf-8'; + case '.css': + return 'text/css; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + default: + return 'application/octet-stream'; + } +} + +function writeBuildError(res) { + res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' }); + res.end(buildInFlight ? 'UI build in progress...\n' : `UI build failed.\n\n${lastBuildError}\n`); +} + +function proxyRequest(req, res) { + const target = new URL(req.url, apiOrigin); + const proxyReq = http.request( + target, + { + method: req.method, + headers: { + ...req.headers, + host: target.host, + connection: 'close', + }, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); + proxyRes.pipe(res); + }, + ); + proxyReq.on('error', (error) => { + res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }); + res.end(`proxy error: ${error.message}\n`); + }); + req.pipe(proxyReq); +} + +function serveLiveReload(req, res) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + }); + res.write('\n'); + liveReloadClients.add(res); + req.on('close', () => { + liveReloadClients.delete(res); + }); +} + +function serveFile(req, res) { + if (!buildOk) { + writeBuildError(res); + return; + } + const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`); + let filePath = path.join(distDir, decodeURIComponent(url.pathname)); + if (url.pathname === '/') { + filePath = path.join(distDir, 'index.html'); + } + if (!filePath.startsWith(distDir)) { + res.writeHead(403); + res.end('forbidden\n'); + return; + } + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + filePath = path.join(distDir, 'index.html'); + } + const baseName = path.basename(filePath); + if (baseName === 'index.html' || baseName === 'config.js') { + const content = fs.readFileSync(filePath, 'utf8'); + res.writeHead(200, { 'content-type': contentType(filePath), 'cache-control': 'no-store' }); + res.end(transformRuntimeContent(baseName, content)); + return; + } + const headers = { 'content-type': contentType(filePath) }; + if (/\.(?:js|css)$/.test(filePath)) { + headers['cache-control'] = 'no-store'; + } + res.writeHead(200, headers); + fs.createReadStream(filePath).pipe(res); +} + +runBuild(); +watchPath(path.join(uiDir, 'src')); +watchPath(path.join(uiDir, 'public')); +watchPath(path.join(uiDir, 'scripts')); + +const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end('bad request\n'); + return; + } + if (req.url === '/__spritz/live-reload') { + serveLiveReload(req, res); + return; + } + if (req.url.startsWith('/api/')) { + proxyRequest(req, res); + return; + } + serveFile(req, res); +}); + +server.on('upgrade', (req, socket, head) => { + if (!req.url || !req.url.startsWith('/api/')) { + socket.destroy(); + return; + } + const target = new URL(req.url, apiOrigin); + const proxyReq = http.request({ + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + path: target.pathname + target.search, + method: req.method, + headers: { + ...req.headers, + host: target.host, + connection: 'Upgrade', + upgrade: req.headers.upgrade || 'websocket', + }, + }); + + proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + const headers = Object.entries(proxyRes.headers) + .map(([key, value]) => `${key}: ${value}`) + .join('\r\n'); + socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headers}\r\n\r\n`); + if (proxyHead.length > 0) { + socket.write(proxyHead); + } + if (head.length > 0) { + proxySocket.write(head); + } + proxySocket.pipe(socket).pipe(proxySocket); + }); + + proxyReq.on('error', () => socket.destroy()); + proxyReq.end(); +}); + +server.listen(port, '127.0.0.1', () => { + process.stdout.write(`spritz ui dev server listening on http://127.0.0.1:${port}\n`); +}); diff --git a/ui/src/acp-page.ts b/ui/src/acp-page.ts index 129df37..bb06264 100644 --- a/ui/src/acp-page.ts +++ b/ui/src/acp-page.ts @@ -1119,9 +1119,9 @@ const nav = document.createElement('div'); nav.className = 'acp-sidebar-nav'; const backLink = document.createElement('a'); - backLink.href = '/'; + backLink.href = '#create'; backLink.className = 'header-link'; - backLink.textContent = 'Back'; + backLink.textContent = 'Create'; const refreshButton = document.createElement('button'); refreshButton.type = 'button'; refreshButton.className = 'ghost'; diff --git a/ui/src/app.ts b/ui/src/app.ts index c7c898c..b2de7ed 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -888,7 +888,22 @@ async function suggestSpritzName() { return String(data?.name || '').trim(); } +function renderSkeletons(count = 3) { + if (!listEl) return; + listEl.innerHTML = ''; + for (let i = 0; i < count; i++) { + const item = document.createElement('div'); + item.className = 'spritz-item skeleton-item'; + item.innerHTML = + '
' + + '
'; + listEl.appendChild(item); + } +} + async function fetchSpritzes() { + const wasEmpty = listEl && !listEl.children.length; + if (wasEmpty) renderSkeletons(); try { const data = await request('/spritzes'); renderList(data.items || []); @@ -967,7 +982,7 @@ function describeChatAction(spritz) { function renderList(items) { if (!items.length) { - listEl.innerHTML = '

No spritzes yet.

'; + listEl.innerHTML = '

No environments yet

Create one above to get started.

'; return; } listEl.innerHTML = ''; @@ -1067,13 +1082,19 @@ function renderList(items) { } const deleteBtn = document.createElement('button'); - deleteBtn.textContent = 'Delete'; + deleteBtn.className = 'destructive icon-btn'; + deleteBtn.innerHTML = ' Delete'; deleteBtn.onclick = async () => { + const deleteBtnHtml = deleteBtn.innerHTML; + deleteBtn.disabled = true; + deleteBtn.textContent = 'Deleting…'; try { await request(`/spritzes/${spritz.metadata?.name}`, { method: 'DELETE' }); await fetchSpritzes(); } catch (err) { - showNotice(err.message || 'Failed to delete spritz.'); + showNotice(err.message || 'Failed to delete environment.'); + deleteBtn.disabled = false; + deleteBtn.innerHTML = deleteBtnHtml; } }; @@ -1409,9 +1430,25 @@ function setupPresets() { }); } +function showCreatePage() { + if (activeTerminalName) cleanupTerminal(); + if (activeACPPage) cleanupACP(); + if (createSection) createSection.hidden = false; + if (listSection) listSection.hidden = false; + setHeaderCopy('Spritz', 'Ephemeral dev environments, managed by API.'); + if (form && refreshBtn) { + applyNameDefaults(); + applyRepoDefaults(); + applyUserConfigDefaults(); + setupPresets(); + restoreCreateFormState(); + fetchSpritzes(); + } +} + function handleRoute() { const chatName = chatNameFromPath(); - if (window.location.hash === '#chat' || chatName) { + if (chatName) { renderACPPage(chatName); return; } @@ -1419,22 +1456,25 @@ function handleRoute() { if (terminalName) { cleanupACP(); renderTerminalPage(terminalName); + return; + } + if (window.location.hash === '#create') { + showCreatePage(); } else { - if (activeTerminalName) cleanupTerminal(); - if (activeACPPage) cleanupACP(); - setHeaderCopy('Spritz', 'Ephemeral dev environments, managed by API.'); - if (form && refreshBtn) { - applyNameDefaults(); - applyRepoDefaults(); - applyUserConfigDefaults(); - setupPresets(); - restoreCreateFormState(); - fetchSpritzes(); - } + // Default: show conversation page (handles '', '#', '#chat') + renderACPPage(''); } } window.addEventListener('hashchange', handleRoute); +const advancedToggle = document.querySelector('.advanced-config-toggle'); +if (advancedToggle) { + advancedToggle.addEventListener('click', () => { + const section = advancedToggle.closest('.advanced-config'); + if (section) section.classList.toggle('open'); + }); +} + if (form && refreshBtn) { const persistCreateFormStateFromEvent = () => { persistCreateFormState(); @@ -1530,6 +1570,12 @@ if (form && refreshBtn) { } } + const submitBtn = form.querySelector('button[type="submit"]'); + const submitBtnHtml = submitBtn ? submitBtn.innerHTML : ''; + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Creating…'; + } try { await request('/spritzes', { method: 'POST', @@ -1545,11 +1591,25 @@ if (form && refreshBtn) { await fetchSpritzes(); showNotice(''); } catch (err) { - showNotice(err.message || 'Failed to create spritz.'); + showNotice(err.message || 'Failed to create environment.'); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = submitBtnHtml; + } } }); - refreshBtn.addEventListener('click', fetchSpritzes); + refreshBtn.addEventListener('click', async () => { + refreshBtn.disabled = true; + refreshBtn.classList.add('loading'); + try { + await fetchSpritzes(); + } finally { + refreshBtn.disabled = false; + refreshBtn.classList.remove('loading'); + } + }); } clearAuthRedirectFlag(); From 15f4e096288385673267dab04173f86da37354d8 Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 17:12:54 +0530 Subject: [PATCH 2/7] fix(ui): improve mobile responsiveness - Stack form rows vertically on small screens - Join name input and Random button flush on mobile - Stack spritz items vertically with actions below info - Reduce card padding, title size, and shell spacing - Full-width create button on mobile - Wrap action buttons and list header --- ui/public/styles.css | 62 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/ui/public/styles.css b/ui/public/styles.css index feabeed..d846f7b 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -148,8 +148,67 @@ form { } @media (max-width: 540px) { + .shell { + padding: 24px 16px; + gap: 16px; + } + + .card { + padding: 20px; + border-radius: 16px; + } + + .page-title { + font-size: 20px; + } + + .page-subtitle { + margin-top: -10px; + font-size: 13px; + } + + form { + gap: 18px; + } + .form-row { flex-direction: column; + gap: 18px; + } + + .name-field { + gap: 0; + } + + .name-field input { + border-radius: 10px 0 0 10px; + min-width: 0; + } + + .name-field .ghost { + border-radius: 0 10px 10px 0; + white-space: nowrap; + } + + .spritz-item { + flex-direction: column; + align-items: flex-start; + gap: 10px; + padding: 14px 0; + } + + .actions { + flex-wrap: wrap; + } + + .list-header { + flex-wrap: wrap; + gap: 8px; + } + + form > button[type="submit"] { + align-self: stretch; + justify-content: center; } } @@ -429,7 +488,8 @@ button.loading svg { .spritz-item { display: flex; justify-content: space-between; - align-items: center; + gap:16px; + align-items: start; padding: 16px 0; border-bottom: 1px solid #e5e5e5; } From 650193635e8574b95ea0deb39ddf1b46de4ee5ce Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 17:15:09 +0530 Subject: [PATCH 3/7] fix(ui): update test to use #create route for list actions The default route now renders the conversation page, so the list actions test needs hash set to #create to trigger spritz list rendering. --- ui/public/app-list-actions.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/public/app-list-actions.test.mjs b/ui/public/app-list-actions.test.mjs index 24cb923..36d0a5a 100644 --- a/ui/public/app-list-actions.test.mjs +++ b/ui/public/app-list-actions.test.mjs @@ -121,7 +121,7 @@ test('spritz list shows a transitional chat action while workspace chat is still }, }, location: { - hash: '', + hash: '#create', pathname: '/', search: '', origin: 'http://example.test', From 27185f559bb17849c9a4034fa7990e8c637a652a Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 17:30:29 +0530 Subject: [PATCH 4/7] fix(ui): optimistic delete with deferred refresh Remove the item immediately on successful delete, show empty state if list is now empty, and refresh in the background after a short delay. --- ui/src/app.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/src/app.ts b/ui/src/app.ts index b2de7ed..fd318f0 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -1090,7 +1090,14 @@ function renderList(items) { deleteBtn.textContent = 'Deleting…'; try { await request(`/spritzes/${spritz.metadata?.name}`, { method: 'DELETE' }); - await fetchSpritzes(); + item.remove(); + if (!listEl.children.length) { + renderList([]); + } + showNotice('Environment deleted.', 'info'); + window.setTimeout(() => { + fetchSpritzes().catch(() => {}); + }, 250); } catch (err) { showNotice(err.message || 'Failed to delete environment.'); deleteBtn.disabled = false; From 73fc9b30a0bb97610c8348d2fc71ad2b5487498b Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 22:32:30 +0530 Subject: [PATCH 5/7] feat(ui): redesign chat console with ChatGPT-inspired layout - Full-screen chat view with no card wrapper - Sidebar: icon nav, compact thread list (title only), grouped New chat + Spritzes buttons - Main header: compact with refresh + open workspace icons - Messages: borderless assistant replies, left-aligned tool/system cards - Composer: ChatGPT-style with auto-grow textarea, fade gradient, send/stop toggle - Markdown rendering: headings, bold, italic, inline code, links, lists - Tooltip system via data-tooltip attribute with bottom position variant - Grid loader component for connection status - Auto-create conversation on page load - Rename "environments" to "Spritzes" throughout - Permission box and event cards redesigned - Fix Open button for same-origin hash navigation --- ui/public/index.html | 14 +- ui/public/styles.css | 563 +++++++++++++++++++++++++++++++++---------- ui/src/acp-page.ts | 168 +++++++------ ui/src/acp-render.ts | 92 ++++++- ui/src/app.ts | 24 +- 5 files changed, 626 insertions(+), 235 deletions(-) diff --git a/ui/public/index.html b/ui/public/index.html index 3b72939..10e56f6 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -12,7 +12,7 @@

Spritz

-

Ephemeral dev environments, managed by API.

+

Ephemeral dev Spritzes, managed by API.

Create
@@ -20,8 +20,9 @@

Spritz

-

Create environment

-

Spin up an ephemeral development environment managed by API.

+
+

Create Spritz

+

Spin up an ephemeral dev Spritz managed by API.

@@ -86,17 +87,20 @@

Create environment

- +
+
+
-

Active environments

+

Active Spritzes

+
diff --git a/ui/public/styles.css b/ui/public/styles.css index d846f7b..2e0863e 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -5,6 +5,49 @@ html { scrollbar-gutter: stable; } +/* Tooltip */ +[data-tooltip] { + position: relative; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px); + padding: 5px 10px; + border-radius: 8px; + background: #1a1a1a; + color: #fff; + font-size: 12px; + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s ease; + z-index: 100; +} + +[data-tooltip]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +[data-tooltip=""]::after { + display: none; +} + +[data-tooltip-pos="bottom"]::after { + bottom: auto; + top: calc(100% + 6px); + transform: translateX(-50%) translateY(-4px); +} + +[data-tooltip-pos="bottom"]:hover::after { + transform: translateX(-50%) translateY(0); +} + :root { color-scheme: light; font-family: "Inter", system-ui, sans-serif; @@ -126,6 +169,16 @@ form { gap: 24px; } +#create-section, +#list-section { + display: contents; +} + +#create-section[hidden], +#list-section[hidden] { + display: none; +} + .page-title { font-size: 24px; font-weight: 600; @@ -604,10 +657,10 @@ button.loading svg { max-width: none; height: 100dvh; min-height: 100dvh; - padding: 20px; + padding: 0; box-sizing: border-box; overflow: hidden; - gap: 12px; + gap: 0; } .shell[data-view="chat"] > header { @@ -622,7 +675,7 @@ button.loading svg { .acp-shell { flex: 1; display: grid; - grid-template-columns: 320px minmax(0, 1fr); + grid-template-columns: 260px minmax(0, 1fr); min-height: 0; padding: 0; overflow: hidden; @@ -632,7 +685,7 @@ button.loading svg { display: flex; flex-direction: column; border-right: 1px solid #e5e5e5; - background: rgba(247, 247, 248, 0.96); + background: #fafafa; min-height: 0; overflow: hidden; } @@ -640,162 +693,212 @@ button.loading svg { .acp-sidebar-top { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; - border-bottom: 1px solid #e5e5e5; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(247, 247, 248, 0.94)); + gap: 8px; + padding: 12px; + background: #fafafa; flex-shrink: 0; + border-bottom: 1px solid #e5e5e5; +} + +.acp-sidebar-actions { + display: flex; + flex-direction: column; + gap: 8px; } .acp-sidebar-nav { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 8px; } -.acp-sidebar-title { - display: flex; - flex-direction: column; - gap: 6px; +.acp-nav-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 10px; + border: 1px solid #e5e5e5; + background: white; + color: #000000; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.acp-nav-icon:hover { + background: #f5f5f5; + border-color: #cccccc; + opacity: 1; } -.acp-sidebar-title h2, .acp-main-copy h2 { margin: 0; } -.acp-sidebar-title p, .acp-main-copy p { margin: 0; - opacity: 0.68; + opacity: 0.6; } .acp-agent-select { width: 100%; - padding: 12px 14px; - border-radius: 16px; + padding: 8px 10px; + border-radius: 8px; border: 1px solid #e5e5e5; background: white; color: #000000; font: inherit; + font-size: 13px; +} + +.acp-new-chat-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #e5e5e5; + background: white; + color: #000000; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.12s ease, border-color 0.12s ease; + flex-shrink: 0; + box-sizing: border-box; +} + +.acp-new-chat-item:hover { + background: #f5f5f5; + border-color: #cccccc; + opacity: 1; +} + +.acp-new-chat-item svg { + flex-shrink: 0; + opacity: 0.6; +} + +.acp-back-link { + border: 1px dashed #e5e5e5; + background: transparent; + font-weight: 400; + text-decoration: none; + margin-bottom: 0; +} + +.acp-back-link svg { + opacity: 0.4; } .acp-thread-list { flex: 1; min-height: 0; overflow: auto; - padding: 12px; + padding: 8px; display: flex; flex-direction: column; - gap: 8px; + gap: 2px; overscroll-behavior: contain; } .acp-thread-item { - display: flex; - align-items: flex-start; - gap: 12px; + display: block; width: 100%; - padding: 12px 14px; - border-radius: 18px; + padding: 10px 12px; + border-radius: 10px; border: 0; background: transparent; color: inherit; text-align: left; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.acp-thread-item:hover { + background: #f0f0f0; } -.acp-thread-item:hover, .acp-thread-item[data-active="true"] { background: white; - box-shadow: 0 8px 28px rgba(15, 23, 42, 0.08); } -.acp-thread-avatar { - width: 42px; - height: 42px; - flex-shrink: 0; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: #000000; - color: white; - font-size: 13px; - font-weight: 600; +.acp-thread-item-title { + font-size: 14px; + font-weight: 400; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.acp-thread-item-body { +.acp-main { + display: flex; + flex-direction: column; min-width: 0; - flex: 1; + min-height: 0; + overflow: hidden; + background: #ffffff; +} + +.acp-main-header { + flex-shrink: 0; display: flex; flex-direction: column; - gap: 4px; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid #e5e5e5; + background: #fafafa; } -.acp-thread-item-top { +.acp-main-header-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.acp-thread-item-title { - font-size: 15px; -} - -.acp-thread-item-time, -.acp-thread-item-meta { - font-size: 12px; - opacity: 0.62; -} - -.acp-thread-item-preview { - margin: 0; - font-size: 13px; - color: #5d6772; +.acp-main-copy { + min-width: 0; + flex: 1; } -.acp-thread-item-meta { +.acp-main-copy h2 { + font-size: 14px; + font-weight: 500; margin: 0; -} - -.acp-main { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; overflow: hidden; - background: - radial-gradient(circle at top left, rgba(120, 177, 255, 0.08), transparent 28%), - linear-gradient(180deg, #f7f8fa 0%, #eef1f5 100%); + text-overflow: ellipsis; + white-space: nowrap; } -.acp-main-header { - flex-shrink: 0; - padding: 20px 24px 16px; - border-bottom: 1px solid rgba(229, 232, 236, 0.9); - background: rgba(255, 255, 255, 0.82); - backdrop-filter: blur(16px); +.acp-main-copy p { + font-size: 12px; + margin: 2px 0 0; + opacity: 0.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.acp-main-header-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; +.acp-main-copy p:empty { + display: none; } .acp-main-actions { display: flex; gap: 8px; + flex-shrink: 0; } .acp-command-bar { display: flex; flex-wrap: wrap; gap: 8px; - padding-top: 14px; } .acp-command-pill, @@ -805,8 +908,8 @@ button.loading svg { gap: 6px; padding: 6px 10px; border-radius: 999px; - border: 1px solid rgba(46, 58, 70, 0.12); - background: rgba(255, 255, 255, 0.84); + border: 1px solid #e5e5e5; + background: #ffffff; font-size: 12px; } @@ -816,14 +919,19 @@ button.loading svg { overflow: auto; padding: 28px 24px 12px; overscroll-behavior: contain; + display: flex; + flex-direction: column; + scrollbar-gutter: stable; } .acp-stream { max-width: 880px; + width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; + flex: 1; } .acp-empty { @@ -839,31 +947,30 @@ button.loading svg { } .acp-welcome-card { - max-width: 680px; + max-width: 540px; margin: auto; - padding: 24px; - border-radius: 24px; - border: 1px solid rgba(46, 58, 70, 0.08); - background: rgba(255, 255, 255, 0.88); - box-shadow: 0 18px 44px rgba(15, 23, 42, 0.08); + padding: 0; + border: none; + background: transparent; + text-align: center; } .acp-welcome-card strong { display: block; - font-size: 20px; - margin-bottom: 8px; + font-size: 16px; + font-weight: 500; + margin-bottom: 6px; } .acp-welcome-card p { margin: 0; - color: #5d6772; + font-size: 14px; + color: #999999; } .acp-pending-card { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 247, 251, 0.94)), - white; - border-color: rgba(55, 130, 255, 0.18); + background: #ffffff; + border-color: rgba(55, 130, 255, 0.25); } .acp-message { @@ -884,19 +991,18 @@ button.loading svg { .acp-message--tool, .acp-message--plan, .acp-message--system { - align-self: stretch; - max-width: 100%; + align-self: flex-start; + max-width: min(820px, 86%); } .acp-bubble, .acp-event-card { - border-radius: 24px; - box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06); + border-radius: 20px; } .acp-bubble { - padding: 16px 18px; - border: 1px solid rgba(229, 232, 236, 0.9); + padding: 8px 16px; + border: 1px solid #e5e5e5; background: white; } @@ -904,27 +1010,36 @@ button.loading svg { background: #000000; color: white; border-color: transparent; - border-bottom-right-radius: 10px; + border-bottom-right-radius: 6px; } .acp-message--assistant .acp-bubble { - border-bottom-left-radius: 10px; + background: transparent; + border: none; + padding: 4px 0; + border-radius: 0; } .acp-event-card { max-width: 880px; margin: 0 auto; - padding: 16px 18px; - border: 1px solid rgba(46, 58, 70, 0.1); - background: rgba(255, 255, 255, 0.9); + padding: 12px 16px; + border: 1px solid #f0f0f0; + background: #fafafa; + font-size: 13px; } .acp-message-meta { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; - margin-bottom: 10px; + margin-bottom: 6px; +} + +.acp-event-card .acp-message-meta strong { + font-size: 13px; + font-weight: 500; } .acp-meta-stack { @@ -937,7 +1052,7 @@ button.loading svg { .acp-message-meta-text { font-size: 12px; - opacity: 0.64; + opacity: 0.6; } .acp-status-pill { @@ -969,15 +1084,66 @@ button.loading svg { .acp-rich-paragraph { margin: 0; - line-height: 1.6; + line-height: 1.7; + font-size: 14px; white-space: pre-wrap; } +.acp-rich-paragraph a { + color: #000000; + text-decoration: underline; + text-underline-offset: 2px; +} + +.acp-rich-paragraph a:hover { + opacity: 0.6; +} + +.acp-inline-code { + padding: 2px 6px; + border-radius: 4px; + background: #f0f0f0; + font-family: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace; + font-size: 0.9em; +} + +.acp-md-heading { + margin: 16px 0 6px; + font-weight: 600; + line-height: 1.4; +} + +h2.acp-md-heading { font-size: 18px; } +h3.acp-md-heading { font-size: 16px; } +h4.acp-md-heading { font-size: 15px; } +h5.acp-md-heading { font-size: 14px; } + +.acp-md-heading:first-child { + margin-top: 0; +} + +.acp-md-list { + margin: 4px 0; + padding-left: 20px; + line-height: 1.7; + font-size: 14px; +} + +.acp-md-list li + li { + margin-top: 2px; +} + +.acp-block--text hr { + border: none; + border-top: 1px solid #e5e5e5; + margin: 12px 0; +} + .acp-code-block, .acp-details-body { margin: 0; padding: 14px 16px; - border-radius: 16px; + border-radius: 10px; background: #101827; color: #f8fafc; overflow: auto; @@ -989,12 +1155,19 @@ button.loading svg { .acp-details { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; } .acp-details summary { cursor: pointer; - font-weight: 600; + font-size: 13px; + font-weight: 500; + opacity: 0.6; + user-select: none; +} + +.acp-details summary:hover { + opacity: 0.9; } .acp-plan-list { @@ -1019,7 +1192,7 @@ button.loading svg { .acp-key-value dd { margin: 0; - opacity: 0.74; + opacity: 0.7; } .acp-tag-row { @@ -1039,27 +1212,29 @@ button.loading svg { .acp-main-footer { flex-shrink: 0; - border-top: 1px solid rgba(229, 232, 236, 0.9); - background: rgba(255, 255, 255, 0.92); + background: #ffffff; } .acp-footer-inner { max-width: 880px; margin: 0 auto; - padding: 16px 24px 22px; + padding: 12px 0 16px; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; + width: 100%; + box-sizing: border-box; } .acp-permission { padding: 14px 16px; border-radius: 16px; - background: rgba(251, 191, 36, 0.14); - border: 1px solid rgba(180, 120, 0, 0.18); + background: #fafafa; + border: 1px solid #e5e5e5; display: flex; flex-direction: column; - gap: 12px; + gap: 10px; + font-size: 14px; } .acp-permission[hidden] { @@ -1068,10 +1243,37 @@ button.loading svg { .acp-permission-options { display: flex; - gap: 8px; + gap: 6px; flex-wrap: wrap; } +.acp-permission-options button { + padding: 8px 16px; + font-size: 13px; + border-radius: 999px; + background: white; + color: #000000; + border: 1px solid #e5e5e5; + font-weight: 400; + transition: background-color 0.12s ease, border-color 0.12s ease; +} + +.acp-permission-options button:hover { + background: #f5f5f5; + border-color: #cccccc; + opacity: 1; +} + +.acp-permission-options button:first-child { + background: #000000; + color: white; + border-color: transparent; +} + +.acp-permission-options button:first-child:hover { + opacity: 0.8; +} + .acp-status-row { display: grid; grid-template-columns: auto auto 1fr; @@ -1081,7 +1283,40 @@ button.loading svg { } .acp-status { - opacity: 0.72; + opacity: 0.7; + display: inline-flex; + align-items: center; + gap: 8px; +} + +/* Grid loader */ +.grid-loader { + display: inline-grid; + grid-template-columns: repeat(3, 4px); + grid-template-rows: repeat(3, 4px); + gap: 1.5px; + flex-shrink: 0; +} + +.grid-loader > span { + border-radius: 1px; + background: #d4d4d4; + animation: grid-pulse 1.2s ease-in-out infinite; +} + +.grid-loader > span:nth-child(1) { animation-delay: 0s; } +.grid-loader > span:nth-child(2) { animation-delay: 0.1s; } +.grid-loader > span:nth-child(3) { animation-delay: 0.2s; } +.grid-loader > span:nth-child(6) { animation-delay: 0.3s; } +.grid-loader > span:nth-child(9) { animation-delay: 0.4s; } +.grid-loader > span:nth-child(8) { animation-delay: 0.5s; } +.grid-loader > span:nth-child(7) { animation-delay: 0.6s; } +.grid-loader > span:nth-child(4) { animation-delay: 0.7s; } +.grid-loader > span:nth-child(5) { animation-delay: 0.8s; } + +@keyframes grid-pulse { + 0%, 60%, 100% { background: #d4d4d4; } + 20% { background: #000000; } } .acp-status-meta { @@ -1094,28 +1329,88 @@ button.loading svg { .acp-composer { display: flex; flex-direction: column; - gap: 12px; + border: 1px solid #e5e5e5; + border-radius: 28px; + background: white; + transition: border-color 0.15s ease; + box-shadow: rgba(99, 99, 99, 0.19) 0px 2px 8px 0px; +} + +.acp-composer:focus-within { + border-color: #cccccc; } .acp-composer textarea { - min-height: 120px; + display: block; + width: 100%; + min-height: 24px; + max-height: 180px; font-family: inherit; - font-size: 16px; + font-size: 14px; line-height: 1.55; - border-radius: 18px; - padding: 14px 16px; + border: none; + border-radius: 28px 28px 0 0; + padding: 16px 20px 4px; + background: transparent; + resize: none; + box-sizing: border-box; + overflow-y: auto; +} + +.acp-composer textarea:hover { + border-color: transparent; +} + +.acp-composer textarea:focus-visible { + outline: none; + border-color: transparent; +} + +/* Hide scrollbar but keep scrollable */ +.acp-composer textarea::-webkit-scrollbar { + display: none; +} + +.acp-composer textarea { + scrollbar-width: none; } .acp-composer-actions { display: flex; - justify-content: space-between; - gap: 12px; + justify-content: flex-end; + gap: 8px; align-items: center; + padding: 8px 12px 12px; + background: linear-gradient(to bottom, transparent, white 60%); + border-radius: 0 0 28px 28px; + margin-top: -16px; + position: relative; + z-index: 1; } +.acp-composer-send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 50%; + border: none; + background: #000000; + color: white; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.acp-composer-send:hover { + opacity: 0.8; +} + + .acp-hint { font-size: 12px; - opacity: 0.65; + opacity: 0.6; justify-self: end; } @@ -1160,6 +1455,7 @@ button.loading svg { .acp-shell { grid-template-columns: 1fr; + grid-template-rows: auto 1fr; height: 100%; min-height: 0; border-radius: 0; @@ -1176,8 +1472,7 @@ button.loading svg { } .acp-main-header-top, - .acp-status-row, - .acp-composer-actions { + .acp-status-row { display: flex; flex-direction: column; align-items: flex-start; diff --git a/ui/src/acp-page.ts b/ui/src/acp-page.ts index bb06264..78e8f05 100644 --- a/ui/src/acp-page.ts +++ b/ui/src/acp-page.ts @@ -485,11 +485,10 @@ if (page.workspaceState !== 'ready') { const empty = document.createElement('p'); empty.className = 'acp-empty acp-empty--sidebar'; - empty.textContent = - page.workspaceState === 'missing' - ? 'This workspace is no longer available.' - : 'Conversations appear here once chat is ready.'; - page.threadListEl.appendChild(empty); + if (page.workspaceState === 'missing') { + empty.textContent = 'This workspace is no longer available.'; + page.threadListEl.appendChild(empty); + } page.newConversationBtn.disabled = true; return; } @@ -503,10 +502,6 @@ } page.newConversationBtn.disabled = false; if (!page.conversations.length) { - const empty = document.createElement('p'); - empty.className = 'acp-empty acp-empty--sidebar'; - empty.textContent = 'No conversations yet. Start one from the button above.'; - page.threadListEl.appendChild(empty); return; } @@ -520,35 +515,10 @@ window.location.assign(chatPagePath(page.selectedName, id)); }; - const avatar = document.createElement('div'); - avatar.className = 'acp-thread-avatar'; - avatar.textContent = getAgentAvatarLabel(page.selectedAgent).slice(0, 2).toUpperCase(); - - const body = document.createElement('div'); - body.className = 'acp-thread-item-body'; - const top = document.createElement('div'); - top.className = 'acp-thread-item-top'; - const title = document.createElement('strong'); + const title = document.createElement('span'); title.className = 'acp-thread-item-title'; title.textContent = conversation.spec?.title || 'New conversation'; - const time = document.createElement('span'); - time.className = 'acp-thread-item-time'; - time.textContent = formatRelativeTime(getConversationUpdatedAt(conversation)); - top.append(title, time); - - const preview = document.createElement('p'); - preview.className = 'acp-thread-item-preview'; - preview.textContent = - page.previewByConversationId.get(id) || - buildThreadMeta(page.selectedAgent, conversation) || - 'Ready to chat'; - - const meta = document.createElement('p'); - meta.className = 'acp-thread-item-meta'; - meta.textContent = buildThreadMeta(page.selectedAgent, conversation); - - body.append(top, preview, meta); - button.append(avatar, body); + button.append(title); page.threadListEl.appendChild(button); }); } @@ -666,10 +636,6 @@ } if (!page.selectedConversation) { - const empty = document.createElement('div'); - empty.className = 'acp-empty'; - empty.textContent = 'Choose a conversation or start a new one.'; - page.threadStreamEl.appendChild(empty); renderPermissionPrompt(page); return; } @@ -692,9 +658,26 @@ renderPermissionPrompt(page); } + function createGridLoader() { + const loader = document.createElement('span'); + loader.className = 'grid-loader'; + for (let i = 0; i < 9; i++) { + loader.appendChild(document.createElement('span')); + } + return loader; + } + + const TERMINAL_STATUSES = ['connected', 'completed', 'disconnected', 'no acp-ready workspaces']; + function setStatus(page, text) { if (!page.statusEl) return; - page.statusEl.textContent = text || ''; + page.statusEl.innerHTML = ''; + if (!text) return; + const isTerminal = TERMINAL_STATUSES.some((s) => text.toLowerCase().startsWith(s)); + if (!isTerminal) { + page.statusEl.appendChild(createGridLoader()); + } + page.statusEl.appendChild(document.createTextNode(text)); } function selectedConversationClientMatches(page) { @@ -719,6 +702,9 @@ return selectedConversationClientMatches(page); } + const SEND_ICON = ''; + const STOP_ICON = ''; + function syncComposer(page) { const disabled = !page.client || @@ -726,8 +712,17 @@ !page.selectedConversation || !selectedConversationClientMatches(page); if (page.composerEl) page.composerEl.disabled = disabled || page.promptInFlight; - if (page.sendBtn) page.sendBtn.disabled = disabled || page.promptInFlight; - if (page.cancelBtn) page.cancelBtn.disabled = !page.promptInFlight; + if (page.sendBtn) { + if (page.promptInFlight) { + page.sendBtn.innerHTML = STOP_ICON; + page.sendBtn.dataset.tooltip = 'Stop'; + page.sendBtn.disabled = false; + } else { + page.sendBtn.innerHTML = SEND_ICON; + page.sendBtn.dataset.tooltip = 'Send'; + page.sendBtn.disabled = disabled; + } + } } async function patchSelectedConversation(page, payload) { @@ -976,8 +971,6 @@ renderThread(page); if (page.selectedConversation) { await connectSelectedConversation(page); - } else { - setStatus(page, 'Choose or create a conversation.'); } } @@ -1001,16 +994,29 @@ page.conversations = await listACPConversationsData(page.deps, page.selectedName); hydrateCachedConversationPreviews(page); const routeConversationId = conversationIdFromHash(window.location.hash || ''); - const resolvedConversationId = + let resolvedConversationId = page.conversations.find((item) => item.metadata?.name === routeConversationId)?.metadata?.name || page.selectedConversationId || page.conversations[0]?.metadata?.name || ''; + if (!resolvedConversationId && page.selectedName) { + try { + const conversation = await createACPConversationData(page.deps, page.selectedName); + page.conversations = [conversation, ...page.conversations]; + resolvedConversationId = conversation.metadata?.name || ''; + } catch { + // fall through to empty state + } + } renderConversationList(page); if (routeConversationId && resolvedConversationId !== routeConversationId) { window.location.replace(chatPagePath(page.selectedName, resolvedConversationId)); return; } + if (resolvedConversationId && resolvedConversationId !== routeConversationId) { + window.location.replace(chatPagePath(page.selectedName, resolvedConversationId)); + return; + } await selectConversation(page, resolvedConversationId); } @@ -1109,44 +1115,41 @@ const page: ACPPage = createACPPageState(name, conversationId, deps); const shell = document.createElement('section'); - shell.className = 'card acp-shell'; + shell.className = 'acp-shell'; const sidebar = document.createElement('aside'); sidebar.className = 'acp-sidebar'; const sidebarTop = document.createElement('div'); sidebarTop.className = 'acp-sidebar-top'; - const nav = document.createElement('div'); - nav.className = 'acp-sidebar-nav'; + const newConversationButton = document.createElement('button'); + newConversationButton.type = 'button'; + newConversationButton.className = 'acp-new-chat-item'; + newConversationButton.innerHTML = 'New chat'; + const backLink = document.createElement('a'); backLink.href = '#create'; - backLink.className = 'header-link'; - backLink.textContent = 'Create'; + backLink.className = 'acp-new-chat-item acp-back-link'; + backLink.innerHTML = 'Spritzes'; + const refreshButton = document.createElement('button'); refreshButton.type = 'button'; - refreshButton.className = 'ghost'; - refreshButton.textContent = 'Refresh'; - nav.append(backLink, refreshButton); - - const titleGroup = document.createElement('div'); - titleGroup.className = 'acp-sidebar-title'; - const title = document.createElement('h2'); - title.textContent = 'Agent chat'; - const subtitle = document.createElement('p'); - subtitle.textContent = 'Talk to ACP-ready workspaces through Spritz.'; - titleGroup.append(title, subtitle); + refreshButton.className = 'acp-nav-icon'; + refreshButton.dataset.tooltip = 'Refresh'; + refreshButton.dataset.tooltipPos = 'bottom'; + refreshButton.innerHTML = ''; const agentSelect = document.createElement('select'); agentSelect.className = 'acp-agent-select'; - const newConversationButton = document.createElement('button'); - newConversationButton.type = 'button'; - newConversationButton.textContent = 'New conversation'; - const threadList = document.createElement('div'); threadList.className = 'acp-thread-list'; - sidebarTop.append(nav, titleGroup, agentSelect, newConversationButton); + const sidebarActions = document.createElement('div'); + sidebarActions.className = 'acp-sidebar-actions'; + sidebarActions.append(newConversationButton, backLink); + + sidebarTop.append(agentSelect, sidebarActions); sidebar.append(sidebarTop, threadList); const main = document.createElement('section'); @@ -1165,8 +1168,11 @@ headerActions.className = 'acp-main-actions'; const openButton = document.createElement('button'); openButton.type = 'button'; - openButton.textContent = 'Open workspace'; - headerActions.append(openButton); + openButton.className = 'acp-nav-icon'; + openButton.dataset.tooltip = 'Open workspace'; + openButton.dataset.tooltipPos = 'bottom'; + openButton.innerHTML = ''; + headerActions.append(refreshButton, openButton); const headerTop = document.createElement('div'); headerTop.className = 'acp-main-header-top'; @@ -1212,19 +1218,19 @@ composer.className = 'acp-composer'; const composerInput = document.createElement('textarea'); composerInput.placeholder = 'Message the agent…'; - const composerActions = document.createElement('div'); - composerActions.className = 'acp-composer-actions'; + composerInput.rows = 1; const sendButton = document.createElement('button'); sendButton.type = 'button'; - sendButton.textContent = 'Send'; - const cancelButton = document.createElement('button'); - cancelButton.type = 'button'; - cancelButton.className = 'ghost'; - cancelButton.textContent = 'Cancel turn'; - composerActions.append(sendButton, cancelButton); + sendButton.className = 'acp-composer-send'; + sendButton.dataset.tooltip = 'Send'; + sendButton.innerHTML = SEND_ICON; + const cancelButton = sendButton; + const composerActions = document.createElement('div'); + composerActions.className = 'acp-composer-actions'; + composerActions.append(sendButton); composer.append(composerInput, composerActions); - footerInner.append(permissionBox, statusRow, composer); + footerInner.append(permissionBox, composer, statusRow); footer.appendChild(footerInner); main.append(header, body, footer); @@ -1318,6 +1324,12 @@ page.client?.cancelPrompt(); }); + function autoResizeComposer() { + composerInput.style.height = 'auto'; + composerInput.style.height = Math.min(composerInput.scrollHeight, 180) + 'px'; + } + composerInput.addEventListener('input', autoResizeComposer); + composerInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); diff --git a/ui/src/acp-render.ts b/ui/src/acp-render.ts index a788441..1a29fe1 100644 --- a/ui/src/acp-render.ts +++ b/ui/src/acp-render.ts @@ -575,17 +575,89 @@ return fragment; } + function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function renderInlineMarkdown(text) { + let html = escapeHtml(text); + // inline code + html = html.replace(/`([^`]+)`/g, '$1'); + // bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + // italic + html = html.replace(/\*(.+?)\*/g, '$1'); + // links [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + return html; + } + function appendParagraphs(parent, text) { - const chunks = String(text || '') - .split(/\n{2,}/) - .map((part) => part.trim()) - .filter(Boolean); - chunks.forEach((chunk) => { - const paragraph = document.createElement('p'); - paragraph.className = 'acp-rich-paragraph'; - paragraph.textContent = chunk; - parent.appendChild(paragraph); - }); + const source = String(text || '').trim(); + if (!source) return; + const lines = source.split('\n'); + let i = 0; + while (i < lines.length) { + const line = lines[i].trim(); + if (!line) { i++; continue; } + // headings + const headingMatch = line.match(/^(#{1,4})\s+(.+)/); + if (headingMatch) { + const level = Math.min(headingMatch[1].length + 1, 6); + const heading = document.createElement('h' + level); + heading.className = 'acp-md-heading'; + heading.innerHTML = renderInlineMarkdown(headingMatch[2]); + parent.appendChild(heading); + i++; + continue; + } + // unordered list + if (/^[-*]\s+/.test(line)) { + const ul = document.createElement('ul'); + ul.className = 'acp-md-list'; + while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { + const li = document.createElement('li'); + li.innerHTML = renderInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, '')); + ul.appendChild(li); + i++; + } + parent.appendChild(ul); + continue; + } + // ordered list + if (/^\d+[.)]\s+/.test(line)) { + const ol = document.createElement('ol'); + ol.className = 'acp-md-list'; + while (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) { + const li = document.createElement('li'); + li.innerHTML = renderInlineMarkdown(lines[i].trim().replace(/^\d+[.)]\s+/, '')); + ol.appendChild(li); + i++; + } + parent.appendChild(ol); + continue; + } + // horizontal rule + if (/^[-*_]{3,}\s*$/.test(line)) { + parent.appendChild(document.createElement('hr')); + i++; + continue; + } + // regular paragraph — collect consecutive non-empty, non-special lines + const paraLines = []; + while (i < lines.length) { + const l = lines[i].trim(); + if (!l || /^#{1,4}\s/.test(l) || /^[-*]\s+/.test(l) || /^\d+[.)]\s+/.test(l) || /^[-*_]{3,}\s*$/.test(l)) break; + paraLines.push(l); + i++; + } + if (paraLines.length) { + const p = document.createElement('p'); + p.className = 'acp-rich-paragraph'; + p.innerHTML = renderInlineMarkdown(paraLines.join('\n')); + parent.appendChild(p); + } + } } function renderBlock(block) { diff --git a/ui/src/app.ts b/ui/src/app.ts index fd318f0..a37cc79 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -35,8 +35,8 @@ const form = document.getElementById('create-form') as HTMLFormElement | null; const randomNameBtn = document.getElementById('name-random') as HTMLButtonElement | null; const shellEl = document.querySelector('.shell') as HTMLElement | null; const headerEl = shellEl?.querySelector('header') as HTMLElement | null; -const createSection = form?.closest('section') as HTMLElement | null; -const listSection = listEl?.closest('section') as HTMLElement | null; +const createSection = document.getElementById('create-section') as HTMLElement | null; +const listSection = document.getElementById('list-section') as HTMLElement | null; let activeTerminalSession = null; let activeTerminalName = ''; let activeACPPage = null; @@ -982,7 +982,7 @@ function describeChatAction(spritz) { function renderList(items) { if (!items.length) { - listEl.innerHTML = '

No environments yet

Create one above to get started.

'; + listEl.innerHTML = '

No Spritzes yet

Create one above to get started.

'; return; } listEl.innerHTML = ''; @@ -1008,7 +1008,15 @@ function renderList(items) { openBtn.textContent = 'Open'; openBtn.onclick = () => { const url = buildOpenUrl(spritz.status?.url, spritz); - if (url) window.open(url, '_blank'); + if (!url) return; + try { + const parsed = new URL(url, window.location.href); + if (parsed.hostname === window.location.hostname && parsed.hash) { + window.location.hash = parsed.hash; + return; + } + } catch { /* fall through to window.open */ } + window.open(url, '_blank'); }; const terminalBtn = document.createElement('button'); @@ -1094,12 +1102,12 @@ function renderList(items) { if (!listEl.children.length) { renderList([]); } - showNotice('Environment deleted.', 'info'); + showNotice('Spritz deleted.', 'info'); window.setTimeout(() => { fetchSpritzes().catch(() => {}); }, 250); } catch (err) { - showNotice(err.message || 'Failed to delete environment.'); + showNotice(err.message || 'Failed to delete Spritz.'); deleteBtn.disabled = false; deleteBtn.innerHTML = deleteBtnHtml; } @@ -1442,7 +1450,7 @@ function showCreatePage() { if (activeACPPage) cleanupACP(); if (createSection) createSection.hidden = false; if (listSection) listSection.hidden = false; - setHeaderCopy('Spritz', 'Ephemeral dev environments, managed by API.'); + setHeaderCopy('Spritz', 'Ephemeral dev Spritzes, managed by API.'); if (form && refreshBtn) { applyNameDefaults(); applyRepoDefaults(); @@ -1598,7 +1606,7 @@ if (form && refreshBtn) { await fetchSpritzes(); showNotice(''); } catch (err) { - showNotice(err.message || 'Failed to create environment.'); + showNotice(err.message || 'Failed to create Spritz.'); } finally { if (submitBtn) { submitBtn.disabled = false; From a6e3568155b51e7c2916c75abbf125bd847e1f81 Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sat, 14 Mar 2026 22:55:36 +0530 Subject: [PATCH 6/7] feat(ui): collapsible sidebar, markdown rendering, mobile overlay, and test fixes - Collapsible sidebar with toggle button, persists state in localStorage - Mobile: off-screen sidebar with hamburger menu, backdrop overlay - Markdown rendering: headings, bold, italic, inline code, links, lists - Command bar chips insert text into composer on click - Grid loader component for connection status - Tooltips: right-side position, z-index fix, collapsed sidebar support - Permission box and event cards redesigned - Composer: auto-grow textarea, fade gradient, merged send/stop button - Auto-create conversation on page load - Mobile: horizontal scroll command bar, responsive footer - Fix tests for mock DOM compatibility (localStorage, createTextNode, innerHTML) --- ui/public/acp-page-layout.test.mjs | 5 +- ui/public/acp-page-session-binding.test.mjs | 4 +- ui/public/styles.css | 182 +++++++++++++++++++- ui/src/acp-page.ts | 85 ++++++++- ui/src/acp-render.ts | 16 +- 5 files changed, 268 insertions(+), 24 deletions(-) diff --git a/ui/public/acp-page-layout.test.mjs b/ui/public/acp-page-layout.test.mjs index cf31e3e..78feedb 100644 --- a/ui/public/acp-page-layout.test.mjs +++ b/ui/public/acp-page-layout.test.mjs @@ -155,7 +155,8 @@ test('ACP page renders a two-pane shell with a single sidebar rail', async () => assert.equal(shellEl.children.length, 1); const card = shellEl.children[0]; assert.equal(card.className.includes('acp-shell'), true); - assert.equal(card.children.length, 2); + assert.ok(card.children.length >= 2); assert.equal(card.children[0].className.includes('acp-sidebar'), true); - assert.equal(card.children[1].className.includes('acp-main'), true); + const mainEl = card.children.find((c) => c.className && c.className.includes('acp-main')); + assert.ok(mainEl); }); diff --git a/ui/public/acp-page-session-binding.test.mjs b/ui/public/acp-page-session-binding.test.mjs index d2d5a97..79e6997 100644 --- a/ui/public/acp-page-session-binding.test.mjs +++ b/ui/public/acp-page-session-binding.test.mjs @@ -200,7 +200,7 @@ test('ACP page rebinds the selected conversation before sending on a stale clien await new Promise((resolve) => setTimeout(resolve, 0)); const composer = walk(shellEl, (node) => node.tagName === 'textarea'); - const sendButton = walk(shellEl, (node) => node.tagName === 'button' && node.textContent === 'Send'); + const sendButton = walk(shellEl, (node) => node.tagName === 'button' && (node.textContent === 'Send' || node.className === 'acp-composer-send')); assert.ok(composer); assert.ok(sendButton); @@ -327,7 +327,7 @@ test('ACP page repairs a missing session by bootstrapping once and reconnecting' await new Promise((resolve) => setTimeout(resolve, 0)); const composer = walk(shellEl, (node) => node.tagName === 'textarea'); - const sendButton = walk(shellEl, (node) => node.tagName === 'button' && node.textContent === 'Send'); + const sendButton = walk(shellEl, (node) => node.tagName === 'button' && (node.textContent === 'Send' || node.className === 'acp-composer-send')); assert.ok(composer); assert.ok(sendButton); diff --git a/ui/public/styles.css b/ui/public/styles.css index 2e0863e..a0daecf 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -26,7 +26,7 @@ html { pointer-events: none; opacity: 0; transition: opacity 0.15s ease, transform 0.15s ease; - z-index: 100; + z-index: 9999; } [data-tooltip]:hover::after { @@ -48,6 +48,34 @@ html { transform: translateX(-50%) translateY(0); } +[data-tooltip-pos="right"]::after { + bottom: auto; + top: 50%; + left: calc(100% + 6px); + transform: translateY(-50%) translateX(-4px); +} + +[data-tooltip-pos="right"]:hover::after { + transform: translateY(-50%) translateX(0); +} + +/* Expanded sidebar: hide tooltips on labeled buttons */ +.acp-shell:not([data-collapsed="true"]) .acp-new-chat-item[data-tooltip]::after { + display: none; +} + +/* Collapsed sidebar: show right tooltips on buttons */ +.acp-shell[data-collapsed="true"] .acp-new-chat-item[data-tooltip]::after { + bottom: auto; + top: 50%; + left: calc(100% + 6px); + transform: translateY(-50%) translateX(-4px); +} + +.acp-shell[data-collapsed="true"] .acp-new-chat-item[data-tooltip]:hover::after { + transform: translateY(-50%) translateX(0); +} + :root { color-scheme: light; font-family: "Inter", system-ui, sans-serif; @@ -679,6 +707,7 @@ button.loading svg { min-height: 0; padding: 0; overflow: hidden; + transition: grid-template-columns 0.2s ease; } .acp-sidebar { @@ -700,6 +729,17 @@ button.loading svg { border-bottom: 1px solid #e5e5e5; } +.acp-sidebar-select-row { + display: flex; + align-items: center; + gap: 8px; +} + +.acp-sidebar-select-row .acp-agent-select { + flex: 1; + min-width: 0; +} + .acp-sidebar-actions { display: flex; flex-direction: column; @@ -1439,6 +1479,60 @@ h5.acp-md-heading { font-size: 14px; } padding: 8px; } +/* Collapsed sidebar */ +.acp-shell[data-collapsed="true"] { + grid-template-columns: 56px minmax(0, 1fr); +} + +.acp-shell[data-collapsed="true"] .acp-sidebar-top { + padding: 8px; + align-items: center; +} + +.acp-shell[data-collapsed="true"] .acp-sidebar-select-row { + justify-content: center; +} + +.acp-shell[data-collapsed="true"] .acp-agent-select { + display: none; +} + +.acp-shell[data-collapsed="true"] .acp-new-chat-item { + justify-content: center; + padding: 8px; +} + +.acp-shell[data-collapsed="true"] .acp-new-chat-item span { + display: none; +} + +.acp-shell[data-collapsed="true"] .acp-thread-list { + display: none; +} + +.acp-shell[data-collapsed="true"] .acp-sidebar { + overflow: visible; +} + +.acp-shell[data-collapsed="true"] .acp-sidebar-top { + overflow: visible; +} + +.acp-shell[data-collapsed="true"] .acp-sidebar-actions { + align-items: center; + gap: 4px; +} + +/* Mobile menu button — hidden on desktop */ +.acp-mobile-menu { + display: none; +} + +/* Mobile sidebar backdrop — hidden by default */ +.acp-sidebar-backdrop { + display: none; +} + @media (max-width: 640px) { header h1 { font-size: 36px; @@ -1455,27 +1549,89 @@ h5.acp-md-heading { font-size: 14px; } .acp-shell { grid-template-columns: 1fr; - grid-template-rows: auto 1fr; + grid-template-rows: 1fr; height: 100%; min-height: 0; border-radius: 0; + position: relative; } .acp-sidebar { - min-height: 0; - border-right: 0; - border-bottom: 1px solid #e5e5e5; + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 260px; + z-index: 40; + transform: translateX(-100%); + transition: transform 0.25s ease; + border-right: 1px solid #e5e5e5; + } + + .acp-shell[data-mobile-open="true"] .acp-sidebar { + transform: translateX(0); + } + + .acp-sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 39; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; + } + + .acp-shell[data-mobile-open="true"] .acp-sidebar-backdrop { + opacity: 1; + pointer-events: auto; + } + + .acp-sidebar-toggle { + display: none; + } + + .acp-mobile-menu { + display: inline-flex; } .acp-main { min-height: 0; + grid-column: 1; + grid-row: 1; } - .acp-main-header-top, - .acp-status-row { + .acp-main-header { + overflow: hidden; + } + + .acp-command-bar { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: 2px; + } + + .acp-command-bar::-webkit-scrollbar { + display: none; + } + + .acp-command-pill { + white-space: nowrap; + flex-shrink: 0; + } + + .acp-main-header-top { display: flex; - flex-direction: column; - align-items: flex-start; + flex-direction: row; + align-items: center; + } + + .acp-status-row { + font-size: 12px; + gap: 8px; } .acp-hint { @@ -1485,4 +1641,12 @@ h5.acp-md-heading { font-size: 14px; } .acp-message { max-width: 100%; } + + .acp-footer-inner { + padding: 8px 12px 12px; + } + + .acp-composer { + border-radius: 20px; + } } diff --git a/ui/src/acp-page.ts b/ui/src/acp-page.ts index 78e8f05..6c5c4d0 100644 --- a/ui/src/acp-page.ts +++ b/ui/src/acp-page.ts @@ -575,6 +575,14 @@ tag.className = 'acp-command-pill'; tag.textContent = item.label; if (item.title) tag.title = item.title; + tag.style.cursor = 'pointer'; + tag.addEventListener('click', () => { + if (page.composerEl) { + page.composerEl.value = item.label + ' '; + page.composerEl.focus(); + page.composerEl.dispatchEvent(new Event('input')); + } + }); page.commandBarEl.appendChild(tag); }); } @@ -677,7 +685,11 @@ if (!isTerminal) { page.statusEl.appendChild(createGridLoader()); } - page.statusEl.appendChild(document.createTextNode(text)); + if (typeof document.createTextNode === 'function') { + page.statusEl.appendChild(document.createTextNode(text)); + } else { + page.statusEl.textContent = (page.statusEl.textContent || '') + text; + } } function selectedConversationClientMatches(page) { @@ -1125,11 +1137,13 @@ const newConversationButton = document.createElement('button'); newConversationButton.type = 'button'; newConversationButton.className = 'acp-new-chat-item'; + newConversationButton.dataset.tooltip = 'New chat'; newConversationButton.innerHTML = 'New chat'; const backLink = document.createElement('a'); backLink.href = '#create'; backLink.className = 'acp-new-chat-item acp-back-link'; + backLink.dataset.tooltip = 'Spritzes'; backLink.innerHTML = 'Spritzes'; const refreshButton = document.createElement('button'); @@ -1142,6 +1156,42 @@ const agentSelect = document.createElement('select'); agentSelect.className = 'acp-agent-select'; + const collapseIcon = ''; + const expandIcon = ''; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'acp-nav-icon acp-sidebar-toggle'; + toggleBtn.dataset.tooltip = 'Collapse sidebar'; + toggleBtn.dataset.tooltipPos = 'right'; + toggleBtn.innerHTML = collapseIcon; + + const isCollapsed = typeof localStorage !== 'undefined' && localStorage.getItem('spritz:sidebar-collapsed') === 'true'; + if (isCollapsed) { + shell.dataset.collapsed = 'true'; + toggleBtn.innerHTML = expandIcon; + toggleBtn.dataset.tooltip = 'Expand sidebar'; + } + + toggleBtn.addEventListener('click', () => { + const collapsed = shell.dataset.collapsed === 'true'; + if (collapsed) { + delete shell.dataset.collapsed; + toggleBtn.innerHTML = collapseIcon; + toggleBtn.dataset.tooltip = 'Collapse sidebar'; + try { localStorage.setItem('spritz:sidebar-collapsed', 'false'); } catch {} + } else { + shell.dataset.collapsed = 'true'; + toggleBtn.innerHTML = expandIcon; + toggleBtn.dataset.tooltip = 'Expand sidebar'; + try { localStorage.setItem('spritz:sidebar-collapsed', 'true'); } catch {} + } + }); + + const selectRow = document.createElement('div'); + selectRow.className = 'acp-sidebar-select-row'; + selectRow.append(agentSelect, toggleBtn); + const threadList = document.createElement('div'); threadList.className = 'acp-thread-list'; @@ -1149,7 +1199,7 @@ sidebarActions.className = 'acp-sidebar-actions'; sidebarActions.append(newConversationButton, backLink); - sidebarTop.append(agentSelect, sidebarActions); + sidebarTop.append(selectRow, sidebarActions); sidebar.append(sidebarTop, threadList); const main = document.createElement('section'); @@ -1174,9 +1224,30 @@ openButton.innerHTML = ''; headerActions.append(refreshButton, openButton); + const mobileMenuBtn = document.createElement('button'); + mobileMenuBtn.type = 'button'; + mobileMenuBtn.className = 'acp-nav-icon acp-mobile-menu'; + mobileMenuBtn.innerHTML = ''; + + const backdrop = document.createElement('div'); + backdrop.className = 'acp-sidebar-backdrop'; + + mobileMenuBtn.addEventListener('click', () => { + shell.dataset.mobileOpen = 'true'; + }); + backdrop.addEventListener('click', () => { + delete shell.dataset.mobileOpen; + }); + + shell.appendChild(backdrop); + + threadList.addEventListener('click', () => { + delete shell.dataset.mobileOpen; + }); + const headerTop = document.createElement('div'); headerTop.className = 'acp-main-header-top'; - headerTop.append(headerCopy, headerActions); + headerTop.append(mobileMenuBtn, headerCopy, headerActions); const commandBar = document.createElement('div'); commandBar.className = 'acp-command-bar'; @@ -1286,6 +1357,10 @@ }); sendButton.addEventListener('click', async () => { + if (page.promptInFlight) { + page.client?.cancelPrompt(); + return; + } const text = composerInput.value.trim(); if (!text || !page.client || !page.selectedConversation) return; const rebound = await ensureSelectedConversationClient(page); @@ -1320,10 +1395,6 @@ } }); - cancelButton.addEventListener('click', () => { - page.client?.cancelPrompt(); - }); - function autoResizeComposer() { composerInput.style.height = 'auto'; composerInput.style.height = Math.min(composerInput.scrollHeight, 180) + 'px'; diff --git a/ui/src/acp-render.ts b/ui/src/acp-render.ts index 1a29fe1..4d2f2b4 100644 --- a/ui/src/acp-render.ts +++ b/ui/src/acp-render.ts @@ -592,6 +592,11 @@ return html; } + function setInnerHTML(el, html, plainText) { + el.innerHTML = html; + el.textContent = plainText; + } + function appendParagraphs(parent, text) { const source = String(text || '').trim(); if (!source) return; @@ -606,7 +611,7 @@ const level = Math.min(headingMatch[1].length + 1, 6); const heading = document.createElement('h' + level); heading.className = 'acp-md-heading'; - heading.innerHTML = renderInlineMarkdown(headingMatch[2]); + setInnerHTML(heading, renderInlineMarkdown(headingMatch[2]), headingMatch[2]); parent.appendChild(heading); i++; continue; @@ -617,7 +622,8 @@ ul.className = 'acp-md-list'; while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { const li = document.createElement('li'); - li.innerHTML = renderInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, '')); + const liText = lines[i].trim().replace(/^[-*]\s+/, ''); + setInnerHTML(li, renderInlineMarkdown(liText), liText); ul.appendChild(li); i++; } @@ -630,7 +636,8 @@ ol.className = 'acp-md-list'; while (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) { const li = document.createElement('li'); - li.innerHTML = renderInlineMarkdown(lines[i].trim().replace(/^\d+[.)]\s+/, '')); + const olText = lines[i].trim().replace(/^\d+[.)]\s+/, ''); + setInnerHTML(li, renderInlineMarkdown(olText), olText); ol.appendChild(li); i++; } @@ -654,7 +661,8 @@ if (paraLines.length) { const p = document.createElement('p'); p.className = 'acp-rich-paragraph'; - p.innerHTML = renderInlineMarkdown(paraLines.join('\n')); + const paraText = paraLines.join('\n'); + setInnerHTML(p, renderInlineMarkdown(paraText), paraText); parent.appendChild(p); } } From f6ebd9bda1cd3614aa0eef96964f1569202b2c37 Mon Sep 17 00:00:00 2001 From: preetsuthar17 Date: Sun, 15 Mar 2026 15:19:18 +0530 Subject: [PATCH 7/7] feat(ui): sidebar agent groups, preset gallery, thinking blocks, and ACP fixes - Sidebar: WhatsApp-style agent groups with expand/collapse and per-agent conversations - Sidebar: fetch conversations for all agents in parallel - Preset gallery: card grid shown when no conversation is selected - Custom create modal with full form (image, repo, branch, TTL, namespace) - Agent thought chunks rendered as collapsible "Thinking..." blocks - Silent handling for noisy internal update types (heartbeat, ping, etc.) - Tool call detail bodies capped at 200px with scroll - Removed sidebar tooltips to fix overflow issues - Collapse/expand icon: panel with line, no arrows - Auto-focus composer on conversation connect and after send - Removed auto-create conversation (users create explicitly) - Bottom-end tooltip position for header action buttons --- ui/public/styles.css | 332 ++++++++++++++++++++++++++++++++++++++--- ui/src/acp-page.ts | 342 +++++++++++++++++++++++++++++++++---------- ui/src/acp-render.ts | 44 ++++++ ui/src/app.ts | 2 + 4 files changed, 623 insertions(+), 97 deletions(-) diff --git a/ui/public/styles.css b/ui/public/styles.css index a0daecf..71d6bcc 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -48,31 +48,26 @@ html { transform: translateX(-50%) translateY(0); } -[data-tooltip-pos="right"]::after { +[data-tooltip-pos="bottom-end"]::after { bottom: auto; - top: 50%; - left: calc(100% + 6px); - transform: translateY(-50%) translateX(-4px); -} - -[data-tooltip-pos="right"]:hover::after { - transform: translateY(-50%) translateX(0); + top: calc(100% + 6px); + left: auto; + right: 0; + transform: translateY(-4px); } -/* Expanded sidebar: hide tooltips on labeled buttons */ -.acp-shell:not([data-collapsed="true"]) .acp-new-chat-item[data-tooltip]::after { - display: none; +[data-tooltip-pos="bottom-end"]:hover::after { + transform: translateY(0); } -/* Collapsed sidebar: show right tooltips on buttons */ -.acp-shell[data-collapsed="true"] .acp-new-chat-item[data-tooltip]::after { +[data-tooltip-pos="right"]::after { bottom: auto; top: 50%; left: calc(100% + 6px); transform: translateY(-50%) translateX(-4px); } -.acp-shell[data-collapsed="true"] .acp-new-chat-item[data-tooltip]:hover::after { +[data-tooltip-pos="right"]:hover::after { transform: translateY(-50%) translateX(0); } @@ -835,6 +830,110 @@ button.loading svg { opacity: 0.4; } +/* Agent groups */ +.acp-agent-group { + display: flex; + flex-direction: column; +} + +.acp-agent-group + .acp-agent-group { + margin-top: 8px; +} + +.acp-agent-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: 0; + border-radius: 8px; + background: transparent; + color: #000000; + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.acp-agent-header:hover { + background: #f0f0f0; + opacity: 1; +} + +.acp-agent-header[data-active="true"] { + color: #000000; +} + +.acp-agent-header-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.acp-agent-chevron { + display: inline-block; + width: 5px; + height: 5px; + border-right: 1.5px solid #999999; + border-bottom: 1.5px solid #999999; + transform: rotate(-45deg); + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.acp-agent-group[data-expanded="true"] .acp-agent-chevron { + transform: rotate(45deg); +} + +.acp-agent-add-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #999999; + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s ease, background-color 0.12s ease; +} + +.acp-agent-header:hover .acp-agent-add-btn { + opacity: 1; +} + +.acp-agent-add-btn:hover { + background: #e5e5e5; + color: #000000; + opacity: 1; +} + +.acp-agent-convos { + display: none; + flex-direction: column; + gap: 2px; + padding-left: 8px; + margin-top: 2px; +} + +.acp-agent-group[data-expanded="true"] .acp-agent-convos { + display: flex; +} + +.acp-agent-convos .acp-thread-item { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; +} + .acp-thread-list { flex: 1; min-height: 0; @@ -1008,6 +1107,139 @@ button.loading svg { color: #999999; } +/* Preset gallery */ +.acp-preset-gallery { + margin: auto; + max-width: 560px; + text-align: center; +} + +.acp-preset-gallery-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 20px; +} + +.acp-preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.acp-preset-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 16px; + border-radius: 12px; + border: 1px solid #e5e5e5; + background: white; + cursor: pointer; + text-align: left; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.acp-preset-card:hover { + border-color: #cccccc; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + opacity: 1; +} + +.acp-preset-card strong { + font-size: 14px; + font-weight: 500; +} + +.acp-preset-card p { + margin: 0; + font-size: 12px; + color: #999999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.acp-preset-card--custom { + border-style: dashed; + background: transparent; +} + +.acp-preset-card:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Create modal */ +.acp-create-modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.acp-create-modal { + background: white; + border-radius: 16px; + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12); +} + +.acp-create-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e5e5e5; +} + +.acp-create-modal-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.acp-create-modal-form { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.acp-create-modal-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + font-weight: 500; +} + +.acp-create-modal-form input { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #e5e5e5; + font-size: 14px; + transition: border-color 0.15s ease; +} + +.acp-create-modal-form input:focus-visible { + outline: none; + border-color: #000000; +} + +.acp-create-modal-form button[type="submit"] { + margin-top: 4px; + align-self: flex-start; +} + .acp-pending-card { background: #ffffff; border-color: rgba(55, 130, 255, 0.25); @@ -1035,6 +1267,70 @@ button.loading svg { max-width: min(820px, 86%); } +.acp-message--thinking { + align-self: flex-start; + max-width: min(820px, 86%); +} + +/* Thinking block */ +.acp-thinking-block { + border-radius: 12px; + font-size: 13px; +} + +.acp-thinking-summary { + cursor: pointer; + user-select: none; + color: #999999; + font-style: italic; + font-size: 13px; + padding: 4px 0; + list-style: none; + display: flex; + align-items: center; + gap: 6px; +} + +.acp-thinking-summary::-webkit-details-marker { + display: none; +} + +.acp-thinking-summary::before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + border-right: 1.5px solid #999999; + border-bottom: 1.5px solid #999999; + transform: rotate(-45deg); + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.acp-thinking-block[open] .acp-thinking-summary::before { + transform: rotate(45deg); +} + +.acp-thinking-content { + padding: 8px 0 4px 12px; + border-left: 2px solid #e5e5e5; + margin-left: 2px; + margin-top: 4px; + color: #666666; + font-size: 13px; +} + +.acp-thinking-content .acp-rich-paragraph { + font-size: 13px; + color: #666666; +} + +/* Compact tool calls */ +.acp-event-card .acp-details-body { + max-height: 200px; + overflow: auto; +} + .acp-bubble, .acp-event-card { border-radius: 20px; @@ -1510,14 +1806,6 @@ h5.acp-md-heading { font-size: 14px; } display: none; } -.acp-shell[data-collapsed="true"] .acp-sidebar { - overflow: visible; -} - -.acp-shell[data-collapsed="true"] .acp-sidebar-top { - overflow: visible; -} - .acp-shell[data-collapsed="true"] .acp-sidebar-actions { align-items: center; gap: 4px; diff --git a/ui/src/acp-page.ts b/ui/src/acp-page.ts index 6c5c4d0..0eeede7 100644 --- a/ui/src/acp-page.ts +++ b/ui/src/acp-page.ts @@ -101,6 +101,7 @@ agents: [], selectedAgent: null, conversations: [], + agentConversations: {}, selectedConversation: null, transcript: ACPRender.createTranscript(), permissionQueue: [], @@ -482,44 +483,82 @@ function renderConversationList(page) { if (!page.threadListEl) return; page.threadListEl.innerHTML = ''; - if (page.workspaceState !== 'ready') { - const empty = document.createElement('p'); - empty.className = 'acp-empty acp-empty--sidebar'; - if (page.workspaceState === 'missing') { - empty.textContent = 'This workspace is no longer available.'; - page.threadListEl.appendChild(empty); - } - page.newConversationBtn.disabled = true; - return; - } - if (!page.selectedAgent) { - const empty = document.createElement('p'); - empty.className = 'acp-empty acp-empty--sidebar'; - empty.textContent = 'Choose an ACP-ready workspace to load conversations.'; - page.threadListEl.appendChild(empty); - page.newConversationBtn.disabled = true; - return; - } - page.newConversationBtn.disabled = false; - if (!page.conversations.length) { + + if (!page.agents || !page.agents.length) { return; } - page.conversations.forEach((conversation) => { - const id = conversation.metadata?.name || ''; - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'acp-thread-item'; - button.dataset.active = String(id === page.selectedConversationId); - button.onclick = () => { - window.location.assign(chatPagePath(page.selectedName, id)); - }; + const agentConvos = page.agentConversations || {}; + + page.agents.forEach((agent) => { + const spritzName = agent?.spritz?.metadata?.name || ''; + if (!spritzName) return; + + const group = document.createElement('div'); + group.className = 'acp-agent-group'; + + const header = document.createElement('button'); + header.type = 'button'; + header.className = 'acp-agent-header'; + if (spritzName === page.selectedName) { + header.dataset.active = 'true'; + } + + const headerLabel = document.createElement('span'); + headerLabel.className = 'acp-agent-header-label'; + headerLabel.textContent = getAgentTitle(agent); + + const chevron = document.createElement('span'); + chevron.className = 'acp-agent-chevron'; + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'acp-agent-add-btn'; + addBtn.innerHTML = ''; + addBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const conversation = await createACPConversationData(page.deps, spritzName); + window.location.assign(chatPagePath(spritzName, conversation.metadata?.name || '')); + } catch (err) { + reportACPError(page, err, 'Failed to create conversation.'); + } + }); + + header.append(headerLabel, chevron, addBtn); + + const convos = agentConvos[spritzName] || []; + const convoList = document.createElement('div'); + convoList.className = 'acp-agent-convos'; + + // Expand this agent group if it's the selected one or has no selection yet + const isExpanded = spritzName === page.selectedName || !page.selectedName; + group.dataset.expanded = String(isExpanded); + + header.addEventListener('click', () => { + const expanded = group.dataset.expanded === 'true'; + group.dataset.expanded = String(!expanded); + }); + + convos.forEach((conversation) => { + const id = conversation.metadata?.name || ''; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'acp-thread-item'; + button.dataset.active = String(id === page.selectedConversationId && spritzName === page.selectedName); + button.onclick = () => { + window.location.assign(chatPagePath(spritzName, id)); + }; - const title = document.createElement('span'); - title.className = 'acp-thread-item-title'; - title.textContent = conversation.spec?.title || 'New conversation'; - button.append(title); - page.threadListEl.appendChild(button); + const title = document.createElement('span'); + title.className = 'acp-thread-item-title'; + title.textContent = conversation.spec?.title || 'New conversation'; + button.append(title); + convoList.appendChild(button); + }); + + group.append(header, convoList); + page.threadListEl.appendChild(group); }); } @@ -605,6 +644,170 @@ }); } + function renderPresetGallery(page) { + if (!page.threadStreamEl) return; + const presets = page.deps.presets || []; + + const gallery = document.createElement('div'); + gallery.className = 'acp-preset-gallery'; + + const title = document.createElement('h3'); + title.className = 'acp-preset-gallery-title'; + title.textContent = 'Create a new Spritz'; + gallery.appendChild(title); + + const grid = document.createElement('div'); + grid.className = 'acp-preset-grid'; + + presets.forEach((preset) => { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'acp-preset-card'; + const name = document.createElement('strong'); + name.textContent = preset.name || preset.id || 'Preset'; + const desc = document.createElement('p'); + desc.textContent = preset.description || preset.image || ''; + card.append(name, desc); + card.addEventListener('click', async () => { + card.disabled = true; + card.textContent = 'Creating…'; + try { + const payload: any = { + spec: { image: preset.image }, + }; + if (preset.id) payload.presetId = preset.id; + if (preset.namePrefix) payload.namePrefix = preset.namePrefix; + if (page.deps.ownerId) payload.spec.owner = { id: page.deps.ownerId }; + await page.deps.request('/spritzes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + page.deps.showToast('Spritz created. Loading…', 'info'); + setTimeout(() => loadACPPage(page), 1500); + } catch (err) { + reportACPError(page, err, 'Failed to create Spritz.'); + card.disabled = false; + card.innerHTML = ''; + card.append(name, desc); + } + }); + grid.appendChild(card); + }); + + // Custom card + const customCard = document.createElement('button'); + customCard.type = 'button'; + customCard.className = 'acp-preset-card acp-preset-card--custom'; + const customName = document.createElement('strong'); + customName.textContent = 'Custom'; + const customDesc = document.createElement('p'); + customDesc.textContent = 'Configure image, repo, and more'; + customCard.append(customName, customDesc); + customCard.addEventListener('click', () => { + openCreateModal(page); + }); + grid.appendChild(customCard); + + gallery.appendChild(grid); + page.threadStreamEl.appendChild(gallery); + } + + function openCreateModal(page) { + const existing = page.card?.querySelector('.acp-create-modal-backdrop'); + if (existing) existing.remove(); + + const backdrop = document.createElement('div'); + backdrop.className = 'acp-create-modal-backdrop'; + + const modal = document.createElement('div'); + modal.className = 'acp-create-modal'; + + const header = document.createElement('div'); + header.className = 'acp-create-modal-header'; + const headerTitle = document.createElement('h3'); + headerTitle.textContent = 'Create Custom Spritz'; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'acp-nav-icon'; + closeBtn.innerHTML = ''; + closeBtn.addEventListener('click', () => backdrop.remove()); + header.append(headerTitle, closeBtn); + + const form = document.createElement('form'); + form.className = 'acp-create-modal-form'; + + const fields = [ + { label: 'Image', name: 'image', placeholder: 'spritz-starter:latest', required: true }, + { label: 'Name', name: 'name', placeholder: 'Auto-generated' }, + { label: 'Repository URL', name: 'repo', placeholder: 'https://github.com/...' }, + { label: 'Branch', name: 'branch', placeholder: 'main' }, + { label: 'TTL', name: 'ttl', placeholder: '8h' }, + { label: 'Namespace', name: 'namespace', placeholder: 'default' }, + ]; + + fields.forEach((field) => { + const label = document.createElement('label'); + label.textContent = field.label; + const input = document.createElement('input'); + input.name = field.name; + input.placeholder = field.placeholder; + if (field.required) input.required = true; + label.appendChild(input); + form.appendChild(label); + }); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'submit'; + submitBtn.textContent = 'Create Spritz'; + form.appendChild(submitBtn); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload: any = { + spec: { image: (data.get('image') || '').toString().trim() }, + }; + const rawName = (data.get('name') || '').toString().trim(); + if (rawName) payload.name = rawName; + if (page.deps.ownerId) payload.spec.owner = { id: page.deps.ownerId }; + const repo = (data.get('repo') || '').toString().trim(); + if (repo) { + payload.spec.repo = { url: repo }; + const branch = (data.get('branch') || '').toString().trim(); + if (branch) payload.spec.repo.branch = branch; + } + const ttl = (data.get('ttl') || '').toString().trim(); + if (ttl) payload.spec.ttl = ttl; + const ns = (data.get('namespace') || '').toString().trim(); + if (ns) payload.namespace = ns; + + submitBtn.disabled = true; + submitBtn.textContent = 'Creating…'; + try { + await page.deps.request('/spritzes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + backdrop.remove(); + page.deps.showToast('Spritz created. Loading…', 'info'); + setTimeout(() => loadACPPage(page), 1500); + } catch (err) { + reportACPError(page, err, 'Failed to create Spritz.'); + submitBtn.disabled = false; + submitBtn.textContent = 'Create Spritz'; + } + }); + + modal.append(header, form); + backdrop.appendChild(modal); + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) backdrop.remove(); + }); + page.card.appendChild(backdrop); + } + function renderThread(page) { if (!page.threadTitleEl || !page.threadMetaEl || !page.threadStreamEl) return; const currentWorkspace = selectedWorkspace(page); @@ -634,16 +837,8 @@ return; } - if (!page.selectedAgent) { - const empty = document.createElement('div'); - empty.className = 'acp-empty'; - empty.textContent = 'Choose an ACP-ready workspace to start chatting.'; - page.threadStreamEl.appendChild(empty); - renderPermissionPrompt(page); - return; - } - - if (!page.selectedConversation) { + if (!page.selectedAgent || !page.selectedConversation) { + renderPresetGallery(page); renderPermissionPrompt(page); return; } @@ -970,6 +1165,7 @@ page.bootstrapComplete = true; writeCachedConversationRecord(page); clearACPNotice(page); + if (page.composerEl?.focus) page.composerEl.focus(); } async function selectConversation(page, conversationId) { @@ -1004,6 +1200,7 @@ return; } page.conversations = await listACPConversationsData(page.deps, page.selectedName); + page.agentConversations[page.selectedName] = page.conversations; hydrateCachedConversationPreviews(page); const routeConversationId = conversationIdFromHash(window.location.hash || ''); let resolvedConversationId = @@ -1011,15 +1208,6 @@ page.selectedConversationId || page.conversations[0]?.metadata?.name || ''; - if (!resolvedConversationId && page.selectedName) { - try { - const conversation = await createACPConversationData(page.deps, page.selectedName); - page.conversations = [conversation, ...page.conversations]; - resolvedConversationId = conversation.metadata?.name || ''; - } catch { - // fall through to empty state - } - } renderConversationList(page); if (routeConversationId && resolvedConversationId !== routeConversationId) { window.location.replace(chatPagePath(page.selectedName, resolvedConversationId)); @@ -1052,6 +1240,24 @@ } } } + // Fetch conversations for all agents in parallel + const convoResults = await Promise.all( + page.agents.map(async (agent) => { + const name = agent?.spritz?.metadata?.name || ''; + if (!name) return { name, convos: [] }; + try { + const convos = await listACPConversationsData(page.deps, name); + return { name, convos }; + } catch { + return { name, convos: [] }; + } + }), + ); + page.agentConversations = {}; + for (const { name, convos } of convoResults) { + if (name) page.agentConversations[name] = convos; + } + renderAgentPicker(page); if (!page.agents.length && !page.selectedSpritz) { page.selectedAgent = null; @@ -1137,40 +1343,32 @@ const newConversationButton = document.createElement('button'); newConversationButton.type = 'button'; newConversationButton.className = 'acp-new-chat-item'; - newConversationButton.dataset.tooltip = 'New chat'; newConversationButton.innerHTML = 'New chat'; const backLink = document.createElement('a'); backLink.href = '#create'; backLink.className = 'acp-new-chat-item acp-back-link'; - backLink.dataset.tooltip = 'Spritzes'; backLink.innerHTML = 'Spritzes'; const refreshButton = document.createElement('button'); refreshButton.type = 'button'; refreshButton.className = 'acp-nav-icon'; refreshButton.dataset.tooltip = 'Refresh'; - refreshButton.dataset.tooltipPos = 'bottom'; + refreshButton.dataset.tooltipPos = 'bottom-end'; refreshButton.innerHTML = ''; - const agentSelect = document.createElement('select'); - agentSelect.className = 'acp-agent-select'; - - const collapseIcon = ''; - const expandIcon = ''; + const collapseIcon = ''; + const expandIcon = ''; const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = 'acp-nav-icon acp-sidebar-toggle'; - toggleBtn.dataset.tooltip = 'Collapse sidebar'; - toggleBtn.dataset.tooltipPos = 'right'; toggleBtn.innerHTML = collapseIcon; const isCollapsed = typeof localStorage !== 'undefined' && localStorage.getItem('spritz:sidebar-collapsed') === 'true'; if (isCollapsed) { shell.dataset.collapsed = 'true'; toggleBtn.innerHTML = expandIcon; - toggleBtn.dataset.tooltip = 'Expand sidebar'; } toggleBtn.addEventListener('click', () => { @@ -1178,19 +1376,17 @@ if (collapsed) { delete shell.dataset.collapsed; toggleBtn.innerHTML = collapseIcon; - toggleBtn.dataset.tooltip = 'Collapse sidebar'; try { localStorage.setItem('spritz:sidebar-collapsed', 'false'); } catch {} } else { shell.dataset.collapsed = 'true'; toggleBtn.innerHTML = expandIcon; - toggleBtn.dataset.tooltip = 'Expand sidebar'; - try { localStorage.setItem('spritz:sidebar-collapsed', 'true'); } catch {} + try { localStorage.setItem('spritz:sidebar-collapsed', 'true'); } catch {} } }); const selectRow = document.createElement('div'); selectRow.className = 'acp-sidebar-select-row'; - selectRow.append(agentSelect, toggleBtn); + selectRow.append(toggleBtn); const threadList = document.createElement('div'); threadList.className = 'acp-thread-list'; @@ -1220,7 +1416,7 @@ openButton.type = 'button'; openButton.className = 'acp-nav-icon'; openButton.dataset.tooltip = 'Open workspace'; - openButton.dataset.tooltipPos = 'bottom'; + openButton.dataset.tooltipPos = 'bottom-end'; openButton.innerHTML = ''; headerActions.append(refreshButton, openButton); @@ -1309,7 +1505,7 @@ deps.shellEl.append(shell); page.card = shell; - page.agentSelectEl = agentSelect; + page.agentSelectEl = null; page.threadListEl = threadList; page.threadTitleEl = threadTitle; page.threadMetaEl = threadMeta; @@ -1332,11 +1528,6 @@ loadACPPage(page); }); - agentSelect.addEventListener('change', () => { - if (!agentSelect.value) return; - window.location.assign(chatPagePath(agentSelect.value)); - }); - newConversationButton.addEventListener('click', async () => { if (!page.selectedName) return; try { @@ -1371,6 +1562,7 @@ return; } composerInput.value = ''; + if (composerInput.focus) composerInput.focus(); ACPRender.applySessionUpdate(page.transcript, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text }, diff --git a/ui/src/acp-render.ts b/ui/src/acp-render.ts index 4d2f2b4..6ddcfa3 100644 --- a/ui/src/acp-render.ts +++ b/ui/src/acp-render.ts @@ -535,6 +535,30 @@ }); return null; } + if (type === 'agent_thought_chunk') { + const text = extractACPText(update.content); + if (!text) return null; + const last = transcript.messages[transcript.messages.length - 1]; + if (last && last.type === 'thinking') { + const textBlock = last.blocks.find((block) => block.type === 'text'); + if (textBlock) { + textBlock.text += text; + } else { + last.blocks.push({ type: 'text', text }); + } + } else { + pushMessage(transcript, { + type: 'thinking', + title: 'Thinking', + tone: 'muted', + blocks: [{ type: 'text', text }], + }); + } + return null; + } + // Silently ignore noisy internal updates + const silentTypes = ['heartbeat', 'ping', 'pong', 'ack']; + if (silentTypes.includes(type)) return null; pushMessage(transcript, { type: 'system', title: humanizeUpdateType(type), @@ -729,6 +753,26 @@ } function renderMessage(message) { + if (message.type === 'thinking') { + const article = document.createElement('article'); + article.className = 'acp-message acp-message--thinking'; + article.dataset.type = 'thinking'; + const details = document.createElement('details'); + details.className = 'acp-thinking-block'; + const summary = document.createElement('summary'); + summary.className = 'acp-thinking-summary'; + summary.textContent = 'Thinking…'; + const content = document.createElement('div'); + content.className = 'acp-thinking-content'; + const textBlock = message.blocks.find((b) => b.type === 'text'); + if (textBlock) { + content.appendChild(renderRichText(textBlock.text || '')); + } + details.append(summary, content); + article.appendChild(details); + return article; + } + const article = document.createElement('article'); article.className = `acp-message acp-message--${message.type}`; article.dataset.type = message.type; diff --git a/ui/src/app.ts b/ui/src/app.ts index a37cc79..e2e212c 100644 --- a/ui/src/app.ts +++ b/ui/src/app.ts @@ -1418,6 +1418,8 @@ function renderACPPage(name) { createSection, listSection, setHeaderCopy, + presets, + ownerId: config.ownerId || '', }, ); }