Skip to content

Continue a chat without a project in a new workspace: the agent's summary becomes the handoff - #888

Open
danyaberezun wants to merge 24 commits into
global-search-chatsfrom
global-search-continue
Open

danyaberezun wants to merge 24 commits into
global-search-chatsfrom
global-search-continue

Conversation

@danyaberezun

@danyaberezun danyaberezun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A chat without a project (#887) is where a question starts — but the moment it turns into work it needs a repository, a branch and a workspace, and today the only way there is to copy the conversation by hand into a new workspace chat. The interview asked for the opposite: keep the scratch chat, and let it hand the work over.

Fifth PR of the global-search stack; stacked on #887. Design and three review rounds in TASK-projectless-chat.md ("Continue in workspace").

Rebased on main (Oct 26). CONTINUATION_PROTOCOL_VERSION is now v94 (the stack moved up one version behind #912). The Continue dialog is titled by the chat's displayed label (name or first prompt, as the Chats row), now that #911 is in.

Approach

  • The chat's own agent writes the handoff. chat.continue { workspaceId, projectId } binds one in-memory operation to the source chat and delivers a continue-in-workspace-request custom message to its session as a follow-up — a card in the transcript, queued behind a running turn, never a steer, never a fake user turn. The agent answers by calling the new continue_in_workspace({ summary }) tool; a turn that settles without the call fails the operation with "the agent finished without a summary" and the card offers Retry.
  • The tool does the rest on the host. applyContinuation revalidates the binding and the project, then creates the workspace (default base, the shared initial-terminal provisioning), the chat (host default model) and sends the chat-handoff custom message — the clamped summary plus a link back — as the new chat's first turn, reporting firstMessage: sent | failed apart from the phase. Each created id is recorded as soon as it exists, so a second call (or a same-host retry) reuses partial work and returns the ids. The source record keeps continuedTo: { projectId, workspaceId, sessionId }[], a durable link that survives compaction of the transcript card.
  • In memory, by design. Phases requested → summarizing → creating → started | failed | lost travel on a scoped chat.continuation push (sockets ≥ v94) and chat.continuationStatus answers after a reconnect; a host restart loses the binding, so the tool refuses and the UI says "ask again". The request's acceptance is awaited only 1.5 s (a refusal before the turn fails the operation) so the dialog closes while the turn runs.
  • Only the initiating client follows. The web remembers the operations it started; when the host reports started, that client alone loads the project's workspaces and opens the new chat; other clients just see the cards and the Chats row's "Continued in ".
  • Cards, not prose. ContinuationRequestCard shows the host-owned phase (asking → summarizing → creating → Continued · Open / failed · Retry); HandoffCard renders the summary as Markdown under "Continued from “<title>”" with Open the original chat. The renderers stay props-driven — ChatTranscript supplies the actions and the phase lookup from the store.
  • Tool registration follows set_title (a global inline extension with a host-injected ContinueToolHost): per-session registration was not needed once the host refuses every call without a binding, which also keeps delegation children out. sendHostMessageToSession generalizes the review-fix request path, so there is one way the host authors a message the agent answers.

Changes

  • packages/contracts: PROTOCOL_VERSION = 94 = CONTINUATION_PROTOCOL_VERSION; chat.continue / chat.continuationStatus; ChatContinuation + phases; chat.continuation channel; Workspace.continuedTo (ChatContinuationLink); custom messages continue-in-workspace-request / chat-handoff with guards; CHAT_HANDOFF_SUMMARY_MAX_LENGTH.
  • packages/server: agent/continueTool.ts (+ sendHostMessageToSession), host/continuations.ts (single-flight, phases, tool host, settle observer), handlers, server.ts (publisher with a v94 floor, tool host, settle observation), workspaces/chats.ts recordChatContinuation. Tests: host/continuations.test.ts (a faux agent calls the real tool: workspace, chat, handoff message, link, idempotent second call; settle-without-call fails, retry starts fresh, no binding refuses, closed project refused).
  • apps/web: chat turn kinds continuationRequest / handoff (hydration, live events, rows, actions, height estimates) and the two cards; ChatActions continuation actions provided by ChatTranscript; store chatContinuations + push; panels/chatActions.ts continue/follow + useFollowContinuations (mounted by Shell); ContinueChatDialog from the Chats row and the topbar chat segment; "Continued in " on the row; 24 locale keys.
  • e2e: chats.spec.ts gains the no-agent continuation flow (card, fails without a summary, Retry); chats-continue.live.spec.ts (@agent) covers the real handoff end to end.
  • Review pass (independent reviewer, approve + 3 non-blocking findings, all fixed in 7972b0271): the topbar chat segment offers Continue in workspace…; a card whose operation this client never heard about (a reload) asks chat.continuationStatus once and renders a lost binding as lost; Retry from the card registers the operation so the initiating client still follows it; a new gesture after a failure inherits the workspace and chat the failed operation had created.
  • Specs: architecture Decision 6, goal-and-requirements, contracts, host, agent, workspaces, web chat / panels / store / locationBar; README bullet; task-spec "PR5 — Implemented".

Screenshots

1280×800. Before = #887's chat without a project (no Continue action, no cards); After = the dialog, the request card, and — from the live run — the new workspace chat's handoff card and the source card marked started.

Before — a chat without a project (#887): its menu knows Delete, nothing hands it over After — Continue in workspace… picks the repository
A chat without a project: topbar Chats › Chat, the Chats section with one row, Specs and Review not available The Continue “Chat” in a workspace dialog with the project picker set to sample-project and a Continue button
After — the request card in the source chat (here with no model answering: "Not continued: the agent finished without a summary" and Retry) After (live agent) — the new workspace chat opens on the handoff card; the Chats row reads "Continued in sample-project"
The continuation card in the chat: Continue in workspace — sample-project, the failure line and a Retry button The new workspace-1 chat: a handoff card with the agent's summary and Open the original chat, the Chats row showing Continued in sample-project
After (live agent) — back in the source chat, the card reads "Continued in a new workspace." with Open
The source chat's continuation card in its started phase with an Open button

Testing

  • bun run lint, check:deps, check:boundaries, check:seams, check:i18n, check:spec-surface, check:compiler → green; bunx turbo run typecheck --filter='!@thinkrail/desktop' → 21/21
  • bun run test → 22/22 packages green (server 1819, web 1969, contracts 99)
  • bun run e2e (full no-agent suite, 8 shards) → all passed on this head
  • bun run e2e e2e/chats.spec.ts → 3 passed
  • bun run e2e:full e2e/chats-continue.live.spec.ts → 1 passed (real agent: summary → tool → workspace chat with the handoff card → continuedTo → back to the source, card started with Open)

Related issues

Part of the global-search stack (#804 → #805 → #806 → #887 → this).

Checklist

  • Fast gates pass: bun run lint, bun run typecheck, bun run test
  • E2E suite passes for app-affecting changes (bun run e2e, or bun run e2e:full when touching agent behavior)
  • Before/after screenshots are included for frontend changes, or marked not applicable
  • Relevant SPEC.md / top-level specs updated to reflect any boundary, contract, or behavior change
  • I have read the Contributing guide and agree to the Code of Conduct

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline finding.

try {
workspace = state.workspaceId
? knownWorkspace(state.workspaceId)
: provisionInitialTerminal(await createWorkspace(project.id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking:
Problem: Applying an operation has no in-flight guard; pendingFor also admits its creating phase. Pi 1.2.0 executes these tools in parallel by default.
Failure scenario: The agent emits two continue_in_workspace calls in one response. A probe using the real tool creates two distinct workspaces and chats, sends both handoffs, and records two links for one operation; the final operation state races between them.
Suggested fix: Store and join one apply promise per operation before any await, returning the same result to concurrent calls and retaining the completed-id check.

const continuationOf = useCallback(
(operationId: string): ChatContinuation | undefined => {
const known = continuations[operationId];
if (known || askedStatus.current.has(operationId)) return known;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking:
Problem: Status is fetched only for unknown operations, while cached continuation phases survive reconnect and no reconnect handler refreshes them.
Failure scenario: Disconnect after summarizing, then reconnect after the host finishes, fails, or restarts. The cached phase remains summarizing indefinitely, so the card offers neither Open nor Retry and the initiating client never follows the completed handoff. The transport probe reconnects with zero continuationStatus reads.
Suggested fix: Refresh in-progress operations after each connected welcome, including initiated operations without mounted cards, and map a lost binding to lost so Retry becomes available.

sourceSessionId,
projectId: project.id,
phase: "requested",
...(leftover?.workspaceId ? { workspaceId: leftover.workspaceId } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking:
Problem: Retrying blindly inherits the failed operation's workspaceId, even when that workspace has been removed.
Failure scenario: Workspace creation succeeds but target session startup fails; remove the empty workspace through its menu, then Retry. knownWorkspace rejects the missing id, and every later Continue for the same project inherits it again. Two consecutive retries reproduce the permanent failure.
Suggested fix: Validate the retained workspace before inheriting partial work; if it is gone, discard its workspaceId and dependent sessionId so a new workspace can be created.

@jetbrains-air jetbrains-air Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline finding.

Comment thread packages/server/src/host/continuations.ts
Comment thread apps/web/src/chat/ChatTranscript.tsx
sessionId: targetSessionId,
});
} catch (error) {
log.warn(`continuation link not recorded for ${state.sourceWorkspaceId}: ${String(error)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking:
Problem: Both catch blocks stringify the caught error into the warning instead of passing it to the logger, bypassing mandatory error reporting.
Failure scenario: If persisting continuedTo fails, this function swallows the exception and returns success; notifyLoggedError receives undefined and PostHog never records the failure. The handoff-delivery catch has the same omission.
Suggested fix: Pass the caught error as the second argument to both log.warn calls so the classifier and stack-reporting path receives it.

`Mod+K`, double Shift and a topbar button open one palette that finds chats, workspaces and projects
across every open project, runs commands with their shortcuts shown, deep-links Settings sections and
toggles MCP servers and extensions in place. The empty query keeps the former switcher's role as the
"Waiting for you" group next to Recent and Actions.

Two new web sub-modules: `commands` (the typed contract an invocable action is described with;
handlers stay with their owners) and `search` (props-driven palette, prefix/filter grammar, tiered
ranking over the shared matcher with decay-free frecency and learned query→pick, client-local
preferences, the double-Shift state machine). Shell composes the catalog, pages and row actions and
injects them; `panels` import neither module.
…ions and module specs

Also the README capability line for the palette.
…ne terminal, IME, protocol gates, quoted filters

- Chat-title backfill now covers every untitled workspace (active and context-project first) four
  requests at a time, re-asks a workspace at most once a minute and keeps the titles across opens,
  instead of stopping at the first 24 forever.
- `terminal.new` is a host mutation (placement reserves on the host), so it is disabled offline.
- The action panel's Enter ignores an IME composition like the main input does.
- `settings.extensions` / `settings.mcp` follow the same protocol gates as their Settings sections,
  and the extension catalog / MCP list are requested only when the host serves them.
- Quoted spans are tokenized before state filters, so `"is:running"` stays literal text.
Drop the inline Theme, Extensions and MCP pages with their page stack, the
per-row action panel, in-place toggles and the is:/has: filter grammar.
Every setting row now opens its Settings section, the double-Shift row
included; Mod+K inside the palette closes it; a command always runs.
… keep the title backfill current

The palette's backfill now loads the workspace list of every open project
it has not seen — not only projects that already have session-state
records — so a collapsed project's workspaces are searchable, and it
re-reads its queue whenever the normalized snapshot gains or loses a
workspace, so a palette opened during cold-start hydration catches up.
…om host providers

A registry-free `search` module ranks the corpora only the host can see — file names, spec titles and
bodies, TODO plans, chat titles and transcripts, and the workspaces of unexpanded projects — with the
shared matcher, so the browser merges host hits with its own by tier and score. Roots are the active
worktree plus every open project's folder (never sibling worktrees); spec bodies only from the current
root; chats only from persisted worktrees of open projects.

Per-client `seq` superseding, per-provider time budgets with `truncated`, `indexing` while a file list
loads (git ls-files streamed through a capped NUL parser, a bounded walk otherwise), `failed` for a
throwing provider. Previews are bounded reads: a transcript window from the history index, a
root-contained text head, a cached spec body, a rendered plan.

Narrow reads added where the data lives: `HistoryIndex.sessions()`, `specSnapshot` (TTL + fsChanged
invalidation), `listStoredPlans`, `resolveContainedFile`. Protocol v95 (`SEARCH_PROTOCOL_VERSION`),
and the matcher gains the `content` tier for body matches.
Files, specs, plans and transcript hits from search.query join the browser
sections under the same tiers; the preview pane shows a transcript window
around a hit and the file, spec and plan heads from search.preview.
The host searches files and specs in one root — the active worktree, or
the context project's folder through its Default record on Project Home —
instead of every open project's folder; search.preview keeps only the
transcript window. File and spec rows render through the shared resource
renderers (code, images, sandboxed HTML, markdown) injected by the shell,
so resolveContainedFile, the text-head reader and the plan preview go.
… the host still holds

Address the review: previous host rows stay on screen while a new request
is pending; a latest reply the host marked superseded ends empty instead
of spinning forever, and seq is seeded from the clock so a reloaded page
never reissues a number the host has seen; late host arrivals sort after
every row already on screen, across tiers, so the selected row never drops
out of a capped group; a scoped query asks for the host's maximum page and
the host's total counts into the more row and the group meta. On the host,
a reloading file list or a spec snapshot being rebuilt reports indexing so
the client polls for the fresh corpus, a failed reload keeps the stale list,
and transcript hits order by time alone.
A typed query in the All or Work scope ends in one row «Start work with
“…”…»; Enter on it, or Mod+Enter from any row, closes the palette and
opens the shell's Create-workspace dialog with the text as the prompt, so
the target, base branch and model are chosen where they always are and
nothing starts without the dialog. The row is disabled offline like every
host mutation and is never remembered by the learning store.
A kind:"chats" container rooted at <dataDir>/chats and one kind:"chat"
workspace per chat, stored apart in chats.json (partitioned on save, merged
on load, set aside when corrupt) so an older host never sees them. New
wire methods chat.create (folder + record + the one session), chat.delete
(the only teardown door: transcripts and the folder go to the trash, the
record is marked pending first) and chat.inspect; every repository door
refuses a chat or the container; git never runs in a chat folder, backed
by GIT_CEILING_DIRECTORIES; sockets below CHATS_PROTOCOL_VERSION never
hear about chats; session state records carry pi's session title.
…Create dialog

A Chats section tops the Projects panel (one row per chat with its title,
status and a Delete that names the folder's file count); the topbar reads
Chats › <title> for a chat with no branch or ship strip; the Create
dialog gains a "Chat without a project" target (the only one when no
repository is open) and the Welcome a card for it; git- and spec-backed
tool bodies say "Not available in a chat without a project" while Files,
terminal and Plan keep working; repository listings and fallbacks go
through selectRepoProjects, and a second New chat in a chat starts another
chat.
Architecture Decision 6 gains the third kind-marked exception; goal
capabilities, the workspaces, persistence, host, agent, trash, contracts,
server-graph, panels, shell, location-bar, store and e2e specs describe
the container, the chats store, the refusal doors, the ceiling, the scoped
pushes, the titles on state records and the UI surfaces.
…nd load the Chats list on a first chat

Address the review: the plan read skips the unattributed-change and
adopted-commit git reads for a chat and the Plan page hides its branch
line, commits menu and Open PR; the setup funnel's project_present ignores
the Chats container; a first chat loads the container's list instead of
seeding it; the chats store latch has a test seam.
chat.continue binds one in-memory operation to the source chat and asks
its agent, through a continue-in-workspace-request card delivered as a
follow-up, for a handoff summary; the continue_in_workspace tool then
creates the workspace with its initial terminal, the chat on the host
default model and the chat-handoff first message, remembers the link on
the chat record (continuedTo) and reports the phases over a scoped
chat.continuation push (protocol v97). A turn that settles without the
call fails the operation; a restart loses the binding and the tool says so.
A Continue in workspace… action on the Chats row and the topbar chat
segment picks the repository; the request and the handoff render as
cards (phase, Open, Retry; summary, Open the original chat), the store
keeps the host's continuation phases, the initiating client alone follows
the new chat when it starts, and the Chats row shows where a chat was
continued.
… card's phase after a reload, and reuse a failed operation's workspace

Address the review: the topbar chat segment gains the action the specs
already described; a continuation card the client never heard about asks
chat.continuationStatus once and renders a lost binding as such; Retry
from the card registers the new operation so the initiating client still
follows it; a new gesture after a failure inherits the workspace and chat
the failed operation had already created.
@danyaberezun
danyaberezun force-pushed the global-search-continue branch from 7d19fdd to 1f98c7e Compare October 28, 2026 03:25

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline finding.

@jetbrains-air jetbrains-air Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — please address the blocking inline finding.

await ackSend(
sendHostMessageToSession(
targetSessionId,
CHAT_HANDOFF_CUSTOM_TYPE,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking:
Problem: The destination starts with a custom chat-handoff, but deriveSessionState recognizes only user entries as run boundaries. After the first turn settles, it reports runId and completion as null and completionUnread as false (reproduced on pinned pi 1.4.0).
Failure scenario: Continue a chat and switch away or unfocus the app while the destination agent works. Its completion never lights the attention dot, enters Waiting for you, or triggers a notification; Mark as unread is also unavailable.
Suggested fix: Teach the agent-owned run/completion derivation to recognize the handoff entry as a run boundary, preserving the custom-message card, and update its state spec.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant