From 9ec0472037c8acc4126d6cdeea27948815ef3b49 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Sat, 24 Oct 2026 10:20:17 -0700 Subject: [PATCH] fix(ui): aligned columns, readable panels and clearer small details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 of the UI/UX review fixes: - Web Projects headers line up with their rows. The header's smaller font shrank its ch-based grid columns; only its cells are smaller now. - Light-theme text colours are checked against Latte's mantle, the panel surface, so headings such as "Selected family" pass 4.5:1 on panels too. - Inbox Allow and Deny are at least 24 px tall and say what they allow ("Allow Bash for "). - Machines: the section for agent processes without a session is closed before MCP servers (it used to contain them), both say so when empty, and a machine with no address or version no longer shows a lone "·". - Chat's context chip shows the machine's name instead of its ID. - The TUI header stays on one line at every width, dropping disk, then load, then swap; Machines shows them all. Claude-Session: https://claude.ai/code/session_01T2vtcW6ihn1ywy9vxUq9a4 --- internal/tui/header.go | 11 ++++++++--- .../tui/testdata/machine-80-expanded-false.golden | 4 ++-- .../tui/testdata/machine-80-expanded-true.golden | 4 ++-- internal/view/theme.go | 9 ++++++--- internal/view/theme_test.go | 5 +++-- internal/web/pages.go | 12 ++++++++++++ internal/web/static/app.css | 11 +++++++++++ internal/web/static/chat.js | 2 +- internal/web/templates/chat.html | 2 +- internal/web/templates/dashboard.html | 13 +++++++------ 10 files changed, 53 insertions(+), 20 deletions(-) diff --git a/internal/tui/header.go b/internal/tui/header.go index c80a7b4d..f760f929 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -92,10 +92,15 @@ func (m *app) header() string { loadToken = "warn" } health := []string{headerGauge("mem", sample.MemUsed, sample.MemTotal, m.opts.NoColor), headerGauge("swap", sample.SwapUsed, sample.SwapTotal, m.opts.NoColor), "load " + view.TokenStyle(loadToken, m.opts.NoColor).Render(view.FormatLoad(sample.Load1, "%.1f")) + fmt.Sprintf("/%d", sample.Cores), "disk " + view.HumanBytes(sample.DiskFree)} - right := strings.Join(health, " ") identity = strings.Join(identityParts, " · ") - if lipgloss.Width(identity)+lipgloss.Width(right)+2 <= m.width { - return identity + strings.Repeat(" ", m.width-lipgloss.Width(identity)-lipgloss.Width(right)) + right + // One line at every width: drop health readings from the least important + // (disk, then load, then swap) before the header would wrap. Machines + // shows all of them. + for n := len(health); n > 0; n-- { + right := strings.Join(health[:n], " ") + if lipgloss.Width(identity)+lipgloss.Width(right)+2 <= m.width { + return identity + strings.Repeat(" ", m.width-lipgloss.Width(identity)-lipgloss.Width(right)) + right + } } return styledPack(append(identityParts, health...), m.width, " ") } diff --git a/internal/tui/testdata/machine-80-expanded-false.golden b/internal/tui/testdata/machine-80-expanded-false.golden index 2e322eb6..2558f2a6 100644 --- a/internal/tui/testdata/machine-80-expanded-false.golden +++ b/internal/tui/testdata/machine-80-expanded-false.golden @@ -1,5 +1,4 @@ - driga test-host · ● live · daemon 05:00:00 mem ███░░░ 50% -swap ███░░░ 50% load 0.0/4 disk 12.0G + driga test-host · ● live · daemon · 05:00:00 mem ███░░░ 50% 0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────── METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM @@ -44,6 +43,7 @@ swap ███░░░ 50% load 0.0/4 disk 12.0G + h health · p reclaim processes · d reclaim disk · 0 Flow · M machine enter detail · / filter · s sort · g group by project · o open terminal esc back · ? help · q quit diff --git a/internal/tui/testdata/machine-80-expanded-true.golden b/internal/tui/testdata/machine-80-expanded-true.golden index 245f9e7d..ce6d936a 100644 --- a/internal/tui/testdata/machine-80-expanded-true.golden +++ b/internal/tui/testdata/machine-80-expanded-true.golden @@ -1,5 +1,4 @@ - driga test-host · ● live · daemon 05:00:00 mem ███░░░ 50% -swap ███░░░ 50% load 0.0/4 disk 12.0G + driga test-host · ● live · daemon · 05:00:00 mem ███░░░ 50% 0 Flow 1 Projects [2 Machines] ──────────────────────────────────────────────────────────────────────────────── METRIC / APP / PROCESS CPU/# MEASURE / HOST / ATTRIBUTION MEM @@ -44,6 +43,7 @@ swap ███░░░ 50% load 0.0/4 disk 12.0G + h health · p reclaim processes · d reclaim disk · 0 Flow · M machine enter detail · / filter · s sort · g group by project · o open terminal esc back · ? help · q quit diff --git a/internal/view/theme.go b/internal/view/theme.go index c7cdf33f..491a25e1 100644 --- a/internal/view/theme.go +++ b/internal/view/theme.go @@ -16,12 +16,15 @@ type ThemeToken struct{ Name, Light, Dark string } // Palette roles come from the official Catppuccin palettes (MIT): Latte for // light, Mocha for dark. Where an official Latte color falls below 4.5:1 for -// text on Latte's base, text roles use readable(), the same hue darkened just -// enough. Tinted backgrounds are tint() mixes of the state color into base. +// text on Latte's mantle, the panel surface and darker than base, text roles +// use readable(), the same hue darkened just enough, so they pass on page and +// panels alike. Tinted backgrounds are tint() mixes of the state color into base. var ( lb, mb = catppuccin.Latte.Base(), catppuccin.Mocha.Base() pair = func(l, d catppuccin.Color) [2]string { return [2]string{hex(l), hex(d)} } - text = func(l, d catppuccin.Color) [2]string { return [2]string{readable(l, lb), hex(d)} } + text = func(l, d catppuccin.Color) [2]string { + return [2]string{readable(l, catppuccin.Latte.Mantle()), hex(d)} + } ) func tok(name string, v [2]string) ThemeToken { return ThemeToken{name, v[0], v[1]} } diff --git a/internal/view/theme_test.go b/internal/view/theme_test.go index 13144e6a..bbe382dc 100644 --- a/internal/view/theme_test.go +++ b/internal/view/theme_test.go @@ -80,14 +80,15 @@ func TestThemeFollowsCatppuccinAndStaysReadable(t *testing.T) { if tok.Dark != strings.ToUpper(want[1].Hex) { t.Errorf("%s dark %s, official Mocha %s", tok.Name, tok.Dark, want[1].Hex) } - if c := Contrast(rgb(tok.Light), catppuccin.Latte.Base().RGB); c < 4.5 { + // Light text sits on Latte's base and on its darker mantle panels. + if c := Contrast(rgb(tok.Light), catppuccin.Latte.Mantle().RGB); c < 4.5 { t.Errorf("%s light %s contrast %.2f", tok.Name, tok.Light, c) } if c := Contrast(rgb(tok.Dark), catppuccin.Mocha.Base().RGB); c < 4.5 { t.Errorf("%s dark %s contrast %.2f", tok.Name, tok.Dark, c) } // Passing official colors are used unchanged. - if Contrast(want[0].RGB, catppuccin.Latte.Base().RGB) >= 4.5 && tok.Light != strings.ToUpper(want[0].Hex) { + if Contrast(want[0].RGB, catppuccin.Latte.Mantle().RGB) >= 4.5 && tok.Light != strings.ToUpper(want[0].Hex) { t.Errorf("%s light %s should be official %s", tok.Name, tok.Light, want[0].Hex) } } diff --git a/internal/web/pages.go b/internal/web/pages.go index f9f4c01f..d3cbaa37 100644 --- a/internal/web/pages.go +++ b/internal/web/pages.go @@ -658,6 +658,18 @@ func templateFuncs() template.FuncMap { }, "duration": func(d time.Duration) string { return d.Round(time.Second).String() }, "machineName": func(m view.MachineRow) string { return view.MachineName(m.Machine) }, + // Chat's context chips show machine names, never raw machine IDs. + "machineNames": func(rows []view.MachineRow, local string) string { + names := map[string]string{} + for _, m := range rows { + names[m.MachineID] = view.MachineName(m.Machine) + if m.MachineID == local { + names["local"] = names[m.MachineID] + } + } + b, _ := json.Marshal(names) + return string(b) + }, "link": pageLink, "agentPath": func(id int64) string { return fmt.Sprint("/agents/", id) }, "projectPath": func(id int64) string { return fmt.Sprint("/projects/", id) }, diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 46c72841..66cfb673 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -236,6 +236,14 @@ button:hover { letter-spacing:.06em; text-transform:uppercase; } +/* Row grids are sized in ch: a smaller header font would shrink its columns + and misalign them with the rows, so only the header's cells are smaller. */ +.r.hdr { + font-size:inherit; +} +.r.hdr > * { + font-size:11.5px; +} .m,.muted { color:var(--mid); } @@ -805,3 +813,6 @@ meter::-moz-meter-bar { .app-notice{position:fixed;left:50%;bottom:24px;z-index:50;margin:0;padding:10px 16px;border:1px solid var(--line);border-radius:8px;background:var(--raise);color:var(--fg);box-shadow:0 8px 30px rgb(0 0 0 / .25);transform:translate(-50%,8px);opacity:0;pointer-events:none;transition:opacity .15s,transform .15s} .app-notice.show{opacity:1;transform:translate(-50%,0)} @media (prefers-reduced-motion:reduce){.app-notice{transition:none}} + +/* Prompt answers are the one decision on the page: at least 24 px targets. */ +.prompt-answer{min-height:32px;padding:4px 14px} diff --git a/internal/web/static/chat.js b/internal/web/static/chat.js index 519c0fd2..1bb19cbb 100644 --- a/internal/web/static/chat.js +++ b/internal/web/static/chat.js @@ -81,7 +81,7 @@ pinned.count.textContent=more?"+"+more+" more waiting":"";pinned.count.hidden=!more; } function scope(){const q=new URLSearchParams(location.search),ctx={page:location.pathname,machine:q.get("machine")||"local"};const row=document.querySelector('[data-row].sel');const href=row&&row.getAttribute("href");let session=selectedSession||Number(row&&(row.dataset.session))||Number(q.get("id"))||0;if(href&&/^\/agents\/\d+/.test(href))session=Number(href.match(/\d+/)[0]);if(/^\/agents\/\d+$/.test(location.pathname))session=Number(location.pathname.split("/").pop());if(session)ctx.session_id=session;const map=document.querySelector("[data-flow-board]");if(map?.dataset.selectedMachine)ctx.machine=map.dataset.selectedMachine;const project=q.get("project")||document.querySelector(".status")?.dataset.chatProject||row?.dataset.chatProject;if(project)ctx.project=project;for(const key of removed)delete ctx[key];return ctx;} - function contextChips(){const node=document.getElementById("chat-context");const entries=Object.entries(scope());node.hidden=!entries.length;node.replaceChildren(...(entries.length?[document.createTextNode("Context ")]:[]));for(const [key,value]of entries){const b=document.createElement("button");b.type="button";b.className="chat-chip";b.textContent=(key==="session_id"?"session":key)+": "+value+" ×";b.addEventListener("click",()=>{removed.add(key);contextChips();});node.append(b);}} + function contextChips(){const node=document.getElementById("chat-context");const entries=Object.entries(scope());node.hidden=!entries.length;node.replaceChildren(...(entries.length?[document.createTextNode("Context ")]:[]));let names={};try{names=JSON.parse(node.dataset.machineNames||"{}");}catch(_){}for(const [key,value]of entries){const b=document.createElement("button");b.type="button";b.className="chat-chip";b.textContent=(key==="session_id"?"session":key)+": "+(key==="machine"&&names[value]||value)+" ×";b.addEventListener("click",()=>{removed.add(key);contextChips();});node.append(b);}} function selectSession(session){selectedSession=Number(session);removed.delete("session_id");document.dispatchEvent(new CustomEvent("driga:select-session",{detail:{id:session}}));contextChips();} log.addEventListener("click",event=>{const chip=event.target.closest("[data-chat-session]");if(chip)selectSession(chip.dataset.chatSession);}); function content(node,text){const pattern=/\[\[session:([1-9][0-9]*)\]\]/g;let end=0;for(const match of text.matchAll(pattern)){node.append(document.createTextNode(text.slice(end,match.index)));const chip=document.createElement("button");chip.type="button";chip.className="chat-chip";chip.textContent="session #"+match[1];chip.addEventListener("click",()=>{selectedSession=Number(match[1]);removed.delete("session_id");document.dispatchEvent(new CustomEvent("driga:select-session",{detail:{id:match[1]}}));contextChips();});node.append(chip);end=match.index+match[0].length;}node.append(document.createTextNode(text.slice(end)));} diff --git a/internal/web/templates/chat.html b/internal/web/templates/chat.html index 2be0bea1..e0266a31 100644 --- a/internal/web/templates/chat.html +++ b/internal/web/templates/chat.html @@ -11,7 +11,7 @@
-
+
Enter send · Shift+Enter new line · ↑ edit last · Esc blur / close · c toggle
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 278b6180..fda5689c 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -180,7 +180,7 @@

