Skip to content
Chat View & Messaging

Chat View & Messaging

The chat view is the primary interface for interacting with the Loop agent. It displays a conversation timeline with message bubbles, real-time streaming, agent activity indicators, and a multi-mode input area.

A live chat conversation with the Loop agent alongside the Git panel

Related docs: Layouts | Sidebar | Settings


Architecture

The ChatView component (src/components/chat/ChatView.tsx) receives a ChatState object as props. The state is managed by the useChatState hook, which is hoisted in WorkspaceLayout so the WebSocket connection and messages persist across layout tab switches. See Layouts - Chat State Hoisting .

ChatState Interface

interface ChatState {
  messages: Message[];
  loading: boolean;
  loadMore: () => void;
  hasMore: boolean;
  addMessage: (msg: Message) => void;
  streamingContent: string | null;
  isRunning: boolean;
  toolActivity: { tool_name: string; input: string } | null;
  agentActivity: AgentActivityData | null;
  askUserQuestions: AskUserQuestionData | null;
  exitPlanRequest: ExitPlanModeData | null;
  todos: TodoWriteData | null;
  gateApproval: GateApprovalRequestedData | null;
  clearAskUser: () => void;
  clearExitPlan: () => void;
  clearGateApproval: () => void;
  mode: "agent" | "plan";
  setMode: (mode: "agent" | "plan") => void;
  completionInfo: { duration_ms?: number; num_turns?: number; stop_reason?: string; model?: string } | null;
  triggerContent: string | null;
}

The isRunning flag is scoped per agent run via a run_id tracked internally by the state hooks. When a scheduled task and a chat agent run concurrently on the same channel, each has a unique run_id — the task completing only clears its own running state, not the chat agent’s.

messages.processed is a second end-of-turn signal: it also clears isRunning, both in the open chat and in the background store that a channel restores from when you switch back to it. A run that finishes while another channel is open therefore can’t leave a stuck Stop button behind, even if its agent.status “done” was missed. A queued next turn turns it back on with its own agent.status “running”.

The channel list is the last resort. If both end-of-turn events were lost (for example while the WebSocket reconnected), or a stale list turned Stop on when the chat mounted, the next channel-list fetch clears it. That fetch happens when the channel is opened and every 30 seconds. It only counts if it started after the channel’s last agent.status “running” event and reports agent_running: false. An older fetch could predate the run, so it is ignored. This is safe because the backend registers every chat and task run before it broadcasts “running”.


Message Rendering

Bot Messages (Left-Aligned)

  • Aligned to the left (alignItems: "flex-start")
  • No background color (transparent)
  • Robot icon SVG in the header (antenna, eyes, mouth)
  • Timestamp displayed next to the robot icon
  • Rounded corners: 18px 18px 18px 4px (bottom-left is sharp)

Hovering a bot message fades in a square-on-square copy button in the top-right corner of the bubble; it copies the raw message content and briefly flips to a check.

User Messages (Right-Aligned)

  • Aligned to the right (alignItems: "flex-end")
  • Dark background (#2f2f2f)
  • No icon in header; timestamp shown below the message
  • Rounded corners: 18px 18px 4px 18px (bottom-right is sharp)
  • When the agent is processing, the last user message shows an eyes indicator (processing indicator)
  • Max width: 85% of the container

Message Ids and Links

Each message shows its row id in the database, #1234, next to its time. A message that arrives live carries the same id it has after a reload. The id is part of the message’s link, loop://channel/<channel-id>/<message-id>:

  • Click the #id label, or right-click the message and pick Copy link to message, to copy the link.
  • A loop://channel/... link in a message opens inside the app rather than in the browser.
  • Following a link opens the channel, pages in older messages until the target is loaded, and scrolls it into view.

Fork at a Message

Hovering a message shows +fork, which forks the conversation at that message into a new thread and opens it. The source channel or thread keeps its conversation unchanged. A channel’s fork becomes one of its threads, and a thread’s fork becomes a sibling thread.

  • At an agent reply: the new thread’s session keeps everything up to and including that reply, and its first run continues from there.
  • At a user message: the fork stops just before that message, and the message’s text is put in the new thread’s composer, so you can edit it and send it again. A message that started its session forks into a thread with no session.
  • Worktree threads: the fork also gets its own worktree, as with forking a thread . It starts from the source’s current commit, not from the commit at the time of the message.

Only replies that recorded their place in the session can be forked: replies streamed from a run, not a subagent’s, and not replies stored before Loop recorded this. A user message can be forked once one of its replies has been recorded; messages a fork or resume imports into a thread keep their place too. If the message can’t be forked, the button turns red and says Fork failed, with the reason in its tooltip. The fork button is in the chat view, not the Learn view.

Find in Chat

The magnifier at the top right of the chat, or Cmd/Ctrl+F while the chat has focus, opens a find bar above the messages:

  • It searches every message in the channel, including ones not loaded yet. The match is case-insensitive and literal: % and _ match only themselves.
  • The count shows the current match and the total, as N / M, newest first. It shows no matches when nothing matches.
  • Enter or the up arrow steps to an older match. Shift+Enter or the down arrow steps to a newer one. Both ends wrap.
  • Each match is shown the way a message link shows its message: older messages are paged in until it’s loaded, then it’s scrolled into view and highlighted. Its #id label copies the link to it.
  • Esc or the close button closes the bar. Switching channels closes it too.

Tool calls, tool output and thinking aren’t searched, only messages. The Cmd+K palette searches messages across all channels.

Highlighted Messages

When jumping to a message from search or a message link, the target message:

  • Gets a 2px outline in the accent color (an outline, so the message doesn’t move)
  • Blinks twice
  • Keeps the outline until it has been in view for 5 seconds straight; time scrolled away doesn’t count

Each message has a data-msg-id attribute for scroll targeting.


Markdown Support

Messages are rendered through the MarkdownContent component, which uses a custom line-by-line parser.

Block-Level Elements

ElementSyntaxRendering
Fenced code block``` … ```<pre> with colors.surface background, 8px border-radius, 13px monospace font. Closes on a line of only backticks at least as long as the opening fence. A closed loop-component fence is a component . Hover shows two copy buttons in a gutter on its right: Copy as text copies the code as is; Copy as code block copies the fence (for markdown targets like Slack or GitHub) with a <pre><code> block alongside as rich text (for Teams, docs and mail)
Language label```goShown above the code block in colors.textDim, 11px font
Display math$$...$$ or \[...\], on one line or spread over severalCentered KaTeX formula, scrolls sideways when wider than the chat; hover shows a button that copies the LaTeX
TableGFM header row, separator row, body rows<table>; a pipe escaped with a backslash is text in its cell, not a column break
BlockquoteConsecutive lines starting with > (or a bare >)<blockquote> with a 3px left border in colors.border, muted 13px text; inline formatting applies inside. Hover shows two copy buttons in a gutter on its right: Copy as text copies the quoted lines without the > markers, ready to paste as a reply; Copy as quote copies them with the markers kept
ParagraphAny non-empty line<p> with 2px vertical margin
Empty lineBlank line<br>

Inline Elements

ElementSyntaxRendering
Inline code`code`<code> with colors.surface background, 3px border-radius, 13px monospace font
Bold**text**<strong>
Italic*text*<em>
Inline math$...$, \(...\) or $$...$$ inside a lineKaTeX formula in the text flow; also inside bold, italic and table cells

Inline elements are parsed via one regex, in this order: inline code, math, bold, italic, links. Math comes before bold and italic so a * in a formula isn’t read as emphasis, and code comes first so $ inside backticks stays code.

Math

Formulas are rendered with KaTeX (app/src/components/chat/math.ts), which works offline and ships with the app. $...$ follows pandoc’s rules, so dollar amounts and shell variables stay text: the opening $ must be followed by a non-space, and the closing $ must follow a non-space and must not be followed by a digit or letter. So costs $5 and $10 and $HOME/$USER are left alone, and \$ is a literal dollar sign.

A formula with an error doesn’t break the message: KaTeX shows its source in red at the error. Rendering runs without KaTeX’s trust option, so commands like \href can’t add links or HTML. Messages re-render on every streamed chunk, so rendered formulas are cached.

Components

For more than a formula, an agent can show a component: a template filled with the agent’s own HTML, CSS and JS, rendered inline in the chat. The agent calls the chat_component MCP tool: templates lists the templates and how to fill each one, and show takes a template name, a title, and the content.

TemplateFor
mathMath worked step by step on a math notebook page, squared paper with every line of writing on the grid and each fraction across two squares: fractions with a bar, crossed-out factors, highlights and a boxed result, all plain HTML and CSS. The template loads nothing from the network; $...$ is not rendered inside it, and the agent imports KaTeX in its JS when it needs notation HTML can’t carry
canvasDrawing from JS on a <canvas>: plots, geometry, diagrams, animations
reactAn interactive React 19 app on a dark page: forms, calculators, tables, charts from a React library. An import map provides react, react-dom/client and htm by name, and the app mounts on #root. There’s no build step, so the markup is written with htm’s tagged templates rather than JSX. React libraries are imported from esm.sh with ?external=react,react-dom so they share the page’s single copy of React. Everything loads from esm.sh, so this template needs the network

Projects and ~/.loop/config.json can add templates or replace these under chat_components; see Chat Components .

The backend composes the template and the content into one self-contained HTML document and posts it as an agent message, grouped under the running turn:

```loop-component math Fracții algebrice
<!doctype html>…
```

The fence is longer than any backtick run in the document, so the document can’t close it. The chat renders a closed loop-component fence as a framed component: a title bar with Copy source and Open in the whole pane buttons, over a sandboxed srcdoc iframe (sandbox="allow-scripts", so scripts run with an opaque origin and can’t reach the app). The document reports its height with postMessage, and the frame follows it between 80 and 900 px. A click on an http/https link in the document opens it in the system browser: the document posts the URL to the chat, which opens it like any other external link, but only while the click’s user activation is still live and the frame holds focus (a click anywhere else in the app takes it away), so a script can’t open tabs on its own (the sandbox allows no popups, so the frame can’t open it itself). A component script that cancels the click keeps it. Open in the whole pane maximizes the chat pane and shows the component across it, in place of the chat, sized to the pane; Escape or ✕ goes back to the chat and restores the pane (it stays maximized if it already was). A fence that never closes stays a code block.

Every component gets a small stepper: each <section data-step="Title"> becomes one step, shown one at a time with ◀ n/N ▶ buttons and the arrow keys. Since the document is stored in the message, a component looks the same after a reload, even if its template changes. Only the desktop app renders components; on Slack and Discord the tool returns an error telling the agent to answer in text.


File Links

Paths that look like files in the channel’s working tree are auto-detected in both message text and tool-use input blocks, validated against the loop fs, and rendered as clickable links. Clicking opens the file in the editor panel and scrolls to the parsed line.

Detection

A regex (app/src/utils/fileLinks.ts) extracts candidates from rendered text:

  • Relative paths with at least one / and a file extension (src/foo.ts, internal/api/server.go)
  • Absolute paths starting with / (e.g. /Users/me/dev/loop/main.go)
  • Optional trailing :NNN line suffix is captured separately so the click can jump to that line
  • URLs (https://…, file://…) are excluded

Tool-use blocks dispatch on tool name. Tools whose tool_input summary is itself a bare path — Read, Edit, Write, MultiEdit, NotebookEdit, NotebookRead — render the entire input as a single link. All other tools (Bash, Grep, Glob, …) fall back to the same regex pass over their input string, so paths embedded in commands or queries still link.

Validation

Candidates are batched per channel and POSTed to /api/channels/{id}/files/exists , with the result cached. While a candidate is pending or unknown, the link renders as plain text — layout stays stable, no flicker. Once validated:

  • Exists → renders as an underlined link in colors.active
  • Does not exist → continues to render as plain text

Click Behavior

Clicking a valid link dispatches a loop:open-file CustomEvent with the resolved { rootIndex, relPath } and optional line number. The WorkspaceLayout handler:

  1. Adds an Editor leaf if none exists, placed opposite the Chat panel horizontally (so chat stays on one side and the editor lands on the other).
  2. Calls openFileAtLine(pathKey, line) on the editor state. If the file is already loaded, the scroll happens immediately; otherwise the requested line is queued and flushed once the file content is ready.

The editor centers the target line and focuses the view. When line is null the file simply opens at the top.


Image Paste

Paste an image straight into the chat input (⌘/Ctrl-V). Loop saves it under the channel workspace’s .loop/pastes/ directory and inserts the saved file path into the composer at the cursor — no upload dialog — so the agent reads it on the next message. The same paste works in the agent terminal .

Pasting an image into the chat input inserts the saved file path


Streaming

When the agent is generating a response, the chat view shows a streaming bubble:

  1. A message.streaming event arrives via the WebSocket event stream with partial content.
  2. The StreamingBubble component renders with:
    • Robot icon (same as bot messages)
    • Italic “streaming…” label next to the timestamp
    • Incrementally rendered markdown content
  3. When the full message arrives (message.created with is_bot: true), the streaming content is cleared and the final message is added to the messages list.

Agent Activity Indicators

While the agent is running, activity events are shown between the last message and the streaming bubble.

Activity Types

ActivityIconDisplay
modelRobot emojiModel name (e.g., “claude-sonnet-4-20250514”)
subagent_startedLink emoji“Agent: " + description
subagent_progressMagnifying glass emojiDescription text

Activity text is truncated to 100 characters maximum with “…” appended.

Each activity replaces the one before. Tasks running in the background (background_tasks) show above the composer instead, as monitors do: a pill per task with its kind (Shell, Agent, Workflow…), what it does and how long it has run, ticking each second. They change as tasks start and finish, and go once none are left, or, for a one-shot run, when the run ends. In a live session each pill has a Stop button, which stops that task alone (see POST /api/channels/{id}/live/tasks/{task_id}/stop ). Claude reports no start times, so a task’s timer starts when Loop first sees it running.

Tool Use Indicator

When the agent invokes a tool (tool.use event), an indicator is appended to the timeline:

  • Gear icon
  • Tool name in bold
  • Input summary (collapsed to a one-line preview at 80 chars, except for path-bearing tools whose entire input is rendered as a clickable file link )

Click to expand: clicking a long input (e.g. a multi-line Bash command) toggles the full command, pre-wrapped; clicking the expanded command collapses it back — the same click-to-toggle used for the result below it. The backend caps the tool-input summary at 2000 chars (a safety net, not a display limit), so the whole command survives to the client.

When the matching tool.result event arrives (paired by tool_use_id), the indicator collapses into a tool_use + tool_result pair rendered together — the result is shown as a dimmed second line beneath the tool call, itself click-expandable past 120 chars. Tool calls and their results are persisted as timeline rows, so reloading the channel replays them in chain order alongside the surrounding messages.

Copy buttons: hovering the indicator fades in a small square-on-square copy button at the end of the command line and another at the result — each copies the raw text to the clipboard and briefly flips to a check.

Thinking Bubble

When extended thinking is enabled and the agent emits a thinking block (agent.thinking event), the chat renders a collapsible ThinkingBubble:

  • Brain icon header with a dim background
  • Default collapsed; click to expand the full text
  • Persisted as a kind: "thinking" timeline row, so it survives reloads in its original chain position

Each thinking and tool block is truncated server-side at 8 KiB; truncated rows render the prefix with a small “(truncated)” hint.

Reply Grouping

On reload, ChatMessages.groupTimelineItems routes every bot reply and agent event (thinking, tool_use, tool_result, compacting) under the user message that triggered its run, using the trigger_msg_id field stamped on the row at insert time. This survives out-of-order processing: when a priority-bumped message (e.g. “Deny with prompt”) runs ahead of older queued ones, its events still group under it instead of attaching by array position to whichever neighbouring user row happens to be next in the timeline.

Orphans — events whose trigger_msg_id points outside the currently loaded window (e.g. paginated away) or pre-feature rows that pre-date the column — fall through to positional grouping so reloading an older page still renders cleanly.

Queued user messages act as routing boundaries. While a prior trigger’s run is still streaming events and the user queues a new message, that queued message holds its insertion position: any events from the prior trigger that arrive after the queued message stop routing back under the prior trigger and render below it via positional grouping. Without this, the prior trigger’s bucket of routed events would keep growing and push the queued message ever further down the timeline. Once the queued message starts processing (no longer in the backend queue), routing resumes as normal and the prior trigger’s later events snap back under it.


Completion Summary

After the agent finishes a run, a completion summary is displayed:

  • Timer icon
  • Parts joined with " . " separator:
    • Model name (if available)
    • Duration in seconds (e.g., “12.3s”)
    • Turn count (e.g., “5 turns”)
    • Stop reason (e.g., “end_turn”)

The summary is only shown when the agent is not running and completion info is available.


Gate Approval Card

When the agent container hits a gate rule with decision: approve, the backend broadcasts a gate.approval_requested WebSocket event. The chat state stores the payload on gateApproval and ChatMessages renders an ApprovalCard between the last message and the streaming bubble.

Card Content

  • Header: Gate · {KIND} where kind is CONNECT, EXECVE, or DOCKER-HTTP.
  • Target: the full target string (socket path, command line, or METHOD /path) in a monospace style.
  • Message: the matching rule’s message field, shown as a second-line caption when non-empty.
  • Details: for DOCKER-HTTP prompts on /containers/create, /containers/{id}/exec, /networks/create, and /volumes/create, the proxy extracts the security-relevant body fields (e.g. cmd, user, privileged, binds) and the card renders them as a sorted key: value list under the target. See Gates: Body details surfaced in the prompt for the full key set per endpoint.
  • Diff: for FILE prompts from the git guard , the diff detail is the change the gate will write to a git config file or hook. The card shows it as a scrollable unified diff with added and removed lines coloured, rather than in the key: value list.
  • Buttons: three monospace controls, left-to-right:
    • Allow once — primary accent, lets this one syscall through.
    • Allow for session — secondary outline, caches the allow for the container’s lifetime.
    • Deny ▾ — warning accent split button. Clicking the label body rejects the syscall and lets the agent carry on from the denial; this is the only non-terminal deny, which is why it stays the default action. The ▾ caret opens a ContextMenu anchored under it with the variants that change what happens after the denial:
      • Deny for session — sends the deny-session decision, which caches the denial under the request’s cache key for the container’s lifetime. An agent that retries the same blocked operation is refused without re-prompting.
      • Deny & stop run — denies the gate and then issues /loop stop, ending the run instead of letting the agent continue. The orchestrator’s drain loop claims the next queued message as soon as the cancelled run returns, so this is how to abandon the current run and move straight to what’s waiting behind it. Omitted in terminal panes, where the agent is a TUI on the pane’s stdin rather than the orchestrator run that owns the queue.
      • Deny with prompt… — expands an inline textarea below the card: typing a follow-up and hitting Enter (Shift+Enter for a newline) denies the gate, cancels the now-resumed run, and immediately sends the prompt with interrupt=true so it claims the next slot ahead of any queued messages (without deleting them — see Message Queue Indicators ).

Every menu entry re-checks that no decision is already in flight, since the caret only guards menu-open: a peer window’s click can resolve the card while the menu sits open. While a button is busy it shows a dim “…” label; failures are rendered below the buttons in the warning color so the user can retry.

Dock bounce

While a gate prompt is pending and no Loop window is focused, the desktop app calls app.dock.bounce("critical") on macOS (or flashFrame(true) on Windows/Linux) on a 2-second interval until the user focuses Loop. The bounce is driven from the renderer via approval-needed / approval-resolved IPC messages keyed by req_id, so multiple concurrent gates all clear cleanly. Focus or resolution stops the loop and cancels any in-flight bounce. Turn-end bounces use bounce("informational") instead and fire only once per unfocused window session — a chain of agent turns or scheduled completions still nudges the dock just once.

The bouncer’s pending-id set is also reconciled on every WebSocket reconnect: useChatStateStore’s onOpen snapshots /api/gate/approvals and hands the canonical req_id list back to electron-main via the reconcile-approvals IPC. Any bouncer entry not present in the snapshot is dropped and the loop stops if the set is empty — this is what prevents an orphaned dock-bounce when a gate.approval_resolved event was missed (daemon restart, network blip, deny-by-timeout fired while the renderer was disconnected). See Gates: WS-reconnect rehydration for the renderer-state half of the same reconciliation.

Resolution

Clicking a button calls resolveGateApproval(reqId, decision) which POSTs to /api/gate/approvals/{id} with {decision}. On success the component calls its onResolved callback, which clears gateApproval on the chat state and scrolls to the bottom.

Sending a message while a gate prompt is showing has the same effect as the Deny with prompt… flow: the input auto-denies the pending gate first, then sends the message with interrupt=true. ChatInput receives the pending req_id via the pendingGateReqId prop and routes the send accordingly.

The backend also broadcasts gate.approval_resolved so any other connected client dismisses the card. Because the desktop doesn’t send author_id, the server records the decision under local.DefaultAuthorID.

Terminal-panel overlay variant

The same ApprovalCard component (extracted to src/components/chat/ApprovalCard.tsx) is also rendered as a dimmed, centered overlay inside each Chat Terminal Terminal panel. When a layout has no Chat panel visible (for example, a Swarm layout with three Chat Terminals side-by-side), the panel’s own terminal gets the approval prompt layered on top of the xterm so the operator can decide without switching layouts. The overlay sits in the Terminal’s position: relative content region, uses a rgba(0,0,0,0.55) backdrop, and renders the card with its default margin reset (style={{ margin: 0 }}) so it fits the 520-px-wide popover. Resolution uses the same POST /api/gate/approvals/{id} path; clearGateApproval is passed as onGateApprovalResolved so dismissing from one surface dismisses on all of them.


Chat Input

The input area (ChatInput component) sits at the bottom of the chat view.

Layout

  • Container: max-width: 768px, rounded (16px border-radius), colors.surface background
  • Textarea: transparent background, 14px sans-serif font, no manual resize. It starts at 3 lines and grows with its text, up to 40% of the window (at most 360px), then scrolls. It shrinks back after a send. The same applies when a queued message is loaded for editing.
  • Mode toggle pill (left of send button)
  • Send/Stop button (right)

Mode Toggle

A pill-shaped segmented control with two options:

ModeBehavior
AgentDefault mode. Message is sent as a regular prompt.
PlanMessage is sent with mode: "plan". The agent plans but does not execute.

The active segment has white background with black text; inactive has transparent background with dimmed text.

Send Actions

ActionTrigger
Send messagePress Enter (without Shift)
New linePress Shift+Enter
Send with opposite mode (Queue ↔ Steer)⌘+Enter on macOS / Ctrl+Enter elsewhere — flips the active send mode for this send only without changing the persisted preference
Stop running agentClick the Stop button (square icon with colors.textDim border)

The send button:

  • Not running: White circle with up-arrow icon. Disabled (40% opacity) when textarea is empty. A small QUEUE or STEER chip on the button indicates the active send mode.
  • Running: Transparent with dimmed border, contains a filled square (stop) icon. Pressing Stop is optimistic — the UI flips to “not running” immediately and re-syncs when agent.status lands.

After sending, the textarea is cleared and re-focused via requestAnimationFrame.

Send Mode (Queue vs Steer)

While the agent is running, a small chip beside the send button selects what Enter does. Click the chip to flip between the two modes; the choice is persisted in localStorage under loop-send-mode.

ModeBehavior
Queue (default, QUEUE)Message is appended to the queue and processed when the current run finishes.
Steer (STEER)Cancels the active run and sends the message with interrupt=true (the wire field kept its original name). The server inserts it at priority = MaxQueuedPriority + 1 so it claims the next slot ahead of any already-queued rows; queued rows are preserved (not deleted). The cancelled run’s session is resumed on the next turn, so steering redirects the work rather than discarding it.

⌘/Ctrl+Enter sends with the opposite mode for one send only (it does not persist). The button tooltip and placeholder reflect the active mode and the keyboard hint at all times.

When the agent is not running, the send mode is irrelevant — every send is just a normal message.

Paste Images

When the clipboard contains an image (file kind, image/png|jpeg|gif|webp), ChatInput’s onPaste handler intercepts the paste, base64-encodes the bytes, and POSTs them to /api/channels/{id}/paste-image . The backend writes the image under <workspace>/.loop/pastes/paste-<ts>-<rand>.<ext> and returns the absolute path. The renderer inserts that path at the caret position so the next send carries it verbatim — the agent’s built-in Read tool then picks the file up (it requires absolute paths). Non-image clipboard content falls through to the default textarea paste.

This keeps the message body plain text — no multimodal payload is sent over the wire — and reuses the editor’s inline image rendering when the pasted path is clicked in the timeline.

Quote Reply

Clicking a quote button on a bot message stages it as a reply target above the input. The next send prepends the bot’s content as block-quoted lines (> …) before the new prompt, then clears the quote. The staged quote can be dismissed without sending via the × button next to the preview.

Prompt Suggestions

After a chat turn, the agent predicts the prompt you’re likely to send next, and the empty composer shows it in place of its placeholder, followed by ⇥ Tab. Tab puts it in the composer to send or edit; Enter on an empty composer still sends nothing. It stays while the composer is empty and goes when the next run starts or you switch chats.

The Claude CLI writes the prediction after the turn’s result (--prompt-suggestions), and the daemon forwards it as the agent.prompt_suggestion event. The CLI doesn’t predict one after every turn. Only app chats ask; scheduled tasks, reviews, learn and explain runs, and Discord or Slack channels don’t. Turn it off with claude_prompt_suggestions : each suggestion is one small extra request.


Command Autocomplete

Typing / or /loop triggers command autocomplete.

Trigger Logic

  1. If the text starts with / and is a partial match for /loop, show all commands.
  2. If the text starts with /loop , filter commands by partial subcommand name.
  3. If the text already has a subcommand + arguments (space after subcommand), hide the picker.

Available Commands

CommandDescription
/loop tasksList scheduled tasks
/loop task <id>Show task details
/loop scheduleSchedule a new task
/loop cancel <id>Cancel a task
/loop toggle <id>Enable/disable a task
/loop edit <id>Edit a task
/loop statusCheck bot status
/loop stopStop active run
/loop readmeShow README
/loop template-add <name>Add a template
/loop template-listList templates
/loop allow_user <id>Grant user access
/loop deny_user <id>Revoke user access
/loop iamtheownerClaim channel ownership

Dropdown UI

  • Appears above the input (positioned with bottom: 100%)
  • Dark sidebar background with border, 8px border-radius, shadow
  • Max height: 280px with scrollable area
  • Each item shows: command name (bold, 13px mono), description (12px sans), usage (11px mono, dimmed)
  • Selected item: colors.selectedBg background

Navigation

KeyAction
ArrowDownMove selection down
ArrowUpMove selection up
Tab / EnterAccept selected command
EscapeClose dropdown

Accepting a command fills the textarea with /loop <command> (with trailing space).


Prompt Shortcuts

The chat prompt-shortcut picker opened by typing #

Typing # in the chat input triggers a shortcut picker. Shortcuts are defined in the prompt_shortcuts config array (global or per-project). See Configuration: Prompt Shortcuts .

Trigger Logic

  1. If the text starts with #, show all available shortcuts.
  2. If the text starts with # followed by characters, filter shortcuts by name prefix.
  3. If there are no matching shortcuts, hide the picker.

Shortcut Button

When shortcuts are available, a # button appears to the left of the mode toggle. Clicking it opens the full shortcut list.

Accepting a Shortcut

Selecting a shortcut (via click, Tab, or Enter) clears the input and immediately sends the shortcut’s resolved prompt as a message. The placeholder text changes to “Ask Loop anything, / for commands, # for shortcuts” when shortcuts are loaded.

Dropdown UI

  • Same styling as command autocomplete: dark background, border, shadow
  • Each item shows: shortcut name (bold), description (dimmed)
  • Selected item: colors.selectedBg background

Navigation

KeyAction
ArrowDownMove selection down
ArrowUpMove selection up
Tab / EnterAccept selected shortcut
EscapeClose dropdown

Bash Shortcuts

A separate $ picker surfaces shell-command shortcuts on terminal panes (not in the chat input). Shortcuts are defined in the bash_shortcuts config array (global or per-project). See Configuration: Bash Shortcuts .

The picker lives in the footer bar below each terminal:

  • Docker Shell and Host Shell panes — shows a $ button. Picked commands are sent as raw text + \n (bash doesn’t enable bracketed paste by default).
  • Chat Terminal panes — shows the # prompt-shortcut button instead of $. Picked entries are wrapped in ESC[200~ … ESC[201~\r so multi-line scripts arrive as a single bracketed-paste buffer in Claude’s TUI.

The two buttons are mutually exclusive per pane — a Docker Shell or Host Shell pane never shows #, and a Chat Terminal pane never shows $. Selecting a shortcut sends the resolved command/prompt immediately. Backed by GET /api/bash-shortcuts (project shortcuts merge on top of global ones when channel_id is passed).


@Mention Autocomplete

Typing @ followed by a partial match for “LoopBot” triggers mention autocomplete.

Trigger Conditions

  • @ must be at the start of text or preceded by a space/newline
  • The partial text after @ must be a case-insensitive prefix of “LoopBot”
  • No spaces in the partial text

Acceptance

Pressing Tab or Enter replaces the @partial text with @LoopBot (with trailing space). The cursor is positioned after the space.

UI

  • Small dropdown positioned above the input
  • Single item: @LoopBot with bold text
  • Pre-selected with colors.selectedBg background

@File Picker

Typing @ followed by anything that is not a prefix of “LoopBot” turns the popup into a fuzzy file picker scoped to the channel’s working tree (and any configured extra_dirs).

Trigger Conditions

  • Same @ placement rules as @LoopBot (start of input, or after whitespace).
  • The text after @ is the fuzzy query.

Backend

The picker calls GET /api/channels/{id}/files/search?q=...&limit=... , which walks each root, skips heavyweight subtrees (.git, node_modules, vendor, .next, dist, build, __pycache__) and honors the top-level .gitignore. Requests are debounced and tagged with a sequence number — late responses for stale queries are dropped, so the dropdown never flickers between results sets.

Acceptance

Pressing Tab or Enter (or clicking a row) replaces the @<query> span with @`<rel_path>` (the path wrapped in backticks) plus a trailing space. The accepted path renders as inline code in the sent message and is auto-recognized by the file-link detector , so clicking it in the timeline opens the file in the editor.


AskUserQuestion Card

When the agent calls the AskUserQuestion tool, the run is parked and the chat renders an interactive card (agent.ask_user ). The card supports the tool’s full schema:

  • Multi-panel: up to 4 questions per call, each with its own header chip and option set.
  • Single-select: radio-style pills (◉/○); re-clicking a selected option clears it.
  • Multi-select: questions with multiSelect: true render as checkbox pills (☑/☐) with a multi-select hint next to the header.
  • Description + preview panel: hovering (or keyboard-focusing) an option opens a panel below the options row with the option’s description and, when present, its preview (mockup / code snippet) in a code block. The panel clears when the pointer leaves the whole question block — block-level clearing absorbs the layout shift the panel itself causes in a bottom-anchored chat, so it doesn’t flicker.
  • Other: an implicit free-text option with a textarea (⌘/Ctrl+Enter submits).

Stickiness: the card is rendered whenever an ask is pending, independent of run state, and clears only on the backend’s agent.ask_resolved event — a sibling run starting while the channel is parked cannot hide a still-pending question. On reload / WS reconnect the card rehydrates from GET /api/asks/pending.

Resolution: “Send Answers” (or typing a free-text message in the composer while the card is up) routes through POST /api/channels/{id}/ask/resolve, which inserts the answer as a priority-bumped continuation and resumes the channel’s drain. Messages queued while the card was up run after the answer.


Model / Effort Override

A pill next to the Agent/Plan mode toggle lets any channel, thread, or worktree override the config’s claude_model / claude_effort on demand — no config edit or restart:

  • Collapsed, the pill always names what the next run will use — e.g. opus-5 · high — reading the channel override where one is set and the config defaults otherwise. An active override is marked by the accent color, not by the text. It falls back to model only before the config has loaded.
  • Clicking opens a popover with model presets (matching the config schema’s options) plus a custom-id input, and the effort levels (low … max).
  • “Default” rows show the effective config value for the channel’s dir (global → project → worktree merge) — e.g. Default (claude-sonnet-5-5) — or Default (model default) for effort when claude_effort isn’t set (the CLI then uses the selected model’s own default).
  • Selections apply immediately via PATCH /api/channels/{id}/agent-config and take effect on the channel’s next agent run (chat and scheduled runs alike).

Learn from a run

With Learn on, a finished chat run is reviewed by a learn pass: a hidden session forked from the run looks at what happened and proposes Loop changes that would make the next run faster, safer or less repetitive. Nothing is applied until you accept it.

Learn switch

A learn switch (lightbulb) sits in the composer next to the model/effort pill. It shows the effective value: the channel’s own setting when it has one, else the config default learn.enabled for the channel’s dir. A click stores the opposite value on the channel (PUT /api/channels/{id}/learn), so the channel keeps it whatever the config says later. It applies from the channel’s next run. The change is broadcast as a channel.learn event, so every open window’s composer follows it. The hover title says whether the value comes from the config or the channel.

The switch only shows in desktop app channels. Slack and Discord channels never learn: their proposals could only be seen and applied here.

What triggers a pass

A learn pass starts after a chat run completes in the channel, when all of these hold:

  • the channel is a desktop app channel, not a Slack or Discord one;
  • the channel isn’t itself a learn thread, nor a thread a scheduled task created;
  • the run isn’t parked on a plan or question card;
  • Learn is on for the channel;
  • the run took at least learn.min_turns turns (default 3);
  • the run has a session to fork.

Only one pass runs per channel at a time. A run that finishes while a pass is running (or while you’re talking to the learn thread) is reviewed once the thread is free. If several finish meanwhile, only the latest is reviewed: its session covers the ones before it.

Stopped or failed runs, and scheduled task runs, don’t start one. See Orchestrator: Learn pass for how it runs.

The pass may only look and propose: its built-in tools are only reading and searching files (no editing, shell, web, subagents or worktrees), it can’t change Loop’s config, tasks or threads, and it gets only Loop’s MCP server (read-only tools), not your own servers or the browser. It files at most 5 proposals per pass, and may withdraw earlier ones still waiting on you that the run shows are stale (see Learn view ). Each proposal is one of:

KindWhat Apply does
prompt shortcutAdds a prompt shortcut to the project config.
bash shortcutAdds a bash shortcut to the project config.
scheduled taskCreates an enabled scheduled task in the channel.
gate ruleAdds an agentgate rule to the project config.
mountAdds a bind mount to the project config.
renameRenames the channel or thread.
descriptionSets the channel or thread’s description.
ticket URLLinks the channel or thread to its ticket (only a URL the run actually named).

Config kinds are written to the project’s .loop/config.json with your comments kept; see Configuration: Where Apply writes .

Learn button

Each pass reviews one turn: the turn whose run started it. That turn ends with a Learn button, next to its Explain button below its last bot message, that follows the pass: Learn queued… while it waits for the learn thread, Learning… while it runs, then N proposals (or No proposals) for what it filed, or Learn failed in red. It’s lit in the accent color while the pass is queued or running, or any of its proposals waits on you (pending, failed, or stuck applying for over a minute). A click opens the Learn view on that turn: its proposals are scrolled into view and outlined for a few seconds.

A turn no pass reviewed (Learn was off, or the run was too short) shows a dim Learn instead: a click learns from that turn, whatever the Learn switch says, and opens the Learn view on it. So does a turn whose pass, from an older Loop, a newer turn’s replaced while it was still waiting. The pass forks the session where that turn ended, so it sees the turn as it was even after later turns; a turn from before Loop recorded that forks the channel’s current session instead, so it needs the channel to have one. Once it’s queued the button follows it as above. When the pass fails, or the request does (the error shows at the top of the Learn pane), the button says Learn failed in red and a click tries again.

Learn label

The chat pane shows a learn label for the selected channel at the right of its header, once the channel has a learn thread or proposals; a channel that never had a pass shows none. It only opens the view: what a pass is doing and what it found shows on its turn’s Learn button . A click opens the Learn view; switching channels or layout tabs, adding a layout tab, deleting the active one or resetting it closes it. So does anything that opens in the layout under it: a file link in the chat (the editor opens in the layout), a panel another view opens there, or a memory file opened from search.

Learn view

The Learn view puts the chat and a Learn pane side by side, each half the width and the full height of the layout, over the rest of the layout, which can’t be clicked or tabbed into meanwhile. The layout tabs above stay usable. The Loop logo sits on the seam between them and animates while a learn pass runs (it holds still when the system asks for reduced motion). The chat moves over at once, as it is (draft, scroll and all), and the Learn pane fades in beside it; closing fades the Learn pane out and moves the chat back.

The chat keeps its pane header with its container stats, but not the layout’s split and close buttons. Close the view with the Learn label in that header, the Learn pane’s ✕, or Escape (unless something else handles it first). Opening the view keeps focus in the chat, or puts it in the chat’s composer when it was elsewhere (the layout under the view is inert); closing it with focus in the Learn pane puts focus back in the chat’s composer.

On a canvas layout, the Learn pane docks beside the chat’s tile instead: to its right, the same size, joined to it by the Loop logo on the seam. The chat stays in its tile, and the rest of the canvas stays usable. The two go together: dragging the Learn pane’s header moves the chat’s tile, the dock’s bottom-right corner resizes both, and a click in either brings both to the front. If the pair isn’t fully in view when the dock opens, the canvas pans (and zooms out if needed) to show it. The Learn label in the chat’s tile header opens and closes the dock; the Learn pane’s ✕ and Escape close it too. The dock isn’t a tile: it isn’t saved with the canvas, and closing the chat’s tile closes it.

The Learn pane’s header matches a pane header:

  • Proposals on top, newest first. Each card shows its kind, title, a one-line gist of exactly what Apply writes, and the rationale the pass gave. A gate rule’s gist is the whole rule: its decision, what it matches (the commands and their argument patterns, the file paths and operations, or the socket path; any command or any path when it lists none) and the message the gate shows.
    • Open cards of the kinds that edit the project config (prompt and bash shortcuts, gate rules, mounts) show the edit Apply would make as a diff of that .loop/config.json, worked out as apply does it (see GET /api/learn/proposals/{id}/preview ). It’s worked out again after another proposal edits the config. If the change is already there the card says so, and if applying would fail (a shortcut of that name exists, say) it shows why.
    • Apply applies it. If it fails, the error shows under the card and the button becomes Retry. If the request itself fails (Loop can’t be reached, say), that error shows under the card until the next try. A card’s buttons are disabled while its apply or dismiss is in flight. Requests in flight and their errors outlive the view: close and reopen it and they’re still there.
    • Dismiss drops it. Pending and failed proposals can be dismissed.
    • Applied and dismissed cards stay in the list, dimmed, with their status.
    • Withdrawn cards too: a later pass may withdraw a pending or failed proposal its run shows is stale, or one a newer proposal replaces, and the card shows withdrawn and the pass’s reason instead of its buttons. A withdrawn proposal doesn’t count as waiting on you. If a pass withdraws a card while your apply or dismiss is in flight, Loop refuses the request and proposal is already withdrawn shows under the card; if your request got there first, the withdrawal is refused instead, so a proposal being applied is never withdrawn.
    • A card stuck in applying for over a minute (its outcome was lost, say Loop stopped mid-apply) gets Retry and Dismiss back.
    • Apply all in the header applies every pending proposal one by one, skipping any applied, dismissed, withdrawn or in flight meanwhile. Failed ones are left for a manual Retry.
    • Dismiss all next to it dismisses every open proposal (pending, failed, or stuck applying) one by one, the same way, skipping withdrawn ones. It shows while any proposal is open; neither button can be pressed while either is going through the list. A request that fails shows under its card, as for a single Dismiss.
    • An applied prompt or bash shortcut shows at once in the composer’s # picker and the terminal’s shortcut menus.
    • When a card’s buttons give way to its status, or Apply all and Dismiss all to an empty list, focus moves on to the next open card’s Apply (or Retry), else to the pane’s ✕.
  • The learn thread’s chat below. Watch the pass as it works, or reply to it: ask why it proposed something or ask for changes, and it files revised proposals. The thread’s composer has no Learn switch, since a learn thread doesn’t learn from itself. A reply or pass running when you open the view shows as it is (its Stop button, streamed text and activity). File links in it open the editor in the layout, as in the chat, which closes the view.

Worktree threads: a rename proposal renames the thread only; its git branch and worktree folder keep their names. The Learn pane says so under the card.

Hidden thread

Each channel has one learn thread, created on its first pass. It’s left out of GET /api/channels, so it never shows in the sidebar, and it’s deleted along with its channel or thread, which also cancels a pass still running or queued. It’s named learn: <channel> and follows the channel’s renames on the next pass. Passes queue there and run one at a time, each over its own turn: a pass asked for while another runs waits its turn instead of replacing anything. Each pass forks its turn’s session again, so it doesn’t carry over what you said in the learn thread before; your replies continue the latest pass’s session. Only that latest fork is kept: the session files of the passes before it are deleted. Learn runs, a pass or a reply you asked the learn thread for, don’t mark anything unread, post a desktop notification or bounce the dock (their agent.status events carry trigger: "learn" or trigger: "learn-reply"). Proposals show on the turn’s Learn button, and replies in the open Learn view, instead. If Loop’s connection drops, the learn state, passes and proposals are fetched again when it comes back, so a pass that ended or proposals filed meanwhile aren’t missed.

Explain a turn

An explanation is a write-up of one chat turn for the engineer who has to review it. A hidden session forked from the channel’s chat reads the turn and reports what was asked and done, each file changed, the commands that mattered and their results, the decisions made, risks and gaps, how to review it, and follow-ups. It says what’s unknown or unverified rather than guessing.

Explain button

Each finished turn ends with an Explain button, below its last bot message (not while the turn is still running). A click queues an explanation and opens the Explain view on it. Once there is one, the button shows its state (Explain queued…, Explaining…, Explained or Explain failed), and a click shows it in the Explain view instead of explaining again. When the request itself fails, the button says Explain failed in red and a click tries again.

Explain switch

An explain switch sits in the composer next to the Learn switch. With it on, every turn that completes in the channel is explained on its own. It works like the Learn switch: it shows the effective value (the channel’s own setting, else the config default explain.enabled ), a click stores the opposite on the channel (PUT /api/channels/{id}/explain), and the change is broadcast as a channel.explain event. Turns parked on a plan or question card, and channels with no session yet, aren’t explained automatically.

The switch and the button only show in desktop app channels. Slack and Discord channels, task threads and hidden threads are never explained.

Explain view

The Explain view is the Learn view with an Explain pane instead of the Learn pane: the chat and the Explain pane side by side over the layout, joined by the Loop logo on their seam, which animates while an explanation is queued or running. On a canvas layout, the Explain pane docks beside the chat’s tile the same way. The Explain button opens it, and so does the explain label in the chat pane’s header, just left of the Learn label, once the channel has explanations. Like the Learn label, it only opens the view: each explanation’s state shows on its turn’s Explain button. The label, the Explain pane’s ✕ and Escape close the view, and so does whatever closes the Learn view. The Learn label swaps the Explain pane for the Learn pane, and the Explain label the other way round.

The Explain pane’s header says Explain, with explaining a turn… while one is queued or running. Below it, the channel’s explanations, newest first, one card per turn:

  • its status, time, and the start of the turn’s prompt and reply;
  • Go to message, which scrolls the chat to the turn’s last message;
  • Re-explain, which explains the turn again and replaces the card’s content (disabled while it’s queued or running);
  • the write-up, or the error when the run failed. A Preview | Source toggle in the card’s header switches between the write-up rendered like the editor’s markdown preview (the default) and its original markdown;
  • a Copy button next to that toggle, which copies the whole write-up: its markdown as plain text, and the rendered write-up as rich text for targets that take it (a doc, Slack). The write-up’s text can be selected and copied like the chat’s.

The card a turn’s button asked for is scrolled into view and outlined for a few seconds. If Loop’s connection drops, the explanations are fetched again when it comes back.

How it runs

Each channel has one hidden explain thread, explain: <channel>, created on first use and deleted with the channel or thread. It’s left out of the sidebar, message search and session import. Explanations queue on it, so one runs per channel at a time. Each run forks the channel’s session where the explained turn ended (or its current session, for a turn from before Loop recorded that), and the fork’s session files are deleted once the run ends. It runs with the same read-only restrictions as a learn pass : only reading and searching files, Loop’s read-only MCP tools, no user MCP servers or browser. Explain runs don’t mark anything unread, notify or bounce the dock (trigger: "explain"). The model, effort and extra instructions come from explain in the config. An explanation still queued or running when Loop stops is marked failed at startup, unless its run is still waiting in the queue. See Orchestrator: Explanations .


Plugin switches

Plugins may add their own switches to the composer, after the Learn and Explain switches: a round pill each with the plugin’s icon, dim when off, in the accent color when on, red when the plugin reports an error, with a dashed border while it works. The tooltip names the switch and says what the plugin reports. A click asks the plugin to turn it over; the pill shows the click at once, and turns back with the error in its tooltip when the plugin refuses. See Plugins: Composer switches .

Auto-Scroll

The chat view tracks whether the user is scrolled to the bottom.

Behavior

  • Auto-scroll active: New messages, streaming updates, tool activity, and agent activity automatically pin the view to the bottom.
  • Auto-scroll disabled: If the user scrolls up (more than 40px from the bottom), auto-scroll is paused.
  • Re-enabling: Scrolling back to the bottom re-enables auto-scroll. Sending a message calls scrollToBottom() which re-enables auto-scroll.
  • Jump to latest: While scrolled up, a ↓ Jump to latest pill floats centered above the bottom edge (above the trigger quote when it shows). A click goes straight to the latest message, without a smooth scroll, and re-enables auto-scroll; the pill goes once the view is at the bottom.

Scroll Detection

const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;

Load More

When the user scrolls to the top of the message list and more rows are available (hasMore), loadMore() is called automatically.

A “Load older messages” button is also shown above the message list as a fallback. While loading, the button text changes to “Loading…”.

The loadMore function uses compound (chain_position, id) cursor pagination via the useTimeline hook against /api/channels/{id}/timeline . The endpoint returns interleaved messages, thinking blocks, and tool calls, so a single round-trip backfills both chat and the surrounding agent activity in canonical chain order. Legacy rows that pre-date timeline persistence carry chain_position = 0 and fall through to id ordering, so older channels render exactly as before.


Message Search Integration

The chat view accepts a scrollToMessageId prop from the Command Palette .

Flow

  1. User searches in the Command Palette and clicks a message result.
  2. App sets scrollToMessageId and selects the appropriate channel.
  3. ChatView finds the element with data-msg-id={scrollToMessageId}.
  4. The element is scrolled into view with behavior: "smooth", block: "center".
  5. Auto-scroll is disabled to prevent the view from jumping back.
  6. The message is highlighted with an indigo background (fades after 2 seconds).
  7. onScrollComplete() is called to clear the scroll target.

Empty State

When there are no messages and loading is complete, the chat view shows:

  • Centered Loop logo
  • Full-width input area at the bottom
  • Docker isolation label: “Running interactively in an isolated Docker container” (see Docker Isolation Label )

Copy-on-Select

Selecting text anywhere in the workspace automatically copies it to the clipboard. A single top-level useCopyOnSelect hook (mounted in WorkspaceLayout) listens for mouseup events on the document and copies any non-empty selection to the clipboard. The hook:

  • Skips editable elements: Selections inside <textarea> or <input> are not auto-copied (avoids interfering with chat input, form fields, etc.).
  • Supports xterm.js terminals: For canvas-rendered terminal panels (Agent, Shell), the hook reads the selection via a _xtermGetSelection property set on the .xterm DOM element by useXTerminal.
  • Works on non-secure HTTP contexts: Falls back to document.execCommand("copy") with a hidden textarea and clipboardData.setData when navigator.clipboard is unavailable (e.g., host.docker.internal).

Chat Drafts

Unsent text in the chat input is automatically persisted to localStorage under the key loop-chat-drafts, keyed by channel ID. Drafts survive channel switches and app restarts.

EventAction
TypingdraftText.set(channelId, text)
Clearing inputdraftText.delete(channelId)
Sending messagedraftText.delete(channelId)
Mounting ChatInputRestore draft via draftText.get(channelId), cursor moved to end
Accepting command/mentionDraft updated to reflect accepted text

Message History (ArrowUp / ArrowDown)

The chat input supports shell-style message history navigation. Pressing ArrowUp cycles through previously sent messages; ArrowDown returns toward the current draft.

Behavior

KeyConditionAction
ArrowUpCursor at position 0Save current text as draft, load previous sent message
ArrowDownCursor at end of text, in history modeLoad next sent message, or restore draft at the end

Seeding

On first ArrowUp press, if the local history buffer is empty, it is seeded from backend messages (is_bot === false) for the current channel. Subsequent presses use the local buffer.

Implementation

History is stored in useRef arrays (historyRef, historyIdxRef, draftRef) to avoid unnecessary re-renders. Each sent message is appended to the history buffer before clearing the input. The index -1 means “not in history mode.”


Message Queue Indicators

When multiple messages are sent while the agent is running, unprocessed messages are tracked and annotated with status labels.

Canonical Queue Source

useChatState keeps a queuedMessages slice sourced from GET /api/channels/{id}/queued — the backend returns every row with kind = 'message' AND is_bot = 0 AND is_processed = 0 for the channel, already ordered by (priority DESC, id ASC). The hook refetches the list on channel mount and on every event that could change it: message.created (non-bot), message.deleted, message.updated, messages.processed, and agent.status (running). Pulling from the backend avoids the previous bug where the FE filtered the locally-loaded chat history — when many pages were paginated in, older orphan rows could leak into the popup, and when few pages were loaded, the in-flight row might not even be in scope.

The processing-row pointer processingMsgId is still backend-driven: the agent.status event carries msg_id on running/completed/error transitions, and useChatState mirrors that into a single state field. ChatMessages filters processingMsgId out of the queue list to derive what the popup and per-row “queued” labels render.

Processing State

ChatMessages uses processingMsgId to pick which loaded user-message bubble gets the processing label. The queued label is driven by membership in backendQueue rather than the local message’s is_processed field, so a row stays correctly annotated even if its is_processed flag hasn’t yet been updated by a messages.processed event roundtrip. This handles priority-bumped messages (e.g. the gate’s “Deny with prompt” interrupt) that run ahead of older queued rows — the FE can’t infer order from array position because chat history stays chronological.

Until the first agent.status event arrives (or after a hard reload while isRunning is still true), the FE falls back to the first entry in backendQueue as the processing one so the indicator is never absent during the brief startup window.

StateLabelStyle
Processingprocessing10px dimmed text, pill badge below the message
Queuedqueued10px dimmed text, pill badge below the message
Delayed⏱ m:ss countdownlive-ticking chip next to the queued pill (see Delay Countdown )

Delay Countdown

A message queued via queue_message with delay_seconds carries a not_before (unix seconds) on its row. DelayCountdown (src/components/chat/DelayCountdown.tsx) renders a ⏱ m:ss (or h:mm:ss) chip that re-computes not_before − now on a 1s setInterval and removes itself once the delay elapses — at which point the backend’s delay poller claims the row and it starts running. formatCountdown clamps negatives to 0:00 and floors fractional seconds. The chip renders on both surfaces that show the message:

  • Queued Messages Popup — one chip per delayed row, sourced from the not_before field on GET /api/channels/{id}/queued .
  • Message bubble — the delayed user bubble shows the same chip next to its queued pill. not_before reaches the bubble both on reload (from the messages endpoint) and on live insert, because the message.created event carries not_before — so the countdown appears immediately without a refresh.

Trigger Quote

TriggerQuote is a sticky floating banner pinned to either the top or bottom of the scroll container depending on which case fires. An IntersectionObserver tags every user-message DOM node ([data-msg-uuid][data-is-user="true"]) as above, visible, or below the viewport, and ChatMessages picks an anchor message to surface with this priority:

  1. In-flight, off-screen → bottom. If a run is in flight and the triggering user message is not currently visible (scrolled past or on a not-yet-loaded older page), the banner pins to the bottom and quotes that message — near the live tail so the user keeps context about what’s currently running. Content comes from the trigger_content field on the agent.status running event so it still works when the row hasn’t been paginated in. The row itself already carries a processing label, so the banner stays hidden while the row is visible.
  2. No user message visible → top. If no user message is in the viewport at all (we’re parked deep in a stretch of bot output, thinking blocks, or tool calls) and no run is in flight, the banner pins to the top and quotes the most recent user message above the viewport — reads as the prompt the visible content is replying to, and acts as a navigation aid so the user can jump back to it.

The banner displays:

  • A reply-arrow icon (SVG)
  • The anchor message content (truncated to 120 characters)
  • A timestamp (HH:MM format)

Clicking the banner scrolls the anchor message back into view. The state persists across channel switches via the chat state store; the trigger_content-sourced variant is cleared when the agent run terminates (completed / error).

Queued Messages Popup

A QueuedMessagesPopup component (src/components/chat/QueuedMessagesPopup.tsx) renders above the chat input whenever there are one or more unprocessed user messages waiting behind the currently-running one. It is hidden when the queue is empty.

  • List source — the rows shown come from chatState.queuedMessages, which is the GET /api/channels/{id}/queued response with the in-flight processingMsgId row filtered out. The list is independent of how many pages of chat history are loaded.
  • Collapsible header — shows N queued with a chevron. Click to expand the list.
  • Row layout — one line per message, truncated with ellipsis. Clicking a row toggles an inline expanded view (full content, pre-wrapped). A delayed row also shows a live ⏱ countdown between the content and the copy button.
  • Steer button — shown on each row only while a run is active, since with the channel idle the queue is already draining and steering would just be a reorder. It calls POST /api/channels/{id}/queued/{msg_id}/steer via the steerQueuedMessage API client, which promotes the row to the front of the queue and cancels the active run — the per-row equivalent of the composer’s steer send mode. The row moves to the top of the local list immediately, since the backend has already re-prioritised it, and the button turns into a highlighted, disabled Steered until the agent takes the row: pressing it again would only stop the agent again. If the call fails, the row stays where it was and can be steered again.
  • Edit button — a pencil on each row moves the message into the composer to edit it (see Editing a queued message ). The row shows editing while it’s open; Steer is hidden and Delete disabled for that row until the edit ends.
  • Delete button — a × button on each row calls DELETE /api/messages/{msg_id}?channel_id=... via the deleteQueuedMessage API client. The row dims while the request is in flight; the server broadcasts a message.deleted WebSocket event, which both removes the row from the local timeline and re-fetches the queue. Deleting a queued row is safe even mid-run — ClaimNextPending only sees is_running=0 AND is_processed=0 rows, so the deletion lands before the row can be claimed.
  • Safety — the row identified by processingMsgId (see Processing State ) is filtered out of the popup, and the backend refuses to steer a claimed row, so neither path can re-prioritise the message the agent is already running. Use the existing stop button to cancel an in-flight run without queueing anything, or “Deny with prompt” on a gate card to steer with a new prompt that runs ahead of the queue without dropping queued rows.

Editing a queued message

useQueuedEdit (src/hooks/useQueuedEdit.ts) drives the flow and ChatInput renders it:

  1. Start — the pencil calls POST /api/channels/{id}/queued/{msg_id}/hold. If the message already started, the call gets 409 and the row flashes already started instead of opening the edit. Otherwise the composer parks whatever draft was in it, loads the message text, and shows a banner: Editing a queued message — it won’t start until you save or cancel. The held message can’t start, and neither can anything queued behind it (see Edit holds ).
  2. Save — Enter (or the banner’s Save) sends PUT /api/channels/{id}/queued/{msg_id}. The row updates in place, the composer gets its parked draft back, and the queue resumes. Other windows pick up the new text from the message.updated event.
  3. Cancel — Escape (or Cancel) releases the hold with DELETE …/hold and restores the parked draft. Switching channels mid-edit also releases it.

The hold is a lease: the hook renews it every 2 minutes, and the backend drops it after 5 minutes without a renewal, so a closed or sleeping app can’t stall the queue for good. That leaves one race — the lease lapses and a run claims the message before the save lands. Save and claim are serialized on the database, so exactly one wins; if the claim did, the save gets 409 and the run uses the original text. The same ending applies when the message leaves the queue for any other reason while being edited (deleted from another window, or seen running). In every case the edit ends, the composer keeps the edited text, and a notice says the message left the queue so the text can be sent as a new message instead.


Monitors

Above the composer, one row per monitor the channel’s agent left running with the monitor MCP tool: its description, how long it has run, and a Cancel button that stops it without waking the agent. Hovering a row shows its command. Rows come from GET /api/channels/{id}/monitors , refetched on each WebSocket reconnect, and follow the monitor.started / monitor.finished events. A monitor that ends drops out; the message it wakes the agent with shows in the chat like any queued message.

Live Sessions

A chat’s turns run in a live session: one Claude Code process, in its own Docker container, that stays up between turns and reads each new prompt from its input. Everything else about the chat is unchanged: messages queue and steer as before, Stop interrupts the turn, asks and plans prompt the same way, and a model or settings change applies from the next turn.

  • Lazy start. The session starts with the channel’s next message when none is running, resuming the channel’s Claude session, and every later message reuses it. In the app, typing a message already starts it, so the container is up by the time you send. It stops after live_session_idle_sec (default 600; 0 never stops it) without a turn or background task; the next message starts a new one on the same session.
  • Background work. Tasks the agent left in the background (shells, subagents, monitors ) keep running while the session waits. When one finishes, the agent can take a turn on its own; its reply posts to the chat like any other, on Slack and Discord too. Such a turn carries on the one before it, so its last reply ends that turn and takes its Explain and Learn buttons. A turn someone starts from outside Loop shows its prompt in the chat as a user message from remote, before the reply; the turn is that prompt’s.
  • Threads. A +thread or +wt thread’s first turn forks the parent’s session, as before, into its own live session; a fork at a message resumes at that message. A +wt thread runs in its worktree.
  • Restarts. A change to what the process was started with (its model, image, env, mounts, MCP servers, gates) restarts it on the same Claude session with reason config changed. A turn on another Claude session than the one running (a session resumed from the session list, say) restarts it with session changed.
  • One-shot runs. Learn , Explain , reviews, plugin and read-only runs, workflow steps and scheduled tasks run one-shot (claude --print) as they did. A scheduled task that runs in a thread with a live session stops that session first (scheduled run), once it holds the thread; the next chat message resumes the session with the task’s turns in it. A one-shot run on the channel’s own session stops its live session the same way (one-shot run).
  • Turning it off. live_sessions: false in the global config runs every chat turn one-shot, as before. live_session_platforms limits live sessions to some platforms (["local"] keeps Slack and Discord one-shot); empty means all.

Live status

The footer’s infinity icon shows the session: animated during a turn, solid in the theme’s accent color while a live session waits idle between turns, and dimmed when none is running (hover it for which). The background tasks the session waits on show as pills above the composer (see Activity ), and when the session ends for a reason worth knowing, a line there says why, e.g. “Session ended: stopped for a scheduled task”. A session that stopped after being idle, or was stopped through the API, shows only the dimmed icon. It follows the live.status event and is fetched from GET /api/channels/{id}/live on open and on each reconnect.

Side chats

Chat (Resume with fork) and Chat (Fresh session) in a pane’s add menu open a side chat next to the channel’s own: an unlisted chat with its own live session and container, that doesn’t touch the channel’s chat or session. A fork starts from the channel’s current session; a fresh one starts a new session in the same project. A pane can open any number of them. Closing the pane stops its session and deletes the side chat; side chats left behind by a closed window are removed the next time the layout loads. See POST /api/channels/{id}/side-chats .


Docker Isolation Label

Below the input area, a small label reads “Running interactively in an isolated Docker container” when the chat’s turns run in a live session , or “Running non-interactively in an isolated Docker container” when they run one-shot (live sessions off for the channel’s platform, or a hidden learn or explain thread), with a monitor icon. The icon stroke color changes to green (#48bb78) when the agent is running.