feat(chat): structured context attachments with metadata round-trip
Every user-attached context item (diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues/PRs, and new chat-quote comments from the selection menu) is now sent as its own synthetic text part carrying an openchamberContext metadata payload. The model-facing text keeps the previous wording; the timeline reads the metadata back and renders each item as a context card instead of raw prompt text. Legacy messages still render via the old text sniffing. The selection menu gains a Comment option with an inline multiline input, the quoted fragment stays highlighted while commenting, and on mobile the input overlays the composer pill by rendering inside the composer form. Add to chat is renamed Add to input; the menu is restyled and the mobile Copy tile removed. Terminal drafts move their terminal id out of the language field (persisted-draft migration v3), and the dead preview-console source is deleted.
This commit is contained in:
@@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
@@ -757,21 +756,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
let terminal = 0;
|
||||
let prComment = 0;
|
||||
let prCheck = 0;
|
||||
let chatQuote = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else if (draft.source === 'terminal') terminal += 1;
|
||||
else if (draft.source === 'pr-comment') prComment += 1;
|
||||
else if (draft.source === 'pr-check') prCheck += 1;
|
||||
else if (draft.source === 'chat-quote') chatQuote += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
|
||||
return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`;
|
||||
},
|
||||
[inlineDraftKey]
|
||||
)
|
||||
@@ -779,11 +778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
@@ -797,7 +796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
|
||||
if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
@@ -930,12 +929,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
||||
|
||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
if (drafts.length > 0) {
|
||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||
}
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
@@ -961,7 +957,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1140,9 +1136,11 @@ 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.
|
||||
// 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.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
? consumeDrafts(consumedDraftTarget)
|
||||
: [];
|
||||
@@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
linkedIssueContext: linkedIssue?.contextText ?? null,
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
linkedPr: linkedPr
|
||||
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
@@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
},
|
||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
||||
appendComments: (text, comments) =>
|
||||
appendInlineComments(text, comments as InlineCommentDraft[]),
|
||||
buildSkillInstruction: buildSkillMentionInstruction,
|
||||
});
|
||||
|
||||
@@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
reviewCount={reviewCount}
|
||||
prCommentCount={prCommentCount}
|
||||
prCheckCount={prCheckCount}
|
||||
previewConsoleCount={previewConsoleCount}
|
||||
previewAnnotationCount={previewAnnotationCount}
|
||||
chatQuoteCount={chatQuoteCount}
|
||||
draftTarget={inlineDraftTarget}
|
||||
onRemoveDraft={removeInlineCommentDraft}
|
||||
onRemoveReviewDrafts={removeReviewDrafts}
|
||||
|
||||
@@ -124,10 +124,14 @@ and the send path reading the same grammar.
|
||||
drawn caret through a class it only writes while applying an update, so the
|
||||
selection has to be the update that follows the focus.
|
||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||
oldest queued message becomes primary; **inline comments attach to the last
|
||||
body the user authored** rather than becoming their own part; PR instructions
|
||||
precede the PR diff.
|
||||
context drafts and linked references into OpenCode's one-primary-plus-parts
|
||||
shape. The oldest queued message becomes primary. **Every attached context
|
||||
item (inline comments, terminal selections, browser annotations, PR context,
|
||||
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.
|
||||
- `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
|
||||
|
||||
+53
-30
@@ -1,6 +1,8 @@
|
||||
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 {
|
||||
buildOutgoingMessage,
|
||||
type OutgoingMessageDeps,
|
||||
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
|
||||
},
|
||||
sanitizeAttachments: (files) => [...(files ?? [])],
|
||||
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
|
||||
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
|
||||
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
|
||||
...overrides,
|
||||
});
|
||||
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
composerAttachments: [],
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssueContext: null,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline comments', () => {
|
||||
test('attach to the composer text when nothing was queued', () => {
|
||||
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 's1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
side: 'modified',
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('context drafts', () => {
|
||||
test('each becomes a synthetic part carrying structured metadata', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'body',
|
||||
inlineComments: [{}, {}],
|
||||
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('body\n[2 comments]');
|
||||
expect(result.primaryText).toBe('body');
|
||||
expect(result.additionalParts).toHaveLength(2);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft()));
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
|
||||
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
|
||||
expect(result.additionalParts[0].text).toContain('fix this');
|
||||
});
|
||||
|
||||
test('attach to the last authored part when messages were queued', () => {
|
||||
test('context parts precede other synthetic context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
composerText: 'typed',
|
||||
inlineComments: [{}],
|
||||
composerText: 'body',
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
|
||||
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
|
||||
.toEqual(['comment', 'conflict note']);
|
||||
});
|
||||
|
||||
test('fall back to primary when the queue produced no additional parts', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'only queued' }],
|
||||
inlineComments: [{}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('only queued\n[1 comments]');
|
||||
});
|
||||
|
||||
test('no comments changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
|
||||
.toBe('body');
|
||||
test('no drafts changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
|
||||
test('a linked PR sends its instructions before its diff', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'review this',
|
||||
linkedPr: { instructions: 'how to read it', context: 'the diff' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['how to read it', 'the diff']);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
|
||||
});
|
||||
|
||||
test('a linked issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('issue body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['conflict note', 'issue body']);
|
||||
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
|
||||
});
|
||||
|
||||
test('context alone is still worth sending', () => {
|
||||
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
|
||||
const result = buildOutgoingMessage(input({
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssueContext: 'issue',
|
||||
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
|
||||
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' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
*/
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
|
||||
export interface OutgoingPart {
|
||||
text: string;
|
||||
attachments?: AttachedFile[];
|
||||
/** Synthetic parts are context for the model, not shown as user content. */
|
||||
synthetic?: boolean;
|
||||
/** Structured context (see contextParts.ts), persisted with the part. */
|
||||
metadata?: ContextPartMetadata;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** Inline review comments, appended to the user's last authored text. */
|
||||
inlineComments: readonly unknown[];
|
||||
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||
inlineComments: readonly InlineCommentDraft[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssueContext: string | null;
|
||||
linkedPr: { instructions: string; context: string } | null;
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
|
||||
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
|
||||
/** Skills named inline with `/name`. */
|
||||
collectSkillNames: (text: string) => string[];
|
||||
/** Append inline review comments to a message body. */
|
||||
appendComments: (text: string, comments: readonly unknown[]) => string;
|
||||
/** Instruction telling the model which skills the user named. */
|
||||
buildSkillInstruction: (names: string[]) => string | null;
|
||||
}
|
||||
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Inline comments attach to the last thing the user authored, so they read
|
||||
// as a continuation of it rather than as a separate turn.
|
||||
if (input.inlineComments.length > 0) {
|
||||
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
|
||||
? additionalParts[additionalParts.length - 1]
|
||||
: null;
|
||||
if (lastAuthored) {
|
||||
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
|
||||
} else {
|
||||
primaryText = deps.appendComments(primaryText, input.inlineComments);
|
||||
}
|
||||
// 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)));
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never user-visible content.
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssueContext) {
|
||||
additionalParts.push({ text: input.linkedIssueContext, 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.
|
||||
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
|
||||
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
|
||||
const { number, title, url, instructions, context } = input.linkedPr;
|
||||
additionalParts.push({ text: instructions, synthetic: true });
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
|
||||
@@ -20,12 +20,12 @@ export interface ComposerContextChipsProps {
|
||||
reviewCount: number;
|
||||
prCommentCount: number;
|
||||
prCheckCount: number;
|
||||
previewConsoleCount: number;
|
||||
previewAnnotationCount: number;
|
||||
chatQuoteCount: number;
|
||||
draftTarget: InlineCommentDraftTarget | null;
|
||||
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||
onRemoveReviewDrafts: () => void;
|
||||
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
|
||||
onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void;
|
||||
colors: Theme['colors'];
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
||||
reviewCount,
|
||||
prCommentCount,
|
||||
prCheckCount,
|
||||
previewConsoleCount,
|
||||
previewAnnotationCount,
|
||||
chatQuoteCount,
|
||||
draftTarget,
|
||||
onRemoveDraft,
|
||||
onRemoveReviewDrafts,
|
||||
@@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewConsoleCount > 0 ? (
|
||||
{chatQuoteCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.devServerLogs')}
|
||||
count={previewConsoleCount}
|
||||
removeLabel={t('chat.chatInput.devServerLogsRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('preview-console')}
|
||||
label={t('chat.chatInput.chatQuoteContext')}
|
||||
count={chatQuoteCount}
|
||||
removeLabel={t('chat.chatInput.chatQuoteContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('chat-quote')}
|
||||
colors={colors}
|
||||
icon={<Icon name="chat-1" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
data-mobile-composer-pill="true"
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
<ComposerAttachmentControls
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
||||
@@ -33,6 +33,8 @@ interface SelectionPayload {
|
||||
plainText: string;
|
||||
markdownText: string;
|
||||
rect: DOMRect;
|
||||
messageId: string | null;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const normalizeDistilledInsight = (insight: string): string => (
|
||||
@@ -46,6 +48,103 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
||||
const [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
|
||||
const [commentMode, setCommentMode] = React.useState(false);
|
||||
const commentModeRef = React.useRef(false);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// While the comment input owns focus the native selection is gone, so the
|
||||
// quoted fragment is repainted with our own overlay rectangles. Raw
|
||||
// Range.getClientRects() mixes block-container boxes with text boxes and
|
||||
// the translucent overlaps paint double-dark bands, so the rects are taken
|
||||
// from the text nodes only and merged into one strip per visual line.
|
||||
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
|
||||
const updateCommentRects = React.useCallback(() => {
|
||||
const range = pendingSelectionRef.current?.range;
|
||||
if (!range) {
|
||||
setCommentRects(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const textRects: DOMRect[] = [];
|
||||
const pushNodeRects = (node: Text) => {
|
||||
const nodeRange = document.createRange();
|
||||
nodeRange.selectNodeContents(node);
|
||||
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
|
||||
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
|
||||
// Text rects cover only the glyph box; the native selection paints the
|
||||
// full line box, so each rect is stretched to its element's line-height.
|
||||
const lineHeight = node.parentElement
|
||||
? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight)
|
||||
: Number.NaN;
|
||||
for (const rect of nodeRange.getClientRects()) {
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (Number.isFinite(lineHeight) && lineHeight > rect.height) {
|
||||
const expand = (lineHeight - rect.height) / 2;
|
||||
textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight));
|
||||
} else {
|
||||
textRects.push(rect);
|
||||
}
|
||||
}
|
||||
};
|
||||
const root = range.commonAncestorContainer;
|
||||
if (root instanceof Text) {
|
||||
pushNodeRects(root);
|
||||
} else {
|
||||
// SAFETY: the walker is created with SHOW_TEXT, so every node it
|
||||
// yields is a Text node.
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (range.intersectsNode(node)) pushNodeRects(node as Text);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge rects that sit on the same visual line into one strip, the way
|
||||
// the native selection paints a line box.
|
||||
const lines: Array<{ left: number; right: number; top: number; bottom: number }> = [];
|
||||
for (const rect of textRects) {
|
||||
const line = lines.find((candidate) => (
|
||||
Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6
|
||||
));
|
||||
if (line) {
|
||||
line.left = Math.min(line.left, rect.left);
|
||||
line.right = Math.max(line.right, rect.right);
|
||||
line.top = Math.min(line.top, rect.top);
|
||||
line.bottom = Math.max(line.bottom, rect.bottom);
|
||||
} else {
|
||||
lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom });
|
||||
}
|
||||
}
|
||||
setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top)));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commentMode) return;
|
||||
let frame: number | null = null;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
updateCommentRects();
|
||||
});
|
||||
};
|
||||
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
return () => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [commentMode, updateCommentRects]);
|
||||
|
||||
// Grow the comment box with its content, up to five lines.
|
||||
const resizeCommentInput = React.useCallback(() => {
|
||||
const element = commentInputRef.current;
|
||||
if (!element) return;
|
||||
element.style.height = 'auto';
|
||||
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||
@@ -57,6 +156,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
@@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const sessions = useSessions();
|
||||
|
||||
// Mobile: the comment bar is rendered inside the composer form (its
|
||||
// positioning context), so it inherits the runtime's own keyboard handling
|
||||
// — browser viewport resizing and Capacitor choreography alike. This effect
|
||||
// only centers it on the composer pill in the form's local coordinates; no
|
||||
// viewport math, which Safari's keyboard handling reliably breaks for
|
||||
// fixed elements.
|
||||
React.useEffect(() => {
|
||||
if (!commentMode || !isMobile) return;
|
||||
const update = () => {
|
||||
const element = menuRef.current;
|
||||
const host = element?.offsetParent;
|
||||
if (!element || !host) return;
|
||||
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
|
||||
?? document.querySelector('[data-chat-input="true"]');
|
||||
const pillRect = pill?.getBoundingClientRect();
|
||||
if (!pillRect || pillRect.height <= 0) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
|
||||
element.style.left = `${pillRect.left - hostRect.left}px`;
|
||||
element.style.width = `${pillRect.width}px`;
|
||||
element.style.bottom = 'auto';
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
// The composer relayouts with its own transitions and timeouts that emit
|
||||
// no event; a light poll keeps the overlay glued to the pill.
|
||||
const poll = window.setInterval(update, 200);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [commentMode, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMenuVisibleRef.current = position.show;
|
||||
}, [position.show]);
|
||||
@@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
return;
|
||||
@@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
setPosition((prev) => ({ ...prev, show: false }));
|
||||
setSelectedText('');
|
||||
setSelectedTextMarkdown('');
|
||||
setSelectedMessageId(null);
|
||||
setCommentMode(false);
|
||||
commentModeRef.current = false;
|
||||
setCommentText('');
|
||||
isMenuVisibleRef.current = false;
|
||||
}, []);
|
||||
|
||||
@@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
// Position menu above the selection
|
||||
@@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
setSelectedText(plainText);
|
||||
setSelectedTextMarkdown(markdownText);
|
||||
setSelectedMessageId(messageId);
|
||||
setPosition({
|
||||
x: menuX,
|
||||
y: menuY,
|
||||
@@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
const handleSelectionChange = React.useCallback(() => {
|
||||
// While the comment input is open, clicking or typing in it collapses the
|
||||
// text selection; the captured quote must survive that.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Store the selection but don't show menu yet if dragging
|
||||
const anchorElement = range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
pendingSelectionRef.current = {
|
||||
plainText: text,
|
||||
markdownText: rangeToMarkdown(range, text),
|
||||
rect,
|
||||
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
|
||||
range: range.cloneRange(),
|
||||
};
|
||||
|
||||
// Only show menu if we're not currently dragging
|
||||
@@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (!container) return;
|
||||
|
||||
// Track when dragging starts
|
||||
const handleMouseDown = () => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// SAFETY: a MouseEvent target inside the document is always a Node;
|
||||
// `contains` only needs that.
|
||||
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
isDraggingRef.current = true;
|
||||
hideMenu();
|
||||
};
|
||||
@@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
// Small delay to ensure selection is finalized
|
||||
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
||||
mouseUpTimeoutRef.current = null;
|
||||
// The click that opened the comment input cleared the selection on
|
||||
// purpose; the input must survive this deferred check.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim()) {
|
||||
showMenu();
|
||||
@@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
!window.getSelection()?.toString().trim()
|
||||
(commentModeRef.current || !window.getSelection()?.toString().trim())
|
||||
) {
|
||||
hideMenu();
|
||||
}
|
||||
@@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
setCommentMode(true);
|
||||
commentModeRef.current = true;
|
||||
updateCommentRects();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
commentInputRef.current?.focus();
|
||||
});
|
||||
}, [selectedTextMarkdown, updateCommentRects]);
|
||||
|
||||
const handleAttachComment = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
|
||||
hideMenu();
|
||||
return;
|
||||
}
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'chat-quote',
|
||||
fileLabel: selectedMessageId ?? '',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
code: selectedTextMarkdown,
|
||||
language: '',
|
||||
text: commentText.trim(),
|
||||
});
|
||||
hideMenu();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
@@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const result = await copyTextToClipboard(selectedText);
|
||||
if (!result.ok) {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, hideMenu]);
|
||||
|
||||
const currentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
@@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
if (!position.show) return null;
|
||||
|
||||
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[5]">
|
||||
{commentRects.map((rect, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="oc-chat-comment-rect absolute"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
const commentInput = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'py-1 pr-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={commentInputRef}
|
||||
rows={1}
|
||||
value={commentText}
|
||||
onChange={(event) => {
|
||||
setCommentText(event.target.value);
|
||||
resizeCommentInput();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
|
||||
// keyboards use Enter for line breaks; attaching is the button's job.
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
|
||||
event.preventDefault();
|
||||
handleAttachComment();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
hideMenu();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||
className={cn(
|
||||
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]',
|
||||
// The width cap sizes the floating desktop pill; on mobile the pill
|
||||
// spans the bottom bar and the cap would strand slack space to the
|
||||
// right of the attach button.
|
||||
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
|
||||
)}
|
||||
style={{ minHeight: 0, height: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAttachComment}
|
||||
className={cn(
|
||||
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
|
||||
isMobile ? 'h-9 w-9' : 'h-8 w-8'
|
||||
)}
|
||||
aria-label={t('chat.textSelection.comment.attach')}
|
||||
title={t('chat.textSelection.comment.attach')}
|
||||
>
|
||||
<Icon name="attachment-2" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
||||
if (isMobile) {
|
||||
if (commentMode) {
|
||||
// Overlay the comment input onto the composer pill: rendering into the
|
||||
// composer form (position: relative) inherits the runtime's keyboard
|
||||
// handling in both browser and Capacitor; the centering effect above
|
||||
// glues it to the pill in the form's local coordinates.
|
||||
const composerHost = document.querySelector('form.oc-mobile-composer');
|
||||
const bar = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'z-50',
|
||||
composerHost
|
||||
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
|
||||
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
|
||||
)}
|
||||
>
|
||||
{commentInput}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{commentHighlightOverlay}
|
||||
{createPortal(bar, composerHost ?? document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
||||
'rounded-2xl border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] p-2 shadow-lg',
|
||||
'bg-[var(--surface-elevated)] p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'safe-area-bottom',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
@@ -408,6 +683,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
@@ -421,7 +712,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -440,22 +731,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.actions.copy')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
@@ -491,73 +766,90 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 whitespace-nowrap',
|
||||
'rounded-lg border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none',
|
||||
'px-1.5 py-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
'flex items-center whitespace-nowrap',
|
||||
'rounded-full border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'p-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.addToInput')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.newSession')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
@@ -96,6 +97,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
||||
if (!synthetic) return true;
|
||||
if (part.type !== 'text') return false;
|
||||
if (readContextPart(part)) return true;
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = rawPart.synthetic === true;
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
? 'application/vnd.github.issue-link'
|
||||
: 'application/vnd.github.pull-request-link',
|
||||
filename: contextPayload.kind === 'github-issue'
|
||||
? `Issue #${contextPayload.number}: ${contextPayload.title}`
|
||||
: `PR #${contextPayload.number}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
if (contextPayload) {
|
||||
// Other context kinds render through UserContextPart.
|
||||
return part;
|
||||
}
|
||||
// Legacy messages: sniff the pre-metadata text format.
|
||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||
if (attachmentPart) {
|
||||
return attachmentPart;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -54,6 +55,13 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
}
|
||||
}
|
||||
|
||||
// User-attached context (inline comments, terminal selections, and
|
||||
// such) is synthetic transport-wise but is user content: it renders
|
||||
// as a context block and must survive alongside regular text.
|
||||
if (isSynthetic && readContextPart(part)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only filter out synthetic parts if there are non-synthetic parts present
|
||||
// Otherwise, show synthetic parts so the message is displayed
|
||||
if (isSynthetic && hasNonSynthetic) {
|
||||
|
||||
@@ -121,6 +121,14 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
## Quick map of files in this folder
|
||||
|
||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||
- User-attached context (inline code comments, terminal selections, browser
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ContextPartPayload } from '@/lib/messages/contextParts';
|
||||
|
||||
/**
|
||||
* A context item attached to a user message: an inline code comment, a
|
||||
* terminal selection, a browser annotation, or GitHub PR context.
|
||||
*
|
||||
* Each item renders as one card so the user's comment reads as part of the
|
||||
* annotation, not as more message text: a header naming the source (with an
|
||||
* expand affordance when captured code/output exists), and the comment text
|
||||
* below it inside the same card. A header with nothing to reveal renders
|
||||
* without the expand affordance.
|
||||
*/
|
||||
|
||||
const ContextCard: React.FC<{
|
||||
icon: IconName;
|
||||
summary: string;
|
||||
/** Full untruncated context, shown on hover. */
|
||||
title?: string;
|
||||
body: string;
|
||||
text: string;
|
||||
}> = ({ icon, summary, title, body, text }) => {
|
||||
const hasBody = body.trim().length > 0;
|
||||
const hasText = text.trim().length > 0;
|
||||
|
||||
const header = hasBody ? (
|
||||
<details className="min-w-0">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] [&::-webkit-details-marker]:hidden" title={title}>
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform [details[open]_&]:rotate-90" />
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-[var(--interactive-border)] bg-[var(--surface-background)] px-2.5 py-2 font-mono text-xs text-[var(--surface-foreground)]">{body}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)]" title={title}>
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
|
||||
{header}
|
||||
{hasText ? (
|
||||
<div className="whitespace-pre-wrap break-words border-t border-[var(--interactive-border)] px-2.5 py-2 font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
const fullTitle = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file: payload.fileLabel, start: payload.startLine, end: payload.endLine });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} />;
|
||||
}
|
||||
case 'terminal':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="terminal"
|
||||
summary={t('chat.message.terminalContext', {
|
||||
terminal: payload.terminalLabel,
|
||||
start: payload.startLine,
|
||||
end: payload.endLine,
|
||||
})}
|
||||
body={payload.output}
|
||||
text=""
|
||||
/>
|
||||
);
|
||||
case 'browser-annotation':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="global"
|
||||
summary={t('chat.message.context.browserAnnotation', { page: payload.pageUrl })}
|
||||
title={payload.pageUrl}
|
||||
body={payload.prompt}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-comment':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="git-pull-request"
|
||||
summary={t('chat.message.context.prComment', { label: payload.label })}
|
||||
body={payload.body}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-check':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="close-circle"
|
||||
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
||||
body={payload.output}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'chat-quote':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="chat-1"
|
||||
summary={t('chat.message.context.chatQuote')}
|
||||
body={payload.quote}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(UserContextPart);
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
import UserContextPart from './UserContextPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -30,6 +32,10 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
// Structured context (inline comments, terminal selections, annotations,
|
||||
// PR context) renders as a dedicated block instead of raw prompt text.
|
||||
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
||||
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
@@ -224,6 +230,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (contextPayload) {
|
||||
return <UserContextPart payload={contextPayload} />;
|
||||
}
|
||||
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -642,7 +642,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
startLine: selection.startLine,
|
||||
endLine: selection.endLine,
|
||||
code: selection.text,
|
||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
||||
language: '',
|
||||
terminalId: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
|
||||
@@ -85,6 +85,18 @@ button,
|
||||
background: color-mix(in srgb, var(--primary-base) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Quoted chat fragment kept visibly highlighted while its comment input is
|
||||
open (the native selection collapses once the input takes focus). The
|
||||
rectangles come from the selection's own client rects, so shape and shade
|
||||
both match the native selection that was just showing. */
|
||||
.oc-chat-comment-rect {
|
||||
background-color: var(--interactive-selection);
|
||||
}
|
||||
|
||||
:root.light .oc-chat-comment-rect {
|
||||
background-color: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent);
|
||||
}
|
||||
|
||||
.pierre-diff-wrapper {
|
||||
--diffs-bg-selection-override: color-mix(in srgb, var(--primary-base) 22%, transparent);
|
||||
--diffs-bg-selection-background-override: color-mix(in srgb, var(--primary-base) 14%, transparent);
|
||||
|
||||
@@ -222,7 +222,8 @@ export const buildAnnotationOverlayScript = (
|
||||
'.editor{position:fixed;left:0;top:0;display:none;align-items:center;gap:8px;width:min(420px,calc(100vw - 24px));padding:6px;padding-left:16px;border-radius:22px;border:1px solid ' + THEME.border + ';background:' + THEME.glassSurface + ';-webkit-backdrop-filter:' + THEME.glassFilter + ';backdrop-filter:' + THEME.glassFilter + ';box-shadow:0 8px 28px rgba(0,0,0,.3);pointer-events:auto}',
|
||||
'.editor textarea{flex:1;min-width:0;resize:none;border:none;background:transparent;color:' + THEME.text + ';font-size:13px;line-height:20px;outline:none;padding:6px 0;min-height:32px;max-height:104px;display:block}',
|
||||
'.editor textarea::placeholder{color:' + THEME.mutedText + '}',
|
||||
'.editor button{appearance:none;border:none;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';border-radius:999px;padding:8px 18px;font-size:12px;line-height:18px;font-weight:600;cursor:pointer;white-space:nowrap}',
|
||||
'.editor button{appearance:none;border:none;background:' + THEME.primary + ';color:' + THEME.primaryContrast + ';border-radius:999px;width:32px;height:32px;padding:0;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer}',
|
||||
'.editor button svg{width:16px;height:16px;display:block}',
|
||||
'.editor button[disabled]{opacity:.5;cursor:default}'
|
||||
].join('');
|
||||
shadow.appendChild(style);
|
||||
@@ -278,7 +279,11 @@ export const buildAnnotationOverlayScript = (
|
||||
comment.placeholder = LABELS.commentPlaceholder;
|
||||
var submit = document.createElement('button');
|
||||
submit.type = 'button';
|
||||
submit.textContent = LABELS.submit;
|
||||
// Icon-only attach button (Remix attachment-2), matching the chat comment
|
||||
// input; the localized label stays available to assistive tech.
|
||||
submit.setAttribute('aria-label', LABELS.submit);
|
||||
submit.title = LABELS.submit;
|
||||
submit.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.8287 7.75737L9.1718 13.4142C8.78127 13.8047 8.78127 14.4379 9.1718 14.8284C9.56232 15.219 10.1955 15.219 10.586 14.8284L16.2429 9.17158C17.4144 8.00001 17.4144 6.10052 16.2429 4.92894C15.0713 3.75737 13.1718 3.75737 12.0002 4.92894L6.34337 10.5858C4.39075 12.5384 4.39075 15.7042 6.34337 17.6569C8.29599 19.6095 11.4618 19.6095 13.4144 17.6569L19.0713 12L20.4855 13.4142L14.8287 19.0711C12.095 21.8047 7.66283 21.8047 4.92916 19.0711C2.19549 16.3374 2.19549 11.9053 4.92916 9.17158L10.586 3.51473C12.5386 1.56211 15.7045 1.56211 17.6571 3.51473C19.6097 5.46735 19.6097 8.63317 17.6571 10.5858L12.0002 16.2427C10.8287 17.4142 8.92916 17.4142 7.75759 16.2427C6.58601 15.0711 6.58601 13.1716 7.75759 12L13.4144 6.34316L14.8287 7.75737Z" fill="currentColor"/></svg>';
|
||||
editor.append(comment, submit);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2009,9 +2009,12 @@ export const dict = {
|
||||
'chat.textSelection.toast.addToNotesFailed': 'Fehler beim Hinzufügen zu Notizen',
|
||||
'chat.textSelection.toast.addToNotesSuccess': 'Ausgewählter Text zu Notizen hinzugefügt',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt',
|
||||
'chat.textSelection.actions.addToChat': 'Zum Chat hinzufügen',
|
||||
'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen',
|
||||
'chat.textSelection.actions.comment': 'Kommentieren',
|
||||
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
|
||||
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
|
||||
'chat.textSelection.comment.attach': 'Anhängen',
|
||||
'chat.textSelection.actions.newSession': 'Neue Sitzung',
|
||||
'chat.textSelection.actions.copy': 'Kopieren',
|
||||
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen',
|
||||
@@ -2117,8 +2120,6 @@ export const dict = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Fehler beim Umschalten der automatischen Zustimmung zur Berechtigung',
|
||||
'chat.chatInput.reviewComments': 'Kommentare zur Überprüfung:',
|
||||
'chat.chatInput.reviewCommentsRemove': 'Kommentare zur Überprüfung entfernen',
|
||||
'chat.chatInput.devServerLogs': 'Entwicklungsserver-Protokolle:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Entwicklungsserver-Protokolle entfernen',
|
||||
'chat.chatInput.previewAnnotations': 'Vorschau-Anmerkungen:',
|
||||
'chat.chatInput.previewContext': 'Vorschau-Kontext:',
|
||||
'chat.chatInput.previewContextRemove': 'Vorschau-Kontext entfernen',
|
||||
@@ -2903,6 +2904,14 @@ export const dict = {
|
||||
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
|
||||
'terminalView.actions.restart': 'Terminal neu starten',
|
||||
'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Kommentar zu {file}, Zeilen {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Kommentar zu {file}, Zeile {line}',
|
||||
'chat.message.context.chatQuote': 'Zitat aus einer früheren Nachricht',
|
||||
'chat.chatInput.chatQuoteContext': 'Chat-Zitate',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Chat-Zitate entfernen',
|
||||
'chat.message.context.browserAnnotation': 'Browser-Anmerkung ({page})',
|
||||
'chat.message.context.prComment': 'GitHub-PR-Kommentar ({label})',
|
||||
'chat.message.context.prCheck': 'Fehlgeschlagener GitHub-PR-Check ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, Zeilen {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Terminal-Kontext entfernen',
|
||||
'chat.chatInput.prCommentContext': 'PR-Kommentare',
|
||||
|
||||
@@ -5,6 +5,14 @@ export const dict = {
|
||||
'terminalView.actions.attachSelection': 'Attach selected output',
|
||||
'terminalView.actions.restart': 'Restart terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comment on {file}, lines {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Comment on {file}, line {line}',
|
||||
'chat.message.context.chatQuote': 'Quoted from an earlier message',
|
||||
'chat.chatInput.chatQuoteContext': 'Chat quotes',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Remove chat quotes',
|
||||
'chat.message.context.browserAnnotation': 'Browser annotation ({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR comment ({label})',
|
||||
'chat.message.context.prCheck': 'Failed GitHub PR check ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, lines {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Remove terminal context',
|
||||
'chat.chatInput.prCommentContext': 'PR comments',
|
||||
@@ -2182,9 +2190,12 @@ export const dict = {
|
||||
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
|
||||
'chat.textSelection.toast.addToNotesSuccess': 'Added selected text to notes',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
|
||||
'chat.textSelection.actions.addToChat': 'Add to chat',
|
||||
'chat.textSelection.actions.addToInput': 'Add to input',
|
||||
'chat.textSelection.actions.comment': 'Comment',
|
||||
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
|
||||
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
|
||||
'chat.textSelection.comment.attach': 'Attach',
|
||||
'chat.textSelection.actions.newSession': 'New session',
|
||||
'chat.textSelection.actions.copy': 'Copy',
|
||||
'chat.textSelection.actions.addToNotes': 'Add to notes',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection',
|
||||
@@ -2294,8 +2305,6 @@ export const dict = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Failed to toggle permission auto-accept',
|
||||
'chat.chatInput.reviewComments': 'Review comments:',
|
||||
'chat.chatInput.reviewCommentsRemove': 'Remove review comments',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server logs:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Remove Dev Server logs',
|
||||
'chat.chatInput.previewAnnotations': 'Preview annotations:',
|
||||
'chat.chatInput.previewContext': 'Preview context:',
|
||||
'chat.chatInput.previewContextRemove': 'Remove preview context',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comentario en {file}, líneas {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Comentario en {file}, línea {line}',
|
||||
'chat.message.context.chatQuote': 'Cita de un mensaje anterior',
|
||||
'chat.chatInput.chatQuoteContext': 'Citas del chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Quitar citas del chat',
|
||||
'chat.message.context.browserAnnotation': 'Anotación del navegador ({page})',
|
||||
'chat.message.context.prComment': 'Comentario de PR de GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Verificación de PR de GitHub fallida ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, líneas {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Eliminar contexto del terminal',
|
||||
'chat.chatInput.prCommentContext': 'Comentarios del PR',
|
||||
@@ -2160,9 +2168,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas",
|
||||
"chat.textSelection.toast.addToNotesSuccess": "Texto seleccionado añadido a notas",
|
||||
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
|
||||
"chat.textSelection.actions.addToChat": "Añadir al chat",
|
||||
"chat.textSelection.actions.addToInput": "Añadir a la entrada",
|
||||
"chat.textSelection.actions.comment": "Comentar",
|
||||
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
|
||||
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
|
||||
"chat.textSelection.comment.attach": "Adjuntar",
|
||||
"chat.textSelection.actions.newSession": "Nueva sesión",
|
||||
"chat.textSelection.actions.copy": "Copiar",
|
||||
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
|
||||
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección",
|
||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "No se pudo cambiar la aceptación automática de permisos",
|
||||
"chat.chatInput.reviewComments": "Comentarios de revisión:",
|
||||
"chat.chatInput.reviewCommentsRemove": "Quitar comentarios de revisión",
|
||||
"chat.chatInput.devServerLogs": "Logs del Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Quitar logs del Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotaciones de vista previa:",
|
||||
"chat.chatInput.previewContext": "Contexto de vista previa:",
|
||||
"chat.chatInput.previewContextRemove": "Quitar contexto de vista previa",
|
||||
|
||||
@@ -5,6 +5,14 @@ export const dict = {
|
||||
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
|
||||
'terminalView.actions.restart': 'Redémarrer le terminal',
|
||||
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Commentaire sur {file}, lignes {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Commentaire sur {file}, ligne {line}',
|
||||
'chat.message.context.chatQuote': 'Citation d’un message précédent',
|
||||
'chat.chatInput.chatQuoteContext': 'Citations du chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Supprimer les citations du chat',
|
||||
'chat.message.context.browserAnnotation': 'Annotation du navigateur ({page})',
|
||||
'chat.message.context.prComment': 'Commentaire de PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Vérification de PR GitHub échouée ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, lignes {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Supprimer le contexte du terminal',
|
||||
'chat.chatInput.prCommentContext': 'Commentaires de PR',
|
||||
@@ -1909,9 +1917,12 @@ export const dict = {
|
||||
'chat.textSelection.toast.addToNotesFailed': 'Échec de l\'ajout aux notes',
|
||||
'chat.textSelection.toast.addToNotesSuccess': 'Ajout du texte sélectionné aux notes',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes',
|
||||
'chat.textSelection.actions.addToChat': 'Ajouter au chat',
|
||||
'chat.textSelection.actions.addToInput': 'Ajouter à la saisie',
|
||||
'chat.textSelection.actions.comment': 'Commenter',
|
||||
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
|
||||
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
|
||||
'chat.textSelection.comment.attach': 'Joindre',
|
||||
'chat.textSelection.actions.newSession': 'Nouvelle session',
|
||||
'chat.textSelection.actions.copy': 'Copie',
|
||||
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection',
|
||||
@@ -2006,8 +2017,6 @@ export const dict = {
|
||||
'chat.chatInput.toast.openSessionFirst': 'Ouvrir d\'abord une session',
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Échec de l\'activation de l\'acceptation automatique des autorisations',
|
||||
'chat.chatInput.reviewComments': 'Commentaires de révision :',
|
||||
'chat.chatInput.devServerLogs': 'Journaux du serveur de développement :',
|
||||
'chat.chatInput.devServerLogsRemove': 'Supprimer les journaux du serveur de développement',
|
||||
'chat.chatInput.previewAnnotations': 'Aperçu des annotations :',
|
||||
'chat.chatInput.previewContext': 'Contexte d\'aperçu :',
|
||||
'chat.chatInput.previewContextRemove': 'Supprimer le contexte d\'aperçu',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': '選択した出力を添付',
|
||||
'terminalView.actions.restart': 'ターミナルを再起動',
|
||||
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
|
||||
'chat.message.context.codeComment': '{file} の {start}〜{end} 行へのコメント',
|
||||
'chat.message.context.codeCommentLine': '{file} の {line} 行へのコメント',
|
||||
'chat.message.context.chatQuote': '以前のメッセージからの引用',
|
||||
'chat.chatInput.chatQuoteContext': 'チャット引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'チャット引用を削除',
|
||||
'chat.message.context.browserAnnotation': 'ブラウザ注釈({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR コメント({label})',
|
||||
'chat.message.context.prCheck': '失敗した GitHub PR チェック({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}、{start}〜{end}行',
|
||||
'chat.chatInput.terminalContextRemove': 'ターミナルコンテキストを削除',
|
||||
'chat.chatInput.prCommentContext': 'PRコメント',
|
||||
@@ -2178,9 +2186,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.toast.addToNotesFailed': 'メモへの追加に失敗しました',
|
||||
'chat.textSelection.toast.addToNotesSuccess': '選択テキストをメモに追加しました',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
|
||||
'chat.textSelection.actions.addToChat': 'チャットに追加',
|
||||
'chat.textSelection.actions.addToInput': '入力欄に追加',
|
||||
'chat.textSelection.actions.comment': 'コメント',
|
||||
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
|
||||
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
|
||||
'chat.textSelection.comment.attach': '添付',
|
||||
'chat.textSelection.actions.newSession': '新しいセッション',
|
||||
'chat.textSelection.actions.copy': 'コピー',
|
||||
'chat.textSelection.actions.addToNotes': 'メモに追加',
|
||||
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
|
||||
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
|
||||
@@ -2293,8 +2304,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '権限の自動承認の切り替えに失敗しました',
|
||||
'chat.chatInput.reviewComments': 'レビューコメント:',
|
||||
'chat.chatInput.reviewCommentsRemove': 'レビューコメントを削除',
|
||||
'chat.chatInput.devServerLogs': '開発サーバーログ:',
|
||||
'chat.chatInput.devServerLogsRemove': '開発サーバーログを削除',
|
||||
'chat.chatInput.previewAnnotations': 'プレビュー注釈:',
|
||||
'chat.chatInput.previewContext': 'プレビューコンテキスト:',
|
||||
'chat.chatInput.previewContextRemove': 'プレビューコンテキストを削除',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': '선택한 출력 첨부',
|
||||
'terminalView.actions.restart': '터미널 다시 시작',
|
||||
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
|
||||
'chat.message.context.codeComment': '{file} {start}-{end}행에 대한 댓글',
|
||||
'chat.message.context.codeCommentLine': '{file} {line}행에 대한 댓글',
|
||||
'chat.message.context.chatQuote': '이전 메시지에서 인용',
|
||||
'chat.chatInput.chatQuoteContext': '채팅 인용',
|
||||
'chat.chatInput.chatQuoteContextRemove': '채팅 인용 제거',
|
||||
'chat.message.context.browserAnnotation': '브라우저 주석 ({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 댓글 ({label})',
|
||||
'chat.message.context.prCheck': '실패한 GitHub PR 검사 ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, {start}-{end}행',
|
||||
'chat.chatInput.terminalContextRemove': '터미널 컨텍스트 제거',
|
||||
'chat.chatInput.prCommentContext': 'PR 댓글',
|
||||
@@ -2184,9 +2192,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
|
||||
'chat.textSelection.toast.addToNotesSuccess': '선택한 텍스트를 메모에 추가함',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
|
||||
'chat.textSelection.actions.addToChat': '채팅에 추가',
|
||||
'chat.textSelection.actions.addToInput': '입력란에 추가',
|
||||
'chat.textSelection.actions.comment': '댓글',
|
||||
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
|
||||
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
|
||||
'chat.textSelection.comment.attach': '첨부',
|
||||
'chat.textSelection.actions.newSession': '새 세션',
|
||||
'chat.textSelection.actions.copy': '복사',
|
||||
'chat.textSelection.actions.addToNotes': '메모에 추가',
|
||||
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
|
||||
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
|
||||
@@ -2294,8 +2305,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '권한 자동 승인 전환에 실패했습니다',
|
||||
'chat.chatInput.reviewComments': '검토 댓글:',
|
||||
'chat.chatInput.reviewCommentsRemove': '검토 댓글 제거',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 로그:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Dev Server 로그 제거',
|
||||
'chat.chatInput.previewAnnotations': '미리보기 주석:',
|
||||
'chat.chatInput.previewContext': '미리보기 컨텍스트:',
|
||||
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
|
||||
'terminalView.actions.restart': 'Uruchom terminal ponownie',
|
||||
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Komentarz do {file}, wiersze {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Komentarz do {file}, wiersz {line}',
|
||||
'chat.message.context.chatQuote': 'Cytat z wcześniejszej wiadomości',
|
||||
'chat.chatInput.chatQuoteContext': 'Cytaty z czatu',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Usuń cytaty z czatu',
|
||||
'chat.message.context.browserAnnotation': 'Adnotacja przeglądarki ({page})',
|
||||
'chat.message.context.prComment': 'Komentarz PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Nieudane sprawdzenie PR GitHub ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, wiersze {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Usuń kontekst terminala',
|
||||
'chat.chatInput.prCommentContext': 'Komentarze PR',
|
||||
@@ -874,9 +882,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.toast.addToNotesFailed': 'Nie udało się dodać do notatek',
|
||||
'chat.textSelection.toast.addToNotesSuccess': 'Dodano zaznaczony tekst do notatek',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek',
|
||||
'chat.textSelection.actions.addToChat': 'Dodaj do czatu',
|
||||
'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania',
|
||||
'chat.textSelection.actions.comment': 'Skomentuj',
|
||||
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
|
||||
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
|
||||
'chat.textSelection.comment.attach': 'Załącz',
|
||||
'chat.textSelection.actions.newSession': 'Nowa sesja',
|
||||
'chat.textSelection.actions.copy': 'Kopiuj',
|
||||
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem',
|
||||
@@ -1210,8 +1221,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server logs:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Usuń logi serwera deweloperskiego',
|
||||
'chat.chatInput.drop.attachFiles': 'Drop files here to attach',
|
||||
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
||||
'chat.chatInput.fileFallback': 'file',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
|
||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Comentário em {file}, linhas {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Comentário em {file}, linha {line}',
|
||||
'chat.message.context.chatQuote': 'Citação de uma mensagem anterior',
|
||||
'chat.chatInput.chatQuoteContext': 'Citações do chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Remover citações do chat',
|
||||
'chat.message.context.browserAnnotation': 'Anotação do navegador ({page})',
|
||||
'chat.message.context.prComment': 'Comentário de PR do GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Verificação de PR do GitHub com falha ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, linhas {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Remover contexto do terminal',
|
||||
'chat.chatInput.prCommentContext': 'Comentários do PR',
|
||||
@@ -2160,9 +2168,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas",
|
||||
"chat.textSelection.toast.addToNotesSuccess": "Texto selecionado adicionado às notas",
|
||||
"chat.textSelection.toast.addToNotesSummaryFailed": "Não foi possível resumir a seleção; o texto selecionado foi adicionado às notas",
|
||||
"chat.textSelection.actions.addToChat": "Adicionar ao chat",
|
||||
"chat.textSelection.actions.addToInput": "Adicionar à entrada",
|
||||
"chat.textSelection.actions.comment": "Comentar",
|
||||
"chat.textSelection.title.commentOnSelection": "Comentar a seleção",
|
||||
"chat.textSelection.comment.placeholder": "Adicione um comentário opcional...",
|
||||
"chat.textSelection.comment.attach": "Anexar",
|
||||
"chat.textSelection.actions.newSession": "Nova sessão",
|
||||
"chat.textSelection.actions.copy": "Copiar",
|
||||
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
|
||||
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção",
|
||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Não foi possível alterar a aceitação automática de permissões",
|
||||
"chat.chatInput.reviewComments": "Comentários de revisão:",
|
||||
"chat.chatInput.reviewCommentsRemove": "Remover comentários de revisão",
|
||||
"chat.chatInput.devServerLogs": "Logs do Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Remover logs do Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Anotações da visualização:",
|
||||
"chat.chatInput.previewContext": "Contexto da visualização:",
|
||||
"chat.chatInput.previewContextRemove": "Remover contexto da visualização",
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
|
||||
'terminalView.actions.restart': 'Перезапустити термінал',
|
||||
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
|
||||
'chat.message.context.codeComment': 'Коментар до {file}, рядки {start}-{end}',
|
||||
'chat.message.context.codeCommentLine': 'Коментар до {file}, рядок {line}',
|
||||
'chat.message.context.chatQuote': 'Цитата з попереднього повідомлення',
|
||||
'chat.chatInput.chatQuoteContext': 'Цитати з чату',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Прибрати цитати з чату',
|
||||
'chat.message.context.browserAnnotation': 'Анотація браузера ({page})',
|
||||
'chat.message.context.prComment': 'Коментар PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Невдала перевірка PR GitHub ({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal}, рядки {start}-{end}',
|
||||
'chat.chatInput.terminalContextRemove': 'Видалити контекст термінала',
|
||||
'chat.chatInput.prCommentContext': 'Коментарі PR',
|
||||
@@ -2160,9 +2168,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
|
||||
"chat.textSelection.toast.addToNotesSuccess": "Вибраний текст додано до нотаток",
|
||||
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
|
||||
"chat.textSelection.actions.addToChat": "Додати в чат",
|
||||
"chat.textSelection.actions.addToInput": "Додати в поле вводу",
|
||||
"chat.textSelection.actions.comment": "Коментувати",
|
||||
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
|
||||
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
|
||||
"chat.textSelection.comment.attach": "Прикріпити",
|
||||
"chat.textSelection.actions.newSession": "Нова сесія",
|
||||
"chat.textSelection.actions.copy": "Копіювати",
|
||||
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
|
||||
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
|
||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Не вдалося ввімкнути автоматичне прийняття дозволів",
|
||||
"chat.chatInput.reviewComments": "Коментарі рев’ю:",
|
||||
"chat.chatInput.reviewCommentsRemove": "Прибрати коментарі рев’ю",
|
||||
"chat.chatInput.devServerLogs": "Логи Dev Server:",
|
||||
"chat.chatInput.devServerLogsRemove": "Прибрати логи Dev Server",
|
||||
"chat.chatInput.previewAnnotations": "Анотації перегляду:",
|
||||
"chat.chatInput.previewContext": "Контекст перегляду:",
|
||||
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': '附加所选输出',
|
||||
'terminalView.actions.restart': '重启终端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.message.context.codeComment': '对 {file} 第 {start}-{end} 行的评论',
|
||||
'chat.message.context.codeCommentLine': '对 {file} 第 {line} 行的评论',
|
||||
'chat.message.context.chatQuote': '引用自先前的消息',
|
||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
||||
'chat.message.context.browserAnnotation': '浏览器标注({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 评论({label})',
|
||||
'chat.message.context.prCheck': '失败的 GitHub PR 检查({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.chatInput.terminalContextRemove': '移除终端上下文',
|
||||
'chat.chatInput.prCommentContext': 'PR 评论',
|
||||
@@ -2148,9 +2156,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
|
||||
'chat.textSelection.toast.addToNotesSuccess': '已将选中文本添加到笔记',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
|
||||
'chat.textSelection.actions.addToChat': '添加到聊天',
|
||||
'chat.textSelection.actions.addToInput': '添加到输入框',
|
||||
'chat.textSelection.actions.comment': '评论',
|
||||
'chat.textSelection.title.commentOnSelection': '评论所选内容',
|
||||
'chat.textSelection.comment.placeholder': '添加可选评论...',
|
||||
'chat.textSelection.comment.attach': '附加',
|
||||
'chat.textSelection.actions.newSession': '新建会话',
|
||||
'chat.textSelection.actions.copy': '复制',
|
||||
'chat.textSelection.actions.addToNotes': '添加到笔记',
|
||||
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
|
||||
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
|
||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切换权限自动接受失败',
|
||||
'chat.chatInput.reviewComments': '审查评论:',
|
||||
'chat.chatInput.reviewCommentsRemove': '移除审查评论',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 日志:',
|
||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日志',
|
||||
'chat.chatInput.previewAnnotations': '预览注释:',
|
||||
'chat.chatInput.previewContext': '预览上下文:',
|
||||
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
||||
|
||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
||||
'terminalView.actions.attachSelection': '附加所選輸出',
|
||||
'terminalView.actions.restart': '重新啟動終端',
|
||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.message.context.codeComment': '對 {file} 第 {start}-{end} 行的評論',
|
||||
'chat.message.context.codeCommentLine': '對 {file} 第 {line} 行的評論',
|
||||
'chat.message.context.chatQuote': '引用自先前的訊息',
|
||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
||||
'chat.message.context.browserAnnotation': '瀏覽器標註({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 留言({label})',
|
||||
'chat.message.context.prCheck': '失敗的 GitHub PR 檢查({label})',
|
||||
'chat.chatInput.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||
'chat.chatInput.terminalContextRemove': '移除終端上下文',
|
||||
'chat.chatInput.prCommentContext': 'PR 留言',
|
||||
@@ -2152,9 +2160,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.toast.addToNotesFailed': '加入筆記失敗',
|
||||
'chat.textSelection.toast.addToNotesSuccess': '已將選取文字加入筆記',
|
||||
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
|
||||
'chat.textSelection.actions.addToChat': '加入聊天',
|
||||
'chat.textSelection.actions.addToInput': '加入輸入框',
|
||||
'chat.textSelection.actions.comment': '留言',
|
||||
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
|
||||
'chat.textSelection.comment.placeholder': '新增選填留言...',
|
||||
'chat.textSelection.comment.attach': '附加',
|
||||
'chat.textSelection.actions.newSession': '新增會話',
|
||||
'chat.textSelection.actions.copy': '複製',
|
||||
'chat.textSelection.actions.addToNotes': '加入筆記',
|
||||
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
|
||||
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
|
||||
@@ -2264,8 +2275,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切換權限自動接受失敗',
|
||||
'chat.chatInput.reviewComments': '審查留言:',
|
||||
'chat.chatInput.reviewCommentsRemove': '移除審查留言',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server 日誌:',
|
||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日誌',
|
||||
'chat.chatInput.previewAnnotations': '預覽註釋:',
|
||||
'chat.chatInput.previewContext': '預覽上下文:',
|
||||
'chat.chatInput.previewContextRemove': '移除預覽上下文',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import {
|
||||
CONTEXT_METADATA_KEY,
|
||||
contextPayloadFromDraft,
|
||||
createContextPart,
|
||||
formatContextText,
|
||||
readContextPart,
|
||||
type ContextPartPayload,
|
||||
} from './contextParts';
|
||||
|
||||
const draft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 's1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
side: 'modified',
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('model-facing text', () => {
|
||||
test('diff comments keep the pre-metadata wording, including the side', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft())))
|
||||
.toBe('Comment on `src/app.ts` lines 3-5 (modified):\n```ts\nconst x = 1;\n```\n\nfix this');
|
||||
});
|
||||
|
||||
test('file and plan comments omit the side', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'file', side: undefined }))))
|
||||
.toBe('Comment on `src/app.ts` lines 3-5:\n```ts\nconst x = 1;\n```\n\nfix this');
|
||||
});
|
||||
|
||||
test('terminal selections keep the terminal_context envelope', () => {
|
||||
const payload = contextPayloadFromDraft(draft({
|
||||
source: 'terminal',
|
||||
fileLabel: 'Terminal 1',
|
||||
terminalId: 'term-1',
|
||||
language: '',
|
||||
startLine: 12,
|
||||
endLine: 13,
|
||||
code: 'npm run build\nok',
|
||||
text: '',
|
||||
}));
|
||||
expect(formatContextText(payload)).toBe([
|
||||
'<terminal_context>',
|
||||
'- Terminal 1 lines 12-13:',
|
||||
' 12 | npm run build',
|
||||
' 13 | ok',
|
||||
'</terminal_context>',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('annotations send the prompt, with user text appended when present', () => {
|
||||
const base = draft({ source: 'preview-annotation', fileLabel: 'https://app.dev', code: 'prompt body', text: '' });
|
||||
expect(formatContextText(contextPayloadFromDraft(base))).toBe('prompt body');
|
||||
expect(formatContextText(contextPayloadFromDraft({ ...base, text: 'also this' })))
|
||||
.toBe('prompt body\n\nalso this');
|
||||
});
|
||||
|
||||
test('chat quotes send the fragment as a blockquote with the comment below', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1', code: 'first line\nsecond line', text: 'why so?' }))))
|
||||
.toBe('Comment on this fragment of an earlier message in this conversation:\n> first line\n> second line\n\nwhy so?');
|
||||
});
|
||||
|
||||
test('PR comments and checks keep their attachment wording', () => {
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'pr-comment', fileLabel: 'octo/repo#7', code: 'the comment', text: '' }))))
|
||||
.toBe('Attached GitHub PR comment (octo/repo#7):\n\nthe comment');
|
||||
expect(formatContextText(contextPayloadFromDraft(draft({ source: 'pr-check', fileLabel: 'CI / build', code: 'boom', text: 'why?' }))))
|
||||
.toBe('Attached failed GitHub PR check (CI / build):\n```\nboom\n```\n\nwhy?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip through part metadata', () => {
|
||||
const asPart = (payload: ContextPartPayload, text?: string) => ({
|
||||
type: 'text',
|
||||
...createContextPart(payload, text),
|
||||
});
|
||||
|
||||
test('every draft-based kind survives create → read unchanged', () => {
|
||||
const payloads = [
|
||||
contextPayloadFromDraft(draft()),
|
||||
contextPayloadFromDraft(draft({ source: 'plan', side: undefined })),
|
||||
contextPayloadFromDraft(draft({ source: 'terminal', terminalId: 'term-1', language: '' })),
|
||||
contextPayloadFromDraft(draft({ source: 'preview-annotation' })),
|
||||
contextPayloadFromDraft(draft({ source: 'pr-comment' })),
|
||||
contextPayloadFromDraft(draft({ source: 'pr-check' })),
|
||||
contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1' })),
|
||||
];
|
||||
for (const payload of payloads) {
|
||||
expect(readContextPart(asPart(payload))).toEqual(payload);
|
||||
}
|
||||
});
|
||||
|
||||
test('github references carry picker-built text and structured identity', () => {
|
||||
const payload: ContextPartPayload = { kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' };
|
||||
const part = asPart(payload, 'GitHub issue context (JSON)\n{}');
|
||||
expect(part.text).toBe('GitHub issue context (JSON)\n{}');
|
||||
expect(readContextPart(part)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('non-text parts, missing metadata, and malformed payloads read as null', () => {
|
||||
expect(readContextPart({ type: 'file', metadata: {} })).toBeNull();
|
||||
expect(readContextPart({ type: 'text' })).toBeNull();
|
||||
expect(readContextPart({ type: 'text', metadata: { [CONTEXT_METADATA_KEY]: { kind: 'nope' } } })).toBeNull();
|
||||
expect(readContextPart({
|
||||
type: 'text',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'terminal', terminalId: 1, terminalLabel: 'x', startLine: 1, endLine: 1, output: '' } },
|
||||
})).toBeNull();
|
||||
expect(readContextPart({
|
||||
type: 'text',
|
||||
metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-issue', number: 0, title: 't', url: 'u' } },
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Structured context attached to an outgoing message.
|
||||
*
|
||||
* Every user-attached context item — an inline code comment, a terminal
|
||||
* selection, a browser annotation, a GitHub PR comment or failed check, a
|
||||
* linked issue or PR — is sent as its own synthetic text part. The part's
|
||||
* `text` is what the model reads; the part's `metadata[CONTEXT_METADATA_KEY]`
|
||||
* carries the same information structured, so the timeline can render the
|
||||
* context as a dedicated block after the message round-trips through the
|
||||
* OpenCode server (which persists part metadata verbatim).
|
||||
*
|
||||
* This module owns both directions: building the part at send time and
|
||||
* parsing the metadata back at render time. Keeping them together is what
|
||||
* guarantees they cannot drift apart.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { TextPart } from '@opencode-ai/sdk/v2';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendTerminalContexts } from './terminalContext';
|
||||
|
||||
export const CONTEXT_METADATA_KEY = 'openchamberContext';
|
||||
|
||||
export type CodeCommentContext = {
|
||||
kind: 'code-comment';
|
||||
source: 'diff' | 'file' | 'plan';
|
||||
fileLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
side?: 'original' | 'modified';
|
||||
language: string;
|
||||
code: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type TerminalContextPayload = {
|
||||
kind: 'terminal';
|
||||
terminalId: string;
|
||||
terminalLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
output: string;
|
||||
};
|
||||
|
||||
type BrowserAnnotationContext = {
|
||||
kind: 'browser-annotation';
|
||||
pageUrl: string;
|
||||
/** The full annotation prompt shown to the model. */
|
||||
prompt: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PrCommentContext = {
|
||||
kind: 'pr-comment';
|
||||
label: string;
|
||||
body: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PrCheckContext = {
|
||||
kind: 'pr-check';
|
||||
label: string;
|
||||
output: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type GitHubIssueContext = {
|
||||
kind: 'github-issue';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ChatQuoteContext = {
|
||||
kind: 'chat-quote';
|
||||
/** The message the quote came from, when known. */
|
||||
messageId?: string;
|
||||
quote: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type GitHubPrContext = {
|
||||
kind: 'github-pr';
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ContextPartPayload =
|
||||
| CodeCommentContext
|
||||
| TerminalContextPayload
|
||||
| BrowserAnnotationContext
|
||||
| PrCommentContext
|
||||
| PrCheckContext
|
||||
| ChatQuoteContext
|
||||
| GitHubIssueContext
|
||||
| GitHubPrContext;
|
||||
|
||||
export type ContextPartMetadata = { [K in typeof CONTEXT_METADATA_KEY]: ContextPartPayload };
|
||||
|
||||
export type ContextPart = {
|
||||
text: string;
|
||||
synthetic: true;
|
||||
metadata: ContextPartMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* The model-facing text for a context payload. The wording intentionally
|
||||
* matches what OpenChamber sent before parts carried metadata, so model
|
||||
* behavior does not change with the transport format.
|
||||
*/
|
||||
export function formatContextText(payload: ContextPartPayload): string {
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const range = `lines ${payload.startLine}-${payload.endLine}`;
|
||||
const sideNote = payload.source === 'diff' && payload.side ? ` (${payload.side})` : '';
|
||||
return `Comment on \`${payload.fileLabel}\` ${range}${sideNote}:\n\`\`\`${payload.language}\n${payload.code}\n\`\`\`\n\n${payload.text}`;
|
||||
}
|
||||
case 'terminal':
|
||||
return appendTerminalContexts('', [{
|
||||
terminalId: payload.terminalId,
|
||||
terminalLabel: payload.terminalLabel,
|
||||
startLine: payload.startLine,
|
||||
endLine: payload.endLine,
|
||||
text: payload.output,
|
||||
}]);
|
||||
case 'browser-annotation':
|
||||
return payload.text ? `${payload.prompt}\n\n${payload.text}` : payload.prompt;
|
||||
case 'pr-comment':
|
||||
return `Attached GitHub PR comment (${payload.label}):\n\n${payload.body}${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
case 'chat-quote': {
|
||||
const quoted = payload.quote.split('\n').map((line) => `> ${line}`).join('\n');
|
||||
return `Comment on this fragment of an earlier message in this conversation:\n${quoted}${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
}
|
||||
case 'pr-check':
|
||||
return `Attached failed GitHub PR check (${payload.label}):\n\`\`\`\n${payload.output}\n\`\`\`${payload.text ? `\n\n${payload.text}` : ''}`;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Linked issues/PRs carry server-fetched context text built by
|
||||
// their pickers; there is no default text to derive here.
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the synthetic part for one context payload. `text` overrides the
|
||||
* derived text; github-issue/github-pr payloads require it because their
|
||||
* model-facing context is fetched by the picker, not derived from metadata.
|
||||
*/
|
||||
export function createContextPart(payload: ContextPartPayload, text?: string): ContextPart {
|
||||
const resolvedText = text ?? formatContextText(payload);
|
||||
return {
|
||||
text: resolvedText,
|
||||
synthetic: true,
|
||||
metadata: { [CONTEXT_METADATA_KEY]: payload },
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a composer context draft to its structured payload. */
|
||||
export function contextPayloadFromDraft(draft: InlineCommentDraft): ContextPartPayload {
|
||||
switch (draft.source) {
|
||||
case 'terminal':
|
||||
return {
|
||||
kind: 'terminal',
|
||||
terminalId: draft.terminalId ?? '',
|
||||
terminalLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
output: draft.code,
|
||||
};
|
||||
case 'preview-annotation':
|
||||
return {
|
||||
kind: 'browser-annotation',
|
||||
pageUrl: draft.fileLabel,
|
||||
prompt: draft.code,
|
||||
text: draft.text,
|
||||
};
|
||||
case 'pr-comment':
|
||||
return { kind: 'pr-comment', label: draft.fileLabel, body: draft.code, text: draft.text };
|
||||
case 'pr-check':
|
||||
return { kind: 'pr-check', label: draft.fileLabel, output: draft.code, text: draft.text };
|
||||
case 'chat-quote': {
|
||||
const payload: ChatQuoteContext = { kind: 'chat-quote', quote: draft.code, text: draft.text };
|
||||
if (draft.fileLabel) payload.messageId = draft.fileLabel;
|
||||
return payload;
|
||||
}
|
||||
case 'diff':
|
||||
case 'file':
|
||||
case 'plan': {
|
||||
const payload: CodeCommentContext = {
|
||||
kind: 'code-comment',
|
||||
source: draft.source,
|
||||
fileLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
language: draft.language,
|
||||
code: draft.code,
|
||||
text: draft.text,
|
||||
};
|
||||
if (draft.source === 'diff' && draft.side) payload.side = draft.side;
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-back: parsing part metadata at the display boundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const contextPayloadSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('code-comment'),
|
||||
source: z.enum(['diff', 'file', 'plan']),
|
||||
fileLabel: z.string(),
|
||||
startLine: z.number(),
|
||||
endLine: z.number(),
|
||||
side: z.enum(['original', 'modified']).optional(),
|
||||
language: z.string(),
|
||||
code: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('terminal'),
|
||||
terminalId: z.string(),
|
||||
terminalLabel: z.string(),
|
||||
startLine: z.number(),
|
||||
endLine: z.number(),
|
||||
output: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('browser-annotation'),
|
||||
pageUrl: z.string(),
|
||||
prompt: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('pr-comment'),
|
||||
label: z.string(),
|
||||
body: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('pr-check'),
|
||||
label: z.string(),
|
||||
output: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('chat-quote'),
|
||||
messageId: z.string().optional(),
|
||||
quote: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('github-issue'),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('github-pr'),
|
||||
number: z.number().int().positive(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The subset of a message part that context read-back inspects. */
|
||||
export type ContextCarrierPart = { type: string } & Pick<TextPart, 'metadata'>;
|
||||
|
||||
/**
|
||||
* Read the structured context payload from a message part, if it carries one.
|
||||
* The part comes from the server or an optimistic insert, so the payload is
|
||||
* schema-validated before it is trusted.
|
||||
*/
|
||||
export function readContextPart(part: ContextCarrierPart): ContextPartPayload | null {
|
||||
if (part.type !== 'text') return null;
|
||||
const parsed = contextPayloadSchema.safeParse(part.metadata?.[CONTEXT_METADATA_KEY]);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendTerminalContexts } from './terminalContext';
|
||||
|
||||
/**
|
||||
* Format a single inline comment draft into the standard message format
|
||||
* used by diff, plan, and file viewers
|
||||
*/
|
||||
function formatInlineCommentDraft(draft: InlineCommentDraft): string {
|
||||
const { fileLabel, startLine, endLine, side, language, code, text } = draft;
|
||||
|
||||
// Diff format includes side (original/modified)
|
||||
if (draft.source === 'diff' && side) {
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine} (${side}):\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-console') {
|
||||
return `Attached preview context from \`${fileLabel}\`:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'preview-annotation') {
|
||||
return text ? `${code}\n\n${text}` : code;
|
||||
}
|
||||
|
||||
if (draft.source === 'pr-comment') {
|
||||
return `Attached GitHub PR comment (${fileLabel}):\n\n${code}${text ? `\n\n${text}` : ''}`;
|
||||
}
|
||||
|
||||
if (draft.source === 'pr-check') {
|
||||
return `Attached failed GitHub PR check (${fileLabel}):\n\`\`\`\n${code}\n\`\`\`${text ? `\n\n${text}` : ''}`;
|
||||
}
|
||||
|
||||
// Plan and file format (no side)
|
||||
return `Comment on \`${fileLabel}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multiple inline comment drafts into a single string
|
||||
* with each comment separated by a blank line
|
||||
*/
|
||||
function formatInlineCommentDrafts(drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return '';
|
||||
|
||||
if (drafts.every((draft) => draft.source === 'preview-annotation')) {
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
return drafts.map(formatInlineCommentDraft).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Append inline comment drafts to an existing message text
|
||||
* If the text is empty, returns just the formatted comments
|
||||
* Otherwise, appends comments after a blank line separator
|
||||
*/
|
||||
export function appendInlineComments(text: string, drafts: InlineCommentDraft[]): string {
|
||||
if (drafts.length === 0) return text;
|
||||
const terminalDrafts = drafts.filter((draft) => draft.source === 'terminal');
|
||||
const otherDrafts = drafts.filter((draft) => draft.source !== 'terminal');
|
||||
const withComments = otherDrafts.length > 0
|
||||
? (text.trim() ? `${text}\n\n${formatInlineCommentDrafts(otherDrafts)}` : formatInlineCommentDrafts(otherDrafts))
|
||||
: text;
|
||||
if (terminalDrafts.length > 0) {
|
||||
return appendTerminalContexts(withComments, terminalDrafts.map((draft) => ({
|
||||
terminalId: draft.language,
|
||||
terminalLabel: draft.fileLabel,
|
||||
startLine: draft.startLine,
|
||||
endLine: draft.endLine,
|
||||
text: draft.code,
|
||||
})));
|
||||
}
|
||||
return withComments;
|
||||
}
|
||||
@@ -120,4 +120,26 @@ describe("filterSyntheticParts", () => {
|
||||
]
|
||||
expect(filterSyntheticParts(parts)).toEqual(parts)
|
||||
})
|
||||
|
||||
test("keeps synthetic parts carrying user context metadata alongside user text", () => {
|
||||
const userPart = createTextPart("1", "user prompt")
|
||||
const contextPart = {
|
||||
...createTextPart("2", "Comment on `x.ts` lines 1-2:\n```ts\ncode\n```\n\nfix", true),
|
||||
metadata: {
|
||||
openchamberContext: {
|
||||
kind: "code-comment",
|
||||
source: "diff",
|
||||
fileLabel: "x.ts",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
language: "ts",
|
||||
code: "code",
|
||||
text: "fix",
|
||||
},
|
||||
},
|
||||
}
|
||||
const plainSynthetic = createTextPart("3", "instructions", true)
|
||||
expect(filterSyntheticParts([userPart, contextPart, plainSynthetic]))
|
||||
.toEqual([userPart, contextPart])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { readContextPart } from "./contextParts";
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
|
||||
@@ -39,6 +41,13 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User-attached context (inline comments, terminal selections, and
|
||||
// such) is synthetic transport-wise but is user content that renders
|
||||
// as its own context block.
|
||||
if (readContextPart(part)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
|
||||
import type { FilesAPI } from "../api/types";
|
||||
@@ -792,6 +793,7 @@ class OpencodeService {
|
||||
additionalParts?: Array<{
|
||||
text: string;
|
||||
synthetic?: boolean;
|
||||
metadata?: ContextPartMetadata;
|
||||
files?: Array<FileInputLite>;
|
||||
}>;
|
||||
messageId?: string;
|
||||
@@ -842,11 +844,10 @@ class OpencodeService {
|
||||
if (params.additionalParts && params.additionalParts.length > 0) {
|
||||
for (const additional of params.additionalParts) {
|
||||
if (additional.text && additional.text.trim()) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
text: additional.text,
|
||||
...(additional.synthetic ? { synthetic: true } : {}),
|
||||
});
|
||||
const additionalTextPart: TextPartInput = { type: 'text', text: additional.text };
|
||||
if (additional.synthetic) additionalTextPart.synthetic = true;
|
||||
if (additional.metadata) additionalTextPart.metadata = additional.metadata;
|
||||
parts.push(additionalTextPart);
|
||||
}
|
||||
if (additional.files && additional.files.length > 0) {
|
||||
for (const file of additional.files) {
|
||||
@@ -1193,7 +1194,7 @@ class OpencodeService {
|
||||
options?: {
|
||||
id?: string;
|
||||
save?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: ContextPartMetadata;
|
||||
source?: PermissionV2Source;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||
import { migratePersistedDrafts, persistedDraftEnvelopeSchema, useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||
|
||||
const selection = {
|
||||
source: 'terminal' as const,
|
||||
@@ -7,7 +7,8 @@ const selection = {
|
||||
startLine: 4,
|
||||
endLine: 5,
|
||||
code: 'first\nsecond',
|
||||
language: 'term-1',
|
||||
language: '',
|
||||
terminalId: 'term-1',
|
||||
text: '',
|
||||
};
|
||||
const target = { directory: '/repo', sessionKey: 'session-1' };
|
||||
@@ -79,3 +80,64 @@ describe('terminal context drafts', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('persisted draft migration', () => {
|
||||
type PersistedV2Draft = {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
source: string;
|
||||
fileLabel: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
code: string;
|
||||
language: string;
|
||||
text: string;
|
||||
createdAt: number;
|
||||
};
|
||||
const v2Draft = (overrides: Partial<PersistedV2Draft> = {}): PersistedV2Draft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 'session-1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('moves the terminal id out of the language field', () => {
|
||||
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
|
||||
drafts: { key: [v2Draft({ source: 'terminal', language: 'term-9' })] },
|
||||
touchedAt: { key: 1000 },
|
||||
}), 2);
|
||||
expect(migrated.drafts.key[0].terminalId).toBe('term-9');
|
||||
expect(migrated.drafts.key[0].language).toBe('');
|
||||
expect(migrated.touchedAt.key).toBe(1000);
|
||||
});
|
||||
|
||||
test('drops preview-console drafts but keeps the rest of the bucket', () => {
|
||||
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
|
||||
drafts: { key: [v2Draft({ source: 'preview-console' }), v2Draft({ id: 'icd-2' })] },
|
||||
touchedAt: { key: 1000 },
|
||||
}), 2);
|
||||
expect(migrated.drafts.key.map((draft) => draft.id)).toEqual(['icd-2']);
|
||||
});
|
||||
|
||||
test('malformed entries and unknown payload shapes reset safely', () => {
|
||||
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse(null), 2)).toEqual({ drafts: {}, touchedAt: {} });
|
||||
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({ drafts: 'nope' }), 2)).toEqual({ drafts: {}, touchedAt: {} });
|
||||
const migrated = migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({
|
||||
drafts: { key: [{ id: 42 }, v2Draft()] },
|
||||
touchedAt: {},
|
||||
}), 2);
|
||||
expect(migrated.drafts.key).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('pre-v2 payloads reset entirely', () => {
|
||||
expect(migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse({ drafts: { key: [v2Draft()] }, touchedAt: {} }), 1))
|
||||
.toEqual({ drafts: {}, touchedAt: {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
import { z } from 'zod';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check';
|
||||
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check' | 'chat-quote';
|
||||
|
||||
export type InlineCommentDraftTarget = {
|
||||
directory: string;
|
||||
@@ -22,6 +23,8 @@ export interface InlineCommentDraft {
|
||||
code: string;
|
||||
language: string;
|
||||
text: string;
|
||||
/** Owning terminal session; set only for `source: 'terminal'`. */
|
||||
terminalId?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -162,6 +165,63 @@ const boundState = (
|
||||
return { drafts: retainedDrafts, touchedAt: retainedTouchedAt };
|
||||
};
|
||||
|
||||
const EMPTY_PERSISTED_STATE: InlineCommentDraftState = { drafts: {}, touchedAt: {} };
|
||||
|
||||
const persistedDraftSchema = z.object({
|
||||
id: z.string(),
|
||||
sessionKey: z.string(),
|
||||
source: z.enum(['diff', 'plan', 'file', 'preview-annotation', 'terminal', 'pr-comment', 'pr-check', 'chat-quote']),
|
||||
fileLabel: z.string(),
|
||||
startLine: z.number(),
|
||||
endLine: z.number(),
|
||||
side: z.enum(['original', 'modified']).optional(),
|
||||
code: z.string(),
|
||||
language: z.string(),
|
||||
text: z.string(),
|
||||
terminalId: z.string().optional(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
export const persistedDraftEnvelopeSchema = z.object({
|
||||
drafts: z.record(z.string(), z.array(z.unknown())),
|
||||
touchedAt: z.record(z.string(), z.number()).optional(),
|
||||
});
|
||||
|
||||
type PersistedDraftEnvelopeResult = z.ZodSafeParseResult<z.infer<typeof persistedDraftEnvelopeSchema>>;
|
||||
|
||||
/**
|
||||
* v2 → v3: terminal drafts carried their terminal id in `language`; move it to
|
||||
* the dedicated `terminalId` field. Drafts from the removed 'preview-console'
|
||||
* source are dropped, as are malformed entries. Pre-v2 or unreadable payloads
|
||||
* reset entirely (the pre-v3 behavior).
|
||||
*/
|
||||
export const migratePersistedDrafts = (envelope: PersistedDraftEnvelopeResult, version: number): InlineCommentDraftState => {
|
||||
if (version < 2) return EMPTY_PERSISTED_STATE;
|
||||
if (!envelope.success) return EMPTY_PERSISTED_STATE;
|
||||
|
||||
const drafts: Record<string, InlineCommentDraft[]> = {};
|
||||
for (const [key, bucket] of Object.entries(envelope.data.drafts)) {
|
||||
const migrated: InlineCommentDraft[] = [];
|
||||
for (const entry of bucket) {
|
||||
const parsed = persistedDraftSchema.safeParse(entry);
|
||||
if (!parsed.success) continue;
|
||||
const draft = parsed.data;
|
||||
if (draft.source === 'terminal' && !draft.terminalId) {
|
||||
migrated.push({ ...draft, terminalId: draft.language, language: '' });
|
||||
} else {
|
||||
migrated.push(draft);
|
||||
}
|
||||
}
|
||||
if (migrated.length > 0) drafts[key] = migrated;
|
||||
}
|
||||
|
||||
const touchedAt: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(envelope.data.touchedAt ?? {})) {
|
||||
if (key in drafts) touchedAt[key] = value;
|
||||
}
|
||||
return { drafts, touchedAt };
|
||||
};
|
||||
|
||||
const removeDraftKey = (state: InlineCommentDraftState, key: string): InlineCommentDraftState => {
|
||||
if (!(key in state.drafts)) return state;
|
||||
|
||||
@@ -281,9 +341,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
||||
{
|
||||
name: 'openchamber-inline-comment-drafts',
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
version: 2,
|
||||
version: 3,
|
||||
partialize: (state) => ({ drafts: state.drafts, touchedAt: state.touchedAt }),
|
||||
migrate: () => ({ drafts: {}, touchedAt: {} }),
|
||||
migrate: (persisted, version) => migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse(persisted), version),
|
||||
},
|
||||
),
|
||||
{ name: 'inline-comment-draft-store' },
|
||||
|
||||
@@ -500,6 +500,26 @@
|
||||
any input above the keyboard instead of hiding it underneath. The opacity term
|
||||
preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise
|
||||
be overridden by this rule's `transition` shorthand). */
|
||||
/* The chat comment bar (quote-and-comment input) is a body-level portal, so it
|
||||
does not inherit the shell's keyboard inset. Anchor it against the same
|
||||
keyboard vars with the same curve so it rides the keyboard exactly like the
|
||||
composer does, and sits at the composer's own gap above it. */
|
||||
.oc-chat-comment-bar {
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + var(--oc-safe-area-bottom-visual, 0.5rem));
|
||||
}
|
||||
|
||||
:root.oc-capacitor-app .oc-chat-comment-bar {
|
||||
/* The composer keeps --oc-safe-area-bottom-visual of padding above the
|
||||
keyboard (see .bottom-safe-area); using the same term here makes the
|
||||
comment bar land exactly on the compact composer pill. */
|
||||
bottom: calc(
|
||||
var(--oc-keyboard-inset, 0px)
|
||||
+ max(0px, calc(env(safe-area-inset-bottom, 0px) - var(--oc-keyboard-inset, 0px)))
|
||||
+ var(--oc-safe-area-bottom-visual, 0.5rem)
|
||||
);
|
||||
transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||||
}
|
||||
|
||||
:root.oc-capacitor-app .oc-keyboard-inset-surface {
|
||||
bottom: var(--oc-keyboard-inset, 0px);
|
||||
transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand"
|
||||
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
|
||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||
import { prepareAttachmentFiles } from "./attachment-files"
|
||||
|
||||
@@ -115,6 +116,7 @@ export type SyntheticContextPart = {
|
||||
text: string
|
||||
attachments?: AttachedFile[]
|
||||
synthetic?: boolean
|
||||
metadata?: ContextPartMetadata
|
||||
}
|
||||
|
||||
export type VSCodeActiveEditorFile = {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* SDK-calling actions that need domain data read it from sync-refs.
|
||||
*/
|
||||
|
||||
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
|
||||
import { create } from "zustand"
|
||||
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
|
||||
@@ -138,7 +139,7 @@ export function routeMessage(params: {
|
||||
variant?: string
|
||||
inputMode?: "normal" | "shell"
|
||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||
additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
|
||||
delivery?: 'steer'
|
||||
}): Promise<void> {
|
||||
const requestDirectory = params.directory ?? undefined
|
||||
@@ -357,7 +358,7 @@ export type SessionUIState = {
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
@@ -1490,7 +1491,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
agent?: string,
|
||||
attachments?: AttachedFile[],
|
||||
agentMentionName?: string,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||
variant?: string,
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
@@ -1578,7 +1579,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
createdDraftSession.directory,
|
||||
createdDraftSession.sessionId,
|
||||
)
|
||||
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> =
|
||||
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true }] : []
|
||||
// Left undefined when nothing was added, as before: an empty array is not
|
||||
// the same as no additional parts to everything downstream.
|
||||
@@ -1613,6 +1614,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
additionalParts: mergedAdditionalParts?.map((p) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
files: p.attachments?.map((a: AttachedFile) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
@@ -1694,7 +1696,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// Prepended so it reads as background before the message it accompanies,
|
||||
// and empty unless the session is actually missing it.
|
||||
const knowledge = await fetchSessionKnowledge(currentSessionDirectory, targetSessionId || "")
|
||||
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> =
|
||||
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
|
||||
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
|
||||
const partsWithPinnedContext = prefixParts.length > 0
|
||||
? [...prefixParts, ...(additionalParts || [])]
|
||||
@@ -1716,6 +1718,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
additionalParts: partsWithPinnedContext?.map((p) => ({
|
||||
text: p.text,
|
||||
synthetic: p.synthetic,
|
||||
metadata: p.metadata,
|
||||
files: p.attachments?.map((a) => ({
|
||||
type: "file" as const,
|
||||
mime: a.mimeType,
|
||||
|
||||
Reference in New Issue
Block a user