fix webui: proxy, state detection, kill sessions, tool call styling

- Fix vite proxy: /s -> /s/ to avoid intercepting /src/ requests
- Fix state detection: match 'thinking'/'calling' instead of 'running'
- Fetch chat as plain text instead of JSON
- Add kill button on session cards
- Make stop button more visible
- Parse and pretty-print tool call (-> ) lines
- Style tool calls with arrow, function name, and formatted JSON body
This commit is contained in:
Levi Neely 2026-04-24 20:10:58 +02:00
parent 8938409f72
commit a199572a74
8 changed files with 313 additions and 20 deletions

198
.kiro/settings/lsp.json Normal file
View File

@ -0,0 +1,198 @@
{
"languages": {
"ruby": {
"name": "solargraph",
"command": "solargraph",
"args": [
"stdio"
],
"file_extensions": [
"rb"
],
"project_patterns": [
"Gemfile",
"Rakefile"
],
"exclude_patterns": [
"**/vendor/**",
"**/tmp/**"
],
"multi_workspace": false,
"initialization_options": {},
"request_timeout_secs": 60
},
"rust": {
"name": "rust-analyzer",
"command": "rust-analyzer",
"args": [],
"file_extensions": [
"rs"
],
"project_patterns": [
"Cargo.toml"
],
"exclude_patterns": [
"**/target/**"
],
"multi_workspace": false,
"initialization_options": {
"cargo": {
"buildScripts": {
"enable": true
}
},
"diagnostics": {
"enable": true,
"enableExperimental": true
},
"workspace": {
"symbol": {
"search": {
"scope": "workspace"
}
}
}
},
"request_timeout_secs": 60
},
"java": {
"name": "jdtls",
"command": "jdtls",
"args": [],
"file_extensions": [
"java"
],
"project_patterns": [
"pom.xml",
"build.gradle",
"build.gradle.kts",
".project"
],
"exclude_patterns": [
"**/target/**",
"**/build/**",
"**/.gradle/**"
],
"multi_workspace": false,
"initialization_options": {
"settings": {
"java": {
"compile": {
"nullAnalysis": {
"mode": "automatic"
}
},
"configuration": {
"annotationProcessing": {
"enabled": true
}
}
}
}
},
"request_timeout_secs": 60
},
"python": {
"name": "pyright",
"command": "pyright-langserver",
"args": [
"--stdio"
],
"file_extensions": [
"py"
],
"project_patterns": [
"pyproject.toml",
"setup.py",
"requirements.txt",
"pyrightconfig.json"
],
"exclude_patterns": [
"**/__pycache__/**",
"**/venv/**",
"**/.venv/**",
"**/.pytest_cache/**"
],
"multi_workspace": false,
"initialization_options": {},
"request_timeout_secs": 60
},
"typescript": {
"name": "typescript-language-server",
"command": "typescript-language-server",
"args": [
"--stdio"
],
"file_extensions": [
"ts",
"js",
"tsx",
"jsx"
],
"project_patterns": [
"package.json",
"tsconfig.json"
],
"exclude_patterns": [
"**/node_modules/**",
"**/dist/**"
],
"multi_workspace": false,
"initialization_options": {
"preferences": {
"disableSuggestions": false
}
},
"request_timeout_secs": 60
},
"go": {
"name": "gopls",
"command": "gopls",
"args": [],
"file_extensions": [
"go"
],
"project_patterns": [
"go.mod",
"go.sum"
],
"exclude_patterns": [
"**/vendor/**"
],
"multi_workspace": false,
"initialization_options": {
"usePlaceholders": true,
"completeUnimported": true
},
"request_timeout_secs": 60
},
"cpp": {
"name": "clangd",
"command": "clangd",
"args": [
"--background-index"
],
"file_extensions": [
"cpp",
"cc",
"cxx",
"c",
"h",
"hpp",
"hxx"
],
"project_patterns": [
"CMakeLists.txt",
"compile_commands.json",
"Makefile"
],
"exclude_patterns": [
"**/build/**",
"**/cmake-build-**/**"
],
"multi_workspace": false,
"initialization_options": {},
"request_timeout_secs": 60
}
}
}

