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 { useUserMessageHistory } from "@/sync/sync-context";
|
||||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
@@ -757,21 +756,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
React.useCallback(
|
React.useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||||
let previewConsole = 0;
|
|
||||||
let previewAnnotation = 0;
|
let previewAnnotation = 0;
|
||||||
let review = 0;
|
let review = 0;
|
||||||
let terminal = 0;
|
let terminal = 0;
|
||||||
let prComment = 0;
|
let prComment = 0;
|
||||||
let prCheck = 0;
|
let prCheck = 0;
|
||||||
|
let chatQuote = 0;
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
if (draft.source === 'preview-console') previewConsole += 1;
|
if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
|
||||||
else if (draft.source === 'terminal') terminal += 1;
|
else if (draft.source === 'terminal') terminal += 1;
|
||||||
else if (draft.source === 'pr-comment') prComment += 1;
|
else if (draft.source === 'pr-comment') prComment += 1;
|
||||||
else if (draft.source === 'pr-check') prCheck += 1;
|
else if (draft.source === 'pr-check') prCheck += 1;
|
||||||
|
else if (draft.source === 'chat-quote') chatQuote += 1;
|
||||||
else review += 1;
|
else review += 1;
|
||||||
}
|
}
|
||||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
|
return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`;
|
||||||
},
|
},
|
||||||
[inlineDraftKey]
|
[inlineDraftKey]
|
||||||
)
|
)
|
||||||
@@ -779,11 +778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||||
const hasDrafts = draftCount > 0;
|
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
|
const terminalContextDrafts = terminalContextCount > 0
|
||||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
? (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;
|
if (!inlineDraftTarget) return;
|
||||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||||
for (const draft of drafts) {
|
for (const draft of drafts) {
|
||||||
@@ -797,7 +796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
if (!inlineDraftTarget) return;
|
if (!inlineDraftTarget) return;
|
||||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||||
for (const draft of drafts) {
|
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);
|
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -930,12 +929,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const inputSnapshot = getCurrentInputSnapshot();
|
const inputSnapshot = getCurrentInputSnapshot();
|
||||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||||
|
|
||||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
// Context drafts stay in their store: the send that later delivers the
|
||||||
|
// queue consumes them and attaches them as structured context parts.
|
||||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||||
if (drafts.length > 0) {
|
|
||||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
|
||||||
}
|
|
||||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||||
|
|
||||||
addToQueue(messageQueueTarget, {
|
addToQueue(messageQueueTarget, {
|
||||||
@@ -961,7 +957,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
composerRef.current?.focus();
|
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) => {
|
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||||
setMessage(content);
|
setMessage(content);
|
||||||
@@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inline review comments and synthetic context are consumed before
|
// 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 syntheticParts = consumePendingSyntheticParts();
|
||||||
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
|
const consumedDraftTarget = inlineDraftTarget;
|
||||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||||
? consumeDrafts(consumedDraftTarget)
|
? consumeDrafts(consumedDraftTarget)
|
||||||
: [];
|
: [];
|
||||||
@@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
composerAttachments: attachedFiles,
|
composerAttachments: attachedFiles,
|
||||||
inlineComments: drafts,
|
inlineComments: drafts,
|
||||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
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
|
linkedPr: linkedPr
|
||||||
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||||
: null,
|
: null,
|
||||||
}, {
|
}, {
|
||||||
parseAgentMention: (text) => {
|
parseAgentMention: (text) => {
|
||||||
@@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
},
|
},
|
||||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||||
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
||||||
appendComments: (text, comments) =>
|
|
||||||
appendInlineComments(text, comments as InlineCommentDraft[]),
|
|
||||||
buildSkillInstruction: buildSkillMentionInstruction,
|
buildSkillInstruction: buildSkillMentionInstruction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
reviewCount={reviewCount}
|
reviewCount={reviewCount}
|
||||||
prCommentCount={prCommentCount}
|
prCommentCount={prCommentCount}
|
||||||
prCheckCount={prCheckCount}
|
prCheckCount={prCheckCount}
|
||||||
previewConsoleCount={previewConsoleCount}
|
|
||||||
previewAnnotationCount={previewAnnotationCount}
|
previewAnnotationCount={previewAnnotationCount}
|
||||||
|
chatQuoteCount={chatQuoteCount}
|
||||||
draftTarget={inlineDraftTarget}
|
draftTarget={inlineDraftTarget}
|
||||||
onRemoveDraft={removeInlineCommentDraft}
|
onRemoveDraft={removeInlineCommentDraft}
|
||||||
onRemoveReviewDrafts={removeReviewDrafts}
|
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
|
drawn caret through a class it only writes while applying an update, so the
|
||||||
selection has to be the update that follows the focus.
|
selection has to be the update that follows the focus.
|
||||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
context drafts and linked references into OpenCode's one-primary-plus-parts
|
||||||
oldest queued message becomes primary; **inline comments attach to the last
|
shape. The oldest queued message becomes primary. **Every attached context
|
||||||
body the user authored** rather than becoming their own part; PR instructions
|
item (inline comments, terminal selections, browser annotations, PR context,
|
||||||
precede the PR diff.
|
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,
|
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||||
session) identity. Writes are debounced while typing but forced at every edge
|
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
|
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 { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
|
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||||
|
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
|
||||||
import {
|
import {
|
||||||
buildOutgoingMessage,
|
buildOutgoingMessage,
|
||||||
type OutgoingMessageDeps,
|
type OutgoingMessageDeps,
|
||||||
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
|
|||||||
},
|
},
|
||||||
sanitizeAttachments: (files) => [...(files ?? [])],
|
sanitizeAttachments: (files) => [...(files ?? [])],
|
||||||
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
|
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),
|
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
|||||||
composerAttachments: [],
|
composerAttachments: [],
|
||||||
inlineComments: [],
|
inlineComments: [],
|
||||||
syntheticTexts: [],
|
syntheticTexts: [],
|
||||||
linkedIssueContext: null,
|
linkedIssue: null,
|
||||||
linkedPr: null,
|
linkedPr: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('inline comments', () => {
|
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||||
test('attach to the composer text when nothing was queued', () => {
|
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({
|
const result = buildOutgoingMessage(input({
|
||||||
composerText: 'body',
|
composerText: 'body',
|
||||||
inlineComments: [{}, {}],
|
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
|
||||||
}), deps());
|
}), 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({
|
const result = buildOutgoingMessage(input({
|
||||||
queued: [{ content: 'queued' }],
|
composerText: 'body',
|
||||||
composerText: 'typed',
|
inlineComments: [commentDraft()],
|
||||||
inlineComments: [{}],
|
syntheticTexts: ['conflict note'],
|
||||||
}), deps());
|
}), deps());
|
||||||
expect(result.primaryText).toBe('queued');
|
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
|
||||||
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
|
.toEqual(['comment', 'conflict note']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fall back to primary when the queue produced no additional parts', () => {
|
test('no drafts changes nothing', () => {
|
||||||
const result = buildOutgoingMessage(input({
|
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
|
||||||
queued: [{ content: 'only queued' }],
|
.toEqual([]);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
|
|||||||
test('a linked PR sends its instructions before its diff', () => {
|
test('a linked PR sends its instructions before its diff', () => {
|
||||||
const result = buildOutgoingMessage(input({
|
const result = buildOutgoingMessage(input({
|
||||||
composerText: 'review this',
|
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());
|
}), deps());
|
||||||
expect(result.additionalParts.map((p) => p.text))
|
expect(result.additionalParts.map((p) => p.text))
|
||||||
.toEqual(['how to read it', 'the diff']);
|
.toEqual(['how to read it', 'the diff']);
|
||||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
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', () => {
|
test('a linked issue is sent as context', () => {
|
||||||
const result = buildOutgoingMessage(input({
|
const result = buildOutgoingMessage(input({
|
||||||
composerText: 'fix it',
|
composerText: 'fix it',
|
||||||
linkedIssueContext: 'issue body',
|
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||||
}), deps());
|
}), 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', () => {
|
test('synthetic texts precede the linked references', () => {
|
||||||
const result = buildOutgoingMessage(input({
|
const result = buildOutgoingMessage(input({
|
||||||
composerText: 'x',
|
composerText: 'x',
|
||||||
syntheticTexts: ['conflict note'],
|
syntheticTexts: ['conflict note'],
|
||||||
linkedIssueContext: 'issue body',
|
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||||
}), deps());
|
}), deps());
|
||||||
expect(result.additionalParts.map((p) => p.text))
|
expect(result.additionalParts.map((p) => p.text))
|
||||||
.toEqual(['conflict note', 'issue body']);
|
.toEqual(['conflict note', 'issue body']);
|
||||||
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('context alone is still worth sending', () => {
|
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);
|
expect(result.isEmpty).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
|
|||||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||||
composerText: 'typed /deploy',
|
composerText: 'typed /deploy',
|
||||||
syntheticTexts: ['synthetic'],
|
syntheticTexts: ['synthetic'],
|
||||||
linkedIssueContext: 'issue',
|
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||||
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
|
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||||
}), deps());
|
}), deps());
|
||||||
|
|
||||||
expect(result.primaryText).toBe('q1');
|
expect(result.primaryText).toBe('q1');
|
||||||
|
|||||||
@@ -14,12 +14,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
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 {
|
export interface OutgoingPart {
|
||||||
text: string;
|
text: string;
|
||||||
attachments?: AttachedFile[];
|
attachments?: AttachedFile[];
|
||||||
/** Synthetic parts are context for the model, not shown as user content. */
|
/** Synthetic parts are context for the model, not shown as user content. */
|
||||||
synthetic?: boolean;
|
synthetic?: boolean;
|
||||||
|
/** Structured context (see contextParts.ts), persisted with the part. */
|
||||||
|
metadata?: ContextPartMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutgoingMessage {
|
export interface OutgoingMessage {
|
||||||
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
|
|||||||
/** The composer's own text, or null when this send skips it. */
|
/** The composer's own text, or null when this send skips it. */
|
||||||
composerText: string | null;
|
composerText: string | null;
|
||||||
composerAttachments: readonly AttachedFile[];
|
composerAttachments: readonly AttachedFile[];
|
||||||
/** Inline review comments, appended to the user's last authored text. */
|
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||||
inlineComments: readonly unknown[];
|
inlineComments: readonly InlineCommentDraft[];
|
||||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||||
syntheticTexts: readonly string[];
|
syntheticTexts: readonly string[];
|
||||||
linkedIssueContext: string | null;
|
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||||
linkedPr: { instructions: string; context: 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[];
|
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
|
||||||
/** Skills named inline with `/name`. */
|
/** Skills named inline with `/name`. */
|
||||||
collectSkillNames: (text: string) => string[];
|
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. */
|
/** Instruction telling the model which skills the user named. */
|
||||||
buildSkillInstruction: (names: string[]) => string | null;
|
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
|
// Everything below is context for the model, never plain user text. Each
|
||||||
// as a continuation of it rather than as a separate turn.
|
// attached context item becomes its own synthetic part carrying structured
|
||||||
if (input.inlineComments.length > 0) {
|
// metadata, so the timeline can render it as a context block after the
|
||||||
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
|
// server echoes the message back.
|
||||||
? additionalParts[additionalParts.length - 1]
|
for (const draft of input.inlineComments) {
|
||||||
: null;
|
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
|
||||||
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 user-visible content.
|
|
||||||
for (const text of input.syntheticTexts) {
|
for (const text of input.syntheticTexts) {
|
||||||
additionalParts.push({ text, synthetic: true });
|
additionalParts.push({ text, synthetic: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.linkedIssueContext) {
|
if (input.linkedIssue) {
|
||||||
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
|
const { number, title, url, contextText } = input.linkedIssue;
|
||||||
|
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.linkedPr) {
|
if (input.linkedPr) {
|
||||||
// Instructions before context: the model is told how to read the diff
|
// Instructions before context: the model is told how to read the diff
|
||||||
// before it is given the diff.
|
// before it is given the diff.
|
||||||
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
|
const { number, title, url, instructions, context } = input.linkedPr;
|
||||||
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
|
additionalParts.push({ text: instructions, synthetic: true });
|
||||||
|
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export interface ComposerContextChipsProps {
|
|||||||
reviewCount: number;
|
reviewCount: number;
|
||||||
prCommentCount: number;
|
prCommentCount: number;
|
||||||
prCheckCount: number;
|
prCheckCount: number;
|
||||||
previewConsoleCount: number;
|
|
||||||
previewAnnotationCount: number;
|
previewAnnotationCount: number;
|
||||||
|
chatQuoteCount: number;
|
||||||
draftTarget: InlineCommentDraftTarget | null;
|
draftTarget: InlineCommentDraftTarget | null;
|
||||||
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||||
onRemoveReviewDrafts: () => 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'];
|
colors: Theme['colors'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
|||||||
reviewCount,
|
reviewCount,
|
||||||
prCommentCount,
|
prCommentCount,
|
||||||
prCheckCount,
|
prCheckCount,
|
||||||
previewConsoleCount,
|
|
||||||
previewAnnotationCount,
|
previewAnnotationCount,
|
||||||
|
chatQuoteCount,
|
||||||
draftTarget,
|
draftTarget,
|
||||||
onRemoveDraft,
|
onRemoveDraft,
|
||||||
onRemoveReviewDrafts,
|
onRemoveReviewDrafts,
|
||||||
@@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{previewConsoleCount > 0 ? (
|
{chatQuoteCount > 0 ? (
|
||||||
<CountChip
|
<CountChip
|
||||||
label={t('chat.chatInput.devServerLogs')}
|
label={t('chat.chatInput.chatQuoteContext')}
|
||||||
count={previewConsoleCount}
|
count={chatQuoteCount}
|
||||||
removeLabel={t('chat.chatInput.devServerLogsRemove')}
|
removeLabel={t('chat.chatInput.chatQuoteContextRemove')}
|
||||||
onRemove={() => onRemovePreviewDrafts('preview-console')}
|
onRemove={() => onRemovePreviewDrafts('chat-quote')}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
|
icon={<Icon name="chat-1" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<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 }}
|
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||||
>
|
>
|
||||||
<ComposerAttachmentControls
|
<ComposerAttachmentControls
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { useSessions } from '@/sync/sync-context';
|
import { useSessions } from '@/sync/sync-context';
|
||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
||||||
@@ -33,6 +33,8 @@ interface SelectionPayload {
|
|||||||
plainText: string;
|
plainText: string;
|
||||||
markdownText: string;
|
markdownText: string;
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
|
messageId: string | null;
|
||||||
|
range: Range;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeDistilledInsight = (insight: string): string => (
|
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 [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
const [selectedText, setSelectedText] = React.useState('');
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = 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 isDraggingRef = React.useRef(false);
|
||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const [isAddingToNotes, setIsAddingToNotes] = 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 isMenuVisibleRef = React.useRef(false);
|
||||||
const createSession = useSessionUIStore((state) => state.createSession);
|
const createSession = useSessionUIStore((state) => state.createSession);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
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 setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
@@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const effectiveDirectory = useEffectiveDirectory();
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
const sessions = useSessions();
|
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(() => {
|
React.useEffect(() => {
|
||||||
isMenuVisibleRef.current = position.show;
|
isMenuVisibleRef.current = position.show;
|
||||||
}, [position.show]);
|
}, [position.show]);
|
||||||
@@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
const hideMenu = React.useCallback(() => {
|
const hideMenu = React.useCallback(() => {
|
||||||
pendingSelectionRef.current = null;
|
pendingSelectionRef.current = null;
|
||||||
|
setCommentRects(null);
|
||||||
|
|
||||||
if (!isMenuVisibleRef.current) {
|
if (!isMenuVisibleRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
setPosition((prev) => ({ ...prev, show: false }));
|
setPosition((prev) => ({ ...prev, show: false }));
|
||||||
setSelectedText('');
|
setSelectedText('');
|
||||||
setSelectedTextMarkdown('');
|
setSelectedTextMarkdown('');
|
||||||
|
setSelectedMessageId(null);
|
||||||
|
setCommentMode(false);
|
||||||
|
commentModeRef.current = false;
|
||||||
|
setCommentText('');
|
||||||
isMenuVisibleRef.current = false;
|
isMenuVisibleRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const showMenu = React.useCallback(() => {
|
const showMenu = React.useCallback(() => {
|
||||||
if (!pendingSelectionRef.current) return;
|
if (!pendingSelectionRef.current) return;
|
||||||
|
|
||||||
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||||
const shouldAnimateIn = !position.show;
|
const shouldAnimateIn = !position.show;
|
||||||
|
|
||||||
// Position menu above the selection
|
// Position menu above the selection
|
||||||
@@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
setSelectedText(plainText);
|
setSelectedText(plainText);
|
||||||
setSelectedTextMarkdown(markdownText);
|
setSelectedTextMarkdown(markdownText);
|
||||||
|
setSelectedMessageId(messageId);
|
||||||
setPosition({
|
setPosition({
|
||||||
x: menuX,
|
x: menuX,
|
||||||
y: menuY,
|
y: menuY,
|
||||||
@@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
}, [getDesktopClampedX, isMobile, position.show]);
|
}, [getDesktopClampedX, isMobile, position.show]);
|
||||||
|
|
||||||
const handleSelectionChange = React.useCallback(() => {
|
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 selection = window.getSelection();
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
||||||
@@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
// Store the selection but don't show menu yet if dragging
|
// Store the selection but don't show menu yet if dragging
|
||||||
|
const anchorElement = range.commonAncestorContainer instanceof Element
|
||||||
|
? range.commonAncestorContainer
|
||||||
|
: range.commonAncestorContainer.parentElement;
|
||||||
pendingSelectionRef.current = {
|
pendingSelectionRef.current = {
|
||||||
plainText: text,
|
plainText: text,
|
||||||
markdownText: rangeToMarkdown(range, text),
|
markdownText: rangeToMarkdown(range, text),
|
||||||
rect,
|
rect,
|
||||||
|
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
|
||||||
|
range: range.cloneRange(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only show menu if we're not currently dragging
|
// Only show menu if we're not currently dragging
|
||||||
@@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Track when dragging starts
|
// 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;
|
isDraggingRef.current = true;
|
||||||
hideMenu();
|
hideMenu();
|
||||||
};
|
};
|
||||||
@@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
// Small delay to ensure selection is finalized
|
// Small delay to ensure selection is finalized
|
||||||
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
||||||
mouseUpTimeoutRef.current = null;
|
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();
|
const selection = window.getSelection();
|
||||||
if (selection && selection.toString().trim()) {
|
if (selection && selection.toString().trim()) {
|
||||||
showMenu();
|
showMenu();
|
||||||
@@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
if (
|
if (
|
||||||
menuRef.current &&
|
menuRef.current &&
|
||||||
!menuRef.current.contains(e.target as Node) &&
|
!menuRef.current.contains(e.target as Node) &&
|
||||||
!window.getSelection()?.toString().trim()
|
(commentModeRef.current || !window.getSelection()?.toString().trim())
|
||||||
) {
|
) {
|
||||||
hideMenu();
|
hideMenu();
|
||||||
}
|
}
|
||||||
@@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
});
|
});
|
||||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
}, [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 () => {
|
const handleCreateNewSession = React.useCallback(async () => {
|
||||||
if (!selectedText) return;
|
if (!selectedText) return;
|
||||||
|
|
||||||
@@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
}, [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(() => {
|
const currentSession = React.useMemo(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
|
|
||||||
if (!position.show) return null;
|
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
|
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
||||||
if (isMobile) {
|
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
||||||
'rounded-2xl border border-[var(--interactive-border)]',
|
'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',
|
'safe-area-bottom',
|
||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
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">
|
<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
|
<button
|
||||||
onClick={handleAddToChat}
|
onClick={handleAddToChat}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -421,7 +712,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
|
<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>
|
||||||
|
|
||||||
<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>
|
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||||
</button>
|
</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() ? (
|
{!isVSCodeRuntime() ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToNotes}
|
onClick={handleAddToNotes}
|
||||||
@@ -491,73 +766,90 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
transform: 'translate(-50%, -100%)',
|
transform: 'translate(-50%, -100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
|
||||||
className={cn(
|
<div
|
||||||
'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}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
'flex items-center whitespace-nowrap',
|
||||||
'text-sm font-medium',
|
'rounded-full border border-[var(--interactive-border)]',
|
||||||
'text-[var(--surface-foreground)]',
|
'bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||||
'hover:bg-[var(--interactive-hover)]',
|
'p-1',
|
||||||
'transition-colors duration-150'
|
'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" />
|
<button
|
||||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
|
onClick={handleOpenComment}
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<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
|
<button
|
||||||
onClick={handleCreateNewSession}
|
onClick={handleAddToChat}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
'px-3.5 py-1.5 rounded-full',
|
||||||
'text-sm font-medium',
|
'text-sm font-medium',
|
||||||
'text-[var(--surface-foreground)]',
|
'text-[var(--surface-foreground)]',
|
||||||
'hover:bg-[var(--interactive-hover)]',
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
'transition-colors duration-150'
|
'transition-colors duration-150'
|
||||||
)}
|
)}
|
||||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="chat-new" className="h-4 w-4" />
|
{t('chat.textSelection.actions.addToInput')}
|
||||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
{!isVSCodeRuntime() ? (
|
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||||
<>
|
|
||||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToNotes}
|
onClick={handleCreateNewSession}
|
||||||
disabled={isAddingToNotes}
|
className={cn(
|
||||||
className={cn(
|
'px-3.5 py-1.5 rounded-full',
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
'text-sm font-medium',
|
||||||
'text-sm font-medium',
|
'text-[var(--surface-foreground)]',
|
||||||
'text-[var(--surface-foreground)]',
|
'hover:bg-[var(--interactive-hover)]',
|
||||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
'transition-colors duration-150'
|
||||||
'transition-colors duration-150'
|
)}
|
||||||
)}
|
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
type="button"
|
||||||
type="button"
|
>
|
||||||
>
|
{t('chat.textSelection.actions.newSession')}
|
||||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
|
</button>
|
||||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
|
||||||
</button>
|
{!isVSCodeRuntime() ? (
|
||||||
</>
|
<>
|
||||||
) : null}
|
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||||
</div>
|
|
||||||
|
<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>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
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_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request 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;
|
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
||||||
if (!synthetic) return true;
|
if (!synthetic) return true;
|
||||||
if (part.type !== 'text') return false;
|
if (part.type !== 'text') return false;
|
||||||
|
if (readContextPart(part)) return true;
|
||||||
const text = (part as { text?: unknown }).text;
|
const text = (part as { text?: unknown }).text;
|
||||||
if (typeof text !== 'string') {
|
if (typeof text !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
|||||||
const synthetic = rawPart.synthetic === true;
|
const synthetic = rawPart.synthetic === true;
|
||||||
|
|
||||||
if (synthetic) {
|
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);
|
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||||
if (attachmentPart) {
|
if (attachmentPart) {
|
||||||
return attachmentPart;
|
return attachmentPart;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
import { readContextPart } from '@/lib/messages/contextParts';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
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
|
// Only filter out synthetic parts if there are non-synthetic parts present
|
||||||
// Otherwise, show synthetic parts so the message is displayed
|
// Otherwise, show synthetic parts so the message is displayed
|
||||||
if (isSynthetic && hasNonSynthetic) {
|
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
|
## Quick map of files in this folder
|
||||||
|
|
||||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
- 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`
|
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.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';
|
} from '@/lib/messages/inlineMessageLinks';
|
||||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
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 };
|
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 }) => {
|
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 partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text;
|
const rawText = partWithText.text;
|
||||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
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]);
|
}, [agentMention, openSkill, skillByName, textContent]);
|
||||||
|
|
||||||
|
if (contextPayload) {
|
||||||
|
return <UserContextPart payload={contextPayload} />;
|
||||||
|
}
|
||||||
|
|
||||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -642,7 +642,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
startLine: selection.startLine,
|
startLine: selection.startLine,
|
||||||
endLine: selection.endLine,
|
endLine: selection.endLine,
|
||||||
code: selection.text,
|
code: selection.text,
|
||||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
language: '',
|
||||||
|
terminalId: activeTab.terminalSessionId ?? activeTab.id,
|
||||||
text: '',
|
text: '',
|
||||||
});
|
});
|
||||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ button,
|
|||||||
background: color-mix(in srgb, var(--primary-base) 22%, transparent);
|
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 {
|
.pierre-diff-wrapper {
|
||||||
--diffs-bg-selection-override: color-mix(in srgb, var(--primary-base) 22%, transparent);
|
--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);
|
--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{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{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 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}'
|
'.editor button[disabled]{opacity:.5;cursor:default}'
|
||||||
].join('');
|
].join('');
|
||||||
shadow.appendChild(style);
|
shadow.appendChild(style);
|
||||||
@@ -278,7 +279,11 @@ export const buildAnnotationOverlayScript = (
|
|||||||
comment.placeholder = LABELS.commentPlaceholder;
|
comment.placeholder = LABELS.commentPlaceholder;
|
||||||
var submit = document.createElement('button');
|
var submit = document.createElement('button');
|
||||||
submit.type = '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);
|
editor.append(comment, submit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2009,9 +2009,12 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': 'Fehler beim Hinzufügen zu Notizen',
|
'chat.textSelection.toast.addToNotesFailed': 'Fehler beim Hinzufügen zu Notizen',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': 'Ausgewählter Text zu Notizen hinzugefügt',
|
'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.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.newSession': 'Neue Sitzung',
|
||||||
'chat.textSelection.actions.copy': 'Kopieren',
|
|
||||||
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
|
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
|
||||||
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
|
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
|
||||||
'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen',
|
'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.toast.togglePermissionAutoAcceptFailed': 'Fehler beim Umschalten der automatischen Zustimmung zur Berechtigung',
|
||||||
'chat.chatInput.reviewComments': 'Kommentare zur Überprüfung:',
|
'chat.chatInput.reviewComments': 'Kommentare zur Überprüfung:',
|
||||||
'chat.chatInput.reviewCommentsRemove': 'Kommentare zur Überprüfung entfernen',
|
'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.previewAnnotations': 'Vorschau-Anmerkungen:',
|
||||||
'chat.chatInput.previewContext': 'Vorschau-Kontext:',
|
'chat.chatInput.previewContext': 'Vorschau-Kontext:',
|
||||||
'chat.chatInput.previewContextRemove': 'Vorschau-Kontext entfernen',
|
'chat.chatInput.previewContextRemove': 'Vorschau-Kontext entfernen',
|
||||||
@@ -2903,6 +2904,14 @@ export const dict = {
|
|||||||
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
|
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
|
||||||
'terminalView.actions.restart': 'Terminal neu starten',
|
'terminalView.actions.restart': 'Terminal neu starten',
|
||||||
'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}',
|
'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.terminalContext': '{terminal}, Zeilen {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Terminal-Kontext entfernen',
|
'chat.chatInput.terminalContextRemove': 'Terminal-Kontext entfernen',
|
||||||
'chat.chatInput.prCommentContext': 'PR-Kommentare',
|
'chat.chatInput.prCommentContext': 'PR-Kommentare',
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ export const dict = {
|
|||||||
'terminalView.actions.attachSelection': 'Attach selected output',
|
'terminalView.actions.attachSelection': 'Attach selected output',
|
||||||
'terminalView.actions.restart': 'Restart terminal',
|
'terminalView.actions.restart': 'Restart terminal',
|
||||||
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
|
'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.terminalContext': '{terminal}, lines {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Remove terminal context',
|
'chat.chatInput.terminalContextRemove': 'Remove terminal context',
|
||||||
'chat.chatInput.prCommentContext': 'PR comments',
|
'chat.chatInput.prCommentContext': 'PR comments',
|
||||||
@@ -2182,9 +2190,12 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
|
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': 'Added selected text 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.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.newSession': 'New session',
|
||||||
'chat.textSelection.actions.copy': 'Copy',
|
|
||||||
'chat.textSelection.actions.addToNotes': 'Add to notes',
|
'chat.textSelection.actions.addToNotes': 'Add to notes',
|
||||||
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
|
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
|
||||||
'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection',
|
'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.toast.togglePermissionAutoAcceptFailed': 'Failed to toggle permission auto-accept',
|
||||||
'chat.chatInput.reviewComments': 'Review comments:',
|
'chat.chatInput.reviewComments': 'Review comments:',
|
||||||
'chat.chatInput.reviewCommentsRemove': 'Remove 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.previewAnnotations': 'Preview annotations:',
|
||||||
'chat.chatInput.previewContext': 'Preview context:',
|
'chat.chatInput.previewContext': 'Preview context:',
|
||||||
'chat.chatInput.previewContextRemove': 'Remove 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.attachSelection': 'Adjuntar salida seleccionada',
|
||||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||||
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
|
'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.terminalContext': '{terminal}, líneas {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Eliminar contexto del terminal',
|
'chat.chatInput.terminalContextRemove': 'Eliminar contexto del terminal',
|
||||||
'chat.chatInput.prCommentContext': 'Comentarios del PR',
|
'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.addToNotesFailed": "No se pudo añadir a las notas",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Texto seleccionado añadido a 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.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.newSession": "Nueva sesión",
|
||||||
"chat.textSelection.actions.copy": "Copiar",
|
|
||||||
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
|
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
|
||||||
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
|
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
|
||||||
"chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección",
|
"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.toast.togglePermissionAutoAcceptFailed": "No se pudo cambiar la aceptación automática de permisos",
|
||||||
"chat.chatInput.reviewComments": "Comentarios de revisión:",
|
"chat.chatInput.reviewComments": "Comentarios de revisión:",
|
||||||
"chat.chatInput.reviewCommentsRemove": "Quitar 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.previewAnnotations": "Anotaciones de vista previa:",
|
||||||
"chat.chatInput.previewContext": "Contexto de vista previa:",
|
"chat.chatInput.previewContext": "Contexto de vista previa:",
|
||||||
"chat.chatInput.previewContextRemove": "Quitar 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.attachSelection': 'Joindre la sortie sélectionnée',
|
||||||
'terminalView.actions.restart': 'Redémarrer le terminal',
|
'terminalView.actions.restart': 'Redémarrer le terminal',
|
||||||
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
|
'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.terminalContext': '{terminal}, lignes {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Supprimer le contexte du terminal',
|
'chat.chatInput.terminalContextRemove': 'Supprimer le contexte du terminal',
|
||||||
'chat.chatInput.prCommentContext': 'Commentaires de PR',
|
'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.addToNotesFailed': 'Échec de l\'ajout aux notes',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': 'Ajout du texte sélectionné 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.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.newSession': 'Nouvelle session',
|
||||||
'chat.textSelection.actions.copy': 'Copie',
|
|
||||||
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
|
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
|
||||||
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
|
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
|
||||||
'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection',
|
'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.openSessionFirst': 'Ouvrir d\'abord une session',
|
||||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Échec de l\'activation de l\'acceptation automatique des autorisations',
|
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Échec de l\'activation de l\'acceptation automatique des autorisations',
|
||||||
'chat.chatInput.reviewComments': 'Commentaires de révision :',
|
'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.previewAnnotations': 'Aperçu des annotations :',
|
||||||
'chat.chatInput.previewContext': 'Contexte d\'aperçu :',
|
'chat.chatInput.previewContext': 'Contexte d\'aperçu :',
|
||||||
'chat.chatInput.previewContextRemove': 'Supprimer le 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.attachSelection': '選択した出力を添付',
|
||||||
'terminalView.actions.restart': 'ターミナルを再起動',
|
'terminalView.actions.restart': 'ターミナルを再起動',
|
||||||
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
|
'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.terminalContext': '{terminal}、{start}〜{end}行',
|
||||||
'chat.chatInput.terminalContextRemove': 'ターミナルコンテキストを削除',
|
'chat.chatInput.terminalContextRemove': 'ターミナルコンテキストを削除',
|
||||||
'chat.chatInput.prCommentContext': 'PRコメント',
|
'chat.chatInput.prCommentContext': 'PRコメント',
|
||||||
@@ -2178,9 +2186,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': 'メモへの追加に失敗しました',
|
'chat.textSelection.toast.addToNotesFailed': 'メモへの追加に失敗しました',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '選択テキストをメモに追加しました',
|
'chat.textSelection.toast.addToNotesSuccess': '選択テキストをメモに追加しました',
|
||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
|
'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.newSession': '新しいセッション',
|
||||||
'chat.textSelection.actions.copy': 'コピー',
|
|
||||||
'chat.textSelection.actions.addToNotes': 'メモに追加',
|
'chat.textSelection.actions.addToNotes': 'メモに追加',
|
||||||
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
|
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
|
||||||
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
|
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
|
||||||
@@ -2293,8 +2304,6 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '権限の自動承認の切り替えに失敗しました',
|
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '権限の自動承認の切り替えに失敗しました',
|
||||||
'chat.chatInput.reviewComments': 'レビューコメント:',
|
'chat.chatInput.reviewComments': 'レビューコメント:',
|
||||||
'chat.chatInput.reviewCommentsRemove': 'レビューコメントを削除',
|
'chat.chatInput.reviewCommentsRemove': 'レビューコメントを削除',
|
||||||
'chat.chatInput.devServerLogs': '開発サーバーログ:',
|
|
||||||
'chat.chatInput.devServerLogsRemove': '開発サーバーログを削除',
|
|
||||||
'chat.chatInput.previewAnnotations': 'プレビュー注釈:',
|
'chat.chatInput.previewAnnotations': 'プレビュー注釈:',
|
||||||
'chat.chatInput.previewContext': 'プレビューコンテキスト:',
|
'chat.chatInput.previewContext': 'プレビューコンテキスト:',
|
||||||
'chat.chatInput.previewContextRemove': 'プレビューコンテキストを削除',
|
'chat.chatInput.previewContextRemove': 'プレビューコンテキストを削除',
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'terminalView.actions.attachSelection': '선택한 출력 첨부',
|
'terminalView.actions.attachSelection': '선택한 출력 첨부',
|
||||||
'terminalView.actions.restart': '터미널 다시 시작',
|
'terminalView.actions.restart': '터미널 다시 시작',
|
||||||
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
|
'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.terminalContext': '{terminal}, {start}-{end}행',
|
||||||
'chat.chatInput.terminalContextRemove': '터미널 컨텍스트 제거',
|
'chat.chatInput.terminalContextRemove': '터미널 컨텍스트 제거',
|
||||||
'chat.chatInput.prCommentContext': 'PR 댓글',
|
'chat.chatInput.prCommentContext': 'PR 댓글',
|
||||||
@@ -2184,9 +2192,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
|
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '선택한 텍스트를 메모에 추가함',
|
'chat.textSelection.toast.addToNotesSuccess': '선택한 텍스트를 메모에 추가함',
|
||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
|
'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.newSession': '새 세션',
|
||||||
'chat.textSelection.actions.copy': '복사',
|
|
||||||
'chat.textSelection.actions.addToNotes': '메모에 추가',
|
'chat.textSelection.actions.addToNotes': '메모에 추가',
|
||||||
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
|
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
|
||||||
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
|
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
|
||||||
@@ -2294,8 +2305,6 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '권한 자동 승인 전환에 실패했습니다',
|
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '권한 자동 승인 전환에 실패했습니다',
|
||||||
'chat.chatInput.reviewComments': '검토 댓글:',
|
'chat.chatInput.reviewComments': '검토 댓글:',
|
||||||
'chat.chatInput.reviewCommentsRemove': '검토 댓글 제거',
|
'chat.chatInput.reviewCommentsRemove': '검토 댓글 제거',
|
||||||
'chat.chatInput.devServerLogs': 'Dev Server 로그:',
|
|
||||||
'chat.chatInput.devServerLogsRemove': 'Dev Server 로그 제거',
|
|
||||||
'chat.chatInput.previewAnnotations': '미리보기 주석:',
|
'chat.chatInput.previewAnnotations': '미리보기 주석:',
|
||||||
'chat.chatInput.previewContext': '미리보기 컨텍스트:',
|
'chat.chatInput.previewContext': '미리보기 컨텍스트:',
|
||||||
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
|
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
|
||||||
'terminalView.actions.restart': 'Uruchom terminal ponownie',
|
'terminalView.actions.restart': 'Uruchom terminal ponownie',
|
||||||
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
|
'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.terminalContext': '{terminal}, wiersze {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Usuń kontekst terminala',
|
'chat.chatInput.terminalContextRemove': 'Usuń kontekst terminala',
|
||||||
'chat.chatInput.prCommentContext': 'Komentarze PR',
|
'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.addToNotesFailed': 'Nie udało się dodać do notatek',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': 'Dodano zaznaczony tekst 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.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.newSession': 'Nowa sesja',
|
||||||
'chat.textSelection.actions.copy': 'Kopiuj',
|
|
||||||
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
|
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
|
||||||
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
|
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
|
||||||
'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem',
|
'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.projectTitle': 'Projekt',
|
||||||
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
|
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
|
||||||
'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...',
|
'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.attachFiles': 'Drop files here to attach',
|
||||||
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
||||||
'chat.chatInput.fileFallback': 'file',
|
'chat.chatInput.fileFallback': 'file',
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
|
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
|
||||||
'terminalView.actions.restart': 'Reiniciar terminal',
|
'terminalView.actions.restart': 'Reiniciar terminal',
|
||||||
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
|
'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.terminalContext': '{terminal}, linhas {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Remover contexto do terminal',
|
'chat.chatInput.terminalContextRemove': 'Remover contexto do terminal',
|
||||||
'chat.chatInput.prCommentContext': 'Comentários do PR',
|
'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.addToNotesFailed": "Não foi possível adicionar às notas",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Texto selecionado adicionado à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.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.newSession": "Nova sessão",
|
||||||
"chat.textSelection.actions.copy": "Copiar",
|
|
||||||
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
|
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
|
||||||
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
|
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
|
||||||
"chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção",
|
"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.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.reviewComments": "Comentários de revisão:",
|
||||||
"chat.chatInput.reviewCommentsRemove": "Remover 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.previewAnnotations": "Anotações da visualização:",
|
||||||
"chat.chatInput.previewContext": "Contexto da visualização:",
|
"chat.chatInput.previewContext": "Contexto da visualização:",
|
||||||
"chat.chatInput.previewContextRemove": "Remover 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.attachSelection': 'Прикріпити вибраний вивід',
|
||||||
'terminalView.actions.restart': 'Перезапустити термінал',
|
'terminalView.actions.restart': 'Перезапустити термінал',
|
||||||
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
|
'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.terminalContext': '{terminal}, рядки {start}-{end}',
|
||||||
'chat.chatInput.terminalContextRemove': 'Видалити контекст термінала',
|
'chat.chatInput.terminalContextRemove': 'Видалити контекст термінала',
|
||||||
'chat.chatInput.prCommentContext': 'Коментарі PR',
|
'chat.chatInput.prCommentContext': 'Коментарі PR',
|
||||||
@@ -2160,9 +2168,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
|
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
|
||||||
"chat.textSelection.toast.addToNotesSuccess": "Вибраний текст додано до нотаток",
|
"chat.textSelection.toast.addToNotesSuccess": "Вибраний текст додано до нотаток",
|
||||||
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
|
"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.newSession": "Нова сесія",
|
||||||
"chat.textSelection.actions.copy": "Копіювати",
|
|
||||||
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
|
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
|
||||||
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
|
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
|
||||||
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
|
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
|
||||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Не вдалося ввімкнути автоматичне прийняття дозволів",
|
"chat.chatInput.toast.togglePermissionAutoAcceptFailed": "Не вдалося ввімкнути автоматичне прийняття дозволів",
|
||||||
"chat.chatInput.reviewComments": "Коментарі рев’ю:",
|
"chat.chatInput.reviewComments": "Коментарі рев’ю:",
|
||||||
"chat.chatInput.reviewCommentsRemove": "Прибрати коментарі рев’ю",
|
"chat.chatInput.reviewCommentsRemove": "Прибрати коментарі рев’ю",
|
||||||
"chat.chatInput.devServerLogs": "Логи Dev Server:",
|
|
||||||
"chat.chatInput.devServerLogsRemove": "Прибрати логи Dev Server",
|
|
||||||
"chat.chatInput.previewAnnotations": "Анотації перегляду:",
|
"chat.chatInput.previewAnnotations": "Анотації перегляду:",
|
||||||
"chat.chatInput.previewContext": "Контекст перегляду:",
|
"chat.chatInput.previewContext": "Контекст перегляду:",
|
||||||
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'terminalView.actions.attachSelection': '附加所选输出',
|
'terminalView.actions.attachSelection': '附加所选输出',
|
||||||
'terminalView.actions.restart': '重启终端',
|
'terminalView.actions.restart': '重启终端',
|
||||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
'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.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||||
'chat.chatInput.terminalContextRemove': '移除终端上下文',
|
'chat.chatInput.terminalContextRemove': '移除终端上下文',
|
||||||
'chat.chatInput.prCommentContext': 'PR 评论',
|
'chat.chatInput.prCommentContext': 'PR 评论',
|
||||||
@@ -2148,9 +2156,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
|
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '已将选中文本添加到笔记',
|
'chat.textSelection.toast.addToNotesSuccess': '已将选中文本添加到笔记',
|
||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
|
'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.newSession': '新建会话',
|
||||||
'chat.textSelection.actions.copy': '复制',
|
|
||||||
'chat.textSelection.actions.addToNotes': '添加到笔记',
|
'chat.textSelection.actions.addToNotes': '添加到笔记',
|
||||||
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
|
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
|
||||||
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
|
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
|
||||||
@@ -2260,8 +2271,6 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切换权限自动接受失败',
|
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切换权限自动接受失败',
|
||||||
'chat.chatInput.reviewComments': '审查评论:',
|
'chat.chatInput.reviewComments': '审查评论:',
|
||||||
'chat.chatInput.reviewCommentsRemove': '移除审查评论',
|
'chat.chatInput.reviewCommentsRemove': '移除审查评论',
|
||||||
'chat.chatInput.devServerLogs': 'Dev Server 日志:',
|
|
||||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日志',
|
|
||||||
'chat.chatInput.previewAnnotations': '预览注释:',
|
'chat.chatInput.previewAnnotations': '预览注释:',
|
||||||
'chat.chatInput.previewContext': '预览上下文:',
|
'chat.chatInput.previewContext': '预览上下文:',
|
||||||
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'terminalView.actions.attachSelection': '附加所選輸出',
|
'terminalView.actions.attachSelection': '附加所選輸出',
|
||||||
'terminalView.actions.restart': '重新啟動終端',
|
'terminalView.actions.restart': '重新啟動終端',
|
||||||
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
|
'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.terminalContext': '{terminal},第 {start}-{end} 行',
|
||||||
'chat.chatInput.terminalContextRemove': '移除終端上下文',
|
'chat.chatInput.terminalContextRemove': '移除終端上下文',
|
||||||
'chat.chatInput.prCommentContext': 'PR 留言',
|
'chat.chatInput.prCommentContext': 'PR 留言',
|
||||||
@@ -2152,9 +2160,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesFailed': '加入筆記失敗',
|
'chat.textSelection.toast.addToNotesFailed': '加入筆記失敗',
|
||||||
'chat.textSelection.toast.addToNotesSuccess': '已將選取文字加入筆記',
|
'chat.textSelection.toast.addToNotesSuccess': '已將選取文字加入筆記',
|
||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
|
'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.newSession': '新增會話',
|
||||||
'chat.textSelection.actions.copy': '複製',
|
|
||||||
'chat.textSelection.actions.addToNotes': '加入筆記',
|
'chat.textSelection.actions.addToNotes': '加入筆記',
|
||||||
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
|
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
|
||||||
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
|
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
|
||||||
@@ -2264,8 +2275,6 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切換權限自動接受失敗',
|
'chat.chatInput.toast.togglePermissionAutoAcceptFailed': '切換權限自動接受失敗',
|
||||||
'chat.chatInput.reviewComments': '審查留言:',
|
'chat.chatInput.reviewComments': '審查留言:',
|
||||||
'chat.chatInput.reviewCommentsRemove': '移除審查留言',
|
'chat.chatInput.reviewCommentsRemove': '移除審查留言',
|
||||||
'chat.chatInput.devServerLogs': 'Dev Server 日誌:',
|
|
||||||
'chat.chatInput.devServerLogsRemove': '移除 Dev Server 日誌',
|
|
||||||
'chat.chatInput.previewAnnotations': '預覽註釋:',
|
'chat.chatInput.previewAnnotations': '預覽註釋:',
|
||||||
'chat.chatInput.previewContext': '預覽上下文:',
|
'chat.chatInput.previewContext': '預覽上下文:',
|
||||||
'chat.chatInput.previewContextRemove': '移除預覽上下文',
|
'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)
|
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 type { Part } from "@opencode-ai/sdk/v2";
|
||||||
|
|
||||||
|
import { readContextPart } from "./contextParts";
|
||||||
|
|
||||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request 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;
|
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;
|
const text = (part as { text?: unknown }).text;
|
||||||
if (typeof text !== 'string') {
|
if (typeof text !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||||
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
|
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||||
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
|
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
|
||||||
import type { FilesAPI } from "../api/types";
|
import type { FilesAPI } from "../api/types";
|
||||||
@@ -792,6 +793,7 @@ class OpencodeService {
|
|||||||
additionalParts?: Array<{
|
additionalParts?: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
synthetic?: boolean;
|
synthetic?: boolean;
|
||||||
|
metadata?: ContextPartMetadata;
|
||||||
files?: Array<FileInputLite>;
|
files?: Array<FileInputLite>;
|
||||||
}>;
|
}>;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -842,11 +844,10 @@ class OpencodeService {
|
|||||||
if (params.additionalParts && params.additionalParts.length > 0) {
|
if (params.additionalParts && params.additionalParts.length > 0) {
|
||||||
for (const additional of params.additionalParts) {
|
for (const additional of params.additionalParts) {
|
||||||
if (additional.text && additional.text.trim()) {
|
if (additional.text && additional.text.trim()) {
|
||||||
parts.push({
|
const additionalTextPart: TextPartInput = { type: 'text', text: additional.text };
|
||||||
type: 'text',
|
if (additional.synthetic) additionalTextPart.synthetic = true;
|
||||||
text: additional.text,
|
if (additional.metadata) additionalTextPart.metadata = additional.metadata;
|
||||||
...(additional.synthetic ? { synthetic: true } : {}),
|
parts.push(additionalTextPart);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (additional.files && additional.files.length > 0) {
|
if (additional.files && additional.files.length > 0) {
|
||||||
for (const file of additional.files) {
|
for (const file of additional.files) {
|
||||||
@@ -1193,7 +1194,7 @@ class OpencodeService {
|
|||||||
options?: {
|
options?: {
|
||||||
id?: string;
|
id?: string;
|
||||||
save?: string[];
|
save?: string[];
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: ContextPartMetadata;
|
||||||
source?: PermissionV2Source;
|
source?: PermissionV2Source;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test';
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
import { useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
import { migratePersistedDrafts, persistedDraftEnvelopeSchema, useInlineCommentDraftStore } from './useInlineCommentDraftStore';
|
||||||
|
|
||||||
const selection = {
|
const selection = {
|
||||||
source: 'terminal' as const,
|
source: 'terminal' as const,
|
||||||
@@ -7,7 +7,8 @@ const selection = {
|
|||||||
startLine: 4,
|
startLine: 4,
|
||||||
endLine: 5,
|
endLine: 5,
|
||||||
code: 'first\nsecond',
|
code: 'first\nsecond',
|
||||||
language: 'term-1',
|
language: '',
|
||||||
|
terminalId: 'term-1',
|
||||||
text: '',
|
text: '',
|
||||||
};
|
};
|
||||||
const target = { directory: '/repo', sessionKey: 'session-1' };
|
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 { create } from 'zustand';
|
||||||
|
import { z } from 'zod';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { normalizePath } from '@/lib/pathNormalization';
|
import { normalizePath } from '@/lib/pathNormalization';
|
||||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
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 = {
|
export type InlineCommentDraftTarget = {
|
||||||
directory: string;
|
directory: string;
|
||||||
@@ -22,6 +23,8 @@ export interface InlineCommentDraft {
|
|||||||
code: string;
|
code: string;
|
||||||
language: string;
|
language: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Owning terminal session; set only for `source: 'terminal'`. */
|
||||||
|
terminalId?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +165,63 @@ const boundState = (
|
|||||||
return { drafts: retainedDrafts, touchedAt: retainedTouchedAt };
|
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 => {
|
const removeDraftKey = (state: InlineCommentDraftState, key: string): InlineCommentDraftState => {
|
||||||
if (!(key in state.drafts)) return state;
|
if (!(key in state.drafts)) return state;
|
||||||
|
|
||||||
@@ -281,9 +341,9 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
|
|||||||
{
|
{
|
||||||
name: 'openchamber-inline-comment-drafts',
|
name: 'openchamber-inline-comment-drafts',
|
||||||
storage: createDeferredSafeJSONStorage(),
|
storage: createDeferredSafeJSONStorage(),
|
||||||
version: 2,
|
version: 3,
|
||||||
partialize: (state) => ({ drafts: state.drafts, touchedAt: state.touchedAt }),
|
partialize: (state) => ({ drafts: state.drafts, touchedAt: state.touchedAt }),
|
||||||
migrate: () => ({ drafts: {}, touchedAt: {} }),
|
migrate: (persisted, version) => migratePersistedDrafts(persistedDraftEnvelopeSchema.safeParse(persisted), version),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
{ name: 'inline-comment-draft-store' },
|
{ name: 'inline-comment-draft-store' },
|
||||||
|
|||||||
@@ -500,6 +500,26 @@
|
|||||||
any input above the keyboard instead of hiding it underneath. The opacity term
|
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
|
preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise
|
||||||
be overridden by this rule's `transition` shorthand). */
|
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 {
|
:root.oc-capacitor-app .oc-keyboard-inset-surface {
|
||||||
bottom: var(--oc-keyboard-inset, 0px);
|
bottom: var(--oc-keyboard-inset, 0px);
|
||||||
transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out;
|
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 { create } from "zustand"
|
||||||
|
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
|
||||||
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
import type { AttachedFile } from "@/stores/types/sessionTypes"
|
||||||
import { prepareAttachmentFiles } from "./attachment-files"
|
import { prepareAttachmentFiles } from "./attachment-files"
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export type SyntheticContextPart = {
|
|||||||
text: string
|
text: string
|
||||||
attachments?: AttachedFile[]
|
attachments?: AttachedFile[]
|
||||||
synthetic?: boolean
|
synthetic?: boolean
|
||||||
|
metadata?: ContextPartMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VSCodeActiveEditorFile = {
|
export type VSCodeActiveEditorFile = {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* SDK-calling actions that need domain data read it from sync-refs.
|
* SDK-calling actions that need domain data read it from sync-refs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
|
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
|
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
|
||||||
@@ -138,7 +139,7 @@ export function routeMessage(params: {
|
|||||||
variant?: string
|
variant?: string
|
||||||
inputMode?: "normal" | "shell"
|
inputMode?: "normal" | "shell"
|
||||||
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
|
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'
|
delivery?: 'steer'
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const requestDirectory = params.directory ?? undefined
|
const requestDirectory = params.directory ?? undefined
|
||||||
@@ -357,7 +358,7 @@ export type SessionUIState = {
|
|||||||
agent?: string,
|
agent?: string,
|
||||||
attachments?: AttachedFile[],
|
attachments?: AttachedFile[],
|
||||||
agentMentionName?: string,
|
agentMentionName?: string,
|
||||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||||
variant?: string,
|
variant?: string,
|
||||||
inputMode?: "normal" | "shell",
|
inputMode?: "normal" | "shell",
|
||||||
options?: SendMessageOptions,
|
options?: SendMessageOptions,
|
||||||
@@ -1490,7 +1491,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
agent?: string,
|
agent?: string,
|
||||||
attachments?: AttachedFile[],
|
attachments?: AttachedFile[],
|
||||||
agentMentionName?: string,
|
agentMentionName?: string,
|
||||||
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
|
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
|
||||||
variant?: string,
|
variant?: string,
|
||||||
inputMode?: "normal" | "shell",
|
inputMode?: "normal" | "shell",
|
||||||
options?: SendMessageOptions,
|
options?: SendMessageOptions,
|
||||||
@@ -1578,7 +1579,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
createdDraftSession.directory,
|
createdDraftSession.directory,
|
||||||
createdDraftSession.sessionId,
|
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 }] : []
|
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true }] : []
|
||||||
// Left undefined when nothing was added, as before: an empty array is not
|
// Left undefined when nothing was added, as before: an empty array is not
|
||||||
// the same as no additional parts to everything downstream.
|
// the same as no additional parts to everything downstream.
|
||||||
@@ -1613,6 +1614,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
additionalParts: mergedAdditionalParts?.map((p) => ({
|
additionalParts: mergedAdditionalParts?.map((p) => ({
|
||||||
text: p.text,
|
text: p.text,
|
||||||
synthetic: p.synthetic,
|
synthetic: p.synthetic,
|
||||||
|
metadata: p.metadata,
|
||||||
files: p.attachments?.map((a: AttachedFile) => ({
|
files: p.attachments?.map((a: AttachedFile) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: a.mimeType,
|
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,
|
// Prepended so it reads as background before the message it accompanies,
|
||||||
// and empty unless the session is actually missing it.
|
// and empty unless the session is actually missing it.
|
||||||
const knowledge = await fetchSessionKnowledge(currentSessionDirectory, targetSessionId || "")
|
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 }] : []
|
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
|
||||||
const partsWithPinnedContext = prefixParts.length > 0
|
const partsWithPinnedContext = prefixParts.length > 0
|
||||||
? [...prefixParts, ...(additionalParts || [])]
|
? [...prefixParts, ...(additionalParts || [])]
|
||||||
@@ -1716,6 +1718,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
additionalParts: partsWithPinnedContext?.map((p) => ({
|
additionalParts: partsWithPinnedContext?.map((p) => ({
|
||||||
text: p.text,
|
text: p.text,
|
||||||
synthetic: p.synthetic,
|
synthetic: p.synthetic,
|
||||||
|
metadata: p.metadata,
|
||||||
files: p.attachments?.map((a) => ({
|
files: p.attachments?.map((a) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime: a.mimeType,
|
mime: a.mimeType,
|
||||||
|
|||||||
Reference in New Issue
Block a user