You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Ability to pin specific messages or AI responses within a conversation #7708
In longer working sessions, certain messages become reference points that get revisited repeatedly — a generated to-do list, a config snippet, a decision summary, a list of steps. Right now the only way to find them again is scrolling back or re-asking the model to repeat itself, which breaks flow and wastes context.
Proposed solution
Let users pin any message (their own or the assistant's) within a conversation. Pinned items would:
Be accessible from a persistent sidebar or a collapsible "Pinned" panel, separate from the main scroll
Stay visible/reachable regardless of how far the conversation has scrolled
Support multiple pins per conversation, each with a short auto-generated or user-editable label (e.g. "Deploy checklist", "API keys format")
Be easy to unpin
Ideally, be quick-referenceable back into the chat (e.g. a small "insert into prompt" or "quote" action) so a pinned to-do list or spec can be reused as context in a later message without retyping or scrolling
Use case example:
Working through a multi-step task, the assistant generates a to-do list partway through the conversation. As the session continues, I need to keep checking that list against my progress — pinning it would let me glance at it without losing my place in the ongoing thread.
Why it matters:
This is a common pattern in longer technical or planning sessions — certain outputs function as working reference material rather than one-off answers, and the UI doesn't currently have a way to distinguish or surface them.
This is a feature request (correctly labeled enhancement), not a bug — there is no "pin" concept anywhere in the chat UI today, so nothing is misbehaving. What's being asked for is: a per-message pin toggle, a separate panel listing the pins with editable labels, a way to jump back to the pinned message, and a "quote/insert into prompt" action. Below is what I found about where this would land and the four constraints that will actually shape the implementation.
Which surface this lands on
The structured-message chat is ChatV3Pane, and it is behind the PostHog flag chat-v3 (packages/shared/src/constants.ts:178-183). The default agent surface in a v2 workspace is still TerminalPane, which renders raw PTY output and has no message model at all — pinning is not implementable there without reimplementing it on top of chat-v3. Mobile is in the same position (apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/). apps/web/.../SessionChat/SessionChat.tsx renders mock data on a design page, so it isn't a real target either.
So: this feature is effectively a chat-v3 feature. Worth deciding explicitly before any work starts, because it changes the size of the request considerably.
What already exists and makes this cheap
Stable item ids. Every transcript item carries an id (packages/chat/src/protocol/items.ts:3-8), derived in the Claude adapter as `${messageId}#${index}` from the API message id (packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:182-190), and persisted in the event journal (packages/chat-runtime/src/db/schema/schema.ts:11-23). Ids survive replay.
Item ids are already a durable client-facing reference.forkSessionInputSchema.fromItemId (packages/chat/src/protocol/commands.ts:67-72) is precedent for a client pinning a command to a specific item id — a pinItem/unpinItem command would be architecturally consistent.
Scroll-to-message already works. Rows carry data-item-id (.../Transcript/components/TurnGroupSection/TurnGroupSection.tsx:79 and :102), and Transcript.tsx:99-112 already does querySelector('[data-item-id=...]') + scrollIntoView for the latest user message and the first pending approval. "Jump to pin" reuses that exact mechanism.
An unused selection popover exists.packages/ui/src/components/ai-elements/text-selection-popover.tsx implements a primary/secondary action popover over a text selection — exactly the shape of a "quote" affordance. I found no importers of it anywhere in the repo, so it's available.
MessageActions/MessageAction (packages/ui/src/components/ai-elements/message.tsx:75-121) is the existing hover-action primitive, though the chat-v3 rows don't currently use it — AgentMessageRow is a bare MarkdownView (.../AgentMessageRow/AgentMessageRow.tsx:13).
The four real design decisions
1. Where pins are stored — not localStorage. Pins are entity-scoped (session × item) with unbounded cardinality, which apps/desktop/AGENTS.md explicitly routes to host-side SQLite rather than the renderer's shared ~10 MB localStorage quota. The natural home is the chat runtime DB alongside chat_journal / chat_sessions_local (packages/chat-runtime/src/db/schema/schema.ts) — e.g. a chat_pins(session_id, item_id, label, created_at) table served over the existing /chat-v3/trpc mount (packages/host-service/src/chat-v3/mount.ts:24). That also makes pins survive across machines/clients the way session state does. Note packages/db isn't the right place either — that's the cloud Drizzle schema, and the chat DB has its own migrations.
2. A pin panel cannot resolve content from item id alone. The transcript is paginated (getItemsInputSchema defaults to 200 items, packages/chat/src/protocol/commands.ts:83-88) with hasOlder/loadOlder (packages/chat/src/react/useChatSession/useChatSession.ts:218-238), and a stream reset triggers resync() which throws away everything and re-seeds only the latest page (useChatSession.ts:125-147). Meanwhile displayText returns "" for an item that isn't in the current snapshot (packages/chat/src/core/reducer/reducer.ts:156-161). Concretely: pin a message, scroll far enough that it pages out or the socket resets, and a naive panel renders a blank pin. Either the pin row stores a content snapshot at pin time, or "jump to pin" needs to drive loadOlder() in a loop until the id appears.
3. The composer has no insert API.Composer owns its textarea state internally and exposes only draftKey for localStorage persistence (.../Composer/Composer.tsx:20-52). There is no controlled value prop and no imperative handle, so "insert into prompt" / "quote" needs a small API addition there — and whatever it writes must stay consistent with the debounced draft write at Composer.tsx:38-44. The chat-v3-draft:* key is already registered at apps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts:61-62; any new renderer key would need the same registration (CI fails otherwise).
4. content-visibility: auto on rows. Every entry is rendered with [content-visibility:auto] [contain-intrinsic-size:auto_240px] (TurnGroupSection.tsx:21-22). scrollIntoView against an offscreen, size-estimated row can land at the wrong offset before the real content lays out — the existing anchoring code lives with this, but a user-triggered "jump to pin" will make it much more visible. Expect to need a post-layout correction.
Repro / verification sketch
There's no bug to reproduce, but to see the current gap: enable the chat-v3 flag, open the chat pane in a v2 workspace, run a task long enough for the agent to emit a plan, then scroll — the plan (.../Transcript/components/PlanRow/PlanRow.tsx) is an ordinary inline row with no way to keep it in view. That row is probably the single highest-value pin target given the use case in the issue, and a cheaper first increment might be "keep the latest plan docked" rather than general-purpose pinning.
For tests, the pieces are unit-testable without the UI: a bun:test case over deriveTimeline + a pin store asserting that a pinned id still resolves after loadOlder()/resync() drops it from the snapshot would pin down constraint #2 directly, using the existing fixtures in packages/chat-runtime/src/testing/fixtures/.
Suggested direction
Pins in the chat runtime DB, exposed as list/add/remove/rename over the existing chat-v3 tRPC mount; key on (session_id, item_id) and store a label plus a text snapshot of the pinned content.
Hover MessageAction on AgentMessageRow/UserMessageRow to toggle, auto-labelling from the first heading or line of the item text.
A collapsible panel in SessionView (sibling to Transcript, .../SessionView/SessionView.tsx:48-73), rendering from the stored snapshot so it never blanks, with "jump" using the existing data-item-id + scrollIntoView path.
Add a controlled-value or imperative-insert prop to Composer for the quote action, and wire TextSelectionPopover to it — that also delivers the "quote a selection" half of the request independently of pinning, and is probably the smallest shippable slice.
All new strings need Lingui macros and catalog updates per AGENTS.md (bun run check:i18n).
Activity
github-actions commentedon Sep 21, 2026
Summary
This is a feature request (correctly labeled
enhancement), not a bug — there is no "pin" concept anywhere in the chat UI today, so nothing is misbehaving. What's being asked for is: a per-message pin toggle, a separate panel listing the pins with editable labels, a way to jump back to the pinned message, and a "quote/insert into prompt" action. Below is what I found about where this would land and the four constraints that will actually shape the implementation.Which surface this lands on
The structured-message chat is
ChatV3Pane, and it is behind the PostHog flagchat-v3(packages/shared/src/constants.ts:178-183). The default agent surface in a v2 workspace is stillTerminalPane, which renders raw PTY output and has no message model at all — pinning is not implementable there without reimplementing it on top of chat-v3. Mobile is in the same position (apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalWebView/).apps/web/.../SessionChat/SessionChat.tsxrenders mock data on a design page, so it isn't a real target either.So: this feature is effectively a chat-v3 feature. Worth deciding explicitly before any work starts, because it changes the size of the request considerably.
What already exists and makes this cheap
id(packages/chat/src/protocol/items.ts:3-8), derived in the Claude adapter as`${messageId}#${index}`from the API message id (packages/chat-runtime/src/harness/claude/translateStream/translateStream.ts:182-190), and persisted in the event journal (packages/chat-runtime/src/db/schema/schema.ts:11-23). Ids survive replay.forkSessionInputSchema.fromItemId(packages/chat/src/protocol/commands.ts:67-72) is precedent for a client pinning a command to a specific item id — apinItem/unpinItemcommand would be architecturally consistent.data-item-id(.../Transcript/components/TurnGroupSection/TurnGroupSection.tsx:79and:102), andTranscript.tsx:99-112already doesquerySelector('[data-item-id=...]')+scrollIntoViewfor the latest user message and the first pending approval. "Jump to pin" reuses that exact mechanism.packages/ui/src/components/ai-elements/text-selection-popover.tsximplements a primary/secondary action popover over a text selection — exactly the shape of a "quote" affordance. I found no importers of it anywhere in the repo, so it's available.MessageActions/MessageAction(packages/ui/src/components/ai-elements/message.tsx:75-121) is the existing hover-action primitive, though the chat-v3 rows don't currently use it —AgentMessageRowis a bareMarkdownView(.../AgentMessageRow/AgentMessageRow.tsx:13).The four real design decisions
1. Where pins are stored — not localStorage. Pins are entity-scoped (session × item) with unbounded cardinality, which
apps/desktop/AGENTS.mdexplicitly routes to host-side SQLite rather than the renderer's shared ~10 MB localStorage quota. The natural home is the chat runtime DB alongsidechat_journal/chat_sessions_local(packages/chat-runtime/src/db/schema/schema.ts) — e.g. achat_pins(session_id, item_id, label, created_at)table served over the existing/chat-v3/trpcmount (packages/host-service/src/chat-v3/mount.ts:24). That also makes pins survive across machines/clients the way session state does. Notepackages/dbisn't the right place either — that's the cloud Drizzle schema, and the chat DB has its own migrations.2. A pin panel cannot resolve content from item id alone. The transcript is paginated (
getItemsInputSchemadefaults to 200 items,packages/chat/src/protocol/commands.ts:83-88) withhasOlder/loadOlder(packages/chat/src/react/useChatSession/useChatSession.ts:218-238), and a stream reset triggersresync()which throws away everything and re-seeds only the latest page (useChatSession.ts:125-147). MeanwhiledisplayTextreturns""for an item that isn't in the current snapshot (packages/chat/src/core/reducer/reducer.ts:156-161). Concretely: pin a message, scroll far enough that it pages out or the socket resets, and a naive panel renders a blank pin. Either the pin row stores a content snapshot at pin time, or "jump to pin" needs to driveloadOlder()in a loop until the id appears.3. The composer has no insert API.
Composerowns its textarea state internally and exposes onlydraftKeyfor localStorage persistence (.../Composer/Composer.tsx:20-52). There is no controlledvalueprop and no imperative handle, so "insert into prompt" / "quote" needs a small API addition there — and whatever it writes must stay consistent with the debounced draft write atComposer.tsx:38-44. Thechat-v3-draft:*key is already registered atapps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts:61-62; any new renderer key would need the same registration (CI fails otherwise).4.
content-visibility: autoon rows. Every entry is rendered with[content-visibility:auto] [contain-intrinsic-size:auto_240px](TurnGroupSection.tsx:21-22).scrollIntoViewagainst an offscreen, size-estimated row can land at the wrong offset before the real content lays out — the existing anchoring code lives with this, but a user-triggered "jump to pin" will make it much more visible. Expect to need a post-layout correction.Repro / verification sketch
There's no bug to reproduce, but to see the current gap: enable the
chat-v3flag, open the chat pane in a v2 workspace, run a task long enough for the agent to emit a plan, then scroll — the plan (.../Transcript/components/PlanRow/PlanRow.tsx) is an ordinary inline row with no way to keep it in view. That row is probably the single highest-value pin target given the use case in the issue, and a cheaper first increment might be "keep the latest plan docked" rather than general-purpose pinning.For tests, the pieces are unit-testable without the UI: a
bun:testcase overderiveTimeline+ a pin store asserting that a pinned id still resolves afterloadOlder()/resync()drops it from the snapshot would pin down constraint #2 directly, using the existing fixtures inpackages/chat-runtime/src/testing/fixtures/.Suggested direction
list/add/remove/renameover the existing chat-v3 tRPC mount; key on(session_id, item_id)and store alabelplus a text snapshot of the pinned content.MessageActiononAgentMessageRow/UserMessageRowto toggle, auto-labelling from the first heading or line of the item text.SessionView(sibling toTranscript,.../SessionView/SessionView.tsx:48-73), rendering from the stored snapshot so it never blanks, with "jump" using the existingdata-item-id+scrollIntoViewpath.Composerfor the quote action, and wireTextSelectionPopoverto it — that also delivers the "quote a selection" half of the request independently of pinning, and is probably the smallest shippable slice.All new strings need Lingui macros and catalog updates per
AGENTS.md(bun run check:i18n).