feat(queue): queue a message with everything the composer had attached
Queueing captured only the text and files. Context chips (inline comments, terminal selections, browser annotations, PR comments and checks, quotes, linked issue/PR/Linear references, pending synthetic parts) stayed in the composer and only left with the next manual send, so a queued message the server delivered went out without them and the chips rode an unrelated message later. A queued message now carries what the composer would have sent: the text with its agent mention stripped and file mentions resolved into attachments, the attached context as structured parts, and the skill instruction derived from the text. The server delivers those parts in the composer's order, the VS Code auto-send does the same, and editing a queued message puts the chips and linked references back. A failed queue restores the composer completely. Snapshots and broadcasts omit the captured context like attachment payloads; a take returns it. Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
This commit is contained in:
@@ -3,11 +3,11 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { prepareLocalAttachments, useInputStore } from '@/sync/input-store';
|
||||
import { prepareLocalAttachments, useInputStore, type SyntheticContextPart } from '@/sync/input-store';
|
||||
import {
|
||||
ACCEPTED_ATTACHMENT_EXTENSIONS,
|
||||
ATTACHMENT_ACCEPT,
|
||||
@@ -49,6 +49,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
|
||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||
import { PendingChangesBar } from './PendingChangesBar';
|
||||
import { useChatColumnSession } from './chatColumnSession';
|
||||
@@ -138,7 +139,7 @@ import {
|
||||
toProjectRelativeMentionPath,
|
||||
toServerFileUrl,
|
||||
} from './composer/attachments/filePaths';
|
||||
import { buildOutgoingMessage } from './composer/submit/buildOutgoingMessage';
|
||||
import { buildComposerContext, buildOutgoingMessage } from './composer/submit/buildOutgoingMessage';
|
||||
import {
|
||||
buildCommandVariables,
|
||||
canRunCommand,
|
||||
@@ -215,6 +216,70 @@ const buildSkillMentionInstruction = (skillNames: string[]): string | null => {
|
||||
return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`;
|
||||
};
|
||||
|
||||
type LinkedReferenceAuthor = { login: string; avatarUrl?: string };
|
||||
type LinkedGitHubIssue = { number: number; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor };
|
||||
type LinkedGitHubPr = {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: LinkedReferenceAuthor;
|
||||
};
|
||||
type LinkedLinearIssueRef = { identifier: string; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor };
|
||||
type LinkedReferences = { issue: LinkedGitHubIssue | null; pr: LinkedGitHubPr | null; linear: LinkedLinearIssueRef | null };
|
||||
|
||||
/**
|
||||
* Record what a session was pointed at, so the work-status panel can show it
|
||||
* as a context source long after the message scrolled away. A snapshot only —
|
||||
* never re-fetched, never authoritative. Failures are swallowed: the message
|
||||
* went out (or was queued), and a missing bookkeeping entry must not surface
|
||||
* as an error.
|
||||
*/
|
||||
const recordLinkedReferences = (
|
||||
sessionId: string,
|
||||
directory: Parameters<typeof sessionActions.setLinkedIssue>[1],
|
||||
refs: LinkedReferences,
|
||||
) => {
|
||||
const attachedThread = refs.issue
|
||||
? { attachment: refs.issue, kind: 'issue' as const }
|
||||
: refs.pr
|
||||
? { attachment: refs.pr, kind: 'pull' as const }
|
||||
: null;
|
||||
if (attachedThread) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
directory,
|
||||
buildLinkedIssue({
|
||||
url: attachedThread.attachment.url,
|
||||
number: attachedThread.attachment.number,
|
||||
title: attachedThread.attachment.title,
|
||||
kind: attachedThread.kind,
|
||||
author: attachedThread.attachment.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (refs.linear) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
sessionId,
|
||||
directory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: refs.linear.identifier,
|
||||
title: refs.linear.title,
|
||||
url: refs.linear.url,
|
||||
author: refs.linear.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const hasUserMessages = (sessionId: string, directory?: string) => {
|
||||
return getSyncMessages(sessionId, directory).some((message) => message.role === 'user');
|
||||
};
|
||||
@@ -725,37 +790,61 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
attachments,
|
||||
};
|
||||
}, [resolveInlineFileMention]);
|
||||
|
||||
type DocumentMentionPreparation =
|
||||
| { status: 'ready'; prepared: Map<string, AttachedFile[]> }
|
||||
| { status: 'failed'; filename: string }
|
||||
| { status: 'runtime-changed' };
|
||||
|
||||
/**
|
||||
* Document mentions (`@notes.pdf`) are sent as converted attachments. Their
|
||||
* sources are fetched up front — by the send, or by queueing, since the
|
||||
* server that later delivers a queued message cannot read them.
|
||||
*/
|
||||
const prepareDocumentMentions = React.useCallback(async (
|
||||
texts: readonly string[],
|
||||
reservedFilenames: Set<string>,
|
||||
runtimeKey: string,
|
||||
): Promise<DocumentMentionPreparation> => {
|
||||
const prepared = new Map<string, AttachedFile[]>();
|
||||
for (const rawText of texts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| prepared.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const converted = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!converted || converted.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
prepared.set(mention.serverPath, converted);
|
||||
for (const attachment of converted) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' };
|
||||
return { status: 'failed', filename: mention.filename };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'ready', prepared };
|
||||
}, [resolveInlineFileMention]);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedPr, setLinkedPr] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<LinkedGitHubIssue | null>(null);
|
||||
const [linkedPr, setLinkedPr] = React.useState<LinkedGitHubPr | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<LinkedLinearIssueRef | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
@@ -919,45 +1008,56 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise<void>>(async () => {});
|
||||
|
||||
// Add message to queue instead of sending
|
||||
const handleQueueMessage = React.useCallback(() => {
|
||||
const handleQueueMessage = React.useCallback(async () => {
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const queueRuntimeKey = getRuntimeKey();
|
||||
const queueTarget = messageQueueTarget;
|
||||
const queueSessionId = currentSessionId;
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
// Resolved now, not at delivery: the server that sends a queued
|
||||
// message has no agent list, and the mention must match what was
|
||||
// visible when the user typed it.
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents);
|
||||
const composerAttachments = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
modelID: currentModelId,
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: currentVariant ?? undefined,
|
||||
} : undefined,
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to queue message:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.queueFailed'));
|
||||
// The composer was cleared on queueing; give the text back unless
|
||||
// the user has already typed something new.
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput) {
|
||||
setMessage(messageToQueue);
|
||||
} else {
|
||||
useInputStore.getState().setPendingInputText(messageToQueue, 'append');
|
||||
}
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...attachmentsToQueue]);
|
||||
}
|
||||
});
|
||||
// A queued message is resolved now, not at delivery: the server that
|
||||
// sends it has no agent list, no confirmed mentions, and no way to read
|
||||
// a document the user named — and the mention must match what was
|
||||
// visible when the user typed it.
|
||||
const documentMentions = await prepareDocumentMentions(
|
||||
[messageToQueue],
|
||||
new Set(composerAttachments.map((attachment) => attachment.filename)),
|
||||
queueRuntimeKey,
|
||||
);
|
||||
if (documentMentions.status === 'runtime-changed') return;
|
||||
if (documentMentions.status === 'failed') {
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename }));
|
||||
return;
|
||||
}
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents);
|
||||
const { attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText, documentMentions.prepared);
|
||||
const availableSkillNames = new Set(
|
||||
selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name),
|
||||
);
|
||||
const skillInstruction = buildSkillMentionInstruction(collectInlineSkillMentions(sanitizedText, availableSkillNames));
|
||||
|
||||
// Everything attached to the composer leaves with the message: the
|
||||
// chips are part of what was queued, and come back if it is edited.
|
||||
const syntheticParts = consumePendingSyntheticParts() ?? [];
|
||||
const draftTarget = inlineDraftTarget;
|
||||
const drafts = draftTarget ? consumeDrafts(draftTarget) : [];
|
||||
const linked: LinkedReferences = { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue };
|
||||
const context = buildComposerContext({
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts.map((part) => part.text),
|
||||
linkedIssue: linked.issue
|
||||
? { number: linked.issue.number, title: linked.issue.title, url: linked.issue.url, contextText: linked.issue.contextText }
|
||||
: null,
|
||||
linkedPr: linked.pr
|
||||
? { number: linked.pr.number, title: linked.pr.title, url: linked.pr.url, instructions: linked.pr.instructionsText, context: linked.pr.contextText }
|
||||
: null,
|
||||
linkedLinearIssue: linked.linear
|
||||
? { identifier: linked.linear.identifier, title: linked.linear.title, url: linked.linear.url, contextText: linked.linear.contextText }
|
||||
: null,
|
||||
}, skillInstruction);
|
||||
const attachmentsToQueue = [...composerAttachments, ...mentionAttachments];
|
||||
|
||||
// Sending while the agent works must still take the reader to the
|
||||
// live edge — a queued message produces no user row yet, so the
|
||||
@@ -965,26 +1065,116 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// parked mid-history.
|
||||
scrollToLatest?.();
|
||||
|
||||
// Clear input and attachments
|
||||
// Note: confirmedMentionsRef is NOT cleared here because queued messages
|
||||
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
|
||||
// The ref is cleared in handleSubmit after all queued messages are sent.
|
||||
// Clear the composer. The mentions it had confirmed were resolved
|
||||
// above, so nothing later needs them.
|
||||
setMessage('');
|
||||
if (attachmentsToQueue.length > 0) {
|
||||
confirmedMentionsRef.current.clear();
|
||||
if (composerAttachments.length > 0) {
|
||||
clearAttachedFiles();
|
||||
}
|
||||
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest, agents, t]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
try {
|
||||
await addToQueue(queueTarget, {
|
||||
content: messageToQueue,
|
||||
text: sanitizedText,
|
||||
agentMention: mention?.name,
|
||||
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
|
||||
context: context.length > 0 ? context : undefined,
|
||||
sendConfig: currentProviderId && currentModelId ? {
|
||||
providerID: currentProviderId,
|
||||
modelID: currentModelId,
|
||||
agent: currentAgentName ?? undefined,
|
||||
variant: currentVariant ?? undefined,
|
||||
} : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[queue] failed to queue message:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.queueFailed'));
|
||||
// The composer was cleared on queueing; give everything back. The
|
||||
// text is appended if the user has already typed something new.
|
||||
const currentInput = composerRef.current?.getValue() ?? messageRef.current;
|
||||
if (!currentInput) {
|
||||
setMessage(messageToQueue);
|
||||
} else {
|
||||
useInputStore.getState().setPendingInputText(messageToQueue, 'append');
|
||||
}
|
||||
if (composerAttachments.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...composerAttachments]);
|
||||
}
|
||||
if (draftTarget && drafts.length > 0) {
|
||||
useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, drafts);
|
||||
}
|
||||
if (syntheticParts.length > 0) {
|
||||
useInputStore.getState().setPendingSyntheticParts(syntheticParts);
|
||||
}
|
||||
setLinkedIssue(linked.issue);
|
||||
setLinkedPr(linked.pr);
|
||||
setLinkedLinearIssue(linked.linear);
|
||||
return;
|
||||
}
|
||||
recordLinkedReferences(queueSessionId, queueTarget.directory, linked);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]);
|
||||
|
||||
/** Put the context a queued message was captured with back on the composer chips. */
|
||||
const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => {
|
||||
const synthetic: SyntheticContextPart[] = [];
|
||||
for (const part of context) {
|
||||
if (part.kind === 'synthetic') {
|
||||
synthetic.push({ text: part.text, synthetic: true });
|
||||
continue;
|
||||
}
|
||||
// An instruction is derived from the text, and derived again on send.
|
||||
if (part.kind !== 'context') continue;
|
||||
const payload = part.metadata[CONTEXT_METADATA_KEY];
|
||||
if (payload.kind === 'github-issue') {
|
||||
setLinkedIssue({ number: payload.number, title: payload.title, url: payload.url, contextText: part.text });
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
} else if (payload.kind === 'github-pr') {
|
||||
// The captured context is final: whatever diff it includes is
|
||||
// already in the text, and the branches were not captured.
|
||||
setLinkedPr({
|
||||
number: payload.number,
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
head: '',
|
||||
base: '',
|
||||
includeDiff: false,
|
||||
instructionsText: part.instructions ?? '',
|
||||
contextText: part.text,
|
||||
});
|
||||
setLinkedIssue(null);
|
||||
setLinkedLinearIssue(null);
|
||||
} else if (payload.kind === 'linear-issue') {
|
||||
setLinkedLinearIssue({ identifier: payload.identifier, title: payload.title, url: payload.url, contextText: part.text });
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
} else {
|
||||
const draft = draftFromContextPayload(payload);
|
||||
if (draft && inlineDraftTarget) {
|
||||
useInlineCommentDraftStore.getState().addDraft(inlineDraftTarget, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (synthetic.length > 0) {
|
||||
const pending = useInputStore.getState().pendingSyntheticParts ?? [];
|
||||
useInputStore.getState().setPendingSyntheticParts([...pending, ...synthetic]);
|
||||
}
|
||||
}, [inlineDraftTarget]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((queued: QueuedMessage) => {
|
||||
setMessage(queued.content);
|
||||
restoreQueuedContext(queued.context ?? []);
|
||||
setTimeout(() => {
|
||||
composerRef.current?.focus();
|
||||
}, 0);
|
||||
}, []);
|
||||
}, [restoreQueuedContext]);
|
||||
|
||||
const handleQueuedMessageSend = React.useCallback((messageId: string) => {
|
||||
// Force-sending from the queue during a busy session counts as steer
|
||||
@@ -1079,7 +1269,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// rejected turn winds down and the session returns to idle. This avoids
|
||||
// aborting the turn (which would surface an "aborted" notice).
|
||||
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1101,7 +1291,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
sessionActions.dismissOpenQuestionsForSession(currentSessionId),
|
||||
]);
|
||||
if (deniedPermissions || dismissedQuestions) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1125,43 +1315,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
|
||||
|
||||
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
|
||||
// Queued messages resolved their mentions when they were queued; only
|
||||
// the composer's own text can still name a document.
|
||||
const reservedFilenames = new Set([
|
||||
...attachedFiles.map((attachment) => attachment.filename),
|
||||
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||
]);
|
||||
const mentionTexts = [
|
||||
...queuedProjection.map((queued) => queued.content),
|
||||
...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []),
|
||||
];
|
||||
for (const rawText of mentionTexts) {
|
||||
for (const token of scanMentions(rawText)) {
|
||||
const mention = resolveInlineFileMention(token.name);
|
||||
if (
|
||||
!mention
|
||||
|| !isDocumentAttachmentFilename(mention.filename)
|
||||
|| preparedDocumentMentions.has(mention.serverPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } });
|
||||
if (!response.ok) throw new Error(`Failed to read ${mention.filename}`);
|
||||
const sourceBlob = await response.blob();
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
const source = new File([sourceBlob], mention.filename);
|
||||
const prepared = await prepareLocalAttachments(source, reservedFilenames);
|
||||
if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`);
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
preparedDocumentMentions.set(mention.serverPath, prepared);
|
||||
for (const attachment of prepared) reservedFilenames.add(attachment.filename);
|
||||
} catch {
|
||||
if (getRuntimeKey() !== submitRuntimeKey) return;
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const documentMentions = await prepareDocumentMentions(
|
||||
!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [],
|
||||
reservedFilenames,
|
||||
submitRuntimeKey,
|
||||
);
|
||||
if (documentMentions.status === 'runtime-changed') return;
|
||||
if (documentMentions.status === 'failed') {
|
||||
toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename }));
|
||||
return;
|
||||
}
|
||||
const preparedDocumentMentions = documentMentions.prepared;
|
||||
|
||||
// The composer delivers these itself, so they leave the queue now — the
|
||||
// queue's own delivery (server-side, or the auto-send hook in VS Code)
|
||||
@@ -1180,9 +1350,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
// auto-sends: queueing leaves them in the store on purpose.
|
||||
// assembly so a failed send can restore exactly what it took. What is
|
||||
// here belongs to this send: queueing took its own context with it.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
@@ -1405,16 +1574,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
};
|
||||
|
||||
void sendPromise.then(() => {
|
||||
// Record what this session was pointed at, so the work-status panel
|
||||
// can show it as a context source long after the message scrolled
|
||||
// away. A snapshot only — never re-fetched, never authoritative.
|
||||
// Failures are swallowed: the message went out, and a missing
|
||||
// bookkeeping entry must not surface as a send error.
|
||||
const attachedThread = linkedIssue
|
||||
? { attachment: linkedIssue, kind: 'issue' as const }
|
||||
: linkedPr
|
||||
? { attachment: linkedPr, kind: 'pull' as const }
|
||||
: null;
|
||||
// On a draft there is no session yet in this closure: the send path
|
||||
// creates one and makes it current before resolving, so the id is
|
||||
// read from the store. The fallback is used only when the closure
|
||||
@@ -1427,47 +1586,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
: sessionState.currentSessionDirectory
|
||||
?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null)
|
||||
?? currentDirectory;
|
||||
|
||||
if (attachedThread && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedIssue({
|
||||
url: attachedThread.attachment.url,
|
||||
number: attachedThread.attachment.number,
|
||||
title: attachedThread.attachment.title,
|
||||
kind: attachedThread.kind,
|
||||
author: attachedThread.attachment.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (linkedLinearIssue && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: linkedLinearIssue.identifier,
|
||||
title: linkedLinearIssue.title,
|
||||
url: linkedLinearIssue.url,
|
||||
author: linkedLinearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
if (linkTargetSessionId) {
|
||||
recordLinkedReferences(linkTargetSessionId, linkTargetDirectory, { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue });
|
||||
}
|
||||
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
setLinkedIssue(null);
|
||||
}
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
if (linkedLinearIssue) {
|
||||
setLinkedLinearIssue(null);
|
||||
}
|
||||
// Linked references were sent; clear them from the composer.
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -1555,7 +1681,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
|
||||
if (followUpBehavior === 'queue' && canQueue) {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
} else if (followUpBehavior === 'steer' && canQueue) {
|
||||
void handleSubmitRef.current({ delivery: 'steer' });
|
||||
} else {
|
||||
@@ -1762,7 +1888,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (isCtrlEnter || !canQueue) {
|
||||
handleSubmit();
|
||||
} else {
|
||||
handleQueueMessage();
|
||||
void handleQueueMessage();
|
||||
}
|
||||
} else {
|
||||
// steer: Enter steers into the running turn, Ctrl+Enter sends now.
|
||||
@@ -2839,7 +2965,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
title={linkedPr.title}
|
||||
url={linkedPr.url}
|
||||
author={linkedPr.author}
|
||||
branches={{ head: linkedPr.head, base: linkedPr.base }}
|
||||
branches={linkedPr.head && linkedPr.base ? { head: linkedPr.head, base: linkedPr.base } : undefined}
|
||||
openInBrowserLabel={t('chat.chatInput.linked.pr.openInBrowserAria')}
|
||||
removeLabel={t('chat.chatInput.linked.pr.removeAria')}
|
||||
onReopenPicker={() => setPrPickerOpen(true)}
|
||||
|
||||
@@ -103,7 +103,8 @@ const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessa
|
||||
QueuedMessageChip.displayName = 'QueuedMessageChip';
|
||||
|
||||
interface QueuedMessageChipsProps {
|
||||
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
|
||||
/** The message was taken from the queue in full; the composer restores it. */
|
||||
onEditMessage: (message: QueuedMessage) => void;
|
||||
onSendMessage: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
|
||||
const currentAttachments = useInputStore.getState().attachedFiles;
|
||||
useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]);
|
||||
}
|
||||
onEditMessage(popped.content, popped.attachments);
|
||||
onEditMessage(popped);
|
||||
}).catch((error) => {
|
||||
console.warn('[queue] failed to take queued message for editing:', error);
|
||||
toast.error(t('chat.queuedMessage.toast.takeFailed'));
|
||||
|
||||
@@ -159,8 +159,14 @@ and the send path reading the same grammar.
|
||||
linked issue/PR) becomes its own synthetic text part carrying structured
|
||||
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
|
||||
metadata back to render context blocks. PR instructions precede the PR diff.
|
||||
Queueing a message leaves context drafts in their store on purpose — the send
|
||||
that later delivers the queue consumes them.
|
||||
The same module's `buildComposerContext` captures that context when a message
|
||||
is **queued** instead of sent: the chips leave the composer with the message
|
||||
(as `QueuedContextPart`s on the queue item), the server or the VS Code
|
||||
auto-send delivers them through `queuedContextToParts`, and editing the
|
||||
queued message puts them back. A queued message is placed as captured — its
|
||||
mention, file mentions, and skill instruction were resolved when it was
|
||||
queued, never at delivery — and its context follows it before the next
|
||||
queued message.
|
||||
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||
session) identity. Writes are debounced while typing but forced at every edge
|
||||
where the page may stop running, because a pending timer is not a saved
|
||||
|
||||
+87
-10
@@ -3,8 +3,12 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
|
||||
import type { QueuedContextPart } from '@/stores/messageQueueStore';
|
||||
import {
|
||||
buildComposerContext,
|
||||
buildOutgoingMessage,
|
||||
queuedContextToParts,
|
||||
type ComposerContextInput,
|
||||
type OutgoingMessageDeps,
|
||||
type OutgoingMessageInput,
|
||||
} from '../buildOutgoingMessage';
|
||||
@@ -78,7 +82,7 @@ describe('the composer text alone', () => {
|
||||
describe('queued messages', () => {
|
||||
test('the oldest becomes primary and the rest follow in order', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'first' }, { content: 'second' }, { content: 'third' }],
|
||||
queued: [{ text: 'first' }, { text: 'second' }, { text: 'third' }],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('first');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['second', 'third']);
|
||||
@@ -86,18 +90,43 @@ describe('queued messages', () => {
|
||||
|
||||
test('the composer text lands after everything queued', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
queued: [{ text: 'queued' }],
|
||||
composerText: 'typed now',
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['typed now']);
|
||||
});
|
||||
|
||||
test('the context a message was queued with follows it, before the next message', () => {
|
||||
const metadata = { [CONTEXT_METADATA_KEY]: { kind: 'github-issue' as const, number: 3, title: 'Bug', url: 'https://x/issues/3' } };
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [
|
||||
{ text: 'first', context: [{ kind: 'context', text: 'issue body', metadata }, { kind: 'instruction', text: 'use: deploy' }] },
|
||||
{ text: 'second' },
|
||||
],
|
||||
composerText: 'typed now',
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('first');
|
||||
expect(result.additionalParts.map((p) => p.text)).toEqual(['issue body', 'use: deploy', 'second', 'typed now']);
|
||||
expect(result.additionalParts[0]).toEqual({ text: 'issue body', synthetic: true, metadata });
|
||||
expect(result.additionalParts[1]).toEqual({ text: 'use: deploy', synthetic: true });
|
||||
});
|
||||
|
||||
test('a queued message is placed as captured, never re-resolved', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ text: '@agent:plan see @file:doc and /deploy' }],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('@agent:plan see @file:doc and /deploy');
|
||||
expect(result.primaryAttachments).toEqual([]);
|
||||
expect(result.agentMentionName).toBe(undefined);
|
||||
expect(result.additionalParts).toEqual([]);
|
||||
});
|
||||
|
||||
test('each queued message keeps its own attachments', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [
|
||||
{ content: 'a', attachments: [attachment('one')] },
|
||||
{ content: 'b', attachments: [attachment('two')] },
|
||||
{ text: 'a', attachments: [attachment('one')] },
|
||||
{ text: 'b', attachments: [attachment('two')] },
|
||||
],
|
||||
}), deps());
|
||||
expect(result.primaryAttachments.map((a) => a.id)).toEqual(['one']);
|
||||
@@ -113,14 +142,14 @@ describe('agent mentions', () => {
|
||||
|
||||
test('the first mention wins across queued messages', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }, { content: '@agent:build b' }],
|
||||
queued: [{ text: 'a', agentMention: 'plan' }, { text: 'b', agentMention: 'build' }],
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
});
|
||||
|
||||
test('a queued mention outranks one typed later', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '@agent:plan a' }],
|
||||
queued: [{ text: 'a', agentMention: 'plan' }],
|
||||
composerText: '@agent:build b',
|
||||
}), deps());
|
||||
expect(result.agentMentionName).toBe('plan');
|
||||
@@ -230,10 +259,9 @@ describe('synthetic context', () => {
|
||||
expect(result.additionalParts.at(-1)).toEqual({ text: 'use: deploy', synthetic: true });
|
||||
});
|
||||
|
||||
test('skills are collected across every authored body, without duplicates', () => {
|
||||
test('skills named in the composer are collected without duplicates', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: '/deploy a' }],
|
||||
composerText: '/deploy and /audit',
|
||||
composerText: '/deploy and /audit and /deploy',
|
||||
}), deps());
|
||||
expect(result.additionalParts.at(-1)?.text).toBe('use: deploy,audit');
|
||||
});
|
||||
@@ -262,7 +290,7 @@ describe('synthetic context', () => {
|
||||
describe('full assembly order', () => {
|
||||
test('queued, then typed, then synthetic, then references, then skills', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
queued: [{ text: 'q1' }, { text: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
@@ -283,3 +311,52 @@ describe('full assembly order', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capturing composer context for the queue', () => {
|
||||
const contextInput = (overrides: Partial<ComposerContextInput> = {}): ComposerContextInput => ({
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('captures everything attached, in send order, with the skill instruction last', () => {
|
||||
const context = buildComposerContext(contextInput({
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
|
||||
}), 'use: deploy');
|
||||
|
||||
expect(context.map((part) => part.kind)).toEqual(['context', 'synthetic', 'context', 'context', 'context', 'instruction']);
|
||||
expect(context[0]?.kind).toBe('context');
|
||||
expect(context[0]?.text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
|
||||
expect(context[0]?.kind === 'context' ? context[0].metadata : null)
|
||||
.toEqual({ [CONTEXT_METADATA_KEY]: contextPayloadFromDraft(commentDraft()) });
|
||||
expect(context[3]).toEqual({
|
||||
kind: 'context',
|
||||
text: 'pr-diff',
|
||||
instructions: 'pr-how',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } },
|
||||
});
|
||||
expect(context.at(-1)).toEqual({ kind: 'instruction', text: 'use: deploy' });
|
||||
});
|
||||
|
||||
test('nothing attached captures nothing', () => {
|
||||
expect(buildComposerContext(contextInput(), null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('delivering captured context reproduces the composer parts exactly', () => {
|
||||
const input = contextInput({
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
});
|
||||
const captured: QueuedContextPart[] = buildComposerContext(input, 'use: deploy');
|
||||
const direct = buildOutgoingMessage({ ...input, queued: [], composerText: 'use /deploy', composerAttachments: [] }, deps());
|
||||
expect(queuedContextToParts(captured)).toEqual(direct.additionalParts);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import type { QueuedContextPart } from '@/stores/messageQueueStore';
|
||||
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
|
||||
export interface OutgoingPart {
|
||||
@@ -36,17 +37,20 @@ export interface OutgoingMessage {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued message is already resolved: its agent mention was stripped, its
|
||||
* file mentions became attachments, and the context the composer had attached
|
||||
* travels with it. Assembly only places it.
|
||||
*/
|
||||
export interface QueuedInput {
|
||||
content: string;
|
||||
text: string;
|
||||
agentMention?: string;
|
||||
attachments?: AttachedFile[];
|
||||
context?: readonly QueuedContextPart[];
|
||||
}
|
||||
|
||||
export interface OutgoingMessageInput {
|
||||
/** Messages queued while a turn was running, oldest first. */
|
||||
queued: readonly QueuedInput[];
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** What the composer has attached besides text and files. */
|
||||
export interface ComposerContextInput {
|
||||
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||
inlineComments: readonly InlineCommentDraft[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
@@ -56,6 +60,14 @@ export interface OutgoingMessageInput {
|
||||
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
|
||||
}
|
||||
|
||||
export interface OutgoingMessageInput extends ComposerContextInput {
|
||||
/** Messages queued while a turn was running, oldest first. */
|
||||
queued: readonly QueuedInput[];
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of assembly that depend on stores or async config, injected so the
|
||||
* assembly itself stays pure.
|
||||
@@ -104,20 +116,19 @@ export function buildOutgoingMessage(
|
||||
};
|
||||
|
||||
// Queued messages come first, in the order they were queued: the oldest
|
||||
// becomes the primary message so the turn reads chronologically.
|
||||
// becomes the primary message so the turn reads chronologically. Each one
|
||||
// is followed by the context it was queued with.
|
||||
input.queued.forEach((queued, index) => {
|
||||
const resolved = resolve(queued.content);
|
||||
const attachments = [
|
||||
...deps.sanitizeAttachments(queued.attachments),
|
||||
...resolved.attachments,
|
||||
];
|
||||
noteAgent(queued.agentMention);
|
||||
const attachments = deps.sanitizeAttachments(queued.attachments);
|
||||
|
||||
if (index === 0) {
|
||||
primaryText = resolved.text;
|
||||
primaryText = queued.text;
|
||||
primaryAttachments = attachments;
|
||||
return;
|
||||
} else {
|
||||
additionalParts.push({ text: queued.text, attachments });
|
||||
}
|
||||
additionalParts.push({ text: resolved.text, attachments });
|
||||
additionalParts.push(...queuedContextToParts(queued.context ?? []));
|
||||
});
|
||||
|
||||
// The composer's own text follows, becoming primary only when nothing was
|
||||
@@ -137,40 +148,10 @@ export function buildOutgoingMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never plain user text. Each
|
||||
// attached context item becomes its own synthetic part carrying structured
|
||||
// metadata, so the timeline can render it as a context block after the
|
||||
// server echoes the message back.
|
||||
for (const draft of input.inlineComments) {
|
||||
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
const { number, title, url, instructions, context } = input.linkedPr;
|
||||
additionalParts.push({ text: instructions, synthetic: true });
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
}
|
||||
// Everything the composer had attached follows its text.
|
||||
additionalParts.push(...queuedContextToParts(
|
||||
buildComposerContext(input, deps.buildSkillInstruction(skillNames)),
|
||||
));
|
||||
|
||||
return {
|
||||
primaryText,
|
||||
@@ -180,3 +161,68 @@ export function buildOutgoingMessage(
|
||||
isEmpty: !primaryText && primaryAttachments.length === 0 && additionalParts.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the composer has attached besides text and files, in send
|
||||
* order. Each attached context item becomes its own synthetic part carrying
|
||||
* structured metadata, so the timeline can render it as a context block after
|
||||
* the server echoes the message back. Used both when sending and when queueing:
|
||||
* a queued message takes this context with it, so whoever delivers it later
|
||||
* sends exactly what the composer would have.
|
||||
*/
|
||||
export function buildComposerContext(
|
||||
input: ComposerContextInput,
|
||||
skillInstruction: string | null,
|
||||
): QueuedContextPart[] {
|
||||
const context: QueuedContextPart[] = [];
|
||||
const attach = (part: { text: string; metadata: ContextPartMetadata }, instructions?: string) => {
|
||||
const entry: QueuedContextPart = { kind: 'context', text: part.text, metadata: part.metadata };
|
||||
if (instructions) entry.instructions = instructions;
|
||||
context.push(entry);
|
||||
};
|
||||
|
||||
for (const draft of input.inlineComments) {
|
||||
attach(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
for (const text of input.syntheticTexts) {
|
||||
context.push({ kind: 'synthetic', text });
|
||||
}
|
||||
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
attach(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
const { number, title, url, instructions, context: prContext } = input.linkedPr;
|
||||
attach(createContextPart({ kind: 'github-pr', number, title, url }, prContext), instructions);
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
attach(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (skillInstruction) {
|
||||
context.push({ kind: 'instruction', text: skillInstruction });
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The synthetic parts a captured context is delivered as, in order. */
|
||||
export function queuedContextToParts(context: readonly QueuedContextPart[]): OutgoingPart[] {
|
||||
const parts: OutgoingPart[] = [];
|
||||
for (const part of context) {
|
||||
if (part.kind !== 'context') {
|
||||
parts.push({ text: part.text, synthetic: true });
|
||||
continue;
|
||||
}
|
||||
if (part.instructions) parts.push({ text: part.instructions, synthetic: true });
|
||||
parts.push({ text: part.text, synthetic: true, metadata: part.metadata });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user