Latest handoffs {{define "inbox"}} {{if or (eq .Scope "all") (eq .Scope .LocalMachineID)}} -{{range .Prompts}}

{{.Tool}} · {{.Session}}

{{.Cwd}}

{{.Detail}}
Exact input
{{.InputText}}
{{end}} +{{range .Prompts}}

{{.Tool}} · {{.Session}}

{{.Cwd}}

{{.Detail}}
Exact input
{{.InputText}}
{{end}} {{end}}

{{if .All}}history · current inbox{{else}}Current waits and up to 50 completions from 24h · show all history{{end}}{{if .InboxPrevious}} · previous page{{end}}{{if .InboxNext}} · next page{{end}}

{{if .AttentionOnly}} @@ -258,8 +258,8 @@

{{machineName .}}

{{.Agents}} agents · {{.Inbox}} inbox · last seen {{duration .LastSeenAge}} ago

-

{{.Addr}} · {{.DrigaVersion}} -

{{if .LastError}} +{{if or .Addr .DrigaVersion}}

{{.Addr}}{{if and .Addr .DrigaVersion}} · {{end}}{{.DrigaVersion}} +

{{end}}{{if .LastError}}

{{.LastError}}

{{end}}{{if .SkewWarning}}

{{.SkewWarning}} @@ -278,15 +278,16 @@

