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/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/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', diff --git a/ui/public/index.html b/ui/public/index.html index 45e9efd..10e56f6 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -8,55 +8,69 @@
-
+
+
+

Create Spritz

+

Spin up an ephemeral dev Spritz managed by API.

+
-

Create

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

Active spritzes

- -
+
+
+

Active Spritzes

+ +
+
+
diff --git a/ui/public/styles.css b/ui/public/styles.css index b45911b..71d6bcc 100644 --- a/ui/public/styles.css +++ b/ui/public/styles.css @@ -1,10 +1,81 @@ -@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; +} + +/* 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: 9999; +} + +[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); +} + +[data-tooltip-pos="bottom-end"]::after { + bottom: auto; + top: calc(100% + 6px); + left: auto; + right: 0; + transform: translateY(-4px); +} + +[data-tooltip-pos="bottom-end"]:hover::after { + transform: 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); +} :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 +115,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 +155,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 +174,199 @@ 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; +} + +#create-section, +#list-section { + display: contents; +} + +#create-section[hidden], +#list-section[hidden] { + display: none; +} + +.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) { + .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; + } +} + +.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 +377,294 @@ 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; + gap:16px; + align-items: start; + 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 { @@ -243,10 +680,10 @@ button.ghost { 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 { @@ -261,17 +698,18 @@ button.ghost { .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; + transition: grid-template-columns 0.2s ease; } .acp-sidebar { display: flex; flex-direction: column; - border-right: 1px solid #e5e8ec; - background: rgba(247, 247, 248, 0.96); + border-right: 1px solid #e5e5e5; + background: #fafafa; min-height: 0; overflow: hidden; } @@ -279,124 +717,262 @@ button.ghost { .acp-sidebar-top { display: flex; flex-direction: column; - gap: 16px; - padding: 20px; - border-bottom: 1px solid #e5e8ec; - 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-nav { +.acp-sidebar-select-row { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 8px; +} + +.acp-sidebar-select-row .acp-agent-select { + flex: 1; + min-width: 0; } -.acp-sidebar-title { +.acp-sidebar-actions { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; +} + +.acp-sidebar-nav { + display: flex; + align-items: center; + gap: 8px; +} + +.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; - border: 1px solid #d7dbe0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #e5e5e5; background: white; - color: #1f2a33; + color: #000000; font: inherit; + font-size: 13px; } -.acp-thread-list { - flex: 1; - min-height: 0; - overflow: auto; - padding: 12px; +.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; +} + +/* Agent groups */ +.acp-agent-group { display: flex; flex-direction: column; - gap: 8px; - overscroll-behavior: contain; } -.acp-thread-item { +.acp-agent-group + .acp-agent-group { + margin-top: 8px; +} + +.acp-agent-header { display: flex; - align-items: flex-start; - gap: 12px; + align-items: center; + gap: 8px; width: 100%; - padding: 12px 14px; - border-radius: 18px; + padding: 8px 12px; border: 0; + border-radius: 8px; background: transparent; - color: inherit; + color: #000000; + font-size: 13px; + font-weight: 500; text-align: left; + cursor: pointer; + transition: background-color 0.12s ease; } -.acp-thread-item:hover, -.acp-thread-item[data-active="true"] { - background: white; - box-shadow: 0 8px 28px rgba(15, 23, 42, 0.08); +.acp-agent-header:hover { + background: #f0f0f0; + opacity: 1; } -.acp-thread-avatar { - width: 42px; - height: 42px; +.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; - border-radius: 50%; +} + +.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; - background: #2e3a46; - color: white; - font-size: 13px; - font-weight: 600; + 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-thread-item-body { - min-width: 0; - flex: 1; - display: flex; +.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: 4px; + gap: 2px; + padding-left: 8px; + margin-top: 2px; } -.acp-thread-item-top { +.acp-agent-group[data-expanded="true"] .acp-agent-convos { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; } -.acp-thread-item-title { - font-size: 15px; +.acp-agent-convos .acp-thread-item { + padding: 6px 12px; + font-size: 13px; + border-radius: 6px; } -.acp-thread-item-time, -.acp-thread-item-meta { - font-size: 12px; - opacity: 0.62; +.acp-thread-list { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + overscroll-behavior: contain; } -.acp-thread-item-preview { - margin: 0; - font-size: 13px; - color: #5d6772; +.acp-thread-item { + display: block; + width: 100%; + 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-meta { - margin: 0; +.acp-thread-item:hover { + background: #f0f0f0; +} + +.acp-thread-item[data-active="true"] { + background: white; +} + +.acp-thread-item-title { + font-size: 14px; + font-weight: 400; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .acp-main { @@ -405,36 +981,63 @@ button.ghost { 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%); + background: #ffffff; } .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); + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid #e5e5e5; + background: #fafafa; } .acp-main-header-top { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 16px; + gap: 12px; +} + +.acp-main-copy { + min-width: 0; + flex: 1; +} + +.acp-main-copy h2 { + font-size: 14px; + font-weight: 500; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.acp-main-copy p { + font-size: 12px; + margin: 2px 0 0; + opacity: 0.5; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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, @@ -444,65 +1047,202 @@ button.ghost { 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; } -.acp-main-body { - flex: 1; - min-height: 0; - overflow: auto; - padding: 28px 24px 12px; - overscroll-behavior: contain; +.acp-main-body { + flex: 1; + min-height: 0; + 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 { + margin: auto; + max-width: 420px; + text-align: center; + opacity: 0.7; +} + +.acp-empty--sidebar { + margin: 24px 12px; + text-align: left; +} + +.acp-welcome-card { + max-width: 540px; + margin: auto; + padding: 0; + border: none; + background: transparent; + text-align: center; +} + +.acp-welcome-card strong { + display: block; + font-size: 16px; + font-weight: 500; + margin-bottom: 6px; +} + +.acp-welcome-card p { + margin: 0; + font-size: 14px; + 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-stream { - max-width: 880px; - margin: 0 auto; +.acp-create-modal-form { + padding: 20px; display: flex; flex-direction: column; gap: 16px; } -.acp-empty { - margin: auto; - max-width: 420px; - text-align: center; - opacity: 0.7; -} - -.acp-empty--sidebar { - margin: 24px 12px; - text-align: left; +.acp-create-modal-form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + font-weight: 500; } -.acp-welcome-card { - max-width: 680px; - 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); +.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-welcome-card strong { - display: block; - font-size: 20px; - margin-bottom: 8px; +.acp-create-modal-form input:focus-visible { + outline: none; + border-color: #000000; } -.acp-welcome-card p { - margin: 0; - color: #5d6772; +.acp-create-modal-form button[type="submit"] { + margin-top: 4px; + align-self: flex-start; } .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 { @@ -523,47 +1263,119 @@ button.ghost { .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-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: 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; } .acp-message--user .acp-bubble { - background: #2e3a46; + 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 { @@ -576,7 +1388,7 @@ button.ghost { .acp-message-meta-text { font-size: 12px; - opacity: 0.64; + opacity: 0.6; } .acp-status-pill { @@ -608,15 +1420,66 @@ button.ghost { .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; @@ -628,12 +1491,19 @@ button.ghost { .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 { @@ -658,7 +1528,7 @@ button.ghost { .acp-key-value dd { margin: 0; - opacity: 0.74; + opacity: 0.7; } .acp-tag-row { @@ -678,27 +1548,29 @@ button.ghost { .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] { @@ -707,10 +1579,37 @@ button.ghost { .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; @@ -720,7 +1619,40 @@ button.ghost { } .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 { @@ -733,28 +1665,88 @@ button.ghost { .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; } @@ -766,7 +1758,7 @@ button.ghost { .terminal-back { font-size: 14px; - color: #2e3a46; + color: #000000; text-decoration: none; } @@ -783,6 +1775,52 @@ button.ghost { 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-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; @@ -799,27 +1837,89 @@ button.ghost { .acp-shell { grid-template-columns: 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 #e5e8ec; + 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-composer-actions { + .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 { @@ -829,4 +1929,12 @@ button.ghost { .acp-message { max-width: 100%; } + + .acp-footer-inner { + padding: 8px 12px 12px; + } + + .acp-composer { + border-radius: 20px; + } } 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..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,74 +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'; - empty.textContent = - page.workspaceState === 'missing' - ? 'This workspace is no longer available.' - : 'Conversations appear here once chat is ready.'; - 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) { - 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); + + 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); - 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'); - 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); - page.threadListEl.appendChild(button); + 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); + convoList.appendChild(button); + }); + + group.append(header, convoList); + page.threadListEl.appendChild(group); }); } @@ -605,6 +614,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); }); } @@ -627,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); @@ -656,20 +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) { - const empty = document.createElement('div'); - empty.className = 'acp-empty'; - empty.textContent = 'Choose a conversation or start a new one.'; - page.threadStreamEl.appendChild(empty); + if (!page.selectedAgent || !page.selectedConversation) { + renderPresetGallery(page); renderPermissionPrompt(page); return; } @@ -692,9 +861,30 @@ 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()); + } + if (typeof document.createTextNode === 'function') { + page.statusEl.appendChild(document.createTextNode(text)); + } else { + page.statusEl.textContent = (page.statusEl.textContent || '') + text; + } } function selectedConversationClientMatches(page) { @@ -719,6 +909,9 @@ return selectedConversationClientMatches(page); } + const SEND_ICON = ''; + const STOP_ICON = ''; + function syncComposer(page) { const disabled = !page.client || @@ -726,8 +919,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) { @@ -963,6 +1165,7 @@ page.bootstrapComplete = true; writeCachedConversationRecord(page); clearACPNotice(page); + if (page.composerEl?.focus) page.composerEl.focus(); } async function selectConversation(page, conversationId) { @@ -976,8 +1179,6 @@ renderThread(page); if (page.selectedConversation) { await connectSelectedConversation(page); - } else { - setStatus(page, 'Choose or create a conversation.'); } } @@ -999,9 +1200,10 @@ return; } page.conversations = await listACPConversationsData(page.deps, page.selectedName); + page.agentConversations[page.selectedName] = page.conversations; 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 || @@ -1011,6 +1213,10 @@ window.location.replace(chatPagePath(page.selectedName, resolvedConversationId)); return; } + if (resolvedConversationId && resolvedConversationId !== routeConversationId) { + window.location.replace(chatPagePath(page.selectedName, resolvedConversationId)); + return; + } await selectConversation(page, resolvedConversationId); } @@ -1034,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; @@ -1109,44 +1333,69 @@ 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 = '/'; - backLink.className = 'header-link'; - backLink.textContent = 'Back'; + backLink.href = '#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); - - const agentSelect = document.createElement('select'); - agentSelect.className = 'acp-agent-select'; + refreshButton.className = 'acp-nav-icon'; + refreshButton.dataset.tooltip = 'Refresh'; + refreshButton.dataset.tooltipPos = 'bottom-end'; + refreshButton.innerHTML = ''; + + const collapseIcon = ''; + const expandIcon = ''; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'acp-nav-icon acp-sidebar-toggle'; + toggleBtn.innerHTML = collapseIcon; + + const isCollapsed = typeof localStorage !== 'undefined' && localStorage.getItem('spritz:sidebar-collapsed') === 'true'; + if (isCollapsed) { + shell.dataset.collapsed = 'true'; + toggleBtn.innerHTML = expandIcon; + } + + toggleBtn.addEventListener('click', () => { + const collapsed = shell.dataset.collapsed === 'true'; + if (collapsed) { + delete shell.dataset.collapsed; + toggleBtn.innerHTML = collapseIcon; + try { localStorage.setItem('spritz:sidebar-collapsed', 'false'); } catch {} + } else { + shell.dataset.collapsed = 'true'; + toggleBtn.innerHTML = expandIcon; + try { localStorage.setItem('spritz:sidebar-collapsed', 'true'); } catch {} + } + }); - const newConversationButton = document.createElement('button'); - newConversationButton.type = 'button'; - newConversationButton.textContent = 'New conversation'; + const selectRow = document.createElement('div'); + selectRow.className = 'acp-sidebar-select-row'; + selectRow.append(toggleBtn); 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(selectRow, sidebarActions); sidebar.append(sidebarTop, threadList); const main = document.createElement('section'); @@ -1165,12 +1414,36 @@ 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-end'; + 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'; @@ -1212,19 +1485,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); @@ -1232,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; @@ -1255,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 { @@ -1280,6 +1548,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); @@ -1290,6 +1562,7 @@ return; } composerInput.value = ''; + if (composerInput.focus) composerInput.focus(); ACPRender.applySessionUpdate(page.transcript, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text }, @@ -1314,9 +1587,11 @@ } }); - cancelButton.addEventListener('click', () => { - 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) { diff --git a/ui/src/acp-render.ts b/ui/src/acp-render.ts index a788441..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), @@ -575,17 +599,97 @@ 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 setInnerHTML(el, html, plainText) { + el.innerHTML = html; + el.textContent = plainText; + } + 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'; + setInnerHTML(heading, renderInlineMarkdown(headingMatch[2]), 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'); + const liText = lines[i].trim().replace(/^[-*]\s+/, ''); + setInnerHTML(li, renderInlineMarkdown(liText), liText); + 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'); + const olText = lines[i].trim().replace(/^\d+[.)]\s+/, ''); + setInnerHTML(li, renderInlineMarkdown(olText), olText); + 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'; + const paraText = paraLines.join('\n'); + setInnerHTML(p, renderInlineMarkdown(paraText), paraText); + parent.appendChild(p); + } + } } function renderBlock(block) { @@ -649,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 c7c898c..e2e212c 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; @@ -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 Spritzes yet

Create one above to get started.

'; return; } listEl.innerHTML = ''; @@ -993,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'); @@ -1067,13 +1090,26 @@ 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(); + item.remove(); + if (!listEl.children.length) { + renderList([]); + } + showNotice('Spritz deleted.', 'info'); + window.setTimeout(() => { + fetchSpritzes().catch(() => {}); + }, 250); } catch (err) { - showNotice(err.message || 'Failed to delete spritz.'); + showNotice(err.message || 'Failed to delete Spritz.'); + deleteBtn.disabled = false; + deleteBtn.innerHTML = deleteBtnHtml; } }; @@ -1382,6 +1418,8 @@ function renderACPPage(name) { createSection, listSection, setHeaderCopy, + presets, + ownerId: config.ownerId || '', }, ); } @@ -1409,9 +1447,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 Spritzes, 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 +1473,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 +1587,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 +1608,25 @@ if (form && refreshBtn) { await fetchSpritzes(); showNotice(''); } catch (err) { - showNotice(err.message || 'Failed to create spritz.'); + showNotice(err.message || 'Failed to create Spritz.'); + } 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();