Skip to content

Commit 807e456

Browse files
committed
Search: AI transparency strip surfaces the prompt and caveat
- Adds `AiTransparencyStrip.svelte` between the mode chips and filter chips. It shows the natural-language prompt, the AI's caveat if any, and a disabled "Refine…" button with the tooltip `Coming soon: chat back to the agent`. The Refine button parallels the Content mode chip's visible-disabled treatment; no keyboard shortcut is wired. - Stores the original prompt and caveat in `search-state.svelte.ts` as `lastAiPrompt` / `lastAiCaveat`. `executeAiSearch` captures the prompt BEFORE the IPC call so the strip survives a failed translation. `executeSearch(fromAiTranslation=false)` clears both fields, hiding the strip on any non-AI run. `clearSearchState` (⌘N) clears them too. - Removes the inline `.caveat-row` / `.ai-status` / `.ai-error` divs and the now-dead `getAiStatus` / `getCaveat` / `setAiStatus` / `setCaveat` accessors in the state module. - New Vitest coverage: behavior, a11y, and SearchDialog lifecycle (strip appears on AI run, hides on ⌘N, hides on filename/regex run). - Real-AI verified via the dev app + OpenAI `gpt-5.5`: caveat strip showed "No filename filter — results may be very broad. Add a name or file type to narrow." for the prompt "large files".
1 parent 2c10bba commit 807e456

7 files changed

