feat(chat): /btw — side questions in a temporary forked session (#2796)
* feat(chat): /btw command — side questions in a temporary forked session /btw <question> forks the current session (full context inheritance) and opens a compact peek panel docked above the composer. The composer itself becomes the btw input while the panel is open: sends route to the fork, the placeholder and a mode chip reflect the target, and the stop button aborts the fork's turn. Closing the panel (or the chip's ✕) destroys the fork, leaving the main conversation untouched. The panel shows only the fork's own tail (messages at/after the fork creation time) and live permission/question cards scoped to the fork. - chat/btw/BtwPanel: peek sheet (desktop + mobile), fork-tail view, auto-close on disappearance, Esc to close - lib/btw: startBtwSession (fork + rename + routed send), closeBtwPanel (close = destroy), filterBtwTailMessages - ChatInput: btw-mode send routing via SendMessageOptions.sessionId, btw-aware activity (stop/abort), placeholder + mode chip - useSessionActivity: exported for per-session activity reads - i18n: btw keys across all 11 locales * fix(chat): keep btw sends isolated * refactor(chat): rework /btw into a metadata-scoped peek panel - Link the active btw fork through the parent session's metadata (openchamber.btwSessionID) so the panel exists only in the session that invoked /btw, follows parent navigation, and survives reloads; the fork carries a kind:'btw' marker with its originalSessionID. - Replace the wall-clock history boundary with the id of the newest cloned message (server-generated ascending ids), stored in fork metadata. - Derive panel identity in useBtwPanelState; useBtwStore shrinks to transient per-parent UI state (collapsed/creating/destroying). - Panel UX: dropdown-style glass surface, chat ScrollShadow, single title+chevron collapse toggle, muted header controls, promote action (keep as a full session and navigate to it), Esc collapses instead of destroying, reserved Working indicator row, streaming auto-follow via ResizeObserver keyed on content readiness. - Add a 'peek' chat surface mode that suppresses per-message controls and turn footers inside the panel; user bubbles keep a small gap below. - Hide btw forks from the sidebar, session switcher, and command palette until promoted; mark the fork before inserting it into local stores. - Delete/archive lifecycle: removing the fork unlinks the parent; removing the parent also removes its temporary fork. - patchSessionMetadata now mirrors updated sessions into live stores. - Localize new strings across all 12 dictionaries; add unit tests for metadata helpers, the btw flow, and the UI store. * fix(chat): clamp the btw panel below the app header when the keyboard is open Reuse useMobileAutocompleteMaxHeight (the composer autocomplete precedent) on the panel's scroll body, reserving the panel header and bottom spacer height, so the sheet adapts to the visual viewport instead of riding under the app header on mobile. * fix(lint): drop unused destructured bindings in sessionBtwMetadata CI eslint has no underscore ignore pattern; strip metadata keys with typed copies and delete instead of discard-destructuring. --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a317a156cb
commit
46426e8495
@@ -0,0 +1,170 @@
|
||||
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
|
||||
import { useBtwStore } from '@/stores/useBtwStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs';
|
||||
import { Binary } from '@/sync/binary';
|
||||
|
||||
/**
|
||||
* `/btw <question>`: fork the main session into a temporary session and send
|
||||
* the question there.
|
||||
*
|
||||
* A fork (not an empty child) gives the agent the full inherited conversation
|
||||
* as its window context. The fork is created through the SDK directly (like
|
||||
* reviewFlow) so the main chat's `currentSessionId` is never switched; the
|
||||
* prompt is routed to the fork with `SendMessageOptions.sessionId`.
|
||||
*
|
||||
* The parent session's metadata carries `openchamber.btwSessionID` (see
|
||||
* `sessionBtwMetadata`), so the panel belongs to the parent session alone,
|
||||
* follows the user as they navigate between sessions, and survives reloads.
|
||||
*/
|
||||
export type StartBtwInput = {
|
||||
parentSessionId: string;
|
||||
question: string;
|
||||
directory: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
agent?: string;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
export const btwSessionTitle = (question: string): string => `btw: ${question}`;
|
||||
|
||||
/**
|
||||
* Insert the fork into its directory child store so the sidebar picks it up
|
||||
* immediately, mirroring `forkFromMessage` in session-actions.
|
||||
*/
|
||||
function insertForkIntoDirectoryStore(session: Session, directory: string): void {
|
||||
const store = getSyncChildStores().children.get(directory);
|
||||
if (!store) return;
|
||||
const current = store.getState();
|
||||
const sessions = [...current.session];
|
||||
const searchResult = Binary.search(sessions, session.id, (s) => s.id);
|
||||
if (!searchResult.found) {
|
||||
sessions.splice(searchResult.index, 0, session);
|
||||
store.setState({ session: sessions });
|
||||
}
|
||||
}
|
||||
|
||||
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
||||
const { setPanelState, clearPanelState } = useBtwStore.getState();
|
||||
setPanelState(input.parentSessionId, { creating: true });
|
||||
try {
|
||||
await sessionActions.waitForConnectionOrThrow();
|
||||
const forked = await opencodeClient.forkSession(input.parentSessionId, undefined, input.directory);
|
||||
|
||||
// The server may canonicalize the worktree path; the prompt must use the
|
||||
// same directory identity as the forked session.
|
||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||
// widening only reads it, with the requested directory as the fallback.
|
||||
const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory;
|
||||
registerSessionDirectory(forked.id, sessionDirectory);
|
||||
|
||||
try {
|
||||
// The boundary between inherited history and the fork's own tail is the
|
||||
// id of the newest cloned message. Message ids are server-generated and
|
||||
// ascending, so everything the fork produces sorts after it.
|
||||
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
|
||||
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? null;
|
||||
|
||||
// The fork inherits the parent's metadata and title wholesale: replace
|
||||
// the metadata with the btw marker, and rename it (rename is
|
||||
// best-effort — a failed rename must not fail the btw flow).
|
||||
// The marker lands BEFORE the fork is inserted into local stores: btw
|
||||
// forks are hidden from session lists by this marker, so inserting an
|
||||
// unmarked fork first would flash it in the sidebar.
|
||||
const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) =>
|
||||
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID));
|
||||
// patchSessionMetadata already upserted the marked fork into the global
|
||||
// store; the directory child store still needs the explicit insert.
|
||||
insertForkIntoDirectoryStore(marked, sessionDirectory);
|
||||
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined);
|
||||
|
||||
// Link the parent before sending so the panel opens as soon as the
|
||||
// metadata lands; the question streams into it.
|
||||
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
||||
withBtwSessionLink(metadata, forked.id));
|
||||
|
||||
try {
|
||||
await useSessionUIStore.getState().sendMessage(
|
||||
input.question,
|
||||
input.providerID,
|
||||
input.modelID,
|
||||
input.agent,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
input.variant,
|
||||
'normal',
|
||||
{ sessionId: forked.id, directory: sessionDirectory },
|
||||
);
|
||||
} catch (error) {
|
||||
// A fork without its first question is not a usable btw session:
|
||||
// unlink the parent again before deleting the fork.
|
||||
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
||||
withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
await sessionActions.deleteSession(forked.id).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return forked;
|
||||
} finally {
|
||||
clearPanelState(input.parentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the fork's own tail: messages after the last message cloned from
|
||||
* the parent. A `null` boundary means the fork inherited nothing.
|
||||
*/
|
||||
export function filterBtwTailMessages(
|
||||
records: Array<{ info: Message; parts: Part[] }>,
|
||||
boundaryMessageID: string | null,
|
||||
): Array<{ info: Message; parts: Part[] }> {
|
||||
if (!boundaryMessageID) return records;
|
||||
return records.filter((record) => record.info.id > boundaryMessageID);
|
||||
}
|
||||
|
||||
export type BtwSessionRef = {
|
||||
parentSessionId: string;
|
||||
btwSessionId: string;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Destroy the temporary fork. The panel disappears immediately (optimistic
|
||||
* `destroying` flag); the parent is unlinked and the fork deleted in the
|
||||
* background. Resolves `false` when the server could not confirm deletion —
|
||||
* the fork then remains in the sidebar and the caller should surface that.
|
||||
*/
|
||||
export async function destroyBtwSession(ref: BtwSessionRef): Promise<boolean> {
|
||||
const { setPanelState, clearPanelState } = useBtwStore.getState();
|
||||
setPanelState(ref.parentSessionId, { destroying: true });
|
||||
try {
|
||||
// deleteSession's metadata cleanup also unlinks the parent; doing it first
|
||||
// makes the panel close authoritative even if the delete then fails.
|
||||
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
|
||||
withoutBtwSessionLink(metadata, ref.btwSessionId)).catch(() => undefined);
|
||||
return await sessionActions.deleteSession(ref.btwSessionId);
|
||||
} finally {
|
||||
clearPanelState(ref.parentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the fork as a normal session: unlink it from the parent, drop its btw
|
||||
* marker, and navigate to it. The conversation continues there as a regular
|
||||
* session.
|
||||
*/
|
||||
export async function promoteBtwSession(ref: BtwSessionRef): Promise<void> {
|
||||
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
|
||||
withoutBtwSessionLink(metadata, ref.btwSessionId));
|
||||
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker)
|
||||
.catch(() => undefined);
|
||||
useBtwStore.getState().clearPanelState(ref.parentSessionId);
|
||||
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
|
||||
}
|
||||
Reference in New Issue
Block a user