Threads: lifecycle, search and navigation
A chat session in Caret is a thread. Threads live under ~/.caret/sessions/<workspace-hash>/<id>.json
(summaries in index.json) and show up in two places: the history popover in the AI pane header (⌘⇧H) and the
Agents sidebar (⌘⇧A). Both render the same grouped, foldable list described here.
Lifecycle
Every thread carries a small set of lifecycle fields (Session / SessionSummary in
src-tauri/src/ai/agent/session.rs, mirrored by ChatSession / ChatSessionSummary in src/types.ts):
| Field | Meaning |
|---|---|
pinned, pinnedAt |
Kept in the Pinned section (most recently pinned first). |
snoozedUntil |
Unix ms. While in the future the thread sits in the collapsed Snoozed section; when it passes the thread wakes automatically. |
settled |
"Done": moved to the collapsed Settled section. Set by hand or by the auto-settle setting. |
archived, archivedAt |
Hidden unless the list's archived toggle is on. |
All of these are changed through the row menu (the ... button on a thread) and every change is persisted at once
and announced to the UI with the session-updated agent event, so other views (sidebar, popover, pane header) update
without a reload.
- Pin / Unpin. Pinned threads stay near the top of the list.
- Snooze. Presets: 1 hour, Tonight 18:00 (becomes Tomorrow 18:00 once that has passed), Tomorrow 09:00,
Next week (Mon 09:00). A snoozed row shows a small alarm-clock icon with its wake time ("in 45m", "Tomorrow 09:00",
"Mon 09:00"). Unsnooze appears while a thread is snoozed. Expired snoozes are cleared whenever the list is loaded
(
list_sessions/list_sessions_all); the wake is written back to disk. - Settle / Unsettle. Marks a thread as finished. Settling shows a "Thread settled" toast with Undo for 5 seconds.
- Archive / Unarchive. Archiving shows a "Thread archived" toast with Undo for 5 seconds.
- Sending a message to a snoozed or settled thread wakes it and clears
settled: activity always wins.
Auto-settle
chat.autoSettleDays (Settings → Agent → Threads, default 0 = off) marks threads as settled when they have had no
activity for that many days. Pinned, snoozed, archived and running threads are never auto-settled, and settling never
touches updatedAt. The check runs when the thread list is loaded.
The grouped list
groupSessions(sessions, opts) in src/components/chat/util.ts is a pure function (unit-tested in util.test.ts)
that produces ordered sections; empty sections are omitted:
- Working: threads whose status is busy (
running,awaiting-approval,awaiting-input,summarizing); threads waiting for you come first. - Pinned
- Today, Yesterday, This week, Earlier (by last activity)
- Snoozed (collapsed by default, soonest wake first; the header shows the next wake time while collapsed)
- Settled (collapsed by default)
- Archived (only when the archived toggle is on)
Precedence when a thread qualifies for several sections: archived, working, snoozed, settled, pinned, day bucket. So a snoozed thread hides even if pinned, and a settled thread goes to Settled even if pinned.
Every section header folds; the fold state is persisted (threadFolds in the ui store, localStorage key caret.ui.v1)
together with the archived and by project toggles.
Group by project
The by project toggle (history popover) or the folder icon (Agents sidebar) switches to one group per workspace:
the current workspace first, then projects by most recent activity, each containing the lifecycle sections above. The
data comes from chat_list_sessions_all, which walks every index.json under ~/.caret/sessions; summaries carry
their workspaceRoot, and the group title is the folder's basename. Project groups fold with the key project:<root>.
Selecting a thread from another project asks whether to open that folder (through the normal workspace.openFolder
command); once the new workspace's thread list arrives the thread is selected. Declining leaves everything as is.
Regenerate title
Row menu → Regenerate title, or the chat.regenerateTitle command (palette: "Chat: Regenerate Thread Title").
It clears titleGenerated, asks the session's model (or the default) for a fresh 3 to 6 word title and emits the usual
title event. It needs at least one assistant reply; otherwise the command reports an error.
Message search
Typing at least 3 characters in the history popover search box filters thread titles and previews immediately and,
after a 200 ms debounce, runs chat_search_messages: a case-insensitive substring search over the text of user and
assistant messages in every session file of the current workspace (every workspace when by project is on; archived
threads only when archived is on). Hits appear in a Messages section under the thread matches with the thread
title, role and a ~160 character snippet around the match. Clicking a hit opens the thread and scrolls to the message
(rows carry data-message-id; the message list widens its window if the message is far back and flashes it briefly).
chat.searchMessages (palette: "Chat: Search Messages in All Threads", optional { query } argument) opens the popover
focused on the search box.
Keyboard
| Command | Default | Effect |
|---|---|---|
chat.nextThread |
ctrl+alt+] |
Select the next thread in the visible grouped order (collapsed sections skipped). |
chat.prevThread |
ctrl+alt+[ |
Select the previous thread. |
chat.history |
mod+shift+h |
Open the history popover. |
chat.searchMessages |
none | Open the popover focused on search. |
chat.regenerateTitle |
none | Regenerate the active thread's title. |
Rebind them in Settings → Keybindings or ~/.caret/keybindings.json.
Queue, steer, background threads and notifications
While an agent turn is running you have three ways to get another message in, and the app tells you when a thread needs you.
Queue vs steer
| Queue | Steer | |
|---|---|---|
| When it runs | After the current turn finishes (and after anything queued before it) | Before the agent's next model round, inside the current turn |
| Keyboard while running | Enter (default) |
Alt+Enter (Option+Enter on macOS) |
| Shown as | Editable rows under the chat | A "Steering..." row until the runner injects it, then a normal user message |
| Checkpoint | Opens a new turn and checkpoint | Belongs to the running turn (same turn id, no new checkpoint) |
| External (ACP) agents | Same | Falls back to send now: front of the queue, current turn cancelled |
Steer is for "also do X" or "stop, use Y instead" corrections: the runner drains the steer buffer at the top of every round, appends the text as a user message to the chat and to the model transcript, and if the model had just produced a final answer with no tool calls it does not end the turn but runs one more round with your message. When the transcript currently ends with a user message (tool results) the steered text is appended to it so tool results stay first and roles keep alternating; otherwise it starts a new user message. Anything still in the steer buffer when a turn ends (for example a steer that raced the final round) is moved to the front of the queue so it is never lost. The steer buffer is in memory only; the queue is persisted with the session.
The modifier and the default
Next to the stop button while a turn runs there is a small split control labelled Queue or Steer: its main
button performs that action, the chevron opens a menu to flip the default (persisted as steerByDefault in the ui
store) and to Send in a new thread. Enter always does the default and Alt+Enter does the other one, so with
Steer as default Alt+Enter queues. The tooltip spells out both.
Send in a new thread
Cmd+Shift+Enter (Ctrl+Shift+Enter) sends the draft in a new background session that carries the draft's agent,
model, mode, thinking/effort and worktree choice. You stay on the current thread; an in-app toast "Started in a new
thread" offers Open. Commands: chat.sendInNewThread and chat.steer (both accept { text?, sessionId? } and
default to the active session's draft).
Editable queue
Queued rows can be edited in place (click the text; Enter saves, Shift+Enter inserts a newline, Esc cancels,
blur saves), reordered with the up/down arrows, sent immediately (Send now interrupts the current turn and runs
that item next) or removed. Each queued item now persists the full request it was submitted with (model, thinking,
effort, agent), so a restart no longer loses those choices; items saved by older builds are rebuilt from their text,
context and the session's current model and agent when they drain.
Backend: chat_queue_update(session_id, queued_id, text, context?), chat_queue_move(session_id, queued_id, index)
(index is clamped), chat_steer(req); events queue (full list) and steer (pending steer items; empty once
drained).
Notifications (Settings > General > Notifications)
| Setting | Default | Meaning |
|---|---|---|
notifications.desktop |
on | Master switch for OS notifications |
notifications.onlyWhenUnfocused |
on | Skip notifications while the Caret window has focus |
notifications.onFinish |
on | A thread finished (running to idle; cancelled turns do not notify) |
notifications.onError |
on | A thread failed (message-end error, error event or error status; one notification per failure) |
notifications.onApproval |
on | A command, edit or tool call waits for your approval (body = the approval title) |
notifications.onQuestion |
on | The agent asked a question (body = the question) |
notifications.sound |
off | Short two-tone Web Audio beep when a thread finishes, independent of the desktop notification |
agent.chimeOnFinish |
off | Same beep (older setting, now implemented) |
Notifications are per session and coalesced: the backend emits several events for one transition (for example
message-end error followed by session-status error), and only one notification fires per kind per session
inside a short window. Subagent sessions never notify. The title is the thread title and the body is one line
(finished: the start of the final answer; failed: the error; approval: the request title; question: the question).
Implementation: tauri-plugin-notification (Rust side only, notification:default capability) behind the
app_notify_desktop(title, body) command (api.app.notifyDesktop); src/lib/desktopNotifications.ts watches the
raw agent event stream from chatBridge and holds the pure, unit-tested decision logic (AgentNotifier).
Note: unsigned development builds (npm run tauri dev) may not display notifications on macOS, which only shows
banners for apps with a bundle identifier it trusts; the bundled app (dev.caret.editor) does. The in-app toasts and
the chime work in both.
Forks, provider handoff and multi-model fan-out
Three thread operations share one piece of machinery: a budgeted view of the conversation
(src-tauri/src/ai/agent/handoff.rs).
The handoff budget
chat.handoffBudgetTokens (default 12000, Settings > Agent > Behaviour) bounds how much of a thread travels when
it moves to another agent or model. Tokens are estimated as characters / 4. plan_handoff(messages, budget) walks
the UI messages from the newest backwards: the most recent messages that fit the budget travel verbatim, everything
older is compressed into one <conversation_summary> block. When anything overflows, a quarter of the budget (200 to
1500 tokens) is reserved for that block. The newest message is always kept (clipped in the middle when it alone is
over budget). UI-only system notes (handoff notes, lint follow-ups) are never transferred; compaction summaries are.
The summary is written by the summariser model with the same prompt as transcript compaction. When no model resolves or the call fails, a deterministic digest (one line per compacted message) is used instead, so a handoff never blocks on the LLM.
Forks
chat_fork_session(sessionId, messageId?) clones a session into a new idle thread titled Fork: <title> with
parentSessionId = source and forkOfMessageId = the cut point. It is not a subagent. Queue, worktree, checkpoints,
pending changes, pin and usage are reset; the restore links on messages are dropped because file snapshots belong to
the parent.
With a messageId the fork keeps messages up to and including that one. The LLM transcript is cut exactly when the
turn's transcript_marks are intact: after the last message of a turn the cut is the next turn's mark (or the end);
after a user message or a mid-turn assistant message the transcript before the turn is kept and the retained part of
the turn is re-added as text. When the marks are gone (compaction cleared them) the transcript is rebuilt from the
kept messages through the budget plan. The fork keeps the marks of its retained turns so regenerate and edit work in
it.
UI: Fork from here in the hover bar of every user and assistant message, Fork thread in the chat header, and
the chat.fork command. A fork shows a "Forked from
Provider handoff
chat_handoff_session(sessionId, agentId?, modelId?) moves an idle thread (error when running):
- built-in to ACP agent:
agentIdis set. The ACP bridge sends the budgeted history with the first prompt of a fresh external session (SessionLink.history_pending, also set when the session was prepared eagerly before the first message, which previously skipped the history). The history block uses the session's last compaction or handoff summary for the compacted part. - ACP to built-in: the LLM
transcriptis rebuilt frommessages(summary exchange first when over budget, then the verbatim messages as text turns; consecutive same-role messages are merged),transcript_modeland the marks are cleared, and the external agent's live session is dropped so a later return starts fresh with the transferred history. - model-only change on the built-in agent:
model_idis set andensure_transcript_modeldrops the previous model's thinking blocks.
Every handoff appends a system message rendered as a muted divider ("Handed off to Codex. Transferred 14 messages,
2 summarised.", "Switched to GPT-5."); when something was summarised the note carries the expandable summary. The
command emits SessionReset so the UI reloads the thread.
UI: picking another agent in the composer's agent picker on a thread with messages opens a confirmation ("Hand off this thread to X?"). Picking another model in the model picker on a built-in, idle thread with messages hands off directly (no confirmation). Empty, busy or external-agent threads just change the draft as before.
read_thread tool
Built-in, read-only tool (tools/thread_tools.rs): read_thread(offset?, limit?) returns the earlier messages of
the current thread verbatim, oldest first (default 20, max 50 per page), excluding the turn in flight. It exists so
an agent that received a summary (after compaction or a handoff) can fetch the omitted history instead of guessing;
the system prompt mentions it when the tool is active.
Multi-model fan-out
chat_fan_out(sessionId?, text, modelIds, useWorktree?, mode?, context?, images?, thinking?, effort?) creates one
session per distinct model (forks of sessionId when it has messages, fresh sessions otherwise), sets model_id,
fan_out_group (one uuid for the batch) and the title <title or first line> · <model display name>, creates a
worktree per session when requested, emits SessionCreated for each and submits the same prompt to all of them so
they stream concurrently. Returns the session ids in model order. Session summaries carry fanOutGroup so lists can
badge them.
UI: in the model picker, shift-click a model or press Compare models to select 2 to 4 models; the composer's send button becomes Send to N. In the Agents layout, a thread that belongs to a fan-out group opens the Compare view: 2 to 4 read-only columns with the model name, status and usage/cost footer and a Continue here button that makes that thread active. The chat header shows a "Compare N" chip for such threads.
Worktree policies and scratch threads
Worktree policies (settings.worktrees)
| Key | Default | Effect |
|---|---|---|
perThreadDefault |
false |
New threads start in their own git worktree. The composer's worktree pill reflects it; if the folder is not a git repo the thread is created without a worktree. |
location |
.caret/worktrees |
Where agent worktrees live: relative to the workspace root, absolute, or ~/.... In-repo locations are added to .git/info/exclude. |
branchTemplate |
caret/{slug} |
Placeholders {slug} (thread title + 4 id chars, or agent-<id8>), {model} (slugified model id, auto when none), {id} (8 chars), {date} (YYYY-MM-DD). The result is sanitized to a valid ref name; a collision gets -<id4> appended. |
cleanupInactiveDays |
14 |
A worktree qualifies for cleanup when its thread's updatedAt is older than N days. 0 turns the policy off. |
cleanupSettled |
true |
Worktrees of archived threads qualify (this branch has no settled flag; archived is the settled state). |
cleanupMerged |
true |
Worktrees whose branch is merged into the default branch qualify (origin/HEAD, else main/master/develop/trunk). |
cleanupNoCommits |
true |
Worktrees whose HEAD equals the recorded base commit qualify (Session.worktreeBase, recorded at creation; legacy worktrees fall back to "no commits beyond the default branch"). |
onLocalChanges |
keep |
keep skips dirty worktrees, stash runs git stash push --include-untracked inside the worktree (the stash stays in the repository) before a forced removal, discard force-removes. |
deleteBranchWithWorktree |
true |
Delete the branch with the worktree when it has no commits beyond the default branch. Branches with unmerged commits (or unknown state) are kept and reported. Also applies when a thread is deleted (git branch -d). |
autoCleanup |
false |
On app start (and when a folder opens), run preview + cleanup once per workspace when any policy is on, and post an in-app notification with the report. |
Running threads never qualify. A worktree whose folder is missing always qualifies (stale registration: git worktree prune).
Commands (all api.chat.*):
chat_worktree_status(session_id) -> { path, branch, hasCommitsBeyondBase, dirty, mergedIntoDefault, lastActivity }chat_worktree_cleanup_preview() -> [{ sessionId, title, path, branch, reasons[], dirty }]for the open workspacechat_worktree_cleanup(session_ids) -> { removed[], skipped: [{ sessionId, reason }], notes[] }. Removed threads keep their transcript;worktreePathandbranchare cleared and the cwd falls back to the workspace root (the UI reloads viasession-reset).
UI: Source Control > Worktrees shows agent worktrees with the thread title, the branch as a chip and an "agent" badge, plus a "Clean up agent worktrees" button (header icon and footer button) that opens the preview with checkboxes (dirty worktrees unchecked by default) and a Remove button. Settings > Agent > Worktrees holds the policies.
Scratch threads
A scratch thread is a chat with its own throwaway repository: chat_create_scratch_session(mode, model_id, title, agent_id)
creates ~/.caret/scratch/<id>/ (honours CARET_HOME), runs git init plus an initial empty commit on main, roots the
session (workspaceRoot = cwd = that folder), sets Session.scratch = true and persists it under the "global"
session key so it works with no folder open and is listed in every workspace. Deleting a scratch thread removes its folder.
chat_promote_scratch(session_id, target_dir) moves the folder to target_dir (must not exist or be empty; ~ is
expanded; rename with a copy fallback across devices), rewrites workspaceRoot/cwd, moves the session file to the new
workspace key and the checkpoints from ~/.caret/checkpoints/global/<id> to .../<new key>/<id>, clears scratch and
returns the view. The frontend then opens target_dir as the workspace (api.workspace.open) and re-selects the thread
once the workspace-change reload finishes (useChat.pendingSelect).
UI: command chat.newScratch ("Chat: New Scratch Thread", palette; accepts { mode?, agentId?, title? }, returns the
session id) and a banner at the top of the chat pane for scratch threads ("Scratch thread in ~/.caret/scratch/
Backend reference
Commands (src-tauri/src/ai/agent/chat_commands.rs, wrappers in api.chat.*):
| Command | Arguments | Returns |
|---|---|---|
chat_snooze_session |
sessionId, until?: u64 (ms; omitted or past = unsnooze) |
SessionSummary |
chat_settle_session |
sessionId, settled: bool |
SessionSummary |
chat_list_sessions_all |
includeArchived?: bool |
SessionSummary[] across all workspaces |
chat_regenerate_title |
sessionId |
the new title |
chat_search_messages |
query, includeArchived?, allWorkspaces?, limit? (max 500) |
MessageSearchHit[]: sessionId, sessionTitle, workspaceRoot, messageId, role, snippet, updatedAt |
chat_pin_session and chat_archive_session now also stamp pinnedAt / archivedAt and emit session-updated.
Event: AgentEvent::SessionUpdated { session: SessionSummary } ({ type: 'session-updated', session } on the wire).
The reducer (src/store/chatReducer.ts) patches the summary and mirrors the lifecycle fields onto the loaded session.