Skip to content

Commit 7a5df79

Browse files
luoxuanzaoQoder-AI
andauthored
feat(composer): render captured selections as inline reference chips (#91)
* feat(composer): add inline selection reference token plumbing Extend reference chips to carry selection kinds with custom labels and icons, add composer unregister support, and introduce pure token helpers so editor/browser/canvas selections can render as inline chips. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * refactor(chat): drive selections from composer tokens instead of pills Replace the context-row selection pills with a token sink so the three selection controllers append inline chips to the composer text and reconcile on manual edits. The context row now only hosts image previews. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * feat(chat): render selection tokens as chips in sent bubbles Recognize editor/browser/canvas selection tokens in message text, map each kind to its icon, and document them for the agent so it relies on the accompanying XML snapshots rather than resolving the tokens. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * chore(chat): drop the pill-era selectors and styles left by the token migration The context row now only ever holds the image preview, so the visibility check, its tests, the mock display map and the pill/file-chip CSS rules for the removed indicators are dead weight. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix(composer): keep selection chips decorated when typing right after them Append a trailing space with the token so the decoration boundary holds while the user continues typing at the end of the input. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix(chat): recognize selection tokens whose note path contains spaces The bubble scanner's path part excluded whitespace, so tokens for notes under spaced folders never chipified and rendered as raw text. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix(composer): do not re-append a selection whose chip was sent or removed The editor highlight outlives the sent turn, so the poll re-captured the same range and put the chip back into the cleared composer. Remember the consumed selection signature until the highlight collapses or the selection changes. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix(composer): survive view-reload blips when suppressing consumed selections Reopening a note (e.g. from a sent-message chip) yields one empty-selection poll before the persisted highlight returns, which reset the consumed marker and re-appended the chip. Require two consecutive empty polls before a collapsed selection counts as fresh intent. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * feat(composer): re-select the referenced lines when a selection chip is clicked Clicking a selection chip (composer or sent bubble) opened the note but left the range unmarked; parse the token's line suffix and restore the editor selection so the highlight points back at the exact range. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * fix(composer): keep a consumed selection suppressed until the range changes The empty-poll heuristic still lost the consumed marker while a note reloads (clicking a sent chip), re-appending the selection. Treat a consumed range as permanently spent; only a different selection restores auto-append. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> * refactor(composer): keep selection and chip in plain live sync Drop the consumed-signature bookkeeping: a visible selection always shows its chip, clicking a chip restores the highlight and therefore the chip. Sending a turn collapses the editor selection so the chip leaves with the message instead of snapping back into the cleared composer. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com> --------- Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 5901d7b commit 7a5df79

31 files changed

Lines changed: 1049 additions & 593 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1313

1414
### Changed
1515

16+
- Editor, browser, and canvas selections now appear as inline chips inside the
17+
composer text (matching `@file` mentions) instead of pills on the row above
18+
the input. The selection is still sent as its `<editor_selection>`,
19+
`<browser_selection>`, or `<canvas_selection>` snapshot, and the chip's token
20+
now travels in the message text too.
1621
- The context usage meter beside the composer is now a complete ring that fills
1722
clockwise from the top, replacing the 240° arc. The percentage, the hover
1823
tooltip, and the pale-red warning state above 80% are unchanged.
@@ -24,6 +29,8 @@ version with its date and start a fresh empty `[Unreleased]` above it.
2429

2530
### Removed
2631

32+
- The selection pills above the composer are gone; that row now only shows
33+
image previews.
2734
- The current-note chip above the composer is gone. Use the "Excluded tags"
2835
setting to keep a note out of context.
2936

‎src/features/chat/controllers/browser-selection-controller.ts‎

Lines changed: 78 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,42 @@
11
import type { App, ItemView } from 'obsidian';
22

33
import type { BrowserSelectionContext } from '../../../core/context/types';
4-
import { updateContextRowHasContent } from './context-row-visibility';
5-
import { bindSelectionChipRemove, setSelectionChipLabel } from './selection-chip';
4+
import {
5+
appendSelectionToken,
6+
buildBrowserSelectionToken,
7+
removeSelectionToken,
8+
type SelectionTokenReference,
9+
type SelectionTokenSink,
10+
truncateLabel,
11+
} from './selection-token';
612

713
const BROWSER_SELECTION_POLL_INTERVAL = 250;
14+
const ORPHAN_TOKEN_PATTERN = /@browser:\S+/;
815

916
type BrowserLikeWebview = HTMLElement & {
1017
executeJavaScript?: (code: string, userGesture?: boolean) => Promise<unknown>;
1118
};
1219

1320
export class BrowserSelectionController {
1421
private app: App;
15-
private indicatorEl: HTMLElement;
16-
private inputEl: HTMLElement;
17-
private contextRowEl: HTMLElement;
18-
private onVisibilityChange: (() => void) | null;
22+
private inputEl: HTMLTextAreaElement;
23+
private tokenSink: SelectionTokenSink;
1924
private storedSelection: BrowserSelectionContext | null = null;
25+
private storedToken: string | null = null;
26+
private syncingToken = false;
2027
private pollInterval: number | null = null;
2128
private pollInFlight = false;
29+
private readonly handleInput = (): void => this.reconcileToken();
2230

23-
constructor(
24-
app: App,
25-
indicatorEl: HTMLElement,
26-
inputEl: HTMLElement,
27-
contextRowEl: HTMLElement,
28-
onVisibilityChange?: () => void
29-
) {
31+
constructor(app: App, inputEl: HTMLTextAreaElement, tokenSink: SelectionTokenSink) {
3032
this.app = app;
31-
this.indicatorEl = indicatorEl;
3233
this.inputEl = inputEl;
33-
this.contextRowEl = contextRowEl;
34-
this.onVisibilityChange = onVisibilityChange ?? null;
35-
bindSelectionChipRemove(this.indicatorEl, () => this.clear());
34+
this.tokenSink = tokenSink;
3635
}
3736

3837
start(): void {
3938
if (this.pollInterval) return;
39+
this.inputEl.addEventListener('input', this.handleInput);
4040
this.pollInterval = window.setInterval(() => {
4141
void this.poll();
4242
}, BROWSER_SELECTION_POLL_INTERVAL);
@@ -47,6 +47,7 @@ export class BrowserSelectionController {
4747
window.clearInterval(this.pollInterval);
4848
this.pollInterval = null;
4949
}
50+
this.inputEl.removeEventListener('input', this.handleInput);
5051
this.clear();
5152
}
5253

@@ -65,7 +66,7 @@ export class BrowserSelectionController {
6566
const nextContext = this.buildContext(browserView.view, browserView.viewType, browserView.containerEl, selectedText);
6667
if (!this.isSameSelection(nextContext, this.storedSelection)) {
6768
this.storedSelection = nextContext;
68-
this.updateIndicator();
69+
this.applyToken(this.buildToken(nextContext), nextContext);
6970
}
7071
} else {
7172
this.clearWhenInputIsNotFocused();
@@ -236,50 +237,75 @@ export class BrowserSelectionController {
236237
&& left.url === right.url;
237238
}
238239

239-
private clearWhenInputIsNotFocused(): void {
240-
if (this.inputEl.ownerDocument.activeElement === this.inputEl) return;
241-
if (this.storedSelection) {
242-
this.storedSelection = null;
243-
this.updateIndicator();
244-
}
240+
private displayTitle(context: BrowserSelectionContext): string {
241+
return context.title ?? context.url ?? context.source;
245242
}
246243

247-
private updateIndicator(): void {
248-
if (!this.indicatorEl) return;
244+
private buildToken(context: BrowserSelectionContext): string {
245+
return buildBrowserSelectionToken(this.displayTitle(context));
246+
}
249247

250-
if (this.storedSelection) {
251-
const lineCount = this.storedSelection.selectedText.split(/\r?\n/).length;
252-
const lineLabel = lineCount === 1 ? 'line' : 'lines';
253-
setSelectionChipLabel(this.indicatorEl, `${lineCount} ${lineLabel} selected`);
254-
this.indicatorEl.setAttribute('title', this.buildIndicatorTitle());
255-
this.indicatorEl.removeClass('qoderian-hidden');
256-
} else {
257-
this.indicatorEl.addClass('qoderian-hidden');
258-
setSelectionChipLabel(this.indicatorEl, '');
259-
this.indicatorEl.removeAttribute('title');
248+
private buildReference(token: string, context: BrowserSelectionContext): SelectionTokenReference {
249+
return {
250+
token,
251+
path: context.url ?? context.source,
252+
kind: 'browser-selection',
253+
label: truncateLabel(this.displayTitle(context)),
254+
icon: 'globe',
255+
};
256+
}
257+
258+
private applyToken(nextToken: string | null, context?: BrowserSelectionContext): void {
259+
if (nextToken === this.storedToken) return;
260+
261+
this.syncingToken = true;
262+
try {
263+
const previous = this.storedToken;
264+
this.storedToken = nextToken;
265+
if (previous) {
266+
removeSelectionToken(this.inputEl, previous);
267+
this.tokenSink.unregister(previous);
268+
}
269+
if (nextToken && context) {
270+
appendSelectionToken(this.inputEl, nextToken);
271+
this.tokenSink.register(this.buildReference(nextToken, context));
272+
}
273+
} finally {
274+
this.syncingToken = false;
260275
}
261-
this.updateContextRowVisibility();
262276
}
263277

264-
private buildIndicatorTitle(): string {
265-
if (!this.storedSelection) return '';
278+
private reconcileToken(): void {
279+
if (this.syncingToken) return;
266280

267-
const charCount = this.storedSelection.selectedText.length;
268-
const charLabel = charCount === 1 ? 'char' : 'chars';
269-
const lines = [`${charCount} ${charLabel} selected`, `source=${this.storedSelection.source}`];
270-
if (this.storedSelection.title) {
271-
lines.push(`title=${this.storedSelection.title}`);
281+
const value = this.inputEl.value;
282+
if (this.storedToken) {
283+
if (!value.includes(this.storedToken)) {
284+
const stale = this.storedToken;
285+
this.storedToken = null;
286+
this.storedSelection = null;
287+
this.tokenSink.unregister(stale);
288+
}
289+
return;
272290
}
273-
if (this.storedSelection.url) {
274-
lines.push(this.storedSelection.url);
291+
292+
const orphan = value.match(ORPHAN_TOKEN_PATTERN)?.[0];
293+
if (orphan) {
294+
this.syncingToken = true;
295+
try {
296+
removeSelectionToken(this.inputEl, orphan);
297+
} finally {
298+
this.syncingToken = false;
299+
}
275300
}
276-
return lines.join('\n');
277301
}
278302

279-
updateContextRowVisibility(): void {
280-
if (!this.contextRowEl) return;
281-
updateContextRowHasContent(this.contextRowEl);
282-
this.onVisibilityChange?.();
303+
private clearWhenInputIsNotFocused(): void {
304+
if (this.inputEl.ownerDocument.activeElement === this.inputEl) return;
305+
if (this.storedSelection) {
306+
this.storedSelection = null;
307+
this.applyToken(null);
308+
}
283309
}
284310

285311
getContext(): BrowserSelectionContext | null {
@@ -292,6 +318,6 @@ export class BrowserSelectionController {
292318

293319
clear(): void {
294320
this.storedSelection = null;
295-
this.updateIndicator();
321+
this.applyToken(null);
296322
}
297323
}

‎src/features/chat/controllers/canvas-selection-controller.ts‎

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import type { App, ItemView } from 'obsidian';
22

33
import type { CanvasSelectionContext } from '../../../core/context/types';
4-
import { updateContextRowHasContent } from './context-row-visibility';
5-
import { bindSelectionChipRemove, setSelectionChipLabel } from './selection-chip';
4+
import {
5+
appendSelectionToken,
6+
basename,
7+
buildCanvasSelectionToken,
8+
removeSelectionToken,
9+
type SelectionTokenReference,
10+
type SelectionTokenSink,
11+
truncateLabel,
12+
} from './selection-token';
613

714
const CANVAS_POLL_INTERVAL = 250;
15+
const ORPHAN_TOKEN_PATTERN = /@canvas:\S+/;
816

917
type CanvasSelectionNode = { id?: unknown };
1018

@@ -19,30 +27,23 @@ type CanvasViewLike = ItemView & {
1927

2028
export class CanvasSelectionController {
2129
private app: App;
22-
private indicatorEl: HTMLElement;
23-
private inputEl: HTMLElement;
24-
private contextRowEl: HTMLElement;
25-
private onVisibilityChange: (() => void) | null;
30+
private inputEl: HTMLTextAreaElement;
31+
private tokenSink: SelectionTokenSink;
2632
private storedSelection: CanvasSelectionContext | null = null;
33+
private storedToken: string | null = null;
34+
private syncingToken = false;
2735
private pollInterval: number | null = null;
36+
private readonly handleInput = (): void => this.reconcileToken();
2837

29-
constructor(
30-
app: App,
31-
indicatorEl: HTMLElement,
32-
inputEl: HTMLElement,
33-
contextRowEl: HTMLElement,
34-
onVisibilityChange?: () => void
35-
) {
38+
constructor(app: App, inputEl: HTMLTextAreaElement, tokenSink: SelectionTokenSink) {
3639
this.app = app;
37-
this.indicatorEl = indicatorEl;
3840
this.inputEl = inputEl;
39-
this.contextRowEl = contextRowEl;
40-
this.onVisibilityChange = onVisibilityChange ?? null;
41-
bindSelectionChipRemove(this.indicatorEl, () => this.clear());
41+
this.tokenSink = tokenSink;
4242
}
4343

4444
start(): void {
4545
if (this.pollInterval) return;
46+
this.inputEl.addEventListener('input', this.handleInput);
4647
this.pollInterval = window.setInterval(() => this.poll(), CANVAS_POLL_INTERVAL);
4748
}
4849

@@ -51,6 +52,7 @@ export class CanvasSelectionController {
5152
window.clearInterval(this.pollInterval);
5253
this.pollInterval = null;
5354
}
55+
this.inputEl.removeEventListener('input', this.handleInput);
5456
this.clear();
5557
}
5658

@@ -77,12 +79,12 @@ export class CanvasSelectionController {
7779

7880
if (!sameSelection) {
7981
this.storedSelection = { canvasPath, nodeIds };
80-
this.updateIndicator();
82+
this.applyToken(buildCanvasSelectionToken(canvasPath), canvasPath);
8183
}
8284
} else if (this.getActiveElement() !== this.inputEl) {
8385
if (this.storedSelection) {
8486
this.storedSelection = null;
85-
this.updateIndicator();
87+
this.applyToken(null);
8688
}
8789
}
8890
}
@@ -104,26 +106,59 @@ export class CanvasSelectionController {
104106
return leaf ? (leaf.view as CanvasViewLike) : null;
105107
}
106108

107-
private updateIndicator(): void {
108-
if (!this.indicatorEl) return;
109-
110-
if (this.storedSelection) {
111-
const { nodeIds } = this.storedSelection;
112-
setSelectionChipLabel(this.indicatorEl, nodeIds.length === 1
113-
? `node "${nodeIds[0]}" selected`
114-
: `${nodeIds.length} nodes selected`);
115-
this.indicatorEl.removeClass('qoderian-hidden');
116-
} else {
117-
this.indicatorEl.addClass('qoderian-hidden');
118-
setSelectionChipLabel(this.indicatorEl, '');
109+
private buildReference(token: string, canvasPath: string): SelectionTokenReference {
110+
return {
111+
token,
112+
path: canvasPath,
113+
kind: 'canvas-selection',
114+
label: truncateLabel(basename(canvasPath)),
115+
icon: 'network',
116+
};
117+
}
118+
119+
private applyToken(nextToken: string | null, canvasPath?: string): void {
120+
if (nextToken === this.storedToken) return;
121+
122+
this.syncingToken = true;
123+
try {
124+
const previous = this.storedToken;
125+
this.storedToken = nextToken;
126+
if (previous) {
127+
removeSelectionToken(this.inputEl, previous);
128+
this.tokenSink.unregister(previous);
129+
}
130+
if (nextToken && canvasPath) {
131+
appendSelectionToken(this.inputEl, nextToken);
132+
this.tokenSink.register(this.buildReference(nextToken, canvasPath));
133+
}
134+
} finally {
135+
this.syncingToken = false;
119136
}
120-
this.updateContextRowVisibility();
121137
}
122138

123-
updateContextRowVisibility(): void {
124-
if (!this.contextRowEl) return;
125-
updateContextRowHasContent(this.contextRowEl);
126-
this.onVisibilityChange?.();
139+
private reconcileToken(): void {
140+
if (this.syncingToken) return;
141+
142+
const value = this.inputEl.value;
143+
if (this.storedToken) {
144+
if (!value.includes(this.storedToken)) {
145+
const stale = this.storedToken;
146+
this.storedToken = null;
147+
this.storedSelection = null;
148+
this.tokenSink.unregister(stale);
149+
}
150+
return;
151+
}
152+
153+
const orphan = value.match(ORPHAN_TOKEN_PATTERN)?.[0];
154+
if (orphan) {
155+
this.syncingToken = true;
156+
try {
157+
removeSelectionToken(this.inputEl, orphan);
158+
} finally {
159+
this.syncingToken = false;
160+
}
161+
}
127162
}
128163

129164
getContext(): CanvasSelectionContext | null {
@@ -140,6 +175,6 @@ export class CanvasSelectionController {
140175

141176
clear(): void {
142177
this.storedSelection = null;
143-
this.updateIndicator();
178+
this.applyToken(null);
144179
}
145180
}

0 commit comments

Comments
 (0)