feat(chat): structured context attachments with metadata round-trip
Every user-attached context item (diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues/PRs, and new chat-quote comments from the selection menu) is now sent as its own synthetic text part carrying an openchamberContext metadata payload. The model-facing text keeps the previous wording; the timeline reads the metadata back and renders each item as a context card instead of raw prompt text. Legacy messages still render via the old text sniffing. The selection menu gains a Comment option with an inline multiline input, the quoted fragment stays highlighted while commenting, and on mobile the input overlays the composer pill by rendering inside the composer form. Add to chat is renamed Add to input; the menu is restyled and the mobile Copy tile removed. Terminal drafts move their terminal id out of the language field (persisted-draft migration v3), and the dead preview-console source is deleted.
This commit is contained in:
@@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
@@ -757,21 +756,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
React.useCallback(
|
||||
(state) => {
|
||||
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
|
||||
let previewConsole = 0;
|
||||
let previewAnnotation = 0;
|
||||
let review = 0;
|
||||
let terminal = 0;
|
||||
let prComment = 0;
|
||||
let prCheck = 0;
|
||||
let chatQuote = 0;
|
||||
for (const draft of drafts) {
|
||||
if (draft.source === 'preview-console') previewConsole += 1;
|
||||
else if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
if (draft.source === 'preview-annotation') previewAnnotation += 1;
|
||||
else if (draft.source === 'terminal') terminal += 1;
|
||||
else if (draft.source === 'pr-comment') prComment += 1;
|
||||
else if (draft.source === 'pr-check') prCheck += 1;
|
||||
else if (draft.source === 'chat-quote') chatQuote += 1;
|
||||
else review += 1;
|
||||
}
|
||||
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`;
|
||||
return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`;
|
||||
},
|
||||
[inlineDraftKey]
|
||||
)
|
||||
@@ -779,11 +778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const hasDrafts = draftCount > 0;
|
||||
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
|
||||
const terminalContextDrafts = terminalContextCount > 0
|
||||
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
|
||||
: [];
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => {
|
||||
const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => {
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
@@ -797,7 +796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!inlineDraftTarget) return;
|
||||
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') {
|
||||
if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') {
|
||||
removeInlineCommentDraft(inlineDraftTarget, draft.id);
|
||||
}
|
||||
}
|
||||
@@ -930,12 +929,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const inputSnapshot = getCurrentInputSnapshot();
|
||||
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
|
||||
|
||||
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
|
||||
|
||||
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
if (drafts.length > 0) {
|
||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||
}
|
||||
// Context drafts stay in their store: the send that later delivers the
|
||||
// queue consumes them and attaches them as structured context parts.
|
||||
const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles);
|
||||
|
||||
addToQueue(messageQueueTarget, {
|
||||
@@ -961,7 +957,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (!isMobile) {
|
||||
composerRef.current?.focus();
|
||||
}
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||
|
||||
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||
setMessage(content);
|
||||
@@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
// assembly so a failed send can restore exactly what it took. Context
|
||||
// drafts ride with whichever send goes out next, including queued
|
||||
// auto-sends: queueing leaves them in the store on purpose.
|
||||
const syntheticParts = consumePendingSyntheticParts();
|
||||
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget;
|
||||
const consumedDraftTarget = inlineDraftTarget;
|
||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||
? consumeDrafts(consumedDraftTarget)
|
||||
: [];
|
||||
@@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
composerAttachments: attachedFiles,
|
||||
inlineComments: drafts,
|
||||
syntheticTexts: syntheticParts?.map((part) => part.text) ?? [],
|
||||
linkedIssueContext: linkedIssue?.contextText ?? null,
|
||||
linkedIssue: linkedIssue
|
||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||
: null,
|
||||
linkedPr: linkedPr
|
||||
? { instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
@@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
},
|
||||
sanitizeAttachments: sanitizeAttachmentsForSend,
|
||||
collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames),
|
||||
appendComments: (text, comments) =>
|
||||
appendInlineComments(text, comments as InlineCommentDraft[]),
|
||||
buildSkillInstruction: buildSkillMentionInstruction,
|
||||
});
|
||||
|
||||
@@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
reviewCount={reviewCount}
|
||||
prCommentCount={prCommentCount}
|
||||
prCheckCount={prCheckCount}
|
||||
previewConsoleCount={previewConsoleCount}
|
||||
previewAnnotationCount={previewAnnotationCount}
|
||||
chatQuoteCount={chatQuoteCount}
|
||||
draftTarget={inlineDraftTarget}
|
||||
onRemoveDraft={removeInlineCommentDraft}
|
||||
onRemoveReviewDrafts={removeReviewDrafts}
|
||||
|
||||
@@ -124,10 +124,14 @@ and the send path reading the same grammar.
|
||||
drawn caret through a class it only writes while applying an update, so the
|
||||
selection has to be the update that follows the focus.
|
||||
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
||||
inline comments and context into OpenCode's one-primary-plus-parts shape. The
|
||||
oldest queued message becomes primary; **inline comments attach to the last
|
||||
body the user authored** rather than becoming their own part; PR instructions
|
||||
precede the PR diff.
|
||||
context drafts and linked references into OpenCode's one-primary-plus-parts
|
||||
shape. The oldest queued message becomes primary. **Every attached context
|
||||
item (inline comments, terminal selections, browser annotations, PR context,
|
||||
linked issue/PR) becomes its own synthetic text part carrying structured
|
||||
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
|
||||
metadata back to render context blocks. PR instructions precede the PR diff.
|
||||
Queueing a message leaves context drafts in their store on purpose — the send
|
||||
that later delivers the queue consumes them.
|
||||
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
||||
session) identity. Writes are debounced while typing but forced at every edge
|
||||
where the page may stop running, because a pending timer is not a saved
|
||||
|
||||
+53
-30
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts';
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type OutgoingMessageDeps,
|
||||
@@ -26,7 +28,6 @@ const deps = (overrides: Partial<OutgoingMessageDeps> = {}): OutgoingMessageDeps
|
||||
},
|
||||
sanitizeAttachments: (files) => [...(files ?? [])],
|
||||
collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]),
|
||||
appendComments: (text, comments) => `${text}\n[${comments.length} comments]`,
|
||||
buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null),
|
||||
...overrides,
|
||||
});
|
||||
@@ -37,7 +38,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
composerAttachments: [],
|
||||
inlineComments: [],
|
||||
syntheticTexts: [],
|
||||
linkedIssueContext: null,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
...overrides,
|
||||
});
|
||||
@@ -130,36 +131,51 @@ describe('agent mentions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline comments', () => {
|
||||
test('attach to the composer text when nothing was queued', () => {
|
||||
const commentDraft = (overrides: Partial<InlineCommentDraft> = {}): InlineCommentDraft => ({
|
||||
id: 'icd-1',
|
||||
sessionKey: 's1',
|
||||
source: 'diff',
|
||||
fileLabel: 'src/app.ts',
|
||||
startLine: 3,
|
||||
endLine: 5,
|
||||
side: 'modified',
|
||||
code: 'const x = 1;',
|
||||
language: 'ts',
|
||||
text: 'fix this',
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('context drafts', () => {
|
||||
test('each becomes a synthetic part carrying structured metadata', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'body',
|
||||
inlineComments: [{}, {}],
|
||||
inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('body\n[2 comments]');
|
||||
expect(result.primaryText).toBe('body');
|
||||
expect(result.additionalParts).toHaveLength(2);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft()));
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined })));
|
||||
expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):');
|
||||
expect(result.additionalParts[0].text).toContain('fix this');
|
||||
});
|
||||
|
||||
test('attach to the last authored part when messages were queued', () => {
|
||||
test('context parts precede other synthetic context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'queued' }],
|
||||
composerText: 'typed',
|
||||
inlineComments: [{}],
|
||||
composerText: 'body',
|
||||
inlineComments: [commentDraft()],
|
||||
syntheticTexts: ['conflict note'],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('queued');
|
||||
expect(result.additionalParts[0].text).toBe('typed\n[1 comments]');
|
||||
expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text))
|
||||
.toEqual(['comment', 'conflict note']);
|
||||
});
|
||||
|
||||
test('fall back to primary when the queue produced no additional parts', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
queued: [{ content: 'only queued' }],
|
||||
inlineComments: [{}],
|
||||
}), deps());
|
||||
expect(result.primaryText).toBe('only queued\n[1 comments]');
|
||||
});
|
||||
|
||||
test('no comments changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText)
|
||||
.toBe('body');
|
||||
test('no drafts changes nothing', () => {
|
||||
expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts)
|
||||
.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,26 +183,31 @@ describe('synthetic context', () => {
|
||||
test('a linked PR sends its instructions before its diff', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'review this',
|
||||
linkedPr: { instructions: 'how to read it', context: 'the diff' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['how to read it', 'the diff']);
|
||||
expect(result.additionalParts.every((p) => p.synthetic)).toBe(true);
|
||||
expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' });
|
||||
});
|
||||
|
||||
test('a linked issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]);
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('issue body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
syntheticTexts: ['conflict note'],
|
||||
linkedIssueContext: 'issue body',
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts.map((p) => p.text))
|
||||
.toEqual(['conflict note', 'issue body']);
|
||||
@@ -211,7 +232,9 @@ describe('synthetic context', () => {
|
||||
});
|
||||
|
||||
test('context alone is still worth sending', () => {
|
||||
const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps());
|
||||
const result = buildOutgoingMessage(input({
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' },
|
||||
}), deps());
|
||||
expect(result.isEmpty).toBe(false);
|
||||
});
|
||||
|
||||
@@ -230,8 +253,8 @@ describe('full assembly order', () => {
|
||||
queued: [{ content: 'q1' }, { content: 'q2' }],
|
||||
composerText: 'typed /deploy',
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssueContext: 'issue',
|
||||
linkedPr: { instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
*/
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||
|
||||
export interface OutgoingPart {
|
||||
text: string;
|
||||
attachments?: AttachedFile[];
|
||||
/** Synthetic parts are context for the model, not shown as user content. */
|
||||
synthetic?: boolean;
|
||||
/** Structured context (see contextParts.ts), persisted with the part. */
|
||||
metadata?: ContextPartMetadata;
|
||||
}
|
||||
|
||||
export interface OutgoingMessage {
|
||||
@@ -43,12 +47,12 @@ export interface OutgoingMessageInput {
|
||||
/** The composer's own text, or null when this send skips it. */
|
||||
composerText: string | null;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
/** Inline review comments, appended to the user's last authored text. */
|
||||
inlineComments: readonly unknown[];
|
||||
/** Context drafts (code comments, terminal selections, annotations, PR context). */
|
||||
inlineComments: readonly InlineCommentDraft[];
|
||||
/** Synthetic context produced elsewhere (conflict resolution, and such). */
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssueContext: string | null;
|
||||
linkedPr: { instructions: string; context: string } | null;
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,8 +68,6 @@ export interface OutgoingMessageDeps {
|
||||
sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[];
|
||||
/** Skills named inline with `/name`. */
|
||||
collectSkillNames: (text: string) => string[];
|
||||
/** Append inline review comments to a message body. */
|
||||
appendComments: (text: string, comments: readonly unknown[]) => string;
|
||||
/** Instruction telling the model which skills the user named. */
|
||||
buildSkillInstruction: (names: string[]) => string | null;
|
||||
}
|
||||
@@ -134,33 +136,29 @@ export function buildOutgoingMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Inline comments attach to the last thing the user authored, so they read
|
||||
// as a continuation of it rather than as a separate turn.
|
||||
if (input.inlineComments.length > 0) {
|
||||
const lastAuthored = input.queued.length > 0 && additionalParts.length > 0
|
||||
? additionalParts[additionalParts.length - 1]
|
||||
: null;
|
||||
if (lastAuthored) {
|
||||
lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments);
|
||||
} else {
|
||||
primaryText = deps.appendComments(primaryText, input.inlineComments);
|
||||
}
|
||||
// Everything below is context for the model, never plain user text. Each
|
||||
// attached context item becomes its own synthetic part carrying structured
|
||||
// metadata, so the timeline can render it as a context block after the
|
||||
// server echoes the message back.
|
||||
for (const draft of input.inlineComments) {
|
||||
additionalParts.push(createContextPart(contextPayloadFromDraft(draft)));
|
||||
}
|
||||
|
||||
// Everything below is context for the model, never user-visible content.
|
||||
for (const text of input.syntheticTexts) {
|
||||
additionalParts.push({ text, synthetic: true });
|
||||
}
|
||||
|
||||
if (input.linkedIssueContext) {
|
||||
additionalParts.push({ text: input.linkedIssueContext, synthetic: true });
|
||||
if (input.linkedIssue) {
|
||||
const { number, title, url, contextText } = input.linkedIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText));
|
||||
}
|
||||
|
||||
if (input.linkedPr) {
|
||||
// Instructions before context: the model is told how to read the diff
|
||||
// before it is given the diff.
|
||||
additionalParts.push({ text: input.linkedPr.instructions, synthetic: true });
|
||||
additionalParts.push({ text: input.linkedPr.context, synthetic: true });
|
||||
const { number, title, url, instructions, context } = input.linkedPr;
|
||||
additionalParts.push({ text: instructions, synthetic: true });
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
|
||||
@@ -20,12 +20,12 @@ export interface ComposerContextChipsProps {
|
||||
reviewCount: number;
|
||||
prCommentCount: number;
|
||||
prCheckCount: number;
|
||||
previewConsoleCount: number;
|
||||
previewAnnotationCount: number;
|
||||
chatQuoteCount: number;
|
||||
draftTarget: InlineCommentDraftTarget | null;
|
||||
onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void;
|
||||
onRemoveReviewDrafts: () => void;
|
||||
onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void;
|
||||
onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void;
|
||||
colors: Theme['colors'];
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
||||
reviewCount,
|
||||
prCommentCount,
|
||||
prCheckCount,
|
||||
previewConsoleCount,
|
||||
previewAnnotationCount,
|
||||
chatQuoteCount,
|
||||
draftTarget,
|
||||
onRemoveDraft,
|
||||
onRemoveReviewDrafts,
|
||||
@@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewConsoleCount > 0 ? (
|
||||
{chatQuoteCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.devServerLogs')}
|
||||
count={previewConsoleCount}
|
||||
removeLabel={t('chat.chatInput.devServerLogsRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('preview-console')}
|
||||
label={t('chat.chatInput.chatQuoteContext')}
|
||||
count={chatQuoteCount}
|
||||
removeLabel={t('chat.chatInput.chatQuoteContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('chat-quote')}
|
||||
colors={colors}
|
||||
icon={<Icon name="chat-1" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
data-mobile-composer-pill="true"
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
<ComposerAttachmentControls
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { PROJECT_NOTE_BODY_MAX_LENGTH } from '@/lib/projectContextApi';
|
||||
@@ -33,6 +33,8 @@ interface SelectionPayload {
|
||||
plainText: string;
|
||||
markdownText: string;
|
||||
rect: DOMRect;
|
||||
messageId: string | null;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const normalizeDistilledInsight = (insight: string): string => (
|
||||
@@ -46,6 +48,103 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState('');
|
||||
const [selectedMessageId, setSelectedMessageId] = React.useState<string | null>(null);
|
||||
const [commentMode, setCommentMode] = React.useState(false);
|
||||
const commentModeRef = React.useRef(false);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const commentInputRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// While the comment input owns focus the native selection is gone, so the
|
||||
// quoted fragment is repainted with our own overlay rectangles. Raw
|
||||
// Range.getClientRects() mixes block-container boxes with text boxes and
|
||||
// the translucent overlaps paint double-dark bands, so the rects are taken
|
||||
// from the text nodes only and merged into one strip per visual line.
|
||||
const [commentRects, setCommentRects] = React.useState<DOMRect[] | null>(null);
|
||||
const updateCommentRects = React.useCallback(() => {
|
||||
const range = pendingSelectionRef.current?.range;
|
||||
if (!range) {
|
||||
setCommentRects(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const textRects: DOMRect[] = [];
|
||||
const pushNodeRects = (node: Text) => {
|
||||
const nodeRange = document.createRange();
|
||||
nodeRange.selectNodeContents(node);
|
||||
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
|
||||
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
|
||||
// Text rects cover only the glyph box; the native selection paints the
|
||||
// full line box, so each rect is stretched to its element's line-height.
|
||||
const lineHeight = node.parentElement
|
||||
? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight)
|
||||
: Number.NaN;
|
||||
for (const rect of nodeRange.getClientRects()) {
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
if (Number.isFinite(lineHeight) && lineHeight > rect.height) {
|
||||
const expand = (lineHeight - rect.height) / 2;
|
||||
textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight));
|
||||
} else {
|
||||
textRects.push(rect);
|
||||
}
|
||||
}
|
||||
};
|
||||
const root = range.commonAncestorContainer;
|
||||
if (root instanceof Text) {
|
||||
pushNodeRects(root);
|
||||
} else {
|
||||
// SAFETY: the walker is created with SHOW_TEXT, so every node it
|
||||
// yields is a Text node.
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (range.intersectsNode(node)) pushNodeRects(node as Text);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge rects that sit on the same visual line into one strip, the way
|
||||
// the native selection paints a line box.
|
||||
const lines: Array<{ left: number; right: number; top: number; bottom: number }> = [];
|
||||
for (const rect of textRects) {
|
||||
const line = lines.find((candidate) => (
|
||||
Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6
|
||||
));
|
||||
if (line) {
|
||||
line.left = Math.min(line.left, rect.left);
|
||||
line.right = Math.max(line.right, rect.right);
|
||||
line.top = Math.min(line.top, rect.top);
|
||||
line.bottom = Math.max(line.bottom, rect.bottom);
|
||||
} else {
|
||||
lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom });
|
||||
}
|
||||
}
|
||||
setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top)));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!commentMode) return;
|
||||
let frame: number | null = null;
|
||||
const scheduleUpdate = () => {
|
||||
if (frame !== null) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
updateCommentRects();
|
||||
});
|
||||
};
|
||||
document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
return () => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener('scroll', scheduleUpdate, { capture: true });
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [commentMode, updateCommentRects]);
|
||||
|
||||
// Grow the comment box with its content, up to five lines.
|
||||
const resizeCommentInput = React.useCallback(() => {
|
||||
const element = commentInputRef.current;
|
||||
if (!element) return;
|
||||
element.style.height = 'auto';
|
||||
element.style.height = `${Math.min(element.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [isAddingToNotes, setIsAddingToNotes] = React.useState(false);
|
||||
@@ -57,6 +156,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionUIStore((state) => state.createSession);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
@@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const sessions = useSessions();
|
||||
|
||||
// Mobile: the comment bar is rendered inside the composer form (its
|
||||
// positioning context), so it inherits the runtime's own keyboard handling
|
||||
// — browser viewport resizing and Capacitor choreography alike. This effect
|
||||
// only centers it on the composer pill in the form's local coordinates; no
|
||||
// viewport math, which Safari's keyboard handling reliably breaks for
|
||||
// fixed elements.
|
||||
React.useEffect(() => {
|
||||
if (!commentMode || !isMobile) return;
|
||||
const update = () => {
|
||||
const element = menuRef.current;
|
||||
const host = element?.offsetParent;
|
||||
if (!element || !host) return;
|
||||
const pill = document.querySelector('[data-mobile-composer-pill="true"]')
|
||||
?? document.querySelector('[data-chat-input="true"]');
|
||||
const pillRect = pill?.getBoundingClientRect();
|
||||
if (!pillRect || pillRect.height <= 0) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`;
|
||||
element.style.left = `${pillRect.left - hostRect.left}px`;
|
||||
element.style.width = `${pillRect.width}px`;
|
||||
element.style.bottom = 'auto';
|
||||
};
|
||||
update();
|
||||
const raf = window.requestAnimationFrame(update);
|
||||
// The composer relayouts with its own transitions and timeouts that emit
|
||||
// no event; a light poll keeps the overlay glued to the pill.
|
||||
const poll = window.setInterval(update, 200);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [commentMode, isMobile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMenuVisibleRef.current = position.show;
|
||||
}, [position.show]);
|
||||
@@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
pendingSelectionRef.current = null;
|
||||
setCommentRects(null);
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
return;
|
||||
@@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
setPosition((prev) => ({ ...prev, show: false }));
|
||||
setSelectedText('');
|
||||
setSelectedTextMarkdown('');
|
||||
setSelectedMessageId(null);
|
||||
setCommentMode(false);
|
||||
commentModeRef.current = false;
|
||||
setCommentText('');
|
||||
isMenuVisibleRef.current = false;
|
||||
}, []);
|
||||
|
||||
@@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
const { plainText, markdownText, rect } = pendingSelectionRef.current;
|
||||
const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
// Position menu above the selection
|
||||
@@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
setSelectedText(plainText);
|
||||
setSelectedTextMarkdown(markdownText);
|
||||
setSelectedMessageId(messageId);
|
||||
setPosition({
|
||||
x: menuX,
|
||||
y: menuY,
|
||||
@@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
const handleSelectionChange = React.useCallback(() => {
|
||||
// While the comment input is open, clicking or typing in it collapses the
|
||||
// text selection; the captured quote must survive that.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// Store the selection but don't show menu yet if dragging
|
||||
const anchorElement = range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
pendingSelectionRef.current = {
|
||||
plainText: text,
|
||||
markdownText: rangeToMarkdown(range, text),
|
||||
rect,
|
||||
messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null,
|
||||
range: range.cloneRange(),
|
||||
};
|
||||
|
||||
// Only show menu if we're not currently dragging
|
||||
@@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (!container) return;
|
||||
|
||||
// Track when dragging starts
|
||||
const handleMouseDown = () => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
// SAFETY: a MouseEvent target inside the document is always a Node;
|
||||
// `contains` only needs that.
|
||||
if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
isDraggingRef.current = true;
|
||||
hideMenu();
|
||||
};
|
||||
@@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
// Small delay to ensure selection is finalized
|
||||
mouseUpTimeoutRef.current = window.setTimeout(() => {
|
||||
mouseUpTimeoutRef.current = null;
|
||||
// The click that opened the comment input cleared the selection on
|
||||
// purpose; the input must survive this deferred check.
|
||||
if (commentModeRef.current) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim()) {
|
||||
showMenu();
|
||||
@@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
!window.getSelection()?.toString().trim()
|
||||
(commentModeRef.current || !window.getSelection()?.toString().trim())
|
||||
) {
|
||||
hideMenu();
|
||||
}
|
||||
@@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
});
|
||||
}, [selectedTextMarkdown, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleOpenComment = React.useCallback(() => {
|
||||
if (!selectedTextMarkdown) return;
|
||||
setCommentMode(true);
|
||||
commentModeRef.current = true;
|
||||
updateCommentRects();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
queueMicrotask(() => {
|
||||
commentInputRef.current?.focus();
|
||||
});
|
||||
}, [selectedTextMarkdown, updateCommentRects]);
|
||||
|
||||
const handleAttachComment = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) {
|
||||
hideMenu();
|
||||
return;
|
||||
}
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'chat-quote',
|
||||
fileLabel: selectedMessageId ?? '',
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
code: selectedTextMarkdown,
|
||||
language: '',
|
||||
text: commentText.trim(),
|
||||
});
|
||||
hideMenu();
|
||||
queueMicrotask(() => {
|
||||
focusChatInput();
|
||||
});
|
||||
}, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]);
|
||||
|
||||
const handleCreateNewSession = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
@@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, createSession, setPendingInputText, hideMenu]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!selectedText) return;
|
||||
|
||||
const result = await copyTextToClipboard(selectedText);
|
||||
if (!result.ok) {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
|
||||
hideMenu();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}, [selectedText, hideMenu]);
|
||||
|
||||
const currentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
@@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
|
||||
if (!position.show) return null;
|
||||
|
||||
const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0
|
||||
? createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[5]">
|
||||
{commentRects.map((rect, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="oc-chat-comment-rect absolute"
|
||||
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
const commentInput = (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-end gap-2 rounded-3xl border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] pl-4 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'py-1 pr-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={commentInputRef}
|
||||
rows={1}
|
||||
value={commentText}
|
||||
onChange={(event) => {
|
||||
setCommentText(event.target.value);
|
||||
resizeCommentInput();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Desktop: Enter attaches, Shift+Enter breaks the line. Mobile
|
||||
// keyboards use Enter for line breaks; attaching is the button's job.
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isMobile) {
|
||||
event.preventDefault();
|
||||
handleAttachComment();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
hideMenu();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||
className={cn(
|
||||
'flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]',
|
||||
// The width cap sizes the floating desktop pill; on mobile the pill
|
||||
// spans the bottom bar and the cap would strand slack space to the
|
||||
// right of the attach button.
|
||||
isMobile ? 'w-full min-w-0 py-1.5 text-base leading-6' : 'w-64 max-w-[70vw] py-1.5'
|
||||
)}
|
||||
style={{ minHeight: 0, height: 'auto' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAttachComment}
|
||||
className={cn(
|
||||
'mb-0.5 flex shrink-0 items-center justify-center rounded-full bg-[var(--primary-base)] text-[var(--primary-foreground)] hover:opacity-90 transition-opacity duration-150',
|
||||
isMobile ? 'h-9 w-9' : 'h-8 w-8'
|
||||
)}
|
||||
aria-label={t('chat.textSelection.comment.attach')}
|
||||
title={t('chat.textSelection.comment.attach')}
|
||||
>
|
||||
<Icon name="attachment-2" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Mobile: Show as a bar at the bottom of the screen, above the keyboard
|
||||
if (isMobile) {
|
||||
if (commentMode) {
|
||||
// Overlay the comment input onto the composer pill: rendering into the
|
||||
// composer form (position: relative) inherits the runtime's keyboard
|
||||
// handling in both browser and Capacitor; the centering effect above
|
||||
// glues it to the pill in the form's local coordinates.
|
||||
const composerHost = document.querySelector('form.oc-mobile-composer');
|
||||
const bar = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'z-50',
|
||||
composerHost
|
||||
? 'absolute inset-x-0 bottom-[var(--oc-safe-area-bottom-visual,0.5rem)]'
|
||||
: 'oc-chat-comment-bar fixed left-3 right-3 mx-auto max-w-[420px]',
|
||||
)}
|
||||
>
|
||||
{commentInput}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{commentHighlightOverlay}
|
||||
{createPortal(bar, composerHost ?? document.body)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
'fixed left-3 right-3 bottom-0 z-50 mx-auto max-w-[420px]',
|
||||
'rounded-2xl border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] p-2 shadow-lg',
|
||||
'bg-[var(--surface-elevated)] p-2 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'safe-area-bottom',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
@@ -408,6 +683,22 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-1" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.comment')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
@@ -421,7 +712,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -440,22 +731,6 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
|
||||
'text-sm font-medium leading-tight',
|
||||
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
|
||||
'active:opacity-80',
|
||||
'transition-opacity duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.actions.copy')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="file-copy" className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.copy')}</span>
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
@@ -491,73 +766,90 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 whitespace-nowrap',
|
||||
'rounded-lg border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none',
|
||||
'px-1.5 py-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
{commentMode ? (<>{commentHighlightOverlay}{commentInput}</>) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
'flex items-center whitespace-nowrap',
|
||||
'rounded-full border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]',
|
||||
'p-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToChat')}</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4" />
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.newSession')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenComment}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.commentOnSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.comment')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : <Icon name="booklet" className="h-4 w-4" />}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.addToCurrentChat')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.addToInput')}
|
||||
</button>
|
||||
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleCreateNewSession}
|
||||
className={cn(
|
||||
'px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)]',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.newSessionWithSelection')}
|
||||
type="button"
|
||||
>
|
||||
{t('chat.textSelection.actions.newSession')}
|
||||
</button>
|
||||
|
||||
{!isVSCodeRuntime() ? (
|
||||
<>
|
||||
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
|
||||
|
||||
<button
|
||||
onClick={handleAddToNotes}
|
||||
disabled={isAddingToNotes}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full',
|
||||
'text-sm font-medium',
|
||||
'text-[var(--surface-foreground)]',
|
||||
'hover:bg-[var(--interactive-hover)] disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
'transition-colors duration-150'
|
||||
)}
|
||||
title={t('chat.textSelection.title.saveInsightToNotes')}
|
||||
type="button"
|
||||
>
|
||||
{isAddingToNotes ? <Icon name="loader-4" className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="whitespace-nowrap">{t('chat.textSelection.actions.addToNotes')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
@@ -96,6 +97,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
|
||||
if (!synthetic) return true;
|
||||
if (part.type !== 'text') return false;
|
||||
if (readContextPart(part)) return true;
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
@@ -116,6 +118,27 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = rawPart.synthetic === true;
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
? 'application/vnd.github.issue-link'
|
||||
: 'application/vnd.github.pull-request-link',
|
||||
filename: contextPayload.kind === 'github-issue'
|
||||
? `Issue #${contextPayload.number}: ${contextPayload.title}`
|
||||
: `PR #${contextPayload.number}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
if (contextPayload) {
|
||||
// Other context kinds render through UserContextPart.
|
||||
return part;
|
||||
}
|
||||
// Legacy messages: sniff the pre-metadata text format.
|
||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||
if (attachmentPart) {
|
||||
return attachmentPart;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -54,6 +55,13 @@ export const filterVisibleParts = (parts: Part[], options: VisibleFilterOptions
|
||||
}
|
||||
}
|
||||
|
||||
// User-attached context (inline comments, terminal selections, and
|
||||
// such) is synthetic transport-wise but is user content: it renders
|
||||
// as a context block and must survive alongside regular text.
|
||||
if (isSynthetic && readContextPart(part)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only filter out synthetic parts if there are non-synthetic parts present
|
||||
// Otherwise, show synthetic parts so the message is displayed
|
||||
if (isSynthetic && hasNonSynthetic) {
|
||||
|
||||
@@ -121,6 +121,14 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
## Quick map of files in this folder
|
||||
|
||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||
- User-attached context (inline code comments, terminal selections, browser
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ContextPartPayload } from '@/lib/messages/contextParts';
|
||||
|
||||
/**
|
||||
* A context item attached to a user message: an inline code comment, a
|
||||
* terminal selection, a browser annotation, or GitHub PR context.
|
||||
*
|
||||
* Each item renders as one card so the user's comment reads as part of the
|
||||
* annotation, not as more message text: a header naming the source (with an
|
||||
* expand affordance when captured code/output exists), and the comment text
|
||||
* below it inside the same card. A header with nothing to reveal renders
|
||||
* without the expand affordance.
|
||||
*/
|
||||
|
||||
const ContextCard: React.FC<{
|
||||
icon: IconName;
|
||||
summary: string;
|
||||
/** Full untruncated context, shown on hover. */
|
||||
title?: string;
|
||||
body: string;
|
||||
text: string;
|
||||
}> = ({ icon, summary, title, body, text }) => {
|
||||
const hasBody = body.trim().length > 0;
|
||||
const hasText = text.trim().length > 0;
|
||||
|
||||
const header = hasBody ? (
|
||||
<details className="min-w-0">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] [&::-webkit-details-marker]:hidden" title={title}>
|
||||
<Icon name="arrow-right-s" className="h-3.5 w-3.5 shrink-0 transition-transform [details[open]_&]:rotate-90" />
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap border-t border-[var(--interactive-border)] bg-[var(--surface-background)] px-2.5 py-2 font-mono text-xs text-[var(--surface-foreground)]">{body}</pre>
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-[var(--surface-mutedForeground)]" title={title}>
|
||||
<Icon name={icon} className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{summary}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-1 max-w-full overflow-hidden rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]">
|
||||
{header}
|
||||
{hasText ? (
|
||||
<div className="whitespace-pre-wrap break-words border-t border-[var(--interactive-border)] px-2.5 py-2 font-sans text-sm text-[var(--surface-foreground)]">{text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const basename = (path: string): string => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
switch (payload.kind) {
|
||||
case 'code-comment': {
|
||||
const file = basename(payload.fileLabel);
|
||||
const summary = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine });
|
||||
const fullTitle = payload.startLine === payload.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: payload.fileLabel, line: payload.startLine })
|
||||
: t('chat.message.context.codeComment', { file: payload.fileLabel, start: payload.startLine, end: payload.endLine });
|
||||
return <ContextCard icon="chat-1" summary={summary} title={fullTitle} body={payload.code} text={payload.text} />;
|
||||
}
|
||||
case 'terminal':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="terminal"
|
||||
summary={t('chat.message.terminalContext', {
|
||||
terminal: payload.terminalLabel,
|
||||
start: payload.startLine,
|
||||
end: payload.endLine,
|
||||
})}
|
||||
body={payload.output}
|
||||
text=""
|
||||
/>
|
||||
);
|
||||
case 'browser-annotation':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="global"
|
||||
summary={t('chat.message.context.browserAnnotation', { page: payload.pageUrl })}
|
||||
title={payload.pageUrl}
|
||||
body={payload.prompt}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-comment':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="git-pull-request"
|
||||
summary={t('chat.message.context.prComment', { label: payload.label })}
|
||||
body={payload.body}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'pr-check':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="close-circle"
|
||||
summary={t('chat.message.context.prCheck', { label: payload.label })}
|
||||
body={payload.output}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'chat-quote':
|
||||
return (
|
||||
<ContextCard
|
||||
icon="chat-1"
|
||||
summary={t('chat.message.context.chatQuote')}
|
||||
body={payload.quote}
|
||||
text={payload.text}
|
||||
/>
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(UserContextPart);
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/lib/messages/inlineMessageLinks';
|
||||
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
||||
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
||||
import { readContextPart } from '@/lib/messages/contextParts';
|
||||
import UserContextPart from './UserContextPart';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -30,6 +32,10 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
// Structured context (inline comments, terminal selections, annotations,
|
||||
// PR context) renders as a dedicated block instead of raw prompt text.
|
||||
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
||||
|
||||
const partWithText = part as PartWithText;
|
||||
const rawText = partWithText.text;
|
||||
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
||||
@@ -224,6 +230,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
});
|
||||
}, [agentMention, openSkill, skillByName, textContent]);
|
||||
|
||||
if (contextPayload) {
|
||||
return <UserContextPart payload={contextPayload} />;
|
||||
}
|
||||
|
||||
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -642,7 +642,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
startLine: selection.startLine,
|
||||
endLine: selection.endLine,
|
||||
code: selection.text,
|
||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
||||
language: '',
|
||||
terminalId: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
|
||||
Reference in New Issue
Block a user