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 81eedb04..a331c93c 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; @@ -72,7 +73,14 @@ let dropdownStyle = $state(''); let pendingInsert: { textNode: Text; hashPos: number; cursorPos: number } | null = null; - const MAX_RESULTS = 10; + type HashtagSection = { + key: string; + label?: string; + repoSlug?: string; + repoSubpath?: string | null; + items: HashtagItem[]; + startIndex: number; + }; // Sync editorEl to the textareaEl binding $effect(() => { @@ -97,13 +105,63 @@ 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 sectionsByKey = new Map>(); + + for (const item of items) { + if (selectedTokenKeys.has(`${item.type}:${item.id}`)) continue; + if (!item.title.toLowerCase().includes(filter)) continue; + + const sectionKey = hashtagSectionKey(item); + const section = sectionsByKey.get(sectionKey); + if (section) { + section.items.push(item); + } else { + sectionsByKey.set(sectionKey, { + key: sectionKey, + ...hashtagSectionLabel(item), + items: [item], + }); + } + } + + const sections: HashtagSection[] = []; + let startIndex = 0; + for (const section of sectionsByKey.values()) { + sections.push({ + ...section, + startIndex, + }); + 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) { + if (selectedIndex !== 0) selectedIndex = 0; + return; + } + if (selectedIndex >= itemCount) selectedIndex = itemCount - 1; }); let lastExtractedValue = ''; @@ -464,33 +522,40 @@ style={dropdownStyle} bind:this={dropdownEl} > - {#each filteredItems as item, i} - {@const Icon = dropdownIconMap[item.type]} - -
{ - e.preventDefault(); - selectItem(item); - }} - onmouseenter={() => (selectedIndex = i)} - > - - {#if Icon} - + {#each filteredSections as section (section.key)} +
+
+ {#if section.repoSlug} + + {:else} + {/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} +
+ {#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} + +
+ {/each}
{/each}
@@ -549,7 +614,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); @@ -561,6 +626,33 @@ padding: 4px; } + .hashtag-dropdown-section + .hashtag-dropdown-section { + margin-top: 4px; + } + + .hashtag-section-header { + display: flex; + min-width: 0; + padding: 6px 10px 3px; + font-size: var(--size-xs); + font-weight: 600; + color: var(--text-muted); + } + + .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: center; @@ -608,20 +700,16 @@ color: var(--image-color); } - .hashtag-item-title { + .hashtag-item-text { flex: 1; - font-size: var(--size-sm); - color: var(--text-primary); + display: flex; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + min-width: 0; } - .hashtag-item-context { - font-size: var(--size-xs); - color: var(--text-faint); - flex-shrink: 0; - max-width: 140px; + .hashtag-item-title { + font-size: var(--size-sm); + color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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 75e2b595..3b355648 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,24 @@ 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, + sessionStatus: null, + completionReason: null, + ...overrides, + }; +} + const projectNotes: ProjectNote[] = []; beforeEach(() => { @@ -84,6 +106,146 @@ describe('timelineToHashtagItems', () => { }) ); }); + + it('sorts timeline references newest first across types', () => { + 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: 5000, + updatedAt: 5000, + completedAt: 5000, + 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: 2000, + 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: 6000, + 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: 3000, + updatedAt: 3000, + completedAt: 3000, + }, + { + id: 'new-review', + commitSha: 'newcommit', + scope: 'commit', + sessionId: null, + sessionStatus: null, + sessionProvider: null, + completionReason: null, + title: 'New review', + commentCount: 0, + isAuto: false, + createdAt: 7000, + updatedAt: 7000, + completedAt: 7000, + }, + ], + images: [ + { + id: 'old-image', + filename: 'old.png', + mimeType: 'image/png', + sizeBytes: 1, + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 4000, + }, + { + id: 'new-image', + filename: 'new.png', + mimeType: 'image/png', + sizeBytes: 1, + sessionId: null, + sessionStatus: null, + completionReason: null, + createdAt: 8000, + }, + ], + }); + + expect(timelineToHashtagItems(timeline).map((item) => `${item.type}:${item.id}`)).toEqual([ + 'image:new-image', + 'review:new-review', + 'commit:newcommit', + 'note:new-note', + 'image:old-image', + 'review:old-review', + 'commit:oldcommit', + 'note:old-note', + ]); + }); +}); + +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 87d4dee1..1686bd75 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -35,24 +35,88 @@ export const hashtagTypeColors: Record = image: { color: '--image-color', bg: '--image-bg' }, }; +type SortableHashtagItem = HashtagItem & { + sortTimestamp: number; + sortOrder: number; +}; + +type BranchHashtagContext = { + branchName?: string; + repoSlug?: string; + repoSubpath?: string | null; +}; + +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 sectionDiff = hashtagSectionOrder(a) - hashtagSectionOrder(b); + if (sectionDiff !== 0) return sectionDiff; + + const timestampDiff = b.sortTimestamp - a.sortTimestamp; + if (timestampDiff !== 0) return timestampDiff; + + 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, + 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). */ export async function buildBranchHashtagItems( branchId: string, - projectId: string | null + projectId: string | null, + context: BranchHashtagContext = {} ): 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, + context.branchName, + context.repoSlug, + context.repoSubpath + ), + ...projectNotesToSortableHashtagItems(projectNotes), + ]); } /** @@ -63,7 +127,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,23 +143,39 @@ 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; - items.push(...timelineToHashtagItems(timeline, branch.branchName, repoSlug)); + const repoSubpath = repo?.subpath; + items.push( + ...timelineToSortableHashtagItems(timeline, branch.branchName, repoSlug, repoSubpath) + ); } - items.push(...projectNotesToHashtagItems(projectNotes)); + items.push(...projectNotesToSortableHashtagItems(projectNotes)); - return items; + return sortAndStripHashtagItems(items); } export function timelineToHashtagItems( timeline: BranchTimeline, branchName?: string, - repoSlug?: string + 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; @@ -109,6 +188,9 @@ export function timelineToHashtagItems( bgColor: '--note-bg', branchName, repoSlug, + repoSubpath, + sortTimestamp: note.completedAt ?? note.createdAt, + sortOrder: 0, }); } @@ -122,6 +204,9 @@ export function timelineToHashtagItems( bgColor: '--commit-bg', branchName, repoSlug, + repoSubpath, + sortTimestamp: commit.timestamp, + sortOrder: commit.order, }); } @@ -137,6 +222,9 @@ export function timelineToHashtagItems( bgColor: '--review-bg', branchName, repoSlug, + repoSubpath, + sortTimestamp: review.completedAt ?? review.createdAt, + sortOrder: 0, }); } @@ -150,6 +238,9 @@ export function timelineToHashtagItems( bgColor: '--image-bg', branchName, repoSlug, + repoSubpath, + sortTimestamp: image.createdAt, + sortOrder: 0, }); } @@ -157,6 +248,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) => ({ @@ -165,6 +260,8 @@ export function projectNotesToHashtagItems(notes: ProjectNote[]): HashtagItem[] title: n.title, color: '--note-color', bgColor: '--note-bg', + sortTimestamp: n.completedAt ?? n.createdAt, + sortOrder: 0, })); } diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 0cd37523..fb09f8db 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -463,8 +463,10 @@ export interface HashtagItem { title: string; color: string; bgColor: string; + subtitle?: string; branchName?: string; repoSlug?: string; + repoSubpath?: string | null; } // =============================================================================