From 8c8f1172c134b3c8f7550658293818f5d13d318b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 17:10:41 +1000 Subject: [PATCH 1/5] feat(staged): show hashtag dropdown items with subtitle layout Widen the hashtag dropdown from 340px to 420px and restructure each option from a single-line layout (title + right-aligned context) to a two-row layout with the subtitle below the title. Timeline items from other repos show the repo slug as a subtitle, and project notes show "Project note" as a subtitle. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../lib/features/sessions/HashtagInput.svelte | 33 ++++++++++--------- .../src/lib/features/sessions/hashtagItems.ts | 5 +++ apps/staged/src/lib/types.ts | 1 + 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/HashtagInput.svelte b/apps/staged/src/lib/features/sessions/HashtagInput.svelte index 81eedb04..0a499311 100644 --- a/apps/staged/src/lib/features/sessions/HashtagInput.svelte +++ b/apps/staged/src/lib/features/sessions/HashtagInput.svelte @@ -481,16 +481,12 @@ {/if} - {item.title} - {#if item.repoSlug || item.branchName} - - {#if item.repoSlug}{item.repoSlug}{/if} - {#if item.repoSlug && item.branchName} - · - {/if} - {#if item.branchName}{item.branchName}{/if} - - {/if} + + {item.title} + {#if item.subtitle} + {item.subtitle} + {/if} + {/each} @@ -549,7 +545,7 @@ /* Dropdown — fixed-positioned near the caret via inline style */ .hashtag-dropdown { - width: 340px; + width: 420px; max-width: calc(100vw - 32px); background: var(--bg-chrome); border: 1px solid var(--border-muted); @@ -563,7 +559,7 @@ .hashtag-dropdown-item { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; padding: 6px 10px; border-radius: 6px; @@ -608,8 +604,15 @@ color: var(--image-color); } - .hashtag-item-title { + .hashtag-item-text { flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; + } + + .hashtag-item-title { font-size: var(--size-sm); color: var(--text-primary); overflow: hidden; @@ -617,11 +620,9 @@ white-space: nowrap; } - .hashtag-item-context { + .hashtag-item-subtitle { font-size: var(--size-xs); color: var(--text-faint); - flex-shrink: 0; - max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index 87d4dee1..28e3207e 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -107,6 +107,7 @@ export function timelineToHashtagItems( title: note.title, color: '--note-color', bgColor: '--note-bg', + subtitle: repoSlug, branchName, repoSlug, }); @@ -120,6 +121,7 @@ export function timelineToHashtagItems( title: commit.subject, color: '--commit-color', bgColor: '--commit-bg', + subtitle: repoSlug, branchName, repoSlug, }); @@ -135,6 +137,7 @@ export function timelineToHashtagItems( title, color: '--review-color', bgColor: '--review-bg', + subtitle: repoSlug, branchName, repoSlug, }); @@ -148,6 +151,7 @@ export function timelineToHashtagItems( title: image.filename, color: '--image-color', bgColor: '--image-bg', + subtitle: repoSlug, branchName, repoSlug, }); @@ -165,6 +169,7 @@ export function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] title: n.title, color: '--note-color', bgColor: '--note-bg', + subtitle: 'Project note', })); } diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 0cd37523..559d6a87 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -463,6 +463,7 @@ export interface HashtagItem { title: string; color: string; bgColor: string; + subtitle?: string; branchName?: string; repoSlug?: string; } From fde77e950d3aab96de9a57b0dadf8a7541fa06e5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 15 May 2026 09:43:29 +1000 Subject: [PATCH 2/5] feat(staged): use RepoLabel component for hashtag dropdown subtitles Replace plain-text repo slug subtitles with the shared RepoLabel component, which renders repo+subpath with contrast styling (muted prefix, primary emphasis on last segment). Pass repoSubpath through from ProjectRepo to HashtagItem so repos with subpaths (e.g. block/builderbot / apps/staged) display correctly. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../src/lib/features/sessions/HashtagInput.svelte | 7 ++++++- .../src/lib/features/sessions/hashtagItems.ts | 14 ++++++++------ apps/staged/src/lib/types.ts | 1 + 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/HashtagInput.svelte b/apps/staged/src/lib/features/sessions/HashtagInput.svelte index 0a499311..07e50916 100644 --- a/apps/staged/src/lib/features/sessions/HashtagInput.svelte +++ b/apps/staged/src/lib/features/sessions/HashtagInput.svelte @@ -24,6 +24,7 @@ import { FileText, GitCommitVertical, FileSearch, Image as ImageLucide } from 'lucide-svelte'; import { HASHTAG_TOKEN_RE, hashtagTypeIconSvg, escapeHtml } from './hashtagItems'; import { focusAtEndSync } from '../../shared/focusAtEnd'; + import RepoLabel from '../../shared/RepoLabel.svelte'; type DropdownIconComponent = typeof FileText; @@ -483,7 +484,11 @@ {item.title} - {#if item.subtitle} + {#if item.repoSlug} + + {:else if item.subtitle} {item.subtitle} {/if} diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index 28e3207e..005e7ad1 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -83,7 +83,8 @@ export async function buildProjectHashtagItems( for (const { branch, timeline } of timelines) { const repo = branch.projectRepoId && reposById ? reposById.get(branch.projectRepoId) : null; const repoSlug = repo?.githubRepo; - items.push(...timelineToHashtagItems(timeline, branch.branchName, repoSlug)); + const repoSubpath = repo?.subpath; + items.push(...timelineToHashtagItems(timeline, branch.branchName, repoSlug, repoSubpath)); } items.push(...projectNotesToHashtagItems(projectNotes)); @@ -94,7 +95,8 @@ export async function buildProjectHashtagItems( export function timelineToHashtagItems( timeline: BranchTimeline, branchName?: string, - repoSlug?: string + repoSlug?: string, + repoSubpath?: string | null ): HashtagItem[] { const items: HashtagItem[] = []; @@ -107,9 +109,9 @@ export function timelineToHashtagItems( title: note.title, color: '--note-color', bgColor: '--note-bg', - subtitle: repoSlug, branchName, repoSlug, + repoSubpath, }); } @@ -121,9 +123,9 @@ export function timelineToHashtagItems( title: commit.subject, color: '--commit-color', bgColor: '--commit-bg', - subtitle: repoSlug, branchName, repoSlug, + repoSubpath, }); } @@ -137,9 +139,9 @@ export function timelineToHashtagItems( title, color: '--review-color', bgColor: '--review-bg', - subtitle: repoSlug, branchName, repoSlug, + repoSubpath, }); } @@ -151,9 +153,9 @@ export function timelineToHashtagItems( title: image.filename, color: '--image-color', bgColor: '--image-bg', - subtitle: repoSlug, branchName, repoSlug, + repoSubpath, }); } diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 559d6a87..fb09f8db 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -466,6 +466,7 @@ export interface HashtagItem { subtitle?: string; branchName?: string; repoSlug?: string; + repoSubpath?: string | null; } // ============================================================================= From cd2e7e3a991791d70c735e6bb26461ea18eea65c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 15 May 2026 11:46:39 +1000 Subject: [PATCH 3/5] feat(staged): section hashtag autocomplete dropdown Group hashtag suggestions by project notes, branch notes, commits, reviews, and images while preserving flat keyboard selection. Sort suggestions explicitly newest-first within each section and drop the generic project-note subtitle now covered by the section header. Signed-off-by: Matt Toohey --- .../lib/features/sessions/HashtagInput.svelte | 176 ++++++++++++++---- .../features/sessions/hashtagItems.test.ts | 162 +++++++++++++++- .../src/lib/features/sessions/hashtagItems.ts | 95 ++++++++-- 3 files changed, 387 insertions(+), 46 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/HashtagInput.svelte b/apps/staged/src/lib/features/sessions/HashtagInput.svelte index 07e50916..91ba4116 100644 --- a/apps/staged/src/lib/features/sessions/HashtagInput.svelte +++ b/apps/staged/src/lib/features/sessions/HashtagInput.svelte @@ -73,7 +73,27 @@ let dropdownStyle = $state(''); let pendingInsert: { textNode: Text; hashPos: number; cursorPos: number } | null = null; - const MAX_RESULTS = 10; + type HashtagSection = { + label: string; + items: HashtagItem[]; + startIndex: number; + }; + + const HASHTAG_SECTION_ORDER: HashtagItem['type'][] = [ + 'project-note', + 'note', + 'commit', + 'review', + 'image', + ]; + + const HASHTAG_SECTION_LABELS: Record = { + 'project-note': 'Project notes', + note: 'Branch notes', + commit: 'Commits', + review: 'Reviews', + image: 'Images', + }; // Sync editorEl to the textareaEl binding $effect(() => { @@ -98,13 +118,48 @@ return keys; }); - let filteredItems = $derived.by(() => { + let filteredSections = $derived.by((): HashtagSection[] => { if (!showDropdown) return []; const filter = filterText.toLowerCase(); - return items - .filter((item) => !selectedTokenKeys.has(`${item.type}:${item.id}`)) - .filter((item) => item.title.toLowerCase().includes(filter)) - .slice(0, MAX_RESULTS); + const sectionsByType = new Map(); + + for (const item of items) { + if (selectedTokenKeys.has(`${item.type}:${item.id}`)) continue; + if (!item.title.toLowerCase().includes(filter)) continue; + + const sectionItems = sectionsByType.get(item.type); + if (sectionItems) { + sectionItems.push(item); + } else { + sectionsByType.set(item.type, [item]); + } + } + + const sections: HashtagSection[] = []; + let startIndex = 0; + for (const type of HASHTAG_SECTION_ORDER) { + const sectionItems = sectionsByType.get(type); + if (!sectionItems?.length) continue; + + sections.push({ + label: HASHTAG_SECTION_LABELS[type], + items: sectionItems, + startIndex, + }); + startIndex += sectionItems.length; + } + return sections; + }); + + let filteredItems = $derived.by(() => filteredSections.flatMap((section) => section.items)); + + $effect(() => { + const itemCount = filteredItems.length; + if (itemCount === 0) { + if (selectedIndex !== 0) selectedIndex = 0; + return; + } + if (selectedIndex >= itemCount) selectedIndex = itemCount - 1; }); let lastExtractedValue = ''; @@ -465,33 +520,51 @@ style={dropdownStyle} bind:this={dropdownEl} > - {#each filteredItems as item, i} - {@const Icon = dropdownIconMap[item.type]} - -
{ - e.preventDefault(); - selectItem(item); - }} - onmouseenter={() => (selectedIndex = i)} - > - - {#if Icon} - - {/if} - - - {item.title} - {#if item.repoSlug} - - {:else if item.subtitle} - {item.subtitle} - {/if} - + {#each filteredSections as section} +
+
{section.label}
+ {#each section.items as item, i} + {@const Icon = dropdownIconMap[item.type]} + {@const itemIndex = section.startIndex + i} + +
{ + e.preventDefault(); + selectItem(item); + }} + onmouseenter={() => (selectedIndex = itemIndex)} + > + + {#if Icon} + + {/if} + + + {item.title} + {#if item.repoSlug || item.branchName || item.subtitle} + + {#if item.repoSlug} + + {/if} + {#if item.repoSlug && item.branchName} + - + {/if} + {#if item.branchName} + {item.branchName} + {:else if !item.repoSlug && item.subtitle} + {item.subtitle} + {/if} + + {/if} + +
+ {/each}
{/each}
@@ -562,6 +635,18 @@ padding: 4px; } + .hashtag-dropdown-section + .hashtag-dropdown-section { + margin-top: 4px; + } + + .hashtag-section-header { + padding: 6px 10px 3px; + font-size: var(--size-xs); + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + } + .hashtag-dropdown-item { display: flex; align-items: flex-start; @@ -626,13 +711,38 @@ } .hashtag-item-subtitle { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; font-size: var(--size-xs); color: var(--text-faint); + white-space: nowrap; + } + + .hashtag-subtitle-repo, + .hashtag-subtitle-branch, + .hashtag-subtitle-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .hashtag-subtitle-repo { + min-width: 0; + } + + .hashtag-subtitle-branch, + .hashtag-subtitle-text { + min-width: 0; + color: var(--text-faint); + } + + .hashtag-subtitle-separator { + flex: 0 0 auto; + color: var(--text-faint); + } + .hashtag-dropdown-empty { padding: 8px 12px; font-size: var(--size-sm); diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index 75e2b595..c257b6e9 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Branch, BranchTimeline, ProjectNote } from '../../types'; import { getBranchTimeline, listProjectNotes } from '../../commands'; -import { buildProjectHashtagItems, timelineToHashtagItems } from './hashtagItems'; +import { + buildProjectHashtagItems, + projectNotesToHashtagItems, + timelineToHashtagItems, +} from './hashtagItems'; vi.mock('../../commands', () => ({ getBranchTimeline: vi.fn(), @@ -47,6 +51,22 @@ function emptyTimeline(overrides: Partial = {}): BranchTimeline }; } +function projectNote(overrides: Partial = {}): ProjectNote { + return { + id: 'project-note-1', + projectId: 'project-1', + sessionId: null, + title: 'Project note', + content: '', + createdAt: 0, + updatedAt: 0, + completedAt: 0, + suggestedNextCommitStep: null, + suggestedNextNoteStep: null, + ...overrides, + }; +} + const projectNotes: ProjectNote[] = []; beforeEach(() => { @@ -84,6 +104,146 @@ describe('timelineToHashtagItems', () => { }) ); }); + + it('sorts by section and newest first within each section', () => { + const timeline: BranchTimeline = emptyTimeline({ + notes: [ + { + id: 'old-note', + title: 'Old note', + content: '', + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 1000, + updatedAt: 1000, + completedAt: 1000, + suggestedNextCommitStep: null, + suggestedNextNoteStep: null, + }, + { + id: 'new-note', + title: 'New note', + content: '', + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 2000, + updatedAt: 2000, + completedAt: 2000, + suggestedNextCommitStep: null, + suggestedNextNoteStep: null, + }, + ], + commits: [ + { + id: 'old-commit-id', + sha: 'oldcommit', + shortSha: 'oldcomm', + subject: 'Old commit', + author: 'Test User', + authorEmail: 'test@example.com', + isOwnCommit: true, + timestamp: 1, + order: 0, + sessionId: null, + sessionStatus: null, + completionReason: null, + }, + { + id: 'new-commit-id', + sha: 'newcommit', + shortSha: 'newcomm', + subject: 'New commit', + author: 'Test User', + authorEmail: 'test@example.com', + isOwnCommit: true, + timestamp: 2, + order: 1, + sessionId: null, + sessionStatus: null, + completionReason: null, + }, + ], + reviews: [ + { + id: 'old-review', + commitSha: 'oldcommit', + scope: 'commit', + sessionId: null, + sessionStatus: null, + sessionProvider: null, + completionReason: null, + title: 'Old review', + commentCount: 0, + isAuto: false, + createdAt: 1000, + updatedAt: 1000, + completedAt: 1000, + }, + { + id: 'new-review', + commitSha: 'newcommit', + scope: 'commit', + sessionId: null, + sessionStatus: null, + sessionProvider: null, + completionReason: null, + title: 'New review', + commentCount: 0, + isAuto: false, + createdAt: 2000, + updatedAt: 2000, + completedAt: 2000, + }, + ], + images: [ + { + id: 'old-image', + filename: 'old.png', + mimeType: 'image/png', + sizeBytes: 1, + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 1000, + }, + { + id: 'new-image', + filename: 'new.png', + mimeType: 'image/png', + sizeBytes: 1, + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 2000, + }, + ], + }); + + expect(timelineToHashtagItems(timeline).map((item) => `${item.type}:${item.id}`)).toEqual([ + 'note:new-note', + 'note:old-note', + 'commit:newcommit', + 'commit:oldcommit', + 'review:new-review', + 'review:old-review', + 'image:new-image', + 'image:old-image', + ]); + }); +}); + +describe('projectNotesToHashtagItems', () => { + it('sorts project notes newest first without a generic subtitle', () => { + const items = projectNotesToHashtagItems([ + projectNote({ id: 'old-project-note', title: 'Old project note', completedAt: 1000 }), + projectNote({ id: 'new-project-note', title: 'New project note', completedAt: 2000 }), + ]); + + expect(items.map((item) => item.id)).toEqual(['new-project-note', 'old-project-note']); + expect(items[0]).not.toHaveProperty('subtitle'); + }); }); describe('buildProjectHashtagItems', () => { diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index 005e7ad1..bfba4cb1 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -35,6 +35,53 @@ export const hashtagTypeColors: Record = image: { color: '--image-color', bg: '--image-bg' }, }; +type SortableHashtagItem = HashtagItem & { + sortTimestamp: number; + sortOrder: number; +}; + +const hashtagTypeOrder: Record = { + 'project-note': 0, + note: 1, + commit: 2, + review: 3, + image: 4, +}; + +function sortAndStripHashtagItems(items: SortableHashtagItem[]): HashtagItem[] { + return [...items].sort(compareHashtagItems).map(stripSortMetadata); +} + +function compareHashtagItems(a: SortableHashtagItem, b: SortableHashtagItem): number { + const typeDiff = hashtagTypeOrder[a.type] - hashtagTypeOrder[b.type]; + if (typeDiff !== 0) return typeDiff; + + const timestampDiff = b.sortTimestamp - a.sortTimestamp; + if (timestampDiff !== 0) return timestampDiff; + + const orderDiff = b.sortOrder - a.sortOrder; + if (orderDiff !== 0) return orderDiff; + + return a.title.localeCompare(b.title); +} + +function stripSortMetadata(item: SortableHashtagItem): HashtagItem { + const stripped: HashtagItem = { + type: item.type, + id: item.id, + title: item.title, + color: item.color, + bgColor: item.bgColor, + }; + + if (item.subtitle !== undefined) stripped.subtitle = item.subtitle; + if (item.branchName !== undefined) stripped.branchName = item.branchName; + if (item.repoSlug !== undefined) stripped.repoSlug = item.repoSlug; + if (item.repoSubpath !== undefined) stripped.repoSubpath = item.repoSubpath; + + return stripped; +} + /** * Build hashtag items for a single branch scope (+ optional project notes). */ @@ -42,17 +89,15 @@ export async function buildBranchHashtagItems( branchId: string, projectId: string | null ): Promise { - const items: HashtagItem[] = []; - const [timeline, projectNotes] = await Promise.all([ getBranchTimeline(branchId), projectId ? listProjectNotes(projectId) : Promise.resolve([]), ]); - items.push(...timelineToHashtagItems(timeline)); - items.push(...projectNotesToHashtagItems(projectNotes)); - - return items; + return sortAndStripHashtagItems([ + ...timelineToSortableHashtagItems(timeline), + ...projectNotesToSortableHashtagItems(projectNotes), + ]); } /** @@ -63,7 +108,6 @@ export async function buildProjectHashtagItems( branches: Branch[], reposById?: Map ): Promise { - const items: HashtagItem[] = []; const readyBranches = branches.filter((branch) => branchTimelineReadyKey(branch) !== null); const [timelineResults, projectNotes] = await Promise.all([ @@ -80,16 +124,19 @@ export async function buildProjectHashtagItems( ) .map((r) => r.value); + const items: SortableHashtagItem[] = []; for (const { branch, timeline } of timelines) { const repo = branch.projectRepoId && reposById ? reposById.get(branch.projectRepoId) : null; const repoSlug = repo?.githubRepo; const repoSubpath = repo?.subpath; - items.push(...timelineToHashtagItems(timeline, branch.branchName, repoSlug, repoSubpath)); + items.push( + ...timelineToSortableHashtagItems(timeline, branch.branchName, repoSlug, repoSubpath) + ); } - items.push(...projectNotesToHashtagItems(projectNotes)); + items.push(...projectNotesToSortableHashtagItems(projectNotes)); - return items; + return sortAndStripHashtagItems(items); } export function timelineToHashtagItems( @@ -98,7 +145,18 @@ export function timelineToHashtagItems( repoSlug?: string, repoSubpath?: string | null ): HashtagItem[] { - const items: HashtagItem[] = []; + return sortAndStripHashtagItems( + timelineToSortableHashtagItems(timeline, branchName, repoSlug, repoSubpath) + ); +} + +function timelineToSortableHashtagItems( + timeline: BranchTimeline, + branchName?: string, + repoSlug?: string, + repoSubpath?: string | null +): SortableHashtagItem[] { + const items: SortableHashtagItem[] = []; for (const note of timeline.notes) { if (!note.title.trim()) continue; @@ -112,6 +170,8 @@ export function timelineToHashtagItems( branchName, repoSlug, repoSubpath, + sortTimestamp: note.completedAt ?? note.createdAt, + sortOrder: 0, }); } @@ -126,6 +186,8 @@ export function timelineToHashtagItems( branchName, repoSlug, repoSubpath, + sortTimestamp: commit.timestamp, + sortOrder: commit.order, }); } @@ -142,6 +204,8 @@ export function timelineToHashtagItems( branchName, repoSlug, repoSubpath, + sortTimestamp: review.completedAt ?? review.createdAt, + sortOrder: 0, }); } @@ -156,6 +220,8 @@ export function timelineToHashtagItems( branchName, repoSlug, repoSubpath, + sortTimestamp: image.createdAt, + sortOrder: 0, }); } @@ -163,6 +229,10 @@ export function timelineToHashtagItems( } export function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] { + return sortAndStripHashtagItems(projectNotesToSortableHashtagItems(notes)); +} + +function projectNotesToSortableHashtagItems(notes: ProjectNote[]): SortableHashtagItem[] { return notes .filter((n) => n.title.trim()) .map((n) => ({ @@ -171,7 +241,8 @@ export function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] title: n.title, color: '--note-color', bgColor: '--note-bg', - subtitle: 'Project note', + sortTimestamp: n.completedAt ?? n.createdAt, + sortOrder: 0, })); } From 55ada2e7af593d39c41a1eaeba31005aa188f059 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 15 May 2026 12:24:05 +1000 Subject: [PATCH 4/5] feat(staged): group hashtag suggestions by source Group autocomplete sections by project notes and repo/subpath instead of reference type. Remove per-row subtitles now that source context is carried by section headers, and pass repo context into branch-scoped hashtag lists where available. Signed-off-by: Matt Toohey --- .../lib/features/branches/BranchCard.svelte | 1 + .../diff/DiffCommitSessionLauncher.svelte | 9 +- .../src/lib/features/diff/DiffModal.svelte | 2 + .../lib/features/sessions/HashtagInput.svelte | 144 +++++++----------- .../features/sessions/NewSessionModal.svelte | 6 +- .../lib/features/sessions/SessionModal.svelte | 20 ++- .../features/sessions/hashtagItems.test.ts | 40 ++--- .../src/lib/features/sessions/hashtagItems.ts | 27 +++- 8 files changed, 134 insertions(+), 115 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 351f6ca4..210269b8 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -1636,6 +1636,7 @@ repoDir={branch.worktreePath} branchId={branch.id} projectId={branch.projectId} + {repoLabel} noteInfo={findNoteForSession(sessionMgr.openSessionId)} onOpenNote={(noteId, title, content) => { const sid = sessionMgr.openSessionId; diff --git a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte index 63e6621a..0da01d8e 100644 --- a/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte +++ b/apps/staged/src/lib/features/diff/DiffCommitSessionLauncher.svelte @@ -19,6 +19,8 @@ scope: 'branch' | 'commit'; reviewId?: string; visibleCommentCount: number; + githubRepo?: string; + subpath?: string | null; isRemote: boolean; onStarted: () => void; } @@ -30,6 +32,8 @@ scope, reviewId, visibleCommentCount, + githubRepo, + subpath, isRemote, onStarted, }: Props = $props(); @@ -45,7 +49,10 @@ let hashtagItems = $state([]); $effect(() => { let stale = false; - buildBranchHashtagItems(branchId, projectId ?? null).then((items) => { + buildBranchHashtagItems(branchId, projectId ?? null, { + repoSlug: githubRepo, + repoSubpath: subpath, + }).then((items) => { if (!stale) hashtagItems = items; }); return () => { diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index e97ec697..9711ea6f 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -1206,6 +1206,8 @@ scope={reviewableScope()} reviewId={activeReviewId} visibleCommentCount={currentComments.length} + {githubRepo} + {subpath} {isRemote} onStarted={onClose} /> diff --git a/apps/staged/src/lib/features/sessions/HashtagInput.svelte b/apps/staged/src/lib/features/sessions/HashtagInput.svelte index 91ba4116..a331c93c 100644 --- a/apps/staged/src/lib/features/sessions/HashtagInput.svelte +++ b/apps/staged/src/lib/features/sessions/HashtagInput.svelte @@ -74,27 +74,14 @@ let pendingInsert: { textNode: Text; hashPos: number; cursorPos: number } | null = null; type HashtagSection = { - label: string; + key: string; + label?: string; + repoSlug?: string; + repoSubpath?: string | null; items: HashtagItem[]; startIndex: number; }; - const HASHTAG_SECTION_ORDER: HashtagItem['type'][] = [ - 'project-note', - 'note', - 'commit', - 'review', - 'image', - ]; - - const HASHTAG_SECTION_LABELS: Record = { - 'project-note': 'Project notes', - note: 'Branch notes', - commit: 'Commits', - review: 'Reviews', - image: 'Images', - }; - // Sync editorEl to the textareaEl binding $effect(() => { textareaEl = editorEl ?? null; @@ -121,38 +108,53 @@ let filteredSections = $derived.by((): HashtagSection[] => { if (!showDropdown) return []; const filter = filterText.toLowerCase(); - const sectionsByType = new Map(); + const sectionsByKey = new Map>(); for (const item of items) { if (selectedTokenKeys.has(`${item.type}:${item.id}`)) continue; if (!item.title.toLowerCase().includes(filter)) continue; - const sectionItems = sectionsByType.get(item.type); - if (sectionItems) { - sectionItems.push(item); + const sectionKey = hashtagSectionKey(item); + const section = sectionsByKey.get(sectionKey); + if (section) { + section.items.push(item); } else { - sectionsByType.set(item.type, [item]); + sectionsByKey.set(sectionKey, { + key: sectionKey, + ...hashtagSectionLabel(item), + items: [item], + }); } } const sections: HashtagSection[] = []; let startIndex = 0; - for (const type of HASHTAG_SECTION_ORDER) { - const sectionItems = sectionsByType.get(type); - if (!sectionItems?.length) continue; - + for (const section of sectionsByKey.values()) { sections.push({ - label: HASHTAG_SECTION_LABELS[type], - items: sectionItems, + ...section, startIndex, }); - startIndex += sectionItems.length; + startIndex += section.items.length; } return sections; }); let filteredItems = $derived.by(() => filteredSections.flatMap((section) => section.items)); + function hashtagSectionKey(item: HashtagItem): string { + if (item.type === 'project-note') return 'project-notes'; + if (item.repoSlug) return `repo:${item.repoSlug}\u0000${item.repoSubpath ?? ''}`; + return 'branch-references'; + } + + function hashtagSectionLabel( + item: HashtagItem + ): Pick { + if (item.type === 'project-note') return { label: 'Project notes' }; + if (item.repoSlug) return { repoSlug: item.repoSlug, repoSubpath: item.repoSubpath }; + return { label: 'Branch references' }; + } + $effect(() => { const itemCount = filteredItems.length; if (itemCount === 0) { @@ -520,9 +522,17 @@ style={dropdownStyle} bind:this={dropdownEl} > - {#each filteredSections as section} + {#each filteredSections as section (section.key)}
-
{section.label}
+
+ {#if section.repoSlug} + + {:else} + + {/if} +
{#each section.items as item, i} {@const Icon = dropdownIconMap[item.type]} {@const itemIndex = section.startIndex + i} @@ -543,25 +553,6 @@ {item.title} - {#if item.repoSlug || item.branchName || item.subtitle} - - {#if item.repoSlug} - - {/if} - {#if item.repoSlug && item.branchName} - - - {/if} - {#if item.branchName} - {item.branchName} - {:else if !item.repoSlug && item.subtitle} - {item.subtitle} - {/if} - - {/if}
{/each} @@ -640,16 +631,31 @@ } .hashtag-section-header { + display: flex; + min-width: 0; padding: 6px 10px 3px; font-size: var(--size-xs); font-weight: 600; color: var(--text-muted); - text-transform: uppercase; + } + + .hashtag-section-label, + .hashtag-section-repo { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .hashtag-section-repo :global(.repo-label) { + display: block; + overflow: hidden; + text-overflow: ellipsis; } .hashtag-dropdown-item { display: flex; - align-items: flex-start; + align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; @@ -697,7 +703,6 @@ .hashtag-item-text { flex: 1; display: flex; - flex-direction: column; overflow: hidden; min-width: 0; } @@ -710,39 +715,6 @@ white-space: nowrap; } - .hashtag-item-subtitle { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; - font-size: var(--size-xs); - color: var(--text-faint); - white-space: nowrap; - } - - .hashtag-subtitle-repo, - .hashtag-subtitle-branch, - .hashtag-subtitle-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .hashtag-subtitle-repo { - min-width: 0; - } - - .hashtag-subtitle-branch, - .hashtag-subtitle-text { - min-width: 0; - color: var(--text-faint); - } - - .hashtag-subtitle-separator { - flex: 0 0 auto; - color: var(--text-faint); - } - .hashtag-dropdown-empty { padding: 8px 12px; font-size: var(--size-sm); diff --git a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte index 9c5c4dba..39a3ae95 100644 --- a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte @@ -236,7 +236,11 @@ let hashtagItems = $state([]); $effect(() => { let stale = false; - buildBranchHashtagItems(branch.id, branch.projectId).then((items) => { + buildBranchHashtagItems(branch.id, branch.projectId, { + branchName: branch.branchName, + repoSlug: repoLabel?.headRepo ?? repoLabel?.githubRepo, + repoSubpath: repoLabel?.subpath, + }).then((items) => { if (!stale) hashtagItems = items; }); return () => { diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index a7255082..07a16d8b 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -46,7 +46,7 @@ import { marked } from 'marked'; import { sanitize } from '../../shared/sanitize'; import { isResumableReason } from '../../types'; - import type { Session, SessionMessage, HashtagItem } from '../../types'; + import type { Session, SessionMessage, HashtagItem, ProjectRepo } from '../../types'; import { cancelSession, createImage, @@ -97,12 +97,23 @@ branchId?: string | null; /** Project ID — when provided, enables image attachment on replies. */ projectId?: string | null; + /** Repo label for grouping branch-scoped hashtag suggestions. */ + repoLabel?: Pick | null; /** When set, shows a button to open the associated note. */ noteInfo?: { id: string; title: string; content: string } | null; onOpenNote?: (noteId: string, title: string, content: string) => void; } - let { sessionId, onClose, repoDir, branchId, projectId, noteInfo, onOpenNote }: Props = $props(); + let { + sessionId, + onClose, + repoDir, + branchId, + projectId, + repoLabel = null, + noteInfo, + onOpenNote, + }: Props = $props(); // ========================================================================= // State @@ -139,7 +150,10 @@ $effect(() => { if (branchId) { let stale = false; - buildBranchHashtagItems(branchId, projectId ?? null).then((items) => { + buildBranchHashtagItems(branchId, projectId ?? null, { + repoSlug: repoLabel?.headRepo ?? repoLabel?.githubRepo, + repoSubpath: repoLabel?.subpath, + }).then((items) => { if (!stale) hashtagItems = items; }); return () => { diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index c257b6e9..2dde041c 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -105,7 +105,7 @@ describe('timelineToHashtagItems', () => { ); }); - it('sorts by section and newest first within each section', () => { + it('sorts timeline references newest first across types', () => { const timeline: BranchTimeline = emptyTimeline({ notes: [ { @@ -128,9 +128,9 @@ describe('timelineToHashtagItems', () => { sessionId: null, sessionStatus: null, completionReason: null, - createdAt: 2000, - updatedAt: 2000, - completedAt: 2000, + createdAt: 5000, + updatedAt: 5000, + completedAt: 5000, suggestedNextCommitStep: null, suggestedNextNoteStep: null, }, @@ -144,7 +144,7 @@ describe('timelineToHashtagItems', () => { author: 'Test User', authorEmail: 'test@example.com', isOwnCommit: true, - timestamp: 1, + timestamp: 2000, order: 0, sessionId: null, sessionStatus: null, @@ -158,7 +158,7 @@ describe('timelineToHashtagItems', () => { author: 'Test User', authorEmail: 'test@example.com', isOwnCommit: true, - timestamp: 2, + timestamp: 6000, order: 1, sessionId: null, sessionStatus: null, @@ -177,9 +177,9 @@ describe('timelineToHashtagItems', () => { title: 'Old review', commentCount: 0, isAuto: false, - createdAt: 1000, - updatedAt: 1000, - completedAt: 1000, + createdAt: 3000, + updatedAt: 3000, + completedAt: 3000, }, { id: 'new-review', @@ -192,9 +192,9 @@ describe('timelineToHashtagItems', () => { title: 'New review', commentCount: 0, isAuto: false, - createdAt: 2000, - updatedAt: 2000, - completedAt: 2000, + createdAt: 7000, + updatedAt: 7000, + completedAt: 7000, }, ], images: [ @@ -206,7 +206,7 @@ describe('timelineToHashtagItems', () => { sessionId: null, sessionStatus: null, completionReason: null, - createdAt: 1000, + createdAt: 4000, }, { id: 'new-image', @@ -216,20 +216,20 @@ describe('timelineToHashtagItems', () => { sessionId: null, sessionStatus: null, completionReason: null, - createdAt: 2000, + createdAt: 8000, }, ], }); expect(timelineToHashtagItems(timeline).map((item) => `${item.type}:${item.id}`)).toEqual([ - 'note:new-note', - 'note:old-note', - 'commit:newcommit', - 'commit:oldcommit', - 'review:new-review', - 'review:old-review', 'image:new-image', + 'review:new-review', + 'commit:newcommit', + 'note:new-note', 'image:old-image', + 'review:old-review', + 'commit:oldcommit', + 'note:old-note', ]); }); }); diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index bfba4cb1..1686bd75 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -40,6 +40,12 @@ type SortableHashtagItem = HashtagItem & { sortOrder: number; }; +type BranchHashtagContext = { + branchName?: string; + repoSlug?: string; + repoSubpath?: string | null; +}; + const hashtagTypeOrder: Record = { 'project-note': 0, note: 1, @@ -53,8 +59,8 @@ function sortAndStripHashtagItems(items: SortableHashtagItem[]): HashtagItem[] { } function compareHashtagItems(a: SortableHashtagItem, b: SortableHashtagItem): number { - const typeDiff = hashtagTypeOrder[a.type] - hashtagTypeOrder[b.type]; - if (typeDiff !== 0) return typeDiff; + const sectionDiff = hashtagSectionOrder(a) - hashtagSectionOrder(b); + if (sectionDiff !== 0) return sectionDiff; const timestampDiff = b.sortTimestamp - a.sortTimestamp; if (timestampDiff !== 0) return timestampDiff; @@ -62,9 +68,16 @@ function compareHashtagItems(a: SortableHashtagItem, b: SortableHashtagItem): nu const orderDiff = b.sortOrder - a.sortOrder; if (orderDiff !== 0) return orderDiff; + const typeDiff = hashtagTypeOrder[a.type] - hashtagTypeOrder[b.type]; + if (typeDiff !== 0) return typeDiff; + return a.title.localeCompare(b.title); } +function hashtagSectionOrder(item: HashtagItem): number { + return item.type === 'project-note' ? 0 : 1; +} + function stripSortMetadata(item: SortableHashtagItem): HashtagItem { const stripped: HashtagItem = { type: item.type, @@ -87,7 +100,8 @@ function stripSortMetadata(item: SortableHashtagItem): HashtagItem { */ export async function buildBranchHashtagItems( branchId: string, - projectId: string | null + projectId: string | null, + context: BranchHashtagContext = {} ): Promise { const [timeline, projectNotes] = await Promise.all([ getBranchTimeline(branchId), @@ -95,7 +109,12 @@ export async function buildBranchHashtagItems( ]); return sortAndStripHashtagItems([ - ...timelineToSortableHashtagItems(timeline), + ...timelineToSortableHashtagItems( + timeline, + context.branchName, + context.repoSlug, + context.repoSubpath + ), ...projectNotesToSortableHashtagItems(projectNotes), ]); } From 985be38853967f5e3cef465eb62468e119aed20a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 15 May 2026 17:06:08 +1000 Subject: [PATCH 5/5] fix(staged): add missing ProjectNote fields to test factory Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/staged/src/lib/features/sessions/hashtagItems.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index 2dde041c..3b355648 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -63,6 +63,8 @@ function projectNote(overrides: Partial = {}): ProjectNote { completedAt: 0, suggestedNextCommitStep: null, suggestedNextNoteStep: null, + sessionStatus: null, + completionReason: null, ...overrides, }; }