feat(chat): turn /btw into an isolated composer (#3398)

* feat(chat): turn /btw into an isolated composer

* fix(chat): preserve direct BTW sends and isolate pending preparation

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
ChangeHow
2026-09-07 23:13:07 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7b206b1014
commit 02581d08c5
38 changed files with 1227 additions and 311 deletions
+130 -15
View File
@@ -18,13 +18,16 @@ const childStoreSessions: Session[] = [];
const currentSessionSwitches: string[] = [];
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
const parentSyncMessages: Message[] = [];
const sessionMessageReads: string[] = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
forkSessionImpl(sessionId, messageId, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) =>
getSessionMessagesImpl(id, limit, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) => {
sessionMessageReads.push(id);
return getSessionMessagesImpl(id, limit, directory);
},
},
}));
mock.module('@/sync/session-actions', () => ({
@@ -59,9 +62,10 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
const { preparePendingBtwSend, btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
const { useSelectionStore } = await import('@/sync/selection-store');
const makeSession = (id: string, directory?: string): Session => ({
id,
@@ -72,8 +76,8 @@ const makeSession = (id: string, directory?: string): Session => ({
version: 1,
}) as unknown as Session;
const record = (id: string): { info: Message; parts: Part[] } => ({
info: { id, role: 'user', time: { created: 1 } } as unknown as Message,
const record = (id: string, created = 1): { info: Message; parts: Part[] } => ({
info: { id, sessionID: 'fork-1', role: 'user', time: { created }, agent: 'plan', model: { providerID: 'provider', modelID: 'model' } },
parts: [],
});
@@ -103,6 +107,7 @@ beforeEach(() => {
currentSessionSwitches.length = 0;
metadataPatches.length = 0;
parentSyncMessages.length = 0;
sessionMessageReads.length = 0;
useBtwStore.setState({ byParent: {} });
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
@@ -123,6 +128,17 @@ describe('btwSessionTitle', () => {
});
describe('filterBtwTailMessages', () => {
test('keeps a newer user message whose ID sorts before the inherited boundary', () => {
const records = [record('msg_f001', 1), record('msg_0001', 2), record('msg_f002', 3)];
expect(filterBtwTailMessages(records, 'msg_f001').map((entry) => entry.info.id))
.toEqual(['msg_0001', 'msg_f002']);
});
test('keeps a loaded tail when its inherited boundary is outside the retained page', () => {
const records = [record('msg_0001', 2), record('msg_f002', 3)];
expect(filterBtwTailMessages(records, 'msg_f001')).toEqual(records);
});
test('keeps only messages after the boundary id', () => {
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
@@ -155,14 +171,16 @@ describe('startBtwSession', () => {
let sentText: unknown = null;
let sentOptions: unknown = null;
sendMessageImpl = (...args) => {
sentText = args[0];
sentOptions = args[9];
return Promise.resolve();
sentText = args[0];
sentOptions = args[9];
expect(args[7]).toBe(undefined);
return Promise.resolve();
};
const session = await startBtwSession(startInput);
const session = await startBtwSession({ ...startInput, variant: null });
expect(session.id).toBe('fork-1');
expect(useSelectionStore.getState().getAgentModelVariantForSession('fork-1', 'build', 'provider', 'model')).toBeNull();
expect(registeredDirectories).toEqual(['fork-1:/project']);
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
expect(sentText).toBe('wtf is kafka');
@@ -172,7 +190,7 @@ describe('startBtwSession', () => {
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
]);
// Transient creating flag is cleared once the flow settles.
expect(useBtwStore.getState().byParent).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
});
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
@@ -265,7 +283,7 @@ describe('startBtwSession', () => {
// marker, link, then unlink rollback
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
expect(metadataPatches[2]?.result).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
});
test('a failed boundary fetch deletes the fork', async () => {
@@ -278,6 +296,22 @@ describe('startBtwSession', () => {
expect(deleted).toEqual(['fork-1']);
expect(metadataPatches).toEqual([]);
});
test('rejects a second creation for the same parent before it forks', async () => {
let releaseFork: ((session: Session) => void) | undefined;
const forkStarted = new Promise<void>((resolve) => {
forkSessionImpl = () => {
resolve();
return new Promise((release) => { releaseFork = release; });
};
});
const first = startBtwSession(startInput);
await forkStarted;
await expect(startBtwSession(startInput)).rejects.toThrow('btw session creation already in progress');
releaseFork?.(makeSession('fork-1', '/project'));
await first;
});
});
describe('destroyBtwSession', () => {
@@ -310,7 +344,12 @@ describe('destroyBtwSession', () => {
describe('promoteBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent, strips the marker, and navigates to the fork', async () => {
test('unlinks the parent, strips the marker, and navigates to the fork without generating a title', async () => {
const renamedTitles: string[] = [];
updateSessionTitleImpl = (_sessionId, title) => {
renamedTitles.push(title);
return Promise.resolve();
};
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const base = sessionId === 'fork-1'
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
@@ -323,19 +362,43 @@ describe('promoteBtwSession', () => {
await promoteBtwSession(ref);
expect(metadataPatches).toEqual([
{ sessionId: 'parent-1', result: {} },
// The fork stops being a btw session but stays marked as promoted: its
// transcript still carries the boundary instructions.
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
{ sessionId: 'parent-1', result: {} },
]);
expect(currentSessionSwitches).toEqual(['fork-1']);
expect(sessionMessageReads).toEqual([]);
expect(renamedTitles).toEqual([]);
});
test('a failed unlink aborts the promote without navigating', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed');
const originalMetadata = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } };
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
if (sessionId === 'parent-1') return Promise.reject(new Error('unlink failed'));
const result = updater(originalMetadata);
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
await expect(promoteBtwSession(ref)).rejects.toThrow('unlink failed');
expect(currentSessionSwitches).toEqual([]);
expect(metadataPatches).toEqual([
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
{ sessionId: 'fork-1', result: originalMetadata },
]);
});
test('a failed marker removal preserves the parent link', async () => {
patchSessionMetadataImpl = (sessionId) => {
if (sessionId === 'fork-1') return Promise.reject(new Error('marker failed'));
throw new Error('the parent must remain linked');
};
await expect(promoteBtwSession(ref)).rejects.toThrow('marker failed');
expect(currentSessionSwitches).toEqual([]);
});
});
describe('buildBtwSyntheticTexts', () => {
@@ -358,3 +421,55 @@ describe('buildBtwSyntheticTexts', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
});
});
describe('pending BTW preparation', () => {
test('cancelling and reopening during snippet expansion cannot revive the old send', async () => {
const { getRuntimeKey } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
panels.clearPanelState('parent-1');
panels.setPanelState('parent-1', { pending: true });
finish();
expect(await preparing).toBeNull();
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ pending: true });
});
test('preparation belongs to its parent and rejects duplicate sends', async () => {
const { getRuntimeKey } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
panels.setPanelState('parent-2', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
expect(await preparePendingBtwSend('parent-1', getRuntimeKey(), async () => {})).toBeNull();
panels.clearPanelState('parent-2');
finish();
expect(await preparing).toBe(useBtwStore.getState().byParent['parent-1']?.pendingSend);
});
test('a stale composer cannot fork on the newly selected runtime', async () => {
await expect(startBtwSession({ ...startInput, expectedRuntimeKey: 'obsolete-runtime' }))
.rejects.toThrow('runtime changed');
expect(useBtwStore.getState().byParent).toEqual({});
expect(registeredDirectories).toEqual([]);
});
});
test('switching runtime during snippet expansion invalidates preparation', async () => {
const { getRuntimeKey, initializeRuntimeEndpoint } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
initializeRuntimeEndpoint({ apiBaseUrl: 'https://btw-test.invalid', runtimeKey: 'changed-during-preparation' });
finish();
expect(await preparing).toBeNull();
expect(useBtwStore.getState().byParent).toEqual({});
});
+89 -23
View File
@@ -4,10 +4,12 @@ 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 { useSelectionStore } from '@/sync/selection-store';
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
import { Binary } from '@/sync/binary';
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import { getRuntimeKey } from '@/lib/runtime-switch';
/**
* `/btw <question>`: fork the main session into a temporary session and send
@@ -24,12 +26,14 @@ import type { AttachedFile } from '@/stores/types/sessionTypes';
*/
export type StartBtwInput = {
parentSessionId: string;
expectedRuntimeKey?: string;
question: string;
directory: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
variant?: string | null;
permissionAutoAccept?: boolean;
attachments?: AttachedFile[];
additionalParts?: Array<{
text: string;
@@ -144,11 +148,45 @@ function insertForkIntoDirectoryStore(session: Session, directory: string): void
}
}
/** Preparation can be discarded until the server-side fork starts. */
export async function preparePendingBtwSend(
parentSessionId: string,
expectedRuntimeKey: string,
prepare: () => Promise<void>,
): Promise<symbol | null> {
if (getRuntimeKey() !== expectedRuntimeKey) return null;
const panels = useBtwStore.getState();
const owner = panels.byParent[parentSessionId];
if (!owner?.pending || owner.creating || owner.pendingSend) return null;
const token = Symbol('btw-send');
panels.setPanelState(parentSessionId, { pendingSend: token });
try {
await prepare();
} catch (error) {
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend === token) {
panels.setPanelState(parentSessionId, { pendingSend: undefined });
}
throw error;
}
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend !== token) return null;
if (getRuntimeKey() !== expectedRuntimeKey) {
panels.clearPanelState(parentSessionId);
return null;
}
return token;
}
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
const { setPanelState, clearPanelState } = useBtwStore.getState();
const { setPanelState } = useBtwStore.getState();
if (useBtwStore.getState().byParent[input.parentSessionId]?.creating) {
throw new Error('btw session creation already in progress');
}
const expectedRuntimeKey = input.expectedRuntimeKey ?? getRuntimeKey();
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
setPanelState(input.parentSessionId, { creating: true });
try {
await sessionActions.waitForConnectionOrThrow();
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
// Fork at the parent's last completed assistant turn rather than at HEAD,
// so a `/btw` typed mid-turn does not inherit a half-finished one.
const forkPointMessageID = findLastCompletedAssistantMessageID(
@@ -165,18 +203,28 @@ export async function startBtwSession(input: StartBtwInput): Promise<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.
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
registerSessionDirectory(forked.id, sessionDirectory);
const selections = useSelectionStore.getState();
selections.saveSessionModelSelection(forked.id, input.providerID, input.modelID);
if (input.agent) {
selections.saveSessionAgentSelection(forked.id, input.agent);
selections.saveAgentModelForSession(forked.id, input.agent, input.providerID, input.modelID);
selections.saveAgentModelVariantForSession(forked.id, input.agent, input.providerID, input.modelID, input.variant);
}
if (input.permissionAutoAccept !== undefined) {
const { usePermissionStore } = await import('@/stores/permissionStore');
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
await usePermissionStore.getState().setSessionAutoAccept(forked.id, input.permissionAutoAccept);
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
}
// Locate the inherited-history boundary by identity, not by ID ordering.
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
// A `null` boundary makes the panel show every inherited message, so an
// empty read must not be taken as "the fork inherited nothing" when we
// know it did: having picked a fork point proves the parent had turns.
// Fall back to that id — the fork's own messages are created later and
// still sort after it, so the tail stays complete either way.
// Retain the known fork point as a fallback marker.
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id
?? forkPointMessageID
?? null;
@@ -188,16 +236,19 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// 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));
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID), expectedRuntimeKey);
// 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);
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question), {
directory: sessionDirectory,
expectedRuntimeKey,
}).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));
withBtwSessionLink(metadata, forked.id), expectedRuntimeKey);
try {
await useSessionUIStore.getState().sendMessage(
@@ -211,7 +262,7 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// its most dangerous here, with the parent's in-flight plan as the
// newest thing in its context.
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
input.variant,
input.variant ?? undefined,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },
);
@@ -219,29 +270,31 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// 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);
withoutBtwSessionLink(metadata, forked.id), expectedRuntimeKey).catch(() => undefined);
throw error;
}
} catch (error) {
await sessionActions.deleteSession(forked.id).catch(() => undefined);
await sessionActions.deleteSession(forked.id, { expectedRuntimeKey }).catch(() => undefined);
throw error;
}
return forked;
} finally {
clearPanelState(input.parentSessionId);
if (getRuntimeKey() === expectedRuntimeKey) setPanelState(input.parentSessionId, { creating: false });
}
}
/**
* Keep only the fork's own tail: messages after the last message cloned from
* the parent. A `null` boundary means the fork inherited nothing.
* Records are a chronologically ordered suffix of the session. Keep everything
* after the inherited-history marker; an absent marker is outside that suffix.
* Message IDs are identities, not timestamps (including client-generated IDs).
*/
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);
const boundaryIndex = records.findIndex((record) => record.info.id === boundaryMessageID);
return boundaryIndex < 0 ? records : records.slice(boundaryIndex + 1);
}
export type BtwSessionRef = {
@@ -276,10 +329,23 @@ export async function destroyBtwSession(ref: BtwSessionRef): Promise<boolean> {
* 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);
const expectedRuntimeKey = getRuntimeKey();
let originalForkMetadata: Parameters<typeof withoutBtwSessionMarker>[0] | null = null;
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, (metadata) => {
originalForkMetadata = metadata;
return withoutBtwSessionMarker(metadata);
}, expectedRuntimeKey);
try {
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
withoutBtwSessionLink(metadata, ref.btwSessionId), expectedRuntimeKey);
} catch (error) {
const metadataToRestore = originalForkMetadata;
if (metadataToRestore) {
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, () => metadataToRestore, expectedRuntimeKey)
.catch(() => undefined);
}
throw error;
}
useBtwStore.getState().clearPanelState(ref.parentSessionId);
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
}
+4
View File
@@ -2028,6 +2028,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.',
'chat.btw.working': 'Arbeitet…',
'chat.btw.collapseAria': 'btw-Panel einklappen',
'chat.btw.draftHint': 'Stelle deine Frage',
'chat.btw.cancelAria': 'Diese BTW-Frage verwerfen',
'chat.btw.expandAria': 'btw-Panel ausklappen',
'chat.btw.promoteAria': 'Als eigene Sitzung behalten',
'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden',
@@ -2065,6 +2067,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt',
'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen',
'chat.textSelection.actions.comment': 'Kommentieren',
'chat.textSelection.actions.askOpenChamber': 'Übrigens…',
'chat.textSelection.title.askOpenChamber': 'BTW-Entwurf mit der Auswahl öffnen',
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
'chat.textSelection.comment.attach': 'Anhängen',
+4
View File
@@ -2237,6 +2237,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.',
'chat.btw.working': 'Working…',
'chat.btw.collapseAria': 'Collapse the btw panel',
'chat.btw.draftHint': 'Ask your question',
'chat.btw.cancelAria': 'Cancel this BTW question',
'chat.btw.expandAria': 'Expand the btw panel',
'chat.btw.promoteAria': 'Keep as a separate session',
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
@@ -2282,6 +2284,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
'chat.textSelection.actions.addToInput': 'Add to input',
'chat.textSelection.actions.comment': 'Comment',
'chat.textSelection.actions.askOpenChamber': 'By the way…',
'chat.textSelection.title.askOpenChamber': 'Open a BTW draft with the selection',
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
'chat.textSelection.comment.attach': 'Attach',
+4
View File
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.',
'chat.btw.working': 'Trabajando…',
'chat.btw.collapseAria': 'Contraer el panel btw',
'chat.btw.draftHint': 'Haz tu pregunta',
'chat.btw.cancelAria': 'Cancelar esta pregunta BTW',
'chat.btw.expandAria': 'Expandir el panel btw',
'chat.btw.promoteAria': 'Conservar como sesión aparte',
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
"chat.textSelection.actions.addToInput": "Añadir a la entrada",
"chat.textSelection.actions.comment": "Comentar",
"chat.textSelection.actions.askOpenChamber": "Por cierto…",
"chat.textSelection.title.askOpenChamber": "Abrir un borrador BTW con la selección",
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
"chat.textSelection.comment.attach": "Adjuntar",
+4
View File
@@ -1963,6 +1963,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.',
'chat.btw.working': 'En cours…',
'chat.btw.collapseAria': 'Réduire le panneau btw',
'chat.btw.draftHint': 'Posez votre question',
'chat.btw.cancelAria': 'Annuler cette question BTW',
'chat.btw.expandAria': 'Développer le panneau btw',
'chat.btw.promoteAria': 'Conserver comme session à part',
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
@@ -2005,6 +2007,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes',
'chat.textSelection.actions.addToInput': 'Ajouter à la saisie',
'chat.textSelection.actions.comment': 'Commenter',
'chat.textSelection.actions.askOpenChamber': 'Au fait…',
'chat.textSelection.title.askOpenChamber': 'Ouvrir un brouillon BTW avec la sélection',
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
'chat.textSelection.comment.attach': 'Joindre',
+4
View File
@@ -2232,6 +2232,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。',
'chat.btw.working': '処理中…',
'chat.btw.collapseAria': 'btwパネルを折りたたむ',
'chat.btw.draftHint': '質問を入力してください',
'chat.btw.cancelAria': 'このBTWの質問をキャンセル',
'chat.btw.expandAria': 'btwパネルを展開する',
'chat.btw.promoteAria': '独立したセッションとして保持',
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
@@ -2278,6 +2280,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
'chat.textSelection.actions.addToInput': '入力欄に追加',
'chat.textSelection.actions.comment': 'コメント',
'chat.textSelection.actions.askOpenChamber': 'ところで…',
'chat.textSelection.title.askOpenChamber': '選択したテキストでBTWの下書きを開く',
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
'chat.textSelection.comment.attach': '添付',
+4
View File
@@ -2238,6 +2238,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.',
'chat.btw.working': '작업 중…',
'chat.btw.collapseAria': 'btw 패널 접기',
'chat.btw.draftHint': '질문을 입력하세요',
'chat.btw.cancelAria': '이 BTW 질문 취소',
'chat.btw.expandAria': 'btw 패널 펼치기',
'chat.btw.promoteAria': '별도 세션으로 유지',
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
@@ -2284,6 +2286,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
'chat.textSelection.actions.addToInput': '입력란에 추가',
'chat.textSelection.actions.comment': '댓글',
'chat.textSelection.actions.askOpenChamber': '그런데…',
'chat.textSelection.title.askOpenChamber': '선택한 텍스트로 BTW 초안 열기',
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
'chat.textSelection.comment.attach': '첨부',
+4
View File
@@ -886,6 +886,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.',
'chat.btw.working': 'Pracuje…',
'chat.btw.collapseAria': 'Zwiń panel btw',
'chat.btw.draftHint': 'Zadaj pytanie',
'chat.btw.cancelAria': 'Anuluj to pytanie BTW',
'chat.btw.expandAria': 'Rozwiń panel btw',
'chat.btw.promoteAria': 'Zachowaj jako osobną sesję',
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
@@ -932,6 +934,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek',
'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania',
'chat.textSelection.actions.comment': 'Skomentuj',
'chat.textSelection.actions.askOpenChamber': 'A tak przy okazji…',
'chat.textSelection.title.askOpenChamber': 'Otwórz szkic BTW z zaznaczonym tekstem',
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
'chat.textSelection.comment.attach': 'Załącz',
@@ -2214,9 +2214,13 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.',
'chat.btw.working': 'Trabalhando…',
'chat.btw.collapseAria': 'Recolher o painel btw',
'chat.btw.draftHint': 'Faça sua pergunta',
'chat.btw.cancelAria': 'Cancelar esta pergunta BTW',
'chat.btw.expandAria': 'Expandir o painel btw',
'chat.btw.promoteAria': 'Manter como sessão separada',
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
'chat.textSelection.actions.askOpenChamber': 'A propósito…',
'chat.textSelection.title.askOpenChamber': 'Abrir um rascunho BTW com a seleção',
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
+4
View File
@@ -3278,6 +3278,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'btw session yok edilemedi. Kenar çubuğunda kalacak.',
'chat.btw.working': 'Çalışıyor…',
'chat.btw.collapseAria': 'btw panelini daralt',
'chat.btw.draftHint': 'Sorunuzu sorun',
'chat.btw.cancelAria': 'Bu BTW sorusunu iptal et',
'chat.btw.expandAria': 'btw panelini genişlet',
'chat.btw.promoteAria': 'Ayrı bir session olarak sakla',
'chat.btw.toast.promoteFailed': 'btw session saklanamadı',
@@ -3308,6 +3310,8 @@ export const dict = {
'chat.container.sessionLoadError.authDescription': 'Session\'ınızın süresi doldu, bu yüzden sunucu isteği reddetti. Oturum açın, sohbet yüklenecek.',
'chat.textSelection.actions.addToInput': 'Girdiye ekle',
'chat.textSelection.actions.comment': 'Yorum yap',
'chat.textSelection.actions.askOpenChamber': 'Bu arada…',
'chat.textSelection.title.askOpenChamber': 'Seçili metinle BTW taslağı aç',
'chat.textSelection.title.commentOnSelection': 'Seçime yorum yap',
'chat.textSelection.comment.placeholder': 'İsteğe bağlı bir yorum ekleyin...',
'chat.textSelection.comment.attach': 'Ekle',
+4
View File
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.',
'chat.btw.working': 'Працює…',
'chat.btw.collapseAria': 'Згорнути панель btw',
'chat.btw.draftHint': 'Поставте своє запитання',
'chat.btw.cancelAria': 'Скасувати це запитання BTW',
'chat.btw.expandAria': 'Розгорнути панель btw',
'chat.btw.promoteAria': 'Залишити як окрему сесію',
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
"chat.textSelection.actions.addToInput": "Додати в поле вводу",
"chat.textSelection.actions.comment": "Коментувати",
"chat.textSelection.actions.askOpenChamber": "До речі…",
"chat.textSelection.title.askOpenChamber": "Відкрити BTW-чернетку з виділеним текстом",
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
"chat.textSelection.comment.attach": "Прикріпити",
@@ -2202,6 +2202,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。',
'chat.btw.working': '处理中…',
'chat.btw.collapseAria': '收起 btw 面板',
'chat.btw.draftHint': '提出你的问题',
'chat.btw.cancelAria': '取消这次 BTW 提问',
'chat.btw.expandAria': '展开 btw 面板',
'chat.btw.promoteAria': '保留为独立会话',
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
@@ -2248,6 +2250,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
'chat.textSelection.actions.addToInput': '添加到输入框',
'chat.textSelection.actions.comment': '评论',
'chat.textSelection.actions.askOpenChamber': '顺便问一下…',
'chat.textSelection.title.askOpenChamber': '用所选文本打开 BTW 草稿',
'chat.textSelection.title.commentOnSelection': '评论所选内容',
'chat.textSelection.comment.placeholder': '添加可选评论...',
'chat.textSelection.comment.attach': '附加',
@@ -2206,6 +2206,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。',
'chat.btw.working': '處理中…',
'chat.btw.collapseAria': '收合 btw 面板',
'chat.btw.draftHint': '提出你的問題',
'chat.btw.cancelAria': '取消這次 BTW 提問',
'chat.btw.expandAria': '展開 btw 面板',
'chat.btw.promoteAria': '保留為獨立工作階段',
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
@@ -2252,6 +2254,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
'chat.textSelection.actions.addToInput': '加入輸入框',
'chat.textSelection.actions.comment': '留言',
'chat.textSelection.actions.askOpenChamber': '順便問一下…',
'chat.textSelection.title.askOpenChamber': '用所選文字開啟 BTW 草稿',
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
'chat.textSelection.comment.placeholder': '新增選填留言...',
'chat.textSelection.comment.attach': '附加',
+4 -4
View File
@@ -11,10 +11,10 @@ import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionRev
* survives reloads.
* - The fork itself is marked `openchamber.kind = 'btw'` with
* `originalSessionID` (its parent) and `btwBoundaryMessageID` the id of
* the last message cloned from the parent. Messages with a greater id are
* the fork's own tail and are what the panel renders. Message ids are
* server-generated ascending identifiers, so the boundary is a plain string
* comparison and immune to client clock skew.
* the last message cloned from the parent. The panel locates this marker
* in the chronologically ordered transcript and renders what follows it.
* IDs must not be compared to determine chronology: they can roll over and
* user-message IDs can be generated by a different client clock.
*/
type BtwMetadata = {
kind?: string;
@@ -47,6 +47,11 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
`[data-btw-composer="true"]` owns Escape instead of main-session abort priming.
While active, main and Mini Chat model/effort shortcuts yield; main agent,
expansion and dictation shortcuts also yield. Footer unmounting alone cannot
disable these global registrations or protect the parent composer's selection.
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
# Adding shortcuts