Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions design-system/cockpit/pages/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,22 @@ Header min-height is 52px — it's the drag region, keep it a real grab target.
`request_user_input`), never from the raw JSON in the renderer.
- Tool/system glyphs are text-presentation unicode (`⚙︎` with U+FE0E, `↳`) — if these
ever grow, switch to SVGs from `logos.tsx`; never bare emoji-presentation glyphs.
- **DOM bound:** only the last `RENDER_LAST` (400) messages render, with an explicit
`(showing the last N of M messages)` sys-row. Keep both when touching this.
- **DOM bound, with a way up:** only the last `RENDER_LAST` (400) messages render, and
the sys-row that says so (`EarlierRow`, `transcript-window.tsx`: "showing the last
400 of 1,200 messages · show 400 earlier") is the control that shows the next batch.
Rows prepend above the viewport, so `useTranscriptWindow` re-adds the height that
landed above the reader in a layout effect and `.messages` carries
`overflow-anchor: none` — one adjustment, not the browser's and ours. The window
resets when the conversation changes. Keep all of it when touching this.
- **A transcript-search hit opens at its message.** The palette hands the hit over as
the chat's `anchor` (`TranscriptAnchor`, `chat-binding.ts`); once the log is in,
`findAnchor` (`transcript-anchor.ts`) names the row by its words, speaker and time —
never by index, since the searcher and the parser count messages differently — the
window is raised to hold it with `ANCHOR_CONTEXT` rows above, it scrolls to the
middle (`data-log-key` on every row is what finds it), wears `.anchored` — an accent
halo, `ANCHOR_RING_MS` — and the `role=status` region says so. Applied once per
anchor: the log keeps growing under a live session and must not re-scroll. Words the
log no longer says open at the bottom as before.
- Consecutive duplicate system notices are filtered — providers repeat them.
- `Message` is memoized; keys are absolute log offsets (`log.length - visible.length + i`),
stable because the log is append-only. Don't "fix" this to item ids or bare indexes.
Expand All @@ -117,6 +131,20 @@ Header min-height is 52px — it's the drag region, keep it a real grab target.
elsewhere…", `title` explaining why — and the transcript re-reads from disk as the index
sees each write (App's `diskLogRef`), so the log grows under the reader as a turn of
Cockpit's own would. Cockpit's own turn outranks it: one line, never two.
- **A way down for a reader who scrolled up:** the auto-scroll never hijacks a
scroll-up, so rows arriving below are news — `useUnseenBelow` marks them the moment
the log grows while the scroller is off the bottom, and `JumpToLatest` (`.jump-latest`,
the transcript's last child: a sticky zero-height line whose `.btn-ghost.small` "New
messages" key hangs above the bottom edge without moving a row) takes them there.
Reaching the bottom by hand clears it. Hidden it is `visibility: hidden` and out of
the tab order; the status region already announced the turn, so the key is the way
there, not the announcement. Shared with the roundtable — never rebuild it per view.
- **The pin is per conversation, never per binding object.** App re-makes the binding
mid-turn (the native id from the CLI's first event, a parent chip arriving), and a
reset keyed on the object re-pinned the transcript to the bottom — so a reader who had
scrolled up was yanked back on the next row, exactly the hijack the rule forbids.
`atBottomRef` and the DOM window reset on `conversation` (provider · cwd · native id)
instead; the probe in the tour's `chat-new-below` shot is what caught it.
- Auto-scroll pins to bottom on new messages/busy; busy shows `.pulse` +
"<Agent> is working…" — the `.thinking` line renders in the placard register
(mono uppercase annunciator; the transform is CSS, the DOM text stays sentence
Expand Down
7 changes: 4 additions & 3 deletions design-system/cockpit/pages/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ Two lines in one `.palette-opt`: the session it came from in the session-row gra
time in `.palette-meta`), then `.palette-snippet` — the message windowed around its first
match, `--fs-sm`, clamped to two lines, led by a mono `.palette-role` micro-label (`you` /
`agent` / `tool`) and with the match in a `<mark>` re-voiced as an accent wash under
`--fg` (the UA's yellow-on-black never shows). Picking a hit opens its session; the
transcript view has no per-message anchor yet, so it opens at the bottom like any
session. Row `aria-label` = `<Agent> session: <title> — <role>: <snippet>`.
`--fg` (the UA's yellow-on-black never shows). Picking a hit opens its session **at
that message**: the hit itself rides `onOpenSession` as the chat's `anchor`, and the
chat finds the row by its words (see `pages/chat.md`), rings it and says so. Row
`aria-label` = `<Agent> session: <title> — <role>: <snippet>`.

## Keyboard & ARIA

Expand Down
6 changes: 5 additions & 1 deletion design-system/cockpit/pages/roundtable.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ attribution, never a parallel message grammar.
failed turns → `.sys-row` annotations ("<Agent> turn failed: …"), never bubbles; live
tool calls → the shared `Message` tool rows, transient (cleared when the entry lands).
- Streaming renders plain text (`.streaming-plain`) like chat — markdownify on entry.
- DOM bound: the last 200 entries, with the explicit "(showing the last N of M)" sys-row.
- DOM bound: the last 200 entries, with the chat's `EarlierRow` ("showing the last 200
of 250 messages · show 50 earlier") and its `JumpToLatest` key for a reader who
scrolled up while a seat wrote — both from `transcript-window.tsx`, shared with
the chat (see `pages/chat.md`); a seat streaming into its live block counts as
news the same as an entry landing.
- Auto-scroll pins to bottom unless the user scrolled up (same 48px rule as chat).

## Composer / rounds
Expand Down
5 changes: 5 additions & 0 deletions docs/guide/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ for its result. Your draft stays in the composer meanwhile.
Once you send from Cockpit, the turn streams in as usual and the transcript is Cockpit's
until it ends; a turn typed in the terminal after that shows up here again as it lands.

Long transcripts open on their newest 400 messages; the line at the top says how many
there are and shows the next 400 when you ask, without moving what you were reading. If
you scroll up while the agent is still writing, the transcript stays where you put it and a
**New messages** key appears at the bottom edge — press it to go back to the latest.

The permission mode sits beside **Send** and applies to the next turn you send. Tool activity reads one row per call: the command or file it touched, and its result's first line on the right — expand the row for the full input and output. In a narrow window a long first line steps aside so the command stays readable; a short verdict such as `ok` or `20 passed` keeps its place.

Replies render as markdown: code blocks carry a **Copy** button, and a link opens in your
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ answer. Press <kbd>⌘K</kbd>, type the words, and pick **search transcripts for
the session matches. Cockpit streams through the transcripts on demand (nothing is
indexed or uploaded), scoped to the repo you are looking at — a row in the results widens
the search to every repo. Each hit shows the message around the match, marked, with who
said it; picking one opens that session.
said it; picking one opens that session **at that message** — scrolled into view and
briefly highlighted, however far back it is.

Only what you and the agents *said* is searched: tool calls and their output stay out,
so a file that every session read doesn't match every session. Large transcripts are
Expand Down
45 changes: 45 additions & 0 deletions scripts/ui-tour/tour.mts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,20 @@ const STATIC: readonly Shot[] = [
},
{ view: 'roundtable', name: 'roundtable-open', go: (w) => open(w, /Monorepo or polyrepo/) },
{ view: 'chat', name: 'chat-claude', go: (w) => open(w, /Fix the login flake/) },
// a transcript-search hit opens its session at the message: ringed, mid-viewport
{
view: 'chat',
name: 'chat-from-search',
go: async (w) => {
await home(w)
await w.keyboard.press('ControlOrMeta+k')
await w.keyboard.type('spans')
await w.getByRole('option', { name: /Search transcripts for/ }).click()
await w.getByRole('option', { name: /agent:/ }).first().click()
await w.locator('.messages .anchored').waitFor()
await pause(w, 400)
}
},
{
view: 'chat',
name: 'chat-work-log-open',
Expand Down Expand Up @@ -351,6 +365,37 @@ const LIVE: readonly Shot[] = [
await pause(w, 1500)
}
},
// a turn read from the top: the reply keeps arriving below, and the key says so. The
// chat stays open from the shot above (re-opening a session detaches the view from
// the turn Cockpit is running in it), and this turn waits for that one's Send to be
// back before it starts
{
view: 'live',
name: 'chat-new-below',
go: async (w) => {
if (!(await w.locator('.chat-title').isVisible())) await open(w, /Fix the login flake/)
const box = w.locator('.composer textarea')
await box.fill('And the slow DNS case?')
// Send comes back when the turn above ends — and stays held a beat longer while the
// log's own liveness settles ("working elsewhere"), so wait for it to be pressable
await w.waitForFunction(
() => {
const send = [...document.querySelectorAll<HTMLButtonElement>('.composer button')].find(
(b) => b.textContent?.trim() === 'Send'
)
return !!send && !send.disabled
},
undefined,
{ timeout: 40_000 }
)
await box.press('Enter')
await w.locator('.messages').evaluate((el) => el.scrollTo({ top: 0 }))
// the key's line is zero-height on purpose (Playwright reads that as hidden), so
// wait for the key itself
await w.locator('.jump-latest.on button').waitFor({ timeout: 15_000 })
await pause(w, 300)
}
},
{
view: 'live',
name: 'home-flying',
Expand Down
2 changes: 1 addition & 1 deletion scripts/ui-tour/world.mts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ function populate(world: World): void {
{ speaker: 'user', text: 'Monorepo or one repo per SDK?', at: t1 },
{ speaker: 'claude', seat: 0, text: 'Monorepo: shared spec, one CI, atomic changes.', at: t1 + 60_000 },
{ speaker: 'codex', seat: 1, text: 'Polyrepo keeps each ecosystem idiomatic.', at: t1 + 120_000 },
{ speaker: 'codex', seat: 1, text: 'turn failed: process exited with code 1', at: t1 + 1_200_000, error: true }
{ speaker: 'codex', seat: 1, text: 'codex exited with code 1', at: t1 + 1_200_000, error: true }
]
})

Expand Down
11 changes: 8 additions & 3 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { preloadMarkdown } from './Markdown'
import { initTimeFormat } from './time'
import type { StartSessionRequest } from './NewSession'
import type { ChatBinding, PendingPermission } from './chat-binding'
import type { ChatBinding, PendingPermission, TranscriptAnchor } from './chat-binding'
import type { AccountsSnapshot, AgentOptions } from '../../shared/types'

/** `--rail` on the grid: the width the rail was dragged to, in CSS pixels. */
Expand Down Expand Up @@ -120,6 +120,8 @@ export function App(): JSX.Element {
const [prs, setPrs] = useState<PrStatus[]>([])
const [binding, setBinding] = useState<ChatBinding | null>(null)
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
/** Where the open chat should land: the message a transcript-search hit named */
const [anchor, setAnchor] = useState<TranscriptAnchor | null>(null)
const [activeTurn, setActiveTurn] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [creatingPr, setCreatingPr] = useState(false)
Expand Down Expand Up @@ -393,12 +395,13 @@ export function App(): JSX.Element {
)

const openSession = useCallback(
async (s: SessionMeta) => {
async (s: SessionMeta, opts: { readonly anchor?: TranscriptAnchor } = {}) => {
const seq = ++openSeqRef.current
setChatLog([])
diskLogRef.current = null
setActiveTurn(null)
setSelectedSessionId(s.id)
setAnchor(opts.anchor ?? null)
// restore the account this session's source dir belongs to — otherwise a
// reopened session would silently continue on the default account.
// (SessionMeta.source is the source LABEL; copilot's historical user is
Expand Down Expand Up @@ -456,6 +459,7 @@ export function App(): JSX.Element {
diskLogRef.current = null
setActiveTurn(null)
setSelectedSessionId(entry.sessionId)
setAnchor(null)
setBinding(entry.binding)
if (entry.sessionId) {
void api
Expand Down Expand Up @@ -926,13 +930,14 @@ export function App(): JSX.Element {
onOpenLineage={(id) => void openLineage(id)}
permissions={permissions}
onAnswerPermission={answerPermission}
anchor={anchor}
/>
)}
{paletteOpen && (
<CommandPalette
repos={visibleRepos}
scopeRepo={scopeRepo}
onOpenSession={(s) => void openSession(s)}
onOpenSession={(s, at) => void openSession(s, at ? { anchor: at } : {})}
onNewSession={(repo) => setView({ kind: 'new', repo })}
onGoto={(v: PaletteViewKey) =>
setView(v === 'extensions' ? { kind: v, repoRoot: null } : { kind: v })
Expand Down
Loading
Loading