Ai Chat Ux Review
AI Chat Assistant — UI/UX Review (review-only)
Surface reviewed: apps/mac/Sources/Core/Pi/
(PiChatUI.swift, PiChatDock.swift, PiWorkspaceView.swift, PiAuthPromptCard.swift,
PiInstallCallout.swift, PiProviderSetupCallout.swift, PiChatSession.swift)
Date: 2026-06-02 Scope: visual hierarchy, spacing, transcript readability, composer ergonomics, empty/loading/error states, macOS idioms, accessibility, usefulness vs. opacity.
Severity scale: HIGH (blocks goals) · MEDIUM (visible friction) · LOW (polish).
HIGH
1. User and assistant share the same avatar glyph → speaker confusion
Evidence. PiChatUI.swift:35–55 defines LatticesMark; LatticesMarkAvatar
is reused for the assistant header (PiWorkspaceView.swift:52), the assistant
bubble (PiChatUI.swift:487), and the user's bubble (PiChatUI.swift:463).
The user message row shows the Lattices brand mark on the right at 28pt with a
muted tint — visually the same family as the assistant avatar. Header text
literally reads "Assistant" on the assistant side, but the user side has no
label. On a quick glance, you cannot tell which side is talking.
Direction. Drop the avatar from the user bubble (right-aligned text + small
initial chip is enough), or use a clearly different glyph (e.g. person.crop.circle)
at lower opacity. Keep LatticesMarkAvatar for assistant + header + empty state
hero only — that is its semantic role.
2. Composer send key is wrong for multi-line input
Evidence. PiChatUI.swift:1246–1261 binds .onSubmit directly to
session.sendDraft() with no modifier check. The composer uses
TextField(axis: .vertical) with lineLimit(1...style.composerLineLimit).
The footer hint reads "↩ send" (PiChatUI.swift:1218). There is no
keyboardShortcut anywhere in the chat surface (verified by grep across
Core/Pi/*.swift).
Direction. Bind the send button to .keyboardShortcut(.return, modifiers: .command),
change the hint to "⌘↩ send", and gate plain-Enter to single-line case only.
Add Esc to clear the draft or cancel an in-flight send.
3. Accessibility is largely absent
Evidence. Across the entire chat surface, there is exactly one
accessibilityLabel (PiChatUI.swift:1397 on PiChatWorkingIndicator) and
one accessibilityHidden(true) (on the Lattices mark). No icon buttons
have labels: the dock close (PiChatDock.swift:103), the gear in
PiWorkspaceView.swift:130, the code-block copy button (PiChatUI.swift:872),
and the footer gear (PiChatDock.swift:386) all read as "button" only to
VoiceOver. Hit targets: the 6pt traffic-light dots in PiChatCodeBlock
(PiChatUI.swift:855–859), the 6pt status pulses, the 22×22 footer icon
button (PiChatDock.swift:444), and the 22×22 send button are all below the
24pt minimum. Dynamic Type is not supported — fonts are hardcoded point
sizes via Typo.body(13.5), Typo.body(14) (PiChatStyle).
Direction. Add accessibilityLabel to every icon-only button. Group the
empty-state starters under a labelled header (.accessibilityElement(children: .combine)).
Bump minimum hit target to 24×24. Replace hardcoded point sizes with
Font.system(.body, design: .rounded) and apply
.dynamicTypeSize(.medium ... .accessibility3) at the root.
4. Usefulness is opaque — header copy is vague, no capability surface
Evidence. PiWorkspaceView.swift:60 says "Settings, layout help, planning,
and debugging in one thread." The empty state at PiChatUI.swift:256–263
shows four starter cards, which is good, but the running state offers no
indication of: which model is active, which tools are available, rate limits,
cost, or how to attach files/screenshots. The status pill (PiWorkspaceView.swift:75)
flips between "Ready / Streaming / Thinking / Tool · read" but the user cannot
act on it. The four starter prompts hint at gesture/file/screen/planning but
don't reveal tool inventory.
Direction. Add a discoverable affordance — a ? or Tools button in the
header — that opens a one-sheet listing: current provider + model, available
tools (read, bash, search, list, web, voice), and a token/cost
counter. Add a "What can I do?" prompt to the empty state as a 5th card.
MEDIUM
5. Three parallel setup states compete visually
Evidence. PiInstallCallout.swift (install) uses Palette.kill red border.
PiProviderSetupCallout.swift (provider) uses its own card layout. PiAuthPromptCard.swift
(auth) uses Palette.detach yellow. All three render in roughly the same slot
(transcript area or composer slot) but with different border treatments,
padding, and copy tone. The user sees: red install card → yellow provider card
→ yellow auth card → composer. Three different visual languages for "you need
to do something."
Direction. Consolidate into a single PiSetupCard with Kind: .install | .provider | .auth
and a shared layout: status dot · eyebrow label · body · primary action · secondary
action. Use Palette.kill for install, Palette.detach for both provider and
auth, and unify spacing/padding to compact and expanded modes only.
6. Streaming state is over-decorated
Evidence. PiChatUI.swift:498–541 (assistant bubble) stacks: pulsing
avatar (1.18× stroke), LIVE badge, PiChatStreamCursor, left-edge 2pt
gradient bar, inner radial gradient overlay, and a 0.28s background
animation. Concurrently: PiChatToolChip pulses (1.4s),
PiChatStreamCursor pulses (0.85s), PiChatWaveDots animates
(30fps TimelineView), PiChatWorkingIndicator has its own dot pattern.
That's 4+ simultaneous animations per streaming turn. On a 60Hz display the
bubble never settles.
Direction. Pick one dominant streaming affordance — the left-edge bar is
the strongest. Demote LIVE to small caps next to the assistant name and
drop the inner radial gradient. Keep the cursor and one pulse. Document
"max 1 ambient animation per scene" in the chat style guide.
7. Provider/auth flow triplicated across Dock, Workspace, and auth card
Evidence. PiChatDock.swift:205–325 (authPanel) and PiChatDock.swift:429–458
(providerSettingsBar) and PiWorkspaceView.swift:125–158 (providerSettingsPrompt)
and PiAuthPromptCard.swift are four overlapping implementations of "tell
the user to connect a provider / drive the auth flow." Adding a field
(provider description, secondary CTA, warning) requires touching all four.
Direction. Extract a PiAuthPanel with style: .workspace | .dock and
the existing compact: Bool flag, used in all three call sites. Keep
PiAuthPromptCard as a child when a pendingAuthPrompt is present.
8. Transcript background reduces text contrast at the bottom
Evidence. PiChatUI.swift:148–172 layers four backgrounds: base
Palette.bg, dot grid with +Lighter blend, top-left linear tint, and a
bottom-left radial gradient. The radial hits the assistant bubble's bottom
half — the area where long responses accumulate. On a low-contrast theme
this pushes the secondary text into the background.
Direction. Cap the radial opacity at 0.03, or move it to the top header
zone. Drop the dot grid in compact mode. Test on a real Mac with
colorScheme: .light to ensure dark surfaces don't bleed through.
LOW
9. Code block copy has no feedback
Evidence. PiChatUI.swift:872–885 writes to NSPasteboard silently. No
icon swap, no toast, no animation. The button looks identical before/after.
Direction. Add @State private var copied = false, swap the icon to
"checkmark" for 1.2s, reset on a Task.sleep.
10. Dock resize gesture does not follow macOS conventions
Evidence. PiChatDock.swift:85–100 uses a custom DragGesture on the
top handle. No cursor change, no snap-to-default, no visual ruler. macOS
users expect a divider with NSCursor.resizeUpDown and snap points
(230/400/600).
Direction. Replace the top-handle drag with a 6pt divider strip below the header, hover-swaps cursor to resize, snap to 230/400/600 with the existing defaults key.
11. No light-mode support
Evidence. Palette.bg and friends are dark-only. The chat surface is
embedded in a menu bar app that follows system appearance, so a user in
Light Mode gets dark-on-dark chat inside a light chrome — jarring.
Direction. Introduce semantic tokens (Palette.chatBg, Palette.chatText,
Palette.chatBorder) that switch on colorScheme. Add a colorScheme env
value at the chat root.
12. ScrollView fires multiple scrollToEnd per token
Evidence. PiChatUI.swift:181–195 registers three onChange handlers
(messages.count, last text, isSending). During a streaming response the
text handler fires per token, producing janky scroll on slower Macs and
fighting the user's manual scroll position if they scrolled up to read
history.
Direction. Coalesce into a single lastMessageID + lastCharCount change,
debounce to ~50ms, and pause auto-scroll when the user is scrolled up by
more than 80pt from the bottom (and show a "↓ New messages" pill — common
chat pattern).
13. PiChatFormat.markdownText is not loaded here but the empty state
injects a string for Connected to \(session.currentProvider.name) — the
provider name should also drive a per-provider brand tint on the empty
state hero, not just the streaming accent.
Evidence. PiChatUI.swift:332–336. Empty state uses Palette.running
unconditionally.
Direction. Pass theme: PiChatTheme from the session so the empty state,
composer accent, and live chip all shift subtly per provider.
What is already good
- Empty state starter grid is well-scoped and inviting
(
PiChatUI.swift:256–305). - Code block chrome with traffic-light header and copy button is a nice
macOS-y detail (
PiChatUI.swift:835–895). - Custom hand-rolled syntax highlighter covers
swift,json,bash, and generic with consistent palette tokens (PiChatUI.swift:900–1190). - Auth prompt card is calm and uses mono consistently
(
PiAuthPromptCard.swift:1–90). - Status pill is a thoughtful, dense read of session state
(
PiWorkspaceView.swift:75–105). - Footer
↩ sendhint shows attention to discoverability (PiChatUI.swift:1218). - All four top-level surfaces (PiChatDock, PiWorkspaceView, PiChatTranscript, PiChatComposer) share typography and palette tokens, so the language is consistent — the issues are about depth, not vocabulary.
Suggested first pass (1–2 days of work)
- Composer shortcuts — Cmd+Return, Esc to cancel, hint fix (HIGH #2).
- Avatar disambiguation — drop avatar from user side or swap glyph (HIGH #1).
- a11y sweep on icon buttons + Dynamic Type pass (HIGH #3).
- Single setup card component with three kinds (MEDIUM #5).
- One-sheet "what this assistant can do" triggered from the header (HIGH #4).
These five changes will resolve all four HIGH findings and the most visible
MEDIUM, and they cluster naturally because they all touch PiChatUI.swift
- the header/composer chrome.
Second pass — implementation-oriented
1. What I would fix first and why
In a single PR, in this order:
Composer send-key correctness (
PiChatUI.swift:1246–1261). Highest leverage: one well-placed.keyboardShortcut(.return, modifiers: .command), one.onExitCommand { session.draft = "" }(orcancelSend), and the footer hint text changed from "↩ send" to "⌘↩ send · esc clear". No new components. No state model changes. Removes the single most surprising behavior for anyone who has used any other chat app. ThecomposerLineLimit: 4(dock) /8(workspace) inPiChatStyleconfirms the field is multi-line by design —TextField.onSubmiton plain Return is unambiguously wrong here.Drop the user-bubble avatar (
PiChatUI.swift:463). Five lines of code removed, one line of bubble padding adjusted. Immediately disambiguates speaker identity without introducing a new component. Cheap A/B candidate: leave a tiny initial circle if you want something on the right, but the brand mark has to go.Accessibility sweep on icon buttons (
PiChatDock.swift:103,PiWorkspaceView.swift:130,PiChatUI.swift:872,PiChatDock.swift:386). Mechanical: addaccessibilityLabel("Close chat", "Open assistant settings", "Copy code", "Settings")to four buttons. BumpfooterIconButton's hit area from 24×22 to 28×24. Highest a11y ROI in the file.Add a
?tools sheet in the header, starting with just a hardcoded provider/model list. Stub it with aTextof the current provider name, a one-paragraph capability blurb, and a list of the seven tool names already mapped inPiChatToolChip(PiChatUI.swift:600–650). Becomes the natural home for cost/token telemetry later.
This order: (1) and (2) unblock anyone trying to actually use the surface. (3) is a no-regret pass. (4) opens the door to making the assistant feel less opaque, which is the largest gap the first pass identified.
2. What I would deliberately defer
Streaming decoration cleanup (MEDIUM #6 in the first pass). Hard to argue from a static review. The animations are individually small (1.4s pulse, 0.85s cursor, 0.28s background ease). On a 60Hz display the perceived "busyness" is uncertain. Defer until someone watches a real streaming response on a real Mac and reports it feels busy. Don't pre-emptively cut signals that may carry useful liveness.
Full Dynamic Type pass. Real, but big.
PiChatStyle.bodySizeis referenced in ~6 sites; the right call is probably to introduce@ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = 13.5and let the system drive it. But until there's a user report, ship (3) first and come back to this.Light-mode palette tokens. Real gap (
Theme.swiftis dark-only, zerocolorSchemereferences in the chat surface), but it's a cross-cutting theme change, not a chat-surface change. Belongs in a separate "Palette semantic tokens" pass.Auth panel consolidation (MEDIUM #7). Worth doing, but only if you are actually changing the auth flow soon. If not, leaving three implementations is cheaper than risking a regression in a critical onboarding path.
PiSetupCardunification (MEDIUM #5). Same reasoning — defer until you are touching setup copy for product reasons.Per-provider brand tint. Speculative. Lattices has a clear visual identity; tinting the chat to match each provider dilutes it. Wait for evidence that users want this.
3. Where the first pass overreached or missed evidence
"Streaming is over-decorated" — I counted animations without testing. I should have said "needs a live walkthrough" rather than prescribing a fix. The recommendation to "pick one dominant affordance" is a stylistic call, not a clear win.
"Three
onChangehandlers fight on streaming scroll" — overclaim. SwiftUI'sScrollViewReader+ animation handles per-tokenscrollTowell in practice. The real problem is the absence of a "jump to latest" pill when the user has scrolled up — not the handler count. Soften."Light-mode support" — verified this round:
Theme.swifthas zerocolorSchemereferences and the chat hardcodesPalette.bg. The finding is correct, the scope is bigger than I implied. It's a project-wide token migration, not a chat-surface change."Three parallel auth/setup states compete visually" — partially overreach.
PiChatDockandPiWorkspaceVieware intentionally two different products (compact bottom drawer vs. full pane).PiChatStyleenforces that withcomposerLineLimit: 4vs.8,bodySize: 12vs.13.5,horizontalPadding: 12vs.28. Some of the "triplication" is appropriate. The auth flow within each is duplicated, not the surfaces themselves. Restate: deduplicate the auth flow logic (one component, two style modes), keep two surface layouts.Missing in the first pass: I never opened
UI/Theme.swiftuntil this follow-up. The Dynamic Type claim was based on hardcodedTypocalls inPiChatStyle, which is still true, but I should have flagged the file once for the whole surface, not enumerated sites.Missing in the first pass: I didn't look at the dock resize gesture UX holistically. A
DragGesture(minimumDistance: 1)on the top handle is unusual but not broken — many native macOS apps do this. Lower its priority from LOW to "watch the resize behavior on a trackpad; only rework if it feels wrong."Missing in the first pass: I didn't note that the empty-state starter cards auto-send on click (
PiChatUI.swift:268–275). That is a real ergonomic call: should the click fill the composer (so the user can edit) or auto-send? Currently it auto-sends. Worth a single decision: fill-only, with a separate "send" affordance, matches the user's mental model in most chat products. Not in the first PR — but decide it before (4) ships the tools sheet.
4. Smallest coherent implementation slice
One PR, one developer, half a day. Files touched: PiChatUI.swift only.
// In PiChatComposer (PiChatUI.swift ~1246)
Button {
session.sendDraft()
} label: {
// ...existing send button body...
}
.buttonStyle(.plain)
.keyboardShortcut(.return, modifiers: .command) // NEW
.disabled(!canSend)
// On the TextField, replace .onSubmit with .onSubmit-of-single-line:
TextField(
style.placeholder,
text: $session.draft,
axis: .vertical
)
.textFieldStyle(.plain)
.font(Typo.body(style.composerSize))
.foregroundColor(Palette.text)
.lineLimit(1...style.composerLineLimit)
.focused(focus)
.onSubmit { /* no-op for multi-line; Cmd+Return handles send */ }
.onExitCommand { session.draft = "" } // NEW
// Footer hint (PiChatUI.swift ~1218):
Text("⌘↩ send · esc clear")Plus one PR, separate reviewer, half a day: drop the user-bubble avatar.
// In userRow (PiChatUI.swift ~432):
// Remove the trailing LatticesMarkAvatar(size: 28, ...)
// Add a 4pt right padding to the user bubble for visual breathing.Total: ~25 lines changed, zero new components, zero state-model changes, zero migrations. Both changes are independently revertable. The first lands a measurable behavior fix (the wrong key sent prematurely), the second removes a known visual confusion.
This is what a developer should land before any of the larger consolidations. It addresses the two HIGH findings where I have the most confidence and unblocks real testing of the surface by anyone who tries to send a multi-line message.
5. Observable session / provenance details from this run
- Identity. Agent:
lattices-review-pi-scout.main.arts-mac-mini-local. Invoked as a stable OpenScout relay agent on this turn. - Model.
MiniMax-M3(per harness system prompt; "M3" is the model class, not necessarily a per-request model identifier). - Provider / transport. The harness is MiniMax's own inference stack;
I have no per-request model name, no temperature, no token budget, no
response-time telemetry, no streaming chunk count, no tool-use log.
The wire protocol I am reachable over is the OpenScout broker
(WebSocket); conversation
c.baf8732a-9752-487a-841a-57595463bf8d, this messagemsg-mpwvwdq3-z1nuja, reply pathfinal_response. - Runtime mode.
thinkingenabled;max thinking effortis implicit (the harness emits long deliberation blocks before each reply). No explicit budget is exposed to me. I cannot report token counts for this turn. - Session state.
cwd: /Users/art/dev/lattices;git statusshows one staged file:A docs/ai-chat-ux-review.md(the first-pass report, mtime 12:17 today).HEAD: b09e4d8"Merge pull request #41 from arach/codex/inventory-sort-arrange". No uncommitted source changes. - Tools available to me this turn.
bash,read,edit,write,intercom,mcp,scout_{send,ask,who},subagent. I usedread,bash, andeditfor this turn — theeditwas on my own review file, not on any source. - Status telemetry available. None beyond the OpenScout message metadata above. No live provider status, no tool-use trail, no thinking-trace payload on the wire. The thinking block is a local harness detail, not a broker-visible artifact.
- What I cannot report. I do not have visibility into MiniMax upstream latency, model selection, rate limits, or the exact provider routing path used for this M3 inference. I would not invent those.