Skip to content

Commit 3e5b3ee

Browse files
authored
fix: render selection indicators as removable context chips (#16)
Align with upstream Claudian context pills: editor/browser/canvas selection indicators become left-aligned chips (icon + label + remove button) instead of right-aligned plain text whose in-flow row pushed the input placeholder down; the remove button clears the selection context.
1 parent 70199c6 commit 3e5b3ee

8 files changed

Lines changed: 168 additions & 25 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { App, ItemView } from 'obsidian';
22

33
import type { BrowserSelectionContext } from '../../../core/context/types';
44
import { updateContextRowHasContent } from './context-row-visibility';
5+
import { bindSelectionChipRemove, setSelectionChipLabel } from './selection-chip';
56

67
const BROWSER_SELECTION_POLL_INTERVAL = 250;
78

@@ -31,6 +32,7 @@ export class BrowserSelectionController {
3132
this.inputEl = inputEl;
3233
this.contextRowEl = contextRowEl;
3334
this.onVisibilityChange = onVisibilityChange ?? null;
35+
bindSelectionChipRemove(this.indicatorEl, () => this.clear());
3436
}
3537

3638
start(): void {
@@ -248,12 +250,12 @@ export class BrowserSelectionController {
248250
if (this.storedSelection) {
249251
const lineCount = this.storedSelection.selectedText.split(/\r?\n/).length;
250252
const lineLabel = lineCount === 1 ? 'line' : 'lines';
251-
this.indicatorEl.textContent = `${lineCount} ${lineLabel} selected`;
253+
setSelectionChipLabel(this.indicatorEl, `${lineCount} ${lineLabel} selected`);
252254
this.indicatorEl.setAttribute('title', this.buildIndicatorTitle());
253255
this.indicatorEl.removeClass('qoderian-hidden');
254256
} else {
255257
this.indicatorEl.addClass('qoderian-hidden');
256-
this.indicatorEl.textContent = '';
258+
setSelectionChipLabel(this.indicatorEl, '');
257259
this.indicatorEl.removeAttribute('title');
258260
}
259261
this.updateContextRowVisibility();

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { App, ItemView } from 'obsidian';
22

33
import type { CanvasSelectionContext } from '../../../core/context/types';
44
import { updateContextRowHasContent } from './context-row-visibility';
5+
import { bindSelectionChipRemove, setSelectionChipLabel } from './selection-chip';
56

67
const CANVAS_POLL_INTERVAL = 250;
78

@@ -37,6 +38,7 @@ export class CanvasSelectionController {
3738
this.inputEl = inputEl;
3839
this.contextRowEl = contextRowEl;
3940
this.onVisibilityChange = onVisibilityChange ?? null;
41+
bindSelectionChipRemove(this.indicatorEl, () => this.clear());
4042
}
4143

4244
start(): void {
@@ -107,12 +109,13 @@ export class CanvasSelectionController {
107109

108110
if (this.storedSelection) {
109111
const { nodeIds } = this.storedSelection;
110-
this.indicatorEl.textContent = nodeIds.length === 1
112+
setSelectionChipLabel(this.indicatorEl, nodeIds.length === 1
111113
? `node "${nodeIds[0]}" selected`
112-
: `${nodeIds.length} nodes selected`;
114+
: `${nodeIds.length} nodes selected`);
113115
this.indicatorEl.removeClass('qoderian-hidden');
114116
} else {
115117
this.indicatorEl.addClass('qoderian-hidden');
118+
setSelectionChipLabel(this.indicatorEl, '');
116119
}
117120
this.updateContextRowVisibility();
118121
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { setIcon } from 'obsidian';
2+
3+
/** Chip part classes shared with file chips so all context pills look identical. */
4+
export const SELECTION_CHIP_ICON_CLASS = 'qoderian-file-chip-icon';
5+
export const SELECTION_CHIP_LABEL_CLASS = 'qoderian-file-chip-name';
6+
export const SELECTION_CHIP_REMOVE_CLASS = 'qoderian-file-chip-remove';
7+
8+
/**
9+
* Builds a context chip (icon + label + remove button) for selection indicators,
10+
* matching the file chip pill design.
11+
*/
12+
export function createSelectionChip(
13+
parentEl: HTMLElement,
14+
cls: string,
15+
icon: string
16+
): HTMLElement {
17+
const chipEl = parentEl.createDiv({ cls: `${cls} qoderian-hidden` });
18+
const iconEl = chipEl.createSpan({ cls: SELECTION_CHIP_ICON_CLASS });
19+
setIcon(iconEl, icon);
20+
chipEl.createSpan({ cls: SELECTION_CHIP_LABEL_CLASS });
21+
const removeEl = chipEl.createSpan({ cls: SELECTION_CHIP_REMOVE_CLASS });
22+
removeEl.setText('\u00D7');
23+
removeEl.setAttribute('aria-label', 'Remove');
24+
return chipEl;
25+
}
26+
27+
/** Writes the chip label; falls back to the root element when no label span exists. */
28+
export function setSelectionChipLabel(chipEl: HTMLElement, text: string): void {
29+
const labelEl = chipEl.querySelector<HTMLElement>(`.${SELECTION_CHIP_LABEL_CLASS}`);
30+
(labelEl ?? chipEl).setText(text);
31+
}
32+
33+
/** Wires the chip's remove button, when present, to the given callback. */
34+
export function bindSelectionChipRemove(chipEl: HTMLElement, onRemove: () => void): void {
35+
const removeEl = chipEl.querySelector<HTMLElement>(`.${SELECTION_CHIP_REMOVE_CLASS}`);
36+
removeEl?.addEventListener('click', (event) => {
37+
event.stopPropagation();
38+
onRemove();
39+
});
40+
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type EditorSelectionContext, getEditorView } from '../../../core/editor
55
import { hideSelectionHighlight, showSelectionHighlight } from '../../../shared/components/selection-highlight';
66
import type { StoredSelection } from '../state/types';
77
import { updateContextRowHasContent } from './context-row-visibility';
8+
import { bindSelectionChipRemove, setSelectionChipLabel } from './selection-chip';
89

910
const SELECTION_POLL_INTERVAL = 250;
1011
const INPUT_HANDOFF_GRACE_MS = 1500;
@@ -51,6 +52,7 @@ export class SelectionController {
5152
this.focusScopeEls = this.normalizeFocusScopes(focusScopeEl);
5253
this.contextRowEl = contextRowEl;
5354
this.onVisibilityChange = onVisibilityChange ?? null;
55+
bindSelectionChipRemove(this.indicatorEl, () => this.clear());
5456
}
5557

5658
start(): void {
@@ -384,10 +386,11 @@ export class SelectionController {
384386

385387
if (this.storedSelection) {
386388
const lineText = this.storedSelection.lineCount === 1 ? 'line' : 'lines';
387-
this.indicatorEl.textContent = `${this.storedSelection.lineCount} ${lineText} selected`;
389+
setSelectionChipLabel(this.indicatorEl, `${this.storedSelection.lineCount} ${lineText} selected`);
388390
this.indicatorEl.removeClass('qoderian-hidden');
389391
} else {
390392
this.indicatorEl.addClass('qoderian-hidden');
393+
setSelectionChipLabel(this.indicatorEl, '');
391394
}
392395
this.updateContextRowVisibility();
393396
}

src/features/chat/tabs/tab.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { CanvasSelectionController } from '../controllers/canvas-selection-contr
1818
import { ConversationController } from '../controllers/conversation-controller';
1919
import { InputController } from '../controllers/input-controller';
2020
import { NavigationController } from '../controllers/navigation-controller';
21+
import { createSelectionChip } from '../controllers/selection-chip';
2122
import { SelectionController } from '../controllers/selection-controller';
2223
import { StreamController } from '../controllers/stream-controller';
2324
import { MessageRenderer } from '../rendering/message-renderer';
@@ -494,12 +495,24 @@ export function initializeTabUI(
494495
// Initialize context managers (file/image)
495496
initializeContextManagers(tab, plugin);
496497

497-
// Selection indicator - add to contextRowEl
498-
dom.selectionIndicatorEl = dom.contextRowEl.createDiv({ cls: 'qoderian-selection-indicator qoderian-hidden' });
498+
// Selection chips - add to contextRowEl (pill style: icon + label + remove)
499+
dom.selectionIndicatorEl = createSelectionChip(
500+
dom.contextRowEl,
501+
'qoderian-selection-indicator',
502+
'text-select'
503+
);
499504

500-
dom.browserIndicatorEl = dom.contextRowEl.createDiv({ cls: 'qoderian-browser-selection-indicator qoderian-hidden' });
505+
dom.browserIndicatorEl = createSelectionChip(
506+
dom.contextRowEl,
507+
'qoderian-browser-selection-indicator',
508+
'globe'
509+
);
501510

502-
dom.canvasIndicatorEl = dom.contextRowEl.createDiv({ cls: 'qoderian-canvas-indicator qoderian-hidden' });
511+
dom.canvasIndicatorEl = createSelectionChip(
512+
dom.contextRowEl,
513+
'qoderian-canvas-indicator',
514+
'network'
515+
);
503516

504517
const catalogInfo = options.getQoderCatalogConfig?.() ?? null;
505518
initializeSlashCommands(

src/style/components/input.css

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
box-shadow: var(--qoderian-input-wrapper-box-shadow);
3030
}
3131

32-
/* Context row (file chip start, selection indicator end) - inside input wrapper at top */
32+
/* Context row (context chips: files, images, selections) - inside input wrapper at top */
3333
/* Collapsed by default; expanded via .has-content class; textarea fills remaining space */
3434
.qoderian-context-row {
3535
display: none;
@@ -97,27 +97,29 @@
9797
height: 16.8px;
9898
}
9999

100-
/* Selection indicator (shown when text is selected in editor) */
101-
/* Match file chip height (24px): chip has 16px remove button + 6px padding + 2px border */
102-
/* Indicator: 12px text + 10px padding (5+5) + 2px border = 24px */
100+
/* Selection chips (editor / browser / canvas selection context) */
101+
/* Pill-styled like file chips: icon + label + remove button, left aligned in context row */
103102
.qoderian-selection-indicator,
104103
.qoderian-browser-selection-indicator,
105104
.qoderian-canvas-indicator {
106-
color: #7abaff;
105+
display: inline-flex;
106+
align-items: center;
107+
gap: 4px;
108+
padding: 3px 6px 3px 8px;
109+
background: var(--background-primary);
110+
border: 1px solid var(--background-modifier-border);
111+
border-radius: 12px;
107112
font-size: 12px;
108113
line-height: 1;
109-
opacity: 0.9;
110-
pointer-events: none;
111-
white-space: nowrap;
112-
padding: 5px 6px;
113-
border: 1px solid transparent;
114-
border-radius: 4px;
115-
margin-inline-start: auto;
114+
max-width: min(200px, 100%);
115+
color: var(--text-normal);
116116
flex-shrink: 0;
117-
order: 4;
118-
max-width: min(100%, clamp(220px, 64vw, 560px));
119-
overflow: hidden;
120-
text-overflow: ellipsis;
117+
}
118+
119+
.qoderian-selection-indicator:hover,
120+
.qoderian-browser-selection-indicator:hover,
121+
.qoderian-canvas-indicator:hover {
122+
background: var(--background-modifier-hover);
121123
}
122124

123125
.qoderian-input-wrapper textarea.qoderian-input {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { createMockEl } from '@test/helpers/mock-element';
2+
3+
import {
4+
bindSelectionChipRemove,
5+
createSelectionChip,
6+
SELECTION_CHIP_LABEL_CLASS,
7+
SELECTION_CHIP_REMOVE_CLASS,
8+
setSelectionChipLabel,
9+
} from '@/features/chat/controllers/selection-chip';
10+
11+
describe('selection-chip', () => {
12+
it('builds a hidden chip skeleton with icon, label and remove button', () => {
13+
const parent = createMockEl();
14+
15+
const chip = createSelectionChip(parent as any, 'qoderian-selection-indicator', 'text-select');
16+
17+
expect(chip.hasClass('qoderian-selection-indicator')).toBe(true);
18+
expect(chip.hasClass('qoderian-hidden')).toBe(true);
19+
expect(chip.querySelector('.qoderian-file-chip-icon')).not.toBeNull();
20+
expect(chip.querySelector(`.${SELECTION_CHIP_LABEL_CLASS}`)).not.toBeNull();
21+
const removeEl = chip.querySelector(`.${SELECTION_CHIP_REMOVE_CLASS}`);
22+
expect(removeEl).not.toBeNull();
23+
expect(removeEl!.textContent).toBe('×');
24+
expect(removeEl!.getAttribute('aria-label')).toBe('Remove');
25+
});
26+
27+
it('writes the label into the label span, not the chip root', () => {
28+
const chip = createSelectionChip(createMockEl() as any, 'qoderian-selection-indicator', 'text-select');
29+
30+
setSelectionChipLabel(chip as any, '8 lines selected');
31+
32+
const label = chip.querySelector(`.${SELECTION_CHIP_LABEL_CLASS}`)!;
33+
expect(label.textContent).toBe('8 lines selected');
34+
expect(chip.textContent).toBe('');
35+
});
36+
37+
it('falls back to the root element when no label span exists', () => {
38+
const bare = createMockEl();
39+
40+
setSelectionChipLabel(bare as any, '2 lines selected');
41+
42+
expect(bare.textContent).toBe('2 lines selected');
43+
});
44+
45+
it('invokes onRemove when the remove button is clicked', () => {
46+
const chip = createSelectionChip(createMockEl() as any, 'qoderian-selection-indicator', 'text-select');
47+
const onRemove = jest.fn();
48+
bindSelectionChipRemove(chip as any, onRemove);
49+
50+
const removeEl = chip.querySelector(`.${SELECTION_CHIP_REMOVE_CLASS}`) as any;
51+
removeEl.click();
52+
53+
expect(onRemove).toHaveBeenCalledTimes(1);
54+
});
55+
56+
it('is a no-op when the chip has no remove button', () => {
57+
const bare = createMockEl();
58+
59+
expect(() => bindSelectionChipRemove(bare as any, jest.fn())).not.toThrow();
60+
});
61+
});

tests/unit/features/chat/controllers/selection-controller.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createMockEl } from '@test/helpers/mock-element';
22

3+
import { createSelectionChip } from '@/features/chat/controllers/selection-chip';
34
import { SelectionController } from '@/features/chat/controllers/selection-controller';
45
import { hideSelectionHighlight, showSelectionHighlight } from '@/shared/components/selection-highlight';
56

@@ -169,6 +170,24 @@ describe('SelectionController', () => {
169170
expect(showSelectionHighlight).toHaveBeenCalledWith(editorView, 0, 4);
170171
});
171172

173+
it('clears the selection when the chip remove button is clicked', () => {
174+
const chipEl = createSelectionChip(contextRowEl, 'qoderian-selection-indicator', 'text-select');
175+
const chipController = new SelectionController(app, chipEl as any, inputEl, contextRowEl, undefined, focusScopeEl);
176+
chipController.start();
177+
jest.advanceTimersByTime(250);
178+
179+
expect(chipController.hasSelection()).toBe(true);
180+
const labelEl = chipEl.querySelector('.qoderian-file-chip-name')!;
181+
expect(labelEl.textContent).toBe('1 line selected');
182+
183+
(chipEl.querySelector('.qoderian-file-chip-remove') as any).click();
184+
185+
expect(chipController.hasSelection()).toBe(false);
186+
expect(chipEl.hasClass('qoderian-hidden')).toBe(true);
187+
expect(labelEl.textContent).toBe('');
188+
chipController.stop();
189+
});
190+
172191
it('clears selection immediately when deselected without input handoff intent', () => {
173192
controller.start();
174193
jest.advanceTimersByTime(250);

0 commit comments

Comments
 (0)