View File

@ -9,6 +9,7 @@ import {
sendPrompt,
sendCtl,
createSession,
killSession,
} from './lib/api'
import type { IdxEntry } from './lib/api'
import { parseChat } from './lib/chat'
@ -76,6 +77,13 @@ export function App() {
if (s.length > 0) setSelectedId(s[s.length - 1].id)
}, [])
const handleKill = useCallback(async (id: string) => {
await killSession(id)
if (selectedId === id) setSelectedId(null)
const s = await listSessions()
setSessions(s)
}, [selectedId])
return (
<div class="layout">
<Sidebar
@ -83,6 +91,7 @@ export function App() {
selectedId={selectedId}
onSelect={setSelectedId}
onNew={() => setShowNew(true)}
onKill={handleKill}
/>
<ChatPane
session={sessions.find(s => s.id === selectedId) ?? null}

View File

@ -16,16 +16,16 @@ interface Props {
}
function stateClass(state: string) {
if (state === 'running') return 'running'
if (state === 'idle' || state === 'waiting') return 'idle'
return 'stopped'
if (state === 'idle') return 'idle'
if (state === 'stopped') return 'stopped'
return 'running'
}
export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
const listRef = useRef<HTMLDivElement>(null)
const [atBottom, setAtBottom] = useState(true)
const isRunning = cfg?.state === 'running'
const isRunning = !!cfg?.state && cfg.state !== 'idle' && cfg.state !== 'stopped'
useEffect(() => {
if (atBottom && listRef.current) {
@ -76,6 +76,8 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
<div class="msg-bubble">
{msg.role === 'user' ? (
<div class="user-text">{msg.content}</div>
) : msg.role === 'tool' ? (
<ToolCall content={msg.content} />
) : (
<div
class="md"
@ -100,6 +102,28 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
)
}
function formatToolContent(raw: string): { name: string; body: string } {
const m = raw.match(/^(\w+)\(([\s\S]*)\)\s*$/)
if (!m) return { name: '', body: raw }
try {
const obj = JSON.parse(m[2])
return { name: m[1], body: JSON.stringify(obj, null, 2) }
} catch {
return { name: m[1], body: m[2] }
}
}
function ToolCall({ content }: { content: string }) {
const { name, body } = formatToolContent(content)
return (
<div class="tool-call">
<span class="tool-arrow">→</span>{' '}
{name && <span class="tool-name">{name}</span>}
<pre class="tool-body">{body}</pre>
</div>
)
}
function Welcome() {
const tree: [string, string][] = [
['/s/', 'sessions'],

View File

@ -6,15 +6,16 @@ interface Props {
selectedId: string | null
onSelect: (id: string) => void
onNew: () => void
onKill: (id: string) => void
}
function stateClass(state: string) {
if (state === 'running') return 'running'
if (state === 'idle' || state === 'waiting') return 'idle'
return 'stopped'
if (state === 'idle') return 'idle'
if (state === 'stopped') return 'stopped'
return 'running' // thinking, calling: <tool>, etc.
}
export function Sidebar({ sessions, selectedId, onSelect, onNew }: Props) {
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props) {
return (
<aside class="sidebar">
<div class="sidebar-header">
@ -46,6 +47,11 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew }: Props) {
<div class={`state-dot ${stateClass(s.state)}`} />
<div class="session-id-label">{shortId(s.id)}</div>
<div class="session-state-label">{s.state}</div>
<button
class="btn-kill"
title="Kill session"
onClick={(e: MouseEvent) => { e.stopPropagation(); onKill(s.id) }}
>✕</button>
</div>
<div class="session-cwd" title={s.cwd}>{s.cwd}</div>
<div class="session-badges">

View File

@ -22,17 +22,13 @@ export async function getSessionCfg(id: string): Promise<Record<string, string>>
: {}
}
// The gateway strips empty lines, returning string[] or {"chat":"single line"}.
// The gateway returns chat as plain text (one line per key=value pair).
export async function getChatLines(id: string): Promise<string[]> {
const r = await fetch(`/s/${id}/chat`)
if (!r.ok) return []
const data = await r.json()
if (Array.isArray(data)) return data as string[]
if (data && typeof data === 'object') {
const v = Object.values(data as Record<string, unknown>)[0]
return typeof v === 'string' && v ? [v] : []
}
return []
const text = await r.text()
if (!text.trim()) return []
return text.trimEnd().split('\n')
}
export async function sendPrompt(id: string, prompt: string): Promise<void> {
@ -70,3 +66,7 @@ export async function getBackends(): Promise<string[]> {
const data = await r.json()
return Array.isArray(data) ? (data as string[]) : []
}
export async function killSession(id: string): Promise<void> {
await fetch(`/s/${id}`, { method: 'DELETE' })
}

View File

@ -1,5 +1,5 @@
export interface ChatMessage {
role: 'user' | 'assistant'
role: 'user' | 'assistant' | 'tool'
content: string
}
@ -17,6 +17,9 @@ export function parseChat(lines: string[]): ChatMessage[] {
} else if (line.startsWith('assistant: ')) {
if (cur) messages.push(cur)
cur = { role: 'assistant', content: line.slice(11) }
} else if (line.startsWith('-> ')) {
if (cur) messages.push(cur)
cur = { role: 'tool', content: line.slice(3) }
} else if (cur) {
cur.content += '\n' + line
}

View File

@ -202,6 +202,20 @@ input, select, textarea { font-family: inherit; }
flex-wrap: wrap;
}
.btn-kill {
display: none;
background: none;
border: none;
color: var(--text-faint);
font-size: 11px;
padding: 0 4px;
line-height: 1;
cursor: pointer;
border-radius: var(--r-sm);
}
.btn-kill:hover { color: var(--danger); }
.session-card:hover .btn-kill { display: block; }
/* ---- Badges ---- */
.badge {
font-family: var(--mono);
@ -261,11 +275,12 @@ input, select, textarea { font-family: inherit; }
.btn-primary:hover:not(:disabled) { background: #35ddf5; }
.btn-danger {
background: transparent;
background: rgba(251, 113, 133, 0.12);
border-color: rgba(251, 113, 133, 0.35);
color: var(--danger);
font-weight: 600;
}
.btn-danger:hover:not(:disabled) { background: rgba(251, 113, 133, 0.07); }
.btn-danger:hover:not(:disabled) { background: rgba(251, 113, 133, 0.2); }
/* ---- Main area ---- */
.main {
@ -504,6 +519,44 @@ input, select, textarea { font-family: inherit; }
}
.md th { background: var(--bg-soft); font-weight: 600; }
/* ---- Tool calls ---- */
.message.tool { justify-content: flex-start; }
.message.tool .msg-bubble {
max-width: 100%;
width: 100%;
background: transparent;
border: 1px solid var(--border);
border-left: 3px solid var(--warning);
border-radius: var(--r-sm);
padding: 6px 12px;
}
.tool-call {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
}
.tool-arrow {
color: var(--warning);
font-weight: 600;
}
.tool-name {
color: var(--accent);
font-weight: 500;
}
.tool-body {
margin: 6px 0 0;
padding: 8px 12px;
background: #07070f;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 11.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-dim);
overflow-x: auto;
}
/* ---- Typing indicator ---- */
.typing {
display: flex;

View File

@ -5,7 +5,7 @@ export default defineConfig({
plugins: [preact()],
server: {
proxy: {
'/s': 'http://localhost:8080',
'/s/': 'http://localhost:8080',
'/backends': 'http://localhost:8080',
'/openapi.json': 'http://localhost:8080',
},