{{.MachineName}} · Machine

{{range .Processes}}{{template "machine-process" .}}{{end}}{{else}}

No live session costs.

{{end}}

Agent processes without a session

-{{range .AgentNoSession}}{{template "machine-app" .}}{{end}} +{{range .AgentNoSession}}{{template "machine-app" .}}{{else}}

Every agent process belongs to a session.

{{end}} +

MCP servers and helper processes

-{{range .MCPServers.Commands}}
{{mcpSummary .}}
{{mcpDetails .}}
{{end}} +{{range .MCPServers.Commands}}
{{mcpSummary .}}
{{mcpDetails .}}
{{else}}

No MCP servers running.

{{end}} {{range .MCPServers.Pileups}}
{{.Name}} pile-up · {{.Count}} processes · {{bytes .Memory}} ({{.MemoryMeasure}}){{with mcpTrend .MCPCommand}} · {{.}}{{end}}

Host: {{.Host.Name}} · PID {{.Host.PID}} · age {{duration .Host.Age}} · {{.Host.Cwd}}

Host started {{stamp .Host.StartedAt}} · oldest {{stamp .OldestStart}} · newest {{stamp .NewestStart}}

{{mcpDetails .MCPCommand}}

{{.Advice}}

-
{{else}}

No MCP pile-ups.

{{end}}
+{{else}}

No MCP pile-ups.

{{end}} {{if .LikelyAgent}}

Likely agent work (shared with editor)

{{range .LikelyAgent}}{{template "machine-app" .}}{{end}}
{{end}}
{{$other := machineOtherApps .}}
Other apps · {{bytes $other.Memory}} ({{$other.MemoryMeasure}}) · {{printf "%.1f" $other.CPUPercent}}% CPU

Top 10 apps by memory

{{range .TopMemory}}{{template "machine-app" .}}{{else}}

No process readings.

{{end}}