Lines changed: 432 additions & 103 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Tier-3 a11y tests for `AiTransparencyStrip.svelte`.
3+
*
4+
* Pins that the strip is axe-clean with and without a caveat, and that the disabled "Refine…"
5+
* button doesn't trip nested-interactive or hidden-content rules.
6+
*/
7+
8+
import { describe, it } from 'vitest'
9+
import { mount, tick, type ComponentProps } from 'svelte'
10+
import AiTransparencyStrip from './AiTransparencyStrip.svelte'
11+
import { expectNoA11yViolations } from '$lib/test-a11y'
12+
13+
type Props = ComponentProps<typeof AiTransparencyStrip>
14+
15+
function baseProps(overrides: Partial<Props> = {}): Props {
16+
return {
17+
aiPrompt: 'screenshots from this week',
18+
caveat: '',
19+
...overrides,
20+
}
21+
}
22+
23+
describe('AiTransparencyStrip a11y', () => {
24+
it('has no a11y violations with a prompt only', async () => {
25+
const target = document.createElement('div')
26+
document.body.appendChild(target)
27+
mount(AiTransparencyStrip, { target, props: baseProps() })
28+
await tick()
29+
await expectNoA11yViolations(target)
30+
target.remove()
31+
})
32+
33+
it('has no a11y violations with a caveat', async () => {
34+
const target = document.createElement('div')
35+
document.body.appendChild(target)
36+
mount(AiTransparencyStrip, {
37+
target,
38+
props: baseProps({ caveat: "I treated 'big' as larger than 10 MB." }),
39+
})
40+
await tick()
41+
await expectNoA11yViolations(target)
42+
target.remove()
43+
})
44+
})
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script lang="ts">
2+
/**
3+
* AiTransparencyStrip: shows what the user actually asked the AI, plus the AI's caveat if any,
4+
* and a placeholder "Refine…" button for the future chat-back feature.
5+
*
6+
* Sits between the search bar (and mode chips) and the filter chips. Visible only after an AI
7+
* search has run this session; the parent (`SearchDialog.svelte`) clears the state on ⌘N or when
8+
* a non-AI search runs, which hides the strip.
9+
*
10+
* Why this exists: when the AI translates a natural-language prompt, the result populates
11+
* `query` and `mode` so the user can see (and iterate on) the translated pattern. The original
12+
* prompt would otherwise vanish into the user's memory. The strip surfaces it again, alongside
13+
* any caveat the AI returned ("I ignored the file size you mentioned because…"). This is the
14+
* "radical transparency" principle from the redesign plan (§2.6).
15+
*
16+
* The "Refine…" button is intentionally **visible-disabled** with a tooltip. It signals that
17+
* a chat-back feature is coming without overpromising. Consistent with the Content mode chip's
18+
* disabled-with-tooltip treatment; neither has a keyboard shortcut wired.
19+
*/
20+
import { tooltip } from '$lib/tooltip/tooltip'
21+
22+
interface Props {
23+
/** The natural-language prompt the user typed, before AI translated it. */
24+
aiPrompt: string
25+
/** Optional caveat returned by the AI translator. Empty string hides the caveat row. */
26+
caveat: string
27+
}
28+
29+
const { aiPrompt, caveat }: Props = $props()
30+
</script>
31+
32+
<div class="ai-transparency-strip" aria-label="Last AI search prompt">
33+
<div class="strip-text">
34+
<p class="ai-prompt">{aiPrompt}</p>
35+
{#if caveat}
36+
<p class="ai-caveat">{caveat}</p>
37+
{/if}
38+
</div>
39+
<button
40+
type="button"
41+
class="refine-button"
42+
disabled
43+
aria-label="Refine the AI search (coming soon)"
44+
use:tooltip={'Coming soon: chat back to the agent'}
45+
>
46+
Refine…
47+
</button>
48+
</div>
49+
50+
<style>
51+
.ai-transparency-strip {
52+
display: flex;
53+
align-items: flex-start;
54+
gap: var(--spacing-md);
55+
padding: var(--spacing-sm) var(--spacing-lg);
56+
background: var(--color-bg-primary);
57+
}
58+
59+
.strip-text {
60+
flex: 1;
61+
min-width: 0;
62+
display: flex;
63+
flex-direction: column;
64+
gap: var(--spacing-xxs);
65+
}
66+
67+
.ai-prompt {
68+
margin: 0;
69+
color: var(--color-text-secondary);
70+
font-size: var(--font-size-sm);
71+
line-height: 1.3;
72+
overflow: hidden;
73+
white-space: nowrap;
74+
text-overflow: ellipsis;
75+
}
76+
77+
.ai-caveat {
78+
margin: 0;
79+
color: var(--color-text-tertiary);
80+
font-size: var(--font-size-sm);
81+
font-style: italic;
82+
line-height: 1.3;
83+
overflow: hidden;
84+
white-space: nowrap;
85+
text-overflow: ellipsis;
86+
}
87+
88+
.refine-button {
89+
flex-shrink: 0;
90+
padding: var(--spacing-xxs) var(--spacing-sm);
91+
font-size: var(--font-size-sm);
92+
font-weight: 500;
93+
line-height: 1;
94+
color: var(--color-text-secondary);
95+
background: transparent;
96+
border: 1px solid var(--color-border);
97+
border-radius: var(--radius-sm);
98+
white-space: nowrap;
99+
}
100+
101+
.refine-button:disabled {
102+
opacity: 0.5;
103+
cursor: not-allowed;
104+
}
105+
</style>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Behavior tests for `AiTransparencyStrip.svelte`.
3+
*
4+
* Pins:
5+
* - The original prompt is rendered.
6+
* - The caveat is rendered when present and hidden when empty.
7+
* - The "Refine…" button is disabled and carries the "Coming soon" tooltip.
8+
*/
9+
10+
import { describe, it, expect, vi } from 'vitest'
11+
import { mount, tick } from 'svelte'
12+
import AiTransparencyStrip from './AiTransparencyStrip.svelte'
13+
14+
function setup(props: { aiPrompt: string; caveat: string }): { target: HTMLDivElement; cleanup: () => void } {
15+
const target = document.createElement('div')
16+
document.body.appendChild(target)
17+
mount(AiTransparencyStrip, { target, props })
18+
return {
19+
target,
20+
cleanup: () => {
21+
target.remove()
22+
},
23+
}
24+
}
25+
26+
describe('AiTransparencyStrip', () => {
27+
it('renders the original AI prompt', async () => {
28+
const { target, cleanup } = setup({ aiPrompt: 'screenshots from this week', caveat: '' })
29+
await tick()
30+
const prompt = target.querySelector('.ai-prompt')
31+
expect(prompt?.textContent).toBe('screenshots from this week')
32+
cleanup()
33+
})
34+
35+
it('renders the caveat when present', async () => {
36+
const { target, cleanup } = setup({
37+
aiPrompt: 'big PDFs',
38+
caveat: "I treated 'big' as larger than 10 MB.",
39+
})
40+
await tick()
41+
const caveat = target.querySelector('.ai-caveat')
42+
expect(caveat?.textContent).toBe("I treated 'big' as larger than 10 MB.")
43+
cleanup()
44+
})
45+
46+
it('does not render the caveat row when caveat is empty', async () => {
47+
const { target, cleanup } = setup({ aiPrompt: 'screenshots', caveat: '' })
48+
await tick()
49+
expect(target.querySelector('.ai-caveat')).toBeNull()
50+
cleanup()
51+
})
52+
53+
it('renders a disabled "Refine…" button', async () => {
54+
const { target, cleanup } = setup({ aiPrompt: 'photos', caveat: '' })
55+
await tick()
56+
const button = target.querySelector<HTMLButtonElement>('.refine-button')
57+
expect(button).not.toBeNull()
58+
expect(button?.disabled).toBe(true)
59+
expect(button?.textContent.trim()).toBe('Refine…')
60+
cleanup()
61+
})
62+
63+
it('communicates "coming soon" via the Refine button aria-label and use:tooltip text', async () => {
64+
vi.useFakeTimers()
65+
const { target, cleanup } = setup({ aiPrompt: 'photos', caveat: '' })
66+
await tick()
67+
const button = target.querySelector<HTMLButtonElement>('.refine-button')
68+
// aria-label is the always-available accessible signal that the control is a placeholder.
69+
expect(button?.getAttribute('aria-label')).toMatch(/coming soon/i)
70+
// The use:tooltip action stages its content on mouseenter behind a 400 ms delay.
71+
button?.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
72+
vi.advanceTimersByTime(500)
73+
const tip = document.body.querySelector('[role="tooltip"]')
74+
expect(tip?.textContent).toMatch(/coming soon: chat back to the agent/i)
75+
vi.useRealTimers()
76+
cleanup()
77+
})
78+
})

apps/desktop/src/lib/search/CLAUDE.md

Lines changed: 50 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,40 +11,47 @@ chip row and path-pill column landing in later milestones.
1111

1212
## Files
1313

14-
| File | Purpose |
15-
| ---------------------------------- | -------------------------------------------------------------------------------------------- |
16-
| `SearchDialog.svelte` | Orchestrator: overlay, mount/unmount, keyboard dispatch, search execution, state wiring |
17-
| `SearchBar.svelte` | Unified query input: one `<input>` for AI / filename / regex, placeholder updates per mode |
18-
| `SearchModeChips.svelte` | Mode chip row below the bar: AI / Filename / Content (disabled) / Regex, arrow-key navigable |
19-
| `SearchFilterChips.svelte` | Filter chip strip (Size, Modified, Search in) plus Add filter dropdown. Each opens a popover |
20-
| `FilterChip.svelte` | Single chip: default/configured states, `×` clear, Backspace clear, aria-expanded |
21-
| `FilterChipPopover.svelte` | Generic popover: frosted-glass, auto-flip, focus trap, Esc closes without disrupting dialog |
22-
| `filter-chip-state.ts` | Pure helpers: `deriveSizeChip`, `deriveDateChip`, `deriveScopeChip` (testable in isolation) |
23-
| `SearchResults.svelte` | Column headers + results list + all states (loading, empty, populated) + status bar |
24-
| `search-state.svelte.ts` | Module-level `$state` for query fields, results, index readiness, AI state |
25-
| `search-state.test.ts` | Vitest tests for state helpers (`parseSizeToBytes`, `buildSearchQuery`, etc.) |
26-
| `filter-chip-state.test.ts` | Default → configured → cleared rules for each filter chip's display summary |
27-
| `SearchBar.svelte.test.ts` | Per-mode placeholder, value mirror, `onInput` callback |
28-
| `SearchModeChips.svelte.test.ts` | Chip set, active marker, click + keyboard activation, focus motion (skipping Content) |
29-
| `SearchFilterChips.svelte.test.ts` | Chip rendering, `×` and Backspace clear, popover open/close, Add filter list, scope behavior |
30-
| `SearchDialog.svelte.test.ts` | `⌘N` clears, close+reopen preserves, `⌘1`/`⌘2`/`⌘3` mode switch, `⌘Enter` triggers AI |
31-
| `SearchDialog.a11y.test.ts` | Tier-3 axe-core audit across loading / index-ready / AI-on macro-states |
32-
| `SearchFilterChips.a11y.test.ts` | Tier-3 axe-core audit across default, configured, disabled, and open-popover states |
33-
| `SearchResults.a11y.test.ts` | Tier-3 axe-core audit across result states |
34-
35-
## State shape (post-M2)
14+
| File | Purpose |
15+
| ------------------------------------ | --------------------------------------------------------------------------------------------------------- |
16+
| `SearchDialog.svelte` | Orchestrator: overlay, mount/unmount, keyboard dispatch, search execution, state wiring |
17+
| `SearchBar.svelte` | Unified query input: one `<input>` for AI / filename / regex, placeholder updates per mode |
18+
| `SearchModeChips.svelte` | Mode chip row below the bar: AI / Filename / Content (disabled) / Regex, arrow-key navigable |
19+
| `AiTransparencyStrip.svelte` | Strip below the chip row showing the original AI prompt, the caveat, and a disabled Refine button |
20+
| `SearchFilterChips.svelte` | Filter chip strip (Size, Modified, Search in) plus Add filter dropdown. Each opens a popover |
21+
| `FilterChip.svelte` | Single chip: default/configured states, `×` clear, Backspace clear, aria-expanded |
22+
| `FilterChipPopover.svelte` | Generic popover: frosted-glass, auto-flip, focus trap, Esc closes without disrupting dialog |
23+
| `filter-chip-state.ts` | Pure helpers: `deriveSizeChip`, `deriveDateChip`, `deriveScopeChip` (testable in isolation) |
24+
| `SearchResults.svelte` | Column headers + results list + all states (loading, empty, populated) + status bar |
25+
| `search-state.svelte.ts` | Module-level `$state` for query fields, results, index readiness, AI state |
26+
| `search-state.test.ts` | Vitest tests for state helpers (`parseSizeToBytes`, `buildSearchQuery`, etc.) |
27+
| `filter-chip-state.test.ts` | Default → configured → cleared rules for each filter chip's display summary |
28+
| `SearchBar.svelte.test.ts` | Per-mode placeholder, value mirror, `onInput` callback |
29+
| `SearchModeChips.svelte.test.ts` | Chip set, active marker, click + keyboard activation, focus motion (skipping Content) |
30+
| `SearchFilterChips.svelte.test.ts` | Chip rendering, `×` and Backspace clear, popover open/close, Add filter list, scope behavior |
31+
| `AiTransparencyStrip.svelte.test.ts` | Renders prompt, renders caveat when set, Refine button is disabled with Coming soon tooltip |
32+
| `SearchDialog.svelte.test.ts` | `⌘N` clears, close+reopen preserves, `⌘1`/`⌘2`/`⌘3` mode switch, `⌘Enter` triggers AI, AI strip lifecycle |
33+
| `SearchDialog.a11y.test.ts` | Tier-3 axe-core audit across loading / index-ready / AI-on macro-states |
34+
| `SearchFilterChips.a11y.test.ts` | Tier-3 axe-core audit across default, configured, disabled, and open-popover states |
35+
| `AiTransparencyStrip.a11y.test.ts` | Tier-3 axe-core audit for prompt-only and prompt-plus-caveat states |
36+
| `SearchResults.a11y.test.ts` | Tier-3 axe-core audit across result states |
37+
38+
## State shape (post-M4)
3639

3740
The user's typed text and the active mode are one model:
3841

3942
```ts
4043
let query = $state('') // The text in the bar
4144
let mode = $state<SearchMode>('filename') // 'ai' | 'filename' | 'regex'
45+
let lastAiPrompt = $state<string | null>(null) // The natural-language prompt before AI overwrites `query`
46+
let lastAiCaveat = $state<string | null>(null) // The AI translator's caveat (or null)
4247
```
4348

44-
`buildSearchQuery()` reads both: `mode === 'regex'` produces `patternType: 'regex'`, anything else produces
45-
`patternType: 'glob'`. AI mode is only ever invoked via `executeAiSearch()`, which calls `translateSearchQuery` and then
46-
overwrites `query` + `mode` with the AI's result (so the user sees what was searched). M4 will surface the original
47-
prompt in a transparency strip; for M2 it lives only in the user's memory.
49+
`buildSearchQuery()` reads `query` + `mode`: `mode === 'regex'` produces `patternType: 'regex'`, anything else produces
50+
`patternType: 'glob'`. AI mode is only ever invoked via `executeAiSearch()`, which (1) captures the user's prompt into
51+
`lastAiPrompt`, (2) calls `translateSearchQuery`, (3) overwrites `query` + `mode` with the AI's result so the user can
52+
see and iterate on the translated pattern, and (4) sets `lastAiCaveat` from the result. The `AiTransparencyStrip` is
53+
visible whenever `lastAiPrompt` is non-null; it clears on `⌘N` (via `clearSearchState`) and on any successful non-AI
54+
search (`executeSearch(fromAiTranslation = false)`).
4855

4956
There is **no `aiPrompt` state and no `namePattern` state**. M2 deleted both. Anywhere the old code read `aiPrompt` or
5057
`namePattern`, the new code reads `query`. Anywhere the old code branched on `patternType`, the new code branches on
@@ -120,9 +127,20 @@ a message ("Drive index not ready...") with scan progress if available. Inputs a
120127
extracts keywords, Rust builds the query deterministically), then runs `executeSearch()`. No preflight, no refinement
121128
pass. The previous two-pass system caused ~15% regressions; deterministic structure means there's nothing to refine.
122129

123-
**AI overwrites the bar**: After AI translates, the bar shows the AI's translated pattern (filename / regex), and `mode`
124-
flips accordingly. The user sees what was searched and can keep iterating. The original natural-language prompt is
125-
preserved only in the user's memory until M4 ships the transparency strip.
130+
**AI overwrites the bar; the strip preserves the prompt**: After AI translates, the bar shows the AI's translated
131+
pattern (filename / regex), and `mode` flips accordingly. The user sees what was searched and can keep iterating. The
132+
original natural-language prompt and the AI's caveat are surfaced in the `AiTransparencyStrip` below the chip row. The
133+
strip is the source of truth for "what did I ask the AI?" once the bar has been overwritten. Lifecycle:
134+
135+
- `executeAiSearch(trimmed)` sets `lastAiPrompt = trimmed` BEFORE calling `translateSearchQuery`. The capture is
136+
unconditional: even if the IPC fails, the user still sees what they asked.
137+
- After the translation succeeds, `lastAiCaveat = translateResult.caveat ?? null`.
138+
- `executeSearch(fromAiTranslation: boolean)` clears both fields when `fromAiTranslation` is false. `executeAiSearch`
139+
passes `true`, so the AI flow's tail (`executeSearch(true)`) leaves the strip intact.
140+
- `clearSearchState()` (called by `⌘N`) clears both fields.
141+
142+
The disabled "Refine…" button on the strip is the placeholder for the chat-back UX. No keyboard shortcut is wired (same
143+
contract as the Content mode chip: visible-disabled with an explanatory tooltip is fine; shortcut-but-no-op is hostile).
126144

127145
**Auto mode fallback when AI gets disabled mid-session**: If the AI provider is switched off while the dialog is open
128146
and the active mode is `ai`, the dialog quietly flips to `filename`. The user wouldn't be able to run a search
@@ -175,9 +193,9 @@ close + reopen into a lost-work moment. The only sanctioned reset path is `⌘N`
175193
state from a lifecycle hook, you probably want a user-initiated action instead.
176194

177195
**Gotcha**: The AI's translation overwrites `query` and `mode`. **Why**: We want the bar to show what was searched, not
178-
the natural-language prompt. Until M4 ships the transparency strip, the original prompt is only in the user's memory.
179-
Anyone building on top of this should not assume `query` still contains the user's natural-language input after an AI
180-
run.
196+
the natural-language prompt. The original prompt is preserved separately in `lastAiPrompt` (set by `executeAiSearch`
197+
before the IPC call) so the `AiTransparencyStrip` can render it. Anyone building on top of this should not assume
198+
`query` still contains the user's natural-language input after an AI run; use `getLastAiPrompt()` instead.
181199

182200
## References
183201

0 commit comments

Comments
 (0)