From 9bc1f6212837155c964a7db7103aabbcf14cbce2 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 21 Jul 2026 16:01:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20session=20chat=20nits=20=E2=80=94?= =?UTF-8?q?=20empty=20sends,=20command=20picker=20polish,=20session-state?= =?UTF-8?q?=20prompt=20(#10233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ai): session chat nits: empty sends, picker polish, session state * fix(ai): scope empty-send turns to global chat and pin new behavior * fix(ai): align grouped search nav with display order, enable empty-send button * fix(ai): fork fallback for unlisted workspaces, section headers across branches * style(ai): hint-colored 3xs picker section headers, drop inline row descriptions * style(ai): more spacing between picker sections * fix(ai): require context elements for empty global-chat sends * style(ai): no empty bubble for text-free messages * fix(ai): review round 2 — keyboard tooltip access, requestedMode guard, no display names in prompt * fix(ai): queue context-only drafts pressed while a response streams * fix(ai): shared context identity for queued badges and full queue-context union --- .../src/lib/components/DrillPicker.svelte | 141 ++++++++++++++++-- .../copilot/chat/AIChatInput.svelte | 50 ++++--- .../copilot/chat/AIChatManager.svelte.ts | 121 +++++++++------ .../copilot/chat/AIChatManager.test.ts | 57 ++++++- .../copilot/chat/AIChatMessage.svelte | 4 +- .../copilot/chat/ChatCommandPicker.svelte | 32 ++-- .../copilot/chat/QueuedMessageChip.svelte | 17 ++- .../lib/components/copilot/chat/context.ts | 16 ++ .../copilot/chat/global/core.test.ts | 41 +++++ .../components/copilot/chat/global/core.ts | 65 ++++++++ frontend/src/lib/components/drillPicker.ts | 4 + .../sessions/sessionRuntime.svelte.ts | 20 ++- 12 files changed, 474 insertions(+), 94 deletions(-) diff --git a/frontend/src/lib/components/DrillPicker.svelte b/frontend/src/lib/components/DrillPicker.svelte index 49a6fe0b8e..524a33e249 100644 --- a/frontend/src/lib/components/DrillPicker.svelte +++ b/frontend/src/lib/components/DrillPicker.svelte @@ -16,6 +16,10 @@ leaves and ignores the current scope. import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import SearchItems from '$lib/components/SearchItems.svelte' + import Portal from '$lib/components/Portal.svelte' + import { zIndexes } from '$lib/zIndexes' + import { createFloatingActions } from 'svelte-floating-ui' + import { flip, offset, shift } from 'svelte-floating-ui/dom' import { generateRandomString } from '$lib/utils' import { onMount, untrack, type Snippet } from 'svelte' import { @@ -65,6 +69,9 @@ leaves and ignores the current scope. * branch to carry a `loading` flag. Needed by flat workspace layouts * whose root entries only exist once items are fetched. */ rootLoading?: boolean + /** Full-text hover tooltip for a leaf row (rows truncate their text). + * Return `undefined` to show none for that leaf. */ + rowTooltip?: (leaf: DrillLeaf) => string | undefined } let { @@ -80,7 +87,8 @@ leaves and ignores the current scope. leafSecondary, onScopeChange, onFilterChange, - rootLoading = false + rootLoading = false, + rowTooltip }: Props = $props() let searchInput: TextInput | undefined = $state() @@ -116,6 +124,9 @@ leaves and ignores the current scope. $effect(() => { void filter onFilterChange?.(filter) + // Rows re-render under a stationary pointer without firing mouseleave, + // which would strand the tooltip on a removed row. + tooltipLeave() }) /** Tracks whether the last user action was mouse movement (true) or @@ -141,18 +152,24 @@ leaves and ignores the current scope. ) let searchedItems: (SearchEntry & { marked: string })[] | undefined = $state(undefined) - // Group filtered results by their nearest-branch ancestor for display. + // Group filtered results for display: nearest `searchGroup` branch ancestor + // first, the leaf's own `section` otherwise. const searchResultsByGroup = $derived.by(() => { const groups = new Map< string, - { group: DrillBranch | null; items: (SearchEntry & { marked: string })[] } + { key: string; label: string | null; items: (SearchEntry & { marked: string })[] } >() - if (!searchedItems) return [] as { group: DrillBranch | null; items: SearchEntry[] }[] + if (!searchedItems) return [] as { key: string; label: string | null; items: SearchEntry[] }[] for (const r of searchedItems) { - const gkey = r.group?.key ?? '__none' + const gkey = r.group?.key ?? (r.leaf.section ? `__section:${r.leaf.section}` : '__none') const existing = groups.get(gkey) if (existing) existing.items.push(r) - else groups.set(gkey, { group: r.group, items: [r] }) + else + groups.set(gkey, { + key: gkey, + label: r.group?.label ?? r.leaf.section ?? null, + items: [r] + }) } return Array.from(groups.values()) }) @@ -169,9 +186,26 @@ leaves and ignores the current scope. ) ) + // Browse rows annotated with the section header rendered above them. A + // header is emitted when a leaf's section differs from the previous LEAF's + // section — comparing against the previous entry of any type would insert a + // duplicate header after every branch interleaved between same-section leaves. + const entryRows = $derived.by(() => { + let lastLeafSection: string | undefined + return entryList.map((entry) => { + const section = entry.type === 'leaf' ? entry.node.section : undefined + const header = entry.type === 'leaf' && section !== lastLeafSection ? section : undefined + if (entry.type === 'leaf') lastLeafSection = section + return { entry, header } + }) + }) + + // Search nav must follow DISPLAY order (the grouped blocks), not the raw + // fuzzy ranking — grouping reorders interleaved results, and rank-ordered + // keys would make ArrowUp/Down jump between non-adjacent visible rows. const navKeys = $derived( isSearching - ? (searchedItems ?? ([] as typeof searchItems)).map((r) => r.leaf.key) + ? searchResultsByGroup.flatMap((g) => g.items.map((r) => r.leaf.key)) : entryList.map((e) => e.key) ) @@ -210,13 +244,30 @@ leaves and ignores the current scope. el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } + function leafForKey(key: string): DrillLeaf | undefined { + if (isSearching) return (searchedItems ?? []).find((r) => r.leaf.key === key)?.leaf + const entry = entryList.find((e) => e.key === key) + return entry?.type === 'leaf' ? entry.node : undefined + } + function moveHighlight(delta: 1 | -1) { if (navKeys.length === 0) return const cur = navKeys.indexOf(highlightedKey ?? '') const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length highlightedKey = navKeys[next] mouseActive = false - requestAnimationFrame(scrollHighlightIntoView) + requestAnimationFrame(() => { + scrollHighlightIntoView() + // Keyboard parity for the tooltip: anchor it to the row the highlight + // landed on — otherwise the full descriptions are pointer-only. + if (!rowTooltip || !highlightedKey) return + const leaf = leafForKey(highlightedKey) + const el = pickerRoot?.querySelector( + `[data-nav-key="${CSS.escape(highlightedKey)}"]` + ) + if (leaf && el) tooltipEnter(el, leaf) + else tooltipLeave() + }) } function setHoverHighlight(key: string) { @@ -231,6 +282,40 @@ leaves and ignores the current scope. onPick(leaf) } + // Single shared hover tooltip (opt-in via `rowTooltip`): one floating node + // re-anchored to the hovered row, instead of a popover per row — rows can + // number in the hundreds. Portaled to body because the picker often lives + // inside an overflow-clipped floating container that would cut it off. + let tooltipText = $state(undefined) + let tooltipTimer: ReturnType | undefined + const [tooltipRef, tooltipContent] = createFloatingActions({ + strategy: 'fixed', + placement: 'right-start', + middleware: [offset(8), flip(), shift({ padding: 8 })], + autoUpdate: true + }) + + function tooltipEnter(el: HTMLElement, leaf: DrillLeaf) { + if (!rowTooltip) return + clearTimeout(tooltipTimer) + const text = rowTooltip(leaf) + if (!text) { + tooltipText = undefined + return + } + tooltipTimer = setTimeout(() => { + tooltipRef(el) + tooltipText = text + }, 250) + } + + function tooltipLeave() { + clearTimeout(tooltipTimer) + tooltipText = undefined + } + + $effect(() => () => clearTimeout(tooltipTimer)) + function activate(key: string | undefined) { if (!key) return if (isSearching) { @@ -390,6 +475,7 @@ leaves and ignores the current scope. {@const key = leaf.key} {@const isHl = key === highlightedKey} {@const isCur = !!leaf.current} + {@const tip = rowTooltip?.(leaf)}