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:
parent
8938409f72
commit
a199572a74
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
Reference in New Issue