diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c120ac12679..e1b8a9551b1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -914,7 +914,6 @@ pub fn run() { RunEvent::Exit => { shut_down_app(app_handle, &run_shutdown_done); app_handle.state::().release(); - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] if restart_requested.load(Ordering::SeqCst) { relaunch_after_mesh_shutdown(app_handle); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 164225fd80d..2747bdb1864 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -1,9 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { getSchema } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; + import { + assignMentionHighlightNames, buildHighlightPatterns, + createMentionCaretSettlement, findHighlightMatches, + insertPosForMentionTextInput, + mentionTextInputInsertPos, + positionAfterArrowLeftThroughMentionSpace, + selectionAfterMentionTrailingSpace, + shouldAdvanceMentionCaret, } from "./mentionHighlightExtension.ts"; // ── buildHighlightPatterns ──────────────────────────────────────────── @@ -164,3 +174,141 @@ test("#general should NOT match inside #generally (trailing word boundary)", () const matches = findHighlightMatches("#generally", patterns); assert.equal(matches.length, 0); }); + +const schema = getSchema([ + StarterKit.configure({ + heading: false, + trailingNode: false, + link: false, + }), +]); +const paragraph = (...content) => schema.nodes.paragraph.create(null, content); +const text = (value) => schema.text(value); +const document = (...content) => schema.nodes.doc.create(null, content); + +test("selectionAfterMentionTrailingSpace steps past the space after @Name", () => { + const doc = document(paragraph(text("@quinn "))); + const spacePos = 1 + "@quinn".length; + assert.equal(selectionAfterMentionTrailingSpace(doc, spacePos), spacePos + 1); + assert.equal( + selectionAfterMentionTrailingSpace(doc, spacePos + 1), + spacePos + 1, + ); +}); + +test("selectionAfterMentionTrailingSpace leaves a caret inside the mention name", () => { + const doc = document(paragraph(text("@quinn "))); + assert.equal(selectionAfterMentionTrailingSpace(doc, 4), 4); +}); + +test("selectionAfterMentionTrailingSpace does not move without a trailing space", () => { + const doc = document(paragraph(text("@quinn"))); + const end = 1 + "@quinn".length; + assert.equal(selectionAfterMentionTrailingSpace(doc, end), end); +}); + +test("shouldAdvanceMentionCaret restores a remap while this editor is settling", () => { + assert.equal( + shouldAdvanceMentionCaret({ + from: 7, + next: 8, + settling: true, + docChanged: false, + }), + true, + ); +}); + +test("shouldAdvanceMentionCaret does not steal ArrowLeft after settlement is cancelled", () => { + assert.equal( + shouldAdvanceMentionCaret({ + from: 7, + next: 8, + settling: false, + docChanged: false, + }), + false, + ); +}); + +test("shouldAdvanceMentionCaret still advances after a document change", () => { + assert.equal( + shouldAdvanceMentionCaret({ + from: 7, + next: 8, + settling: false, + docChanged: true, + }), + true, + ); +}); + +test("createMentionCaretSettlement keeps two editors independent", () => { + const composerA = createMentionCaretSettlement(); + const composerB = createMentionCaretSettlement(); + composerA.arm(8); + assert.equal(composerB.peek(), null); + composerB.arm(12); + composerA.cancel(); + assert.equal(composerA.peek(), null); + assert.equal(composerB.peek(), 12); +}); + +test("insertPosForMentionTextInput redirects a caret at the chip edge", () => { + const doc = document(paragraph(text("@quinn "))); + const spacePos = 1 + "@quinn".length; + assert.equal( + insertPosForMentionTextInput(doc, spacePos, spacePos), + spacePos + 1, + ); + assert.equal( + insertPosForMentionTextInput(doc, spacePos + 1, spacePos + 1), + null, + ); +}); + +test("insertPosForMentionTextInput keeps a selected trailing space", () => { + const doc = document(paragraph(text("@quinn "))); + const spacePos = 1 + "@quinn".length; + assert.equal( + insertPosForMentionTextInput(doc, spacePos, spacePos + 1), + spacePos + 1, + ); +}); + +test("mentionTextInputInsertPos honors a deliberate caret after settlement", () => { + const doc = document(paragraph(text("@bob "))); + const spacePos = 1 + "@bob".length; + assert.equal(mentionTextInputInsertPos(doc, spacePos, spacePos, false), null); + assert.equal( + mentionTextInputInsertPos(doc, spacePos, spacePos, true), + spacePos + 1, + ); +}); + +test("positionAfterArrowLeftThroughMentionSpace steps onto the token end", () => { + const doc = document(paragraph(text("@bob "))); + const afterSpace = 1 + "@bob".length + 1; + assert.equal( + positionAfterArrowLeftThroughMentionSpace(doc, afterSpace), + afterSpace - 1, + ); + assert.equal( + positionAfterArrowLeftThroughMentionSpace(doc, afterSpace - 1), + null, + ); +}); + +test("assignMentionHighlightNames skips an unchanged list", () => { + const storage = { names: ["bob"], agentNames: [], channelNames: [] }; + assert.equal(assignMentionHighlightNames(storage, ["bob"], [], []), false); +}); + +test("assignMentionHighlightNames updates when a new mention is added", () => { + const storage = { names: ["bob"], agentNames: [], channelNames: [] }; + assert.equal( + assignMentionHighlightNames(storage, ["bob", "quinn"], [], []), + true, + ); + assert.deepEqual(storage.names, ["bob", "quinn"]); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index a8d3ef8ff0a..f20feef26da 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,5 +1,11 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { + Plugin, + PluginKey, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { @@ -9,6 +15,238 @@ import { export const mentionHighlightKey = new PluginKey("mentionHighlight"); +export type MentionCaretSettlement = { + arm: (pos: number) => void; + peek: () => number | null; + cancel: () => void; +}; + +export function createMentionCaretSettlement(): MentionCaretSettlement { + let pos: number | null = null; + return { + arm(nextPos: number) { + pos = nextPos; + }, + peek() { + return pos; + }, + cancel() { + pos = null; + }, + }; +} + +/** + * Whether to move an empty caret from `from` to `next` after a mention + * trailing space. Settlement is per editor: autocomplete arms it, and + * ArrowLeft/click cancel it so we do not steal an intentional caret. + */ +export function shouldAdvanceMentionCaret({ + from, + next, + settling, + docChanged, +}: { + from: number; + next: number; + settling: boolean; + docChanged: boolean; +}): boolean { + return next !== from && (settling || docChanged); +} + +/** + * Where to insert typed text when the caret (or a one-character selection) + * sits on the trailing space after an `@name` / `#channel` token. + * A selected trailing space would otherwise be replaced, producing + * `@bobhello`. + */ +export function insertPosForMentionTextInput( + doc: ProseMirrorNode, + from: number, + to: number, +): number | null { + const next = selectionAfterMentionTrailingSpace(doc, from); + if (from === to) { + return next === from ? null : next; + } + if (to === next && next === from + 1) { + return next; + } + return null; +} + +/** + * Redirect chip-edge typing only while autocomplete is settling. After a + * deliberate ArrowLeft or chip click, honor the caret so `x` lands in the + * token (`@bobx`) instead of after the space (`@bob x`). + */ +export function mentionTextInputInsertPos( + doc: ProseMirrorNode, + from: number, + to: number, + settling: boolean, +): number | null { + if (!settling) return null; + return insertPosForMentionTextInput(doc, from, to); +} + +/** Caret just after a mention trailing space: ArrowLeft lands on the token end. */ +export function positionAfterArrowLeftThroughMentionSpace( + doc: ProseMirrorNode, + from: number, +): number | null { + if (from <= 0) return null; + const chipEnd = from - 1; + if (selectionAfterMentionTrailingSpace(doc, chipEnd) === from) { + return chipEnd; + } + return null; +} + +export function setDomCaretAtPos( + view: { + domAtPos: (pos: number) => { node: Node; offset: number }; + root: Document | ShadowRoot; + }, + pos: number, +): void { + if (typeof document === "undefined") return; + let mapped: { node: Node; offset: number }; + try { + mapped = view.domAtPos(pos); + } catch { + return; + } + const range = document.createRange(); + try { + range.setStart(mapped.node, mapped.offset); + } catch { + return; + } + range.collapse(true); + const root = view.root; + const selection = + "getSelection" in root && typeof root.getSelection === "function" + ? root.getSelection() + : window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); +} + +export function reassertMentionCaretAfterFocus(view: { + state: { + doc: ProseMirrorNode; + selection: { empty: boolean; from: number }; + tr: Transaction; + }; + dispatch: (tr: Transaction) => void; + domAtPos: (pos: number) => { node: Node; offset: number }; + root: Document | ShadowRoot; +}): void { + if (!view.state.selection.empty) return; + const from = view.state.selection.from; + const next = selectionAfterMentionTrailingSpace(view.state.doc, from); + if (next !== from) { + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, next)), + ); + } + setDomCaretAtPos(view, view.state.selection.from); +} + +export type MentionHighlightStorage = { + names: string[]; + agentNames: string[]; + channelNames: string[]; +}; + +function sameNameList(current: string[], next: string[]): boolean { + return ( + current.length === next.length && + current.every((name, index) => name === next[index]) + ); +} + +export function assignMentionHighlightNames( + storage: MentionHighlightStorage, + names: string[], + agentNames: string[], + channelNames: string[], +): boolean { + if ( + sameNameList(storage.names, names) && + sameNameList(storage.agentNames, agentNames) && + sameNameList(storage.channelNames, channelNames) + ) { + return false; + } + storage.names = names; + storage.agentNames = agentNames; + storage.channelNames = channelNames; + return true; +} + +export function mentionHighlightStorage(editor: { + storage: object; +}): MentionHighlightStorage | undefined { + if (!("mentionHighlight" in editor.storage)) return undefined; + return editor.storage.mentionHighlight as MentionHighlightStorage; +} + +export function settleAutocompleteMentionInsert( + editor: { storage: object }, + tr: Transaction, + text: string, +): void { + const storage = mentionHighlightStorage(editor); + const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text); + if (!mentionInsert) return; + const prefix = mentionInsert[1]; + const label = mentionInsert[2]; + if (storage) { + const known = [ + ...storage.names, + ...storage.agentNames, + ...storage.channelNames, + ]; + if (!known.some((name) => name.toLowerCase() === label.toLowerCase())) { + if (prefix === "#") { + storage.channelNames = [...storage.channelNames, label]; + } else { + storage.names = [...storage.names, label]; + } + } + } + tr.setMeta(mentionHighlightKey, true); +} + +export function syncMentionHighlightFromProps( + editor: { + storage: object; + state: { tr: Transaction }; + view: { dispatch: (tr: Transaction) => void }; + }, + names: string[] | undefined, + agentNames: string[] | undefined, + channelNames: string[] | undefined, +): void { + const storage = mentionHighlightStorage(editor); + if ( + !storage || + !assignMentionHighlightNames( + storage, + names ?? [], + agentNames ?? [], + channelNames ?? [], + ) + ) { + return; + } + editor.view.dispatch(editor.state.tr.setMeta(mentionHighlightKey, true)); +} + /** * TipTap extension that applies inline `mention-chip` decorations * to `@Name` and `#channel-name` patterns in the document. @@ -29,6 +267,7 @@ export const MentionHighlightExtension = Extension.create({ addProseMirrorPlugins() { const extension = this; + const settlement = createMentionCaretSettlement(); return [ new Plugin({ @@ -43,6 +282,16 @@ export const MentionHighlightExtension = Extension.create({ ); }, apply(tr, oldDecorations) { + if ( + tr.getMeta(mentionHighlightKey) && + tr.selection.empty && + (tr.docChanged || settlement.peek() !== null) + ) { + settlement.arm( + selectionAfterMentionTrailingSpace(tr.doc, tr.selection.from), + ); + } + // Names/channels changed — full rebuild required. if (tr.getMeta(mentionHighlightKey)) { return buildDecorations( @@ -85,10 +334,131 @@ export const MentionHighlightExtension = Extension.create({ return oldDecorations.map(tr.mapping, tr.doc); }, }, + appendTransaction(transactions, _oldState, newState) { + if (!newState.selection.empty) { + settlement.cancel(); + return null; + } + const from = newState.selection.from; + const next = selectionAfterMentionTrailingSpace(newState.doc, from); + if ( + !shouldAdvanceMentionCaret({ + from, + next, + settling: settlement.peek() !== null, + docChanged: transactions.some((tr) => tr.docChanged), + }) + ) { + return null; + } + return newState.tr.setSelection( + TextSelection.create(newState.doc, next), + ); + }, + view() { + let applying = false; + return { + update(view) { + if (applying || settlement.peek() === null) return; + if (!view.state.selection.empty) { + settlement.cancel(); + return; + } + const from = view.state.selection.from; + const next = selectionAfterMentionTrailingSpace( + view.state.doc, + from, + ); + if (next !== from) { + applying = true; + try { + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, next), + ), + ); + } finally { + applying = false; + } + } + setDomCaretAtPos(view, view.state.selection.from); + }, + destroy() { + settlement.cancel(); + }, + }; + }, props: { decorations(state) { return this.getState(state) ?? DecorationSet.empty; }, + handleTextInput(view, from, to, text) { + const insertAt = mentionTextInputInsertPos( + view.state.doc, + from, + to, + settlement.peek() !== null, + ); + if (insertAt == null) { + settlement.cancel(); + return false; + } + const tr = view.state.tr.insertText(text, insertAt); + const caret = tr.mapping.map(insertAt, 1); + tr.setSelection(TextSelection.create(tr.doc, caret)); + view.dispatch(tr); + settlement.cancel(); + setDomCaretAtPos(view, caret); + return true; + }, + handleKeyDown(view, event) { + if ( + event.key === "ArrowRight" || + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "Home" || + event.key === "End" + ) { + settlement.cancel(); + return false; + } + if (event.key !== "ArrowLeft" || !view.state.selection.empty) { + return false; + } + settlement.cancel(); + const chipEnd = positionAfterArrowLeftThroughMentionSpace( + view.state.doc, + view.state.selection.from, + ); + if (chipEnd == null) return false; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, chipEnd), + ), + ); + setDomCaretAtPos(view, chipEnd); + return true; + }, + handleClick(view, pos, event) { + const target = event.target; + const onChip = + target instanceof Element && + Boolean(target.closest(".mention-chip")); + settlement.cancel(); + if (!onChip) return false; + const chipEnd = positionAfterArrowLeftThroughMentionSpace( + view.state.doc, + pos, + ); + if (chipEnd == null) return false; + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, chipEnd), + ), + ); + setDomCaretAtPos(view, chipEnd); + return true; + }, }, }), ]; @@ -136,6 +506,28 @@ export function buildHighlightPatterns( return patterns; } +/** + * If `pos` sits at the end of an `@name` / `#channel` token and the next + * character is a space, return the position after that space. + * + * Autocomplete inserts `@Name ` then chip decorations wrap the token. The + * browser can map the caret back to the chip edge, so the next keystroke + * lands before the space (`@quinnhello`). Callers use this to keep typing + * after the token. + */ +export function selectionAfterMentionTrailingSpace( + doc: ProseMirrorNode, + pos: number, +): number { + if (pos < 0 || pos >= doc.content.size) return pos; + const nextChar = doc.textBetween(pos, pos + 1, "\n", "\0"); + if (nextChar !== " ") return pos; + const lookbehind = Math.min(pos, 80); + const before = doc.textBetween(pos - lookbehind, pos, "\n", "\0"); + if (!/(?:^|[\s(])[@#][^\s]+$/.test(before)) return pos; + return pos + 1; +} + /** * Find all highlight matches in a text string given a set of patterns. * Returns an array of { from, to } offsets relative to the text start. @@ -310,25 +702,41 @@ function addMatchesForPatterns( while (match !== null) { const from = position + match.index; const to = from + match[0].length; + const outsideEnd = { inclusiveEnd: false }; if (options?.hidePrefix && /^[@#]/.test(match[0])) { decorations.push( - Decoration.inline(from, from + 1, { - class: "mention-prefix-hidden", - spellcheck: "false", - }), + Decoration.inline( + from, + from + 1, + { + class: "mention-prefix-hidden", + spellcheck: "false", + }, + outsideEnd, + ), ); decorations.push( - Decoration.inline(from + 1, to, { - class: className, - spellcheck: "false", - }), + Decoration.inline( + from + 1, + to, + { + class: className, + spellcheck: "false", + }, + outsideEnd, + ), ); } else { decorations.push( - Decoration.inline(from, to, { - class: className, - spellcheck: "false", - }), + Decoration.inline( + from, + to, + { + class: className, + spellcheck: "false", + }, + outsideEnd, + ), ); } match = pattern.exec(text); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index f812eb91156..36a75f32e5d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -24,7 +24,9 @@ import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip"; import { MentionHighlightExtension, - mentionHighlightKey, + reassertMentionCaretAfterFocus, + settleAutocompleteMentionInsert, + syncMentionHighlightFromProps, } from "./mentionHighlightExtension"; import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; @@ -688,24 +690,15 @@ export function useRichTextEditor({ }, [editor, placeholder]); // Keep mention/channel-highlight decorations in sync with known names. - // NOTE: We use `editor.storage.mentionHighlight` (the mutable storage object - // shared with the ProseMirror plugin closure) rather than finding the - // extension instance via extensionManager — the instance's `.storage` getter - // returns a fresh spread-copy on every access, so mutations are silently lost. + // Mutate `editor.storage.mentionHighlight`; the extension getter copies storage. React.useEffect(() => { if (!editor) return; - // biome-ignore lint/suspicious/noExplicitAny: TipTap's Storage type doesn't include dynamic extension keys - const storage = (editor.storage as any).mentionHighlight as - | { names: string[]; agentNames: string[]; channelNames: string[] } - | undefined; - if (storage) { - storage.names = mentionNames ?? []; - storage.agentNames = agentMentionNames ?? []; - storage.channelNames = channelNames ?? []; - // Force the plugin to re-decorate by dispatching a metadata transaction. - const { tr } = editor.state; - editor.view.dispatch(tr.setMeta(mentionHighlightKey, true)); - } + syncMentionHighlightFromProps( + editor, + mentionNames, + agentMentionNames, + channelNames, + ); }, [editor, mentionNames, agentMentionNames, channelNames]); // Custom-emoji set changes: re-resolve the `src` attr on any existing @@ -872,8 +865,10 @@ export function useRichTextEditor({ // "Position N out of range".) const cursorPM = tr.mapping.map(toPM); tr.setSelection(TextSelection.create(tr.doc, cursorPM)); + settleAutocompleteMentionInsert(editor, tr, text); editor.view.dispatch(tr); editor.view.focus(); + reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); diff --git a/desktop/src/features/messages/ui/MessageThreadTranscript.tsx b/desktop/src/features/messages/ui/MessageThreadTranscript.tsx index fceda286578..7460f9aad3c 100644 --- a/desktop/src/features/messages/ui/MessageThreadTranscript.tsx +++ b/desktop/src/features/messages/ui/MessageThreadTranscript.tsx @@ -21,6 +21,7 @@ type MessageThreadTranscriptProps = { remove: boolean, ) => Promise; profiles?: UserProfileLookup; + renderAfterMessage?: (message: TimelineMessage) => React.ReactNode; testId?: string; }; @@ -36,6 +37,7 @@ export function MessageThreadTranscript({ messages, onToggleReaction, profiles, + renderAfterMessage, testId = "message-thread-transcript", }: MessageThreadTranscriptProps) { const renderItems = React.useMemo(() => { @@ -59,16 +61,18 @@ export function MessageThreadTranscript({ data-testid={testId} > {renderItems.map(({ isContinuation, message }) => ( - + + + {renderAfterMessage?.(message)} + ))} ); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs index 7febdf8b678..36066132c42 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.test.mjs @@ -6,6 +6,7 @@ import { buildProjectSelectionAgentContext, buildProjectsOverviewAgentContext, projectDetailAgentContextBlock, + splitProjectDetailAgentContext, stripProjectDetailAgentContext, untrustedPromptValue, withProjectSelectionAgentContext, @@ -215,8 +216,44 @@ test("selected project context enforces a final serialization budget", () => { }); test("strips hidden page context from the displayed user message", () => { - const content = `Explain this file${projectDetailAgentContextBlock( + const payload = projectDetailAgentContextBlock( buildProjectDetailAgentContext(base), - )}`; + ); + const content = `Explain this file${payload}`; assert.equal(stripProjectDetailAgentContext(content), "Explain this file"); + assert.deepEqual(splitProjectDetailAgentContext(content), { + context: payload.trim(), + message: "Explain this file", + }); +}); + +test("leaves ordinary messages unchanged without inventing context", () => { + assert.deepEqual(splitProjectDetailAgentContext("A normal message"), { + context: null, + message: "A normal message", + }); +}); + +test("splits only the final appended context marker", () => { + const userMessage = + "Discuss this literal example:\n---\nCurrent Buzz project page:\nnot appended"; + const payload = projectDetailAgentContextBlock( + buildProjectDetailAgentContext(base), + ); + assert.deepEqual(splitProjectDetailAgentContext(`${userMessage}${payload}`), { + context: payload.trim(), + message: userMessage, + }); +}); + +test("splits workspace repository context for the shared conversation view", () => { + const payload = + '\n---\nWorkspace repositories:\n- "Buzz" (address: "owner:buzz")'; + assert.deepEqual( + splitProjectDetailAgentContext(`Compare the repos${payload}`), + { + context: payload.trim(), + message: "Compare the repos", + }, + ); }); diff --git a/desktop/src/features/projects/lib/projectDetailAgentContext.ts b/desktop/src/features/projects/lib/projectDetailAgentContext.ts index 0194bad3e18..97142157007 100644 --- a/desktop/src/features/projects/lib/projectDetailAgentContext.ts +++ b/desktop/src/features/projects/lib/projectDetailAgentContext.ts @@ -5,6 +5,12 @@ import { } from "./projectSelection.ts"; const PROJECT_PAGE_CONTEXT_MARKER = "Current Buzz project page:"; +/** Marker for the repository set appended by the full Projects agent page. */ +export const PROJECT_WORKSPACE_CONTEXT_MARKER = "Workspace repositories:"; +const PROJECT_AGENT_CONTEXT_MARKERS = [ + PROJECT_PAGE_CONTEXT_MARKER, + PROJECT_WORKSPACE_CONTEXT_MARKER, +]; const MAX_OVERVIEW_CONTEXT_ITEMS = 200; const MAX_OVERVIEW_CONTEXT_FIELD_LENGTH = 180; const MAX_SELECTION_CONTEXT_ITEMS = 100; @@ -323,8 +329,24 @@ function overviewContextField(value: string | null | undefined) { return normalizedPromptValue(value, MAX_OVERVIEW_CONTEXT_FIELD_LENGTH); } +export function splitProjectDetailAgentContext(content: string): { + context: string | null; + message: string; +} { + const markerIndex = Math.max( + ...PROJECT_AGENT_CONTEXT_MARKERS.map((marker) => + content.lastIndexOf(`---\n${marker}`), + ), + ); + if (markerIndex === -1) { + return { context: null, message: content }; + } + return { + context: content.slice(markerIndex).trim(), + message: content.slice(0, markerIndex).replace(/\n+$/, ""), + }; +} + export function stripProjectDetailAgentContext(content: string) { - const markerIndex = content.indexOf(`---\n${PROJECT_PAGE_CONTEXT_MARKER}`); - if (markerIndex === -1) return content; - return content.slice(0, markerIndex).replace(/\n+$/, ""); + return splitProjectDetailAgentContext(content).message; } diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs new file mode 100644 index 00000000000..2f89decab42 --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + currentPullRequestForSelection, + projectReviewFilesChangedBody, + retainLatestByKey, + reviewDiffWorkspaceBranch, + shouldReplaceRetainedPullRequest, +} from "./projectReviewDisplay.ts"; + +test("retainLatestByKey keeps the previous value when shouldReplace is false", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-1", + { files: [] }, + (next, previous) => + next.files.length > 0 ? true : previous.files.length === 0, + ); + + assert.deepEqual(retained, { files: [1] }); + assert.deepEqual(cache.current.value, { files: [1] }); +}); + +test("retainLatestByKey takes a new key immediately", () => { + const cache = { current: { key: "pr-1", value: { files: [1] } } }; + + const retained = retainLatestByKey( + cache, + "pr-2", + { files: [] }, + (next) => next.files.length > 0, + ); + + assert.deepEqual(retained, { files: [] }); +}); + +test("an explicit selected review does not fall back to another identity", () => { + const selected = { id: "pr-a" }; + const branchReview = { id: "pr-branch" }; + + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [selected, branchReview], + selectedPullRequestId: "pr-a", + }), + selected, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: "pr-a", + }), + null, + ); + assert.equal( + currentPullRequestForSelection({ + fallback: branchReview, + pullRequests: [branchReview], + selectedPullRequestId: null, + }), + branchReview, + ); +}); + +test("retained review identity stays aligned with the diff-query identity across fetch phases", () => { + const reviewA = { id: "pr-a" }; + const renderedCache = { current: { key: "repo:pr-a", value: reviewA } }; + const diffQueryCache = { current: { key: "repo:pr-a", value: reviewA } }; + + const renderedDuringFetch = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + const diffDuringFetch = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, true), + ); + assert.equal(renderedDuringFetch, reviewA); + assert.equal(diffDuringFetch, reviewA); + assert.equal(renderedDuringFetch.id, diffDuringFetch.id); + + const renderedAfterComplete = retainLatestByKey( + renderedCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + const diffAfterComplete = retainLatestByKey( + diffQueryCache, + "repo:pr-a", + currentPullRequestForSelection({ + fallback: { id: "pr-branch" }, + pullRequests: [], + selectedPullRequestId: "pr-a", + }), + (next) => shouldReplaceRetainedPullRequest(next, false), + ); + assert.equal(renderedAfterComplete, null); + assert.equal(diffAfterComplete, null); +}); + +test("review files stay mounted when a populated diff races an unavailable snapshot", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: true, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "files", + ); +}); + +test("review files can show unavailable before a diff exists", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: true, + }), + "unavailable", + ); +}); + +test("review files render the panel for a selected review when the repo is available", () => { + assert.equal( + projectReviewFilesChangedBody({ + hasPopulatedDiff: false, + hasSelectedPullRequest: true, + repositoryUnavailable: false, + }), + "files", + ); +}); + +test("review diffs stay on the target branch, not the head or picker branch", () => { + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: "main" }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: { targetBranch: null }, + }), + "main", + ); + assert.equal( + reviewDiffWorkspaceBranch({ + activeBranch: "variation/bees", + defaultBranch: "main", + pullRequest: null, + }), + "variation/bees", + ); +}); diff --git a/desktop/src/features/projects/lib/projectReviewDisplay.ts b/desktop/src/features/projects/lib/projectReviewDisplay.ts new file mode 100644 index 00000000000..d7ba721238c --- /dev/null +++ b/desktop/src/features/projects/lib/projectReviewDisplay.ts @@ -0,0 +1,101 @@ +type RetainLatestByKeyCache = { + current: { + key: string; + value: T; + }; +}; + +/** + * Keep the latest accepted value for a stable key across transient empties. + * A new key always takes `value` immediately (real navigation). + */ +export function retainLatestByKey( + cache: RetainLatestByKeyCache, + key: string, + value: T, + shouldReplace: (next: T, previous: T) => boolean, +): T { + if (cache.current.key !== key) { + cache.current = { key, value }; + return value; + } + if (shouldReplace(value, cache.current.value)) { + cache.current.value = value; + } + return cache.current.value; +} + +/** + * Keep a selected review through transient empty refetches. Once the fetch + * is idle, accept the completed result — including null — so the rendered + * identity can match the diff-query identity. + */ +export function shouldReplaceRetainedPullRequest( + next: unknown, + isFetching: boolean, +): boolean { + return Boolean(next) || !isFetching; +} + +/** + * Resolve the current review for a selection. An explicit ID that is missing + * from the list is `null` so a completed refetch can clear it; pass + * `fallback` only when no ID is selected (branch auto-select). + */ +export function currentPullRequestForSelection({ + fallback = null, + pullRequests, + selectedPullRequestId, +}: { + fallback?: T | null; + pullRequests: readonly T[] | undefined; + selectedPullRequestId: string | null; +}): T | null { + if (selectedPullRequestId) { + return ( + pullRequests?.find((item) => item.id === selectedPullRequestId) ?? null + ); + } + return fallback; +} + +/** + * Which body to render under a review's Files changed section. + * A populated diff must keep the files panel mounted even when the repository + * snapshot briefly looks unavailable — swapping in the unavailable placeholder + * is the files-section flicker. + */ +export function projectReviewFilesChangedBody({ + hasPopulatedDiff, + hasSelectedPullRequest, + repositoryUnavailable, +}: { + hasPopulatedDiff: boolean; + hasSelectedPullRequest: boolean; + repositoryUnavailable: boolean; +}): "files" | "unavailable" | null { + if (hasSelectedPullRequest && (hasPopulatedDiff || !repositoryUnavailable)) { + return "files"; + } + if (repositoryUnavailable) return "unavailable"; + return null; +} + +/** + * Workspace branch used to fetch a review diff. + * A selected review is `target...head`; do not key that query on the head + * branch or the workspace picker, or Files changed will swap with the + * default-branch snapshot. + */ +export function reviewDiffWorkspaceBranch({ + activeBranch, + defaultBranch, + pullRequest, +}: { + activeBranch: string | null | undefined; + defaultBranch: string | null | undefined; + pullRequest: { targetBranch: string | null } | null | undefined; +}): string | null | undefined { + if (!pullRequest) return activeBranch; + return pullRequest.targetBranch || defaultBranch || activeBranch; +} diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs index 749d5eb5011..6229c775389 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.test.mjs @@ -33,7 +33,7 @@ afterEach(async () => { after(() => dom.window.close()); -async function renderPreview(payload) { +async function renderPreview(payload, options = {}) { const { createElement } = await import("react"); const { render } = await import("@testing-library/react"); const { AgentContextPayloadPreview } = await import( @@ -41,8 +41,9 @@ async function renderPreview(payload) { ); return render( createElement(AgentContextPayloadPreview, { + iconOnly: options.iconOnly, payload, - triggerLabel: "Context", + triggerLabel: options.triggerLabel ?? "Context", }), ); } @@ -83,6 +84,24 @@ test("discloses the exact appended payload before send, adversarial metadata inc assert.equal(screen.queryByTestId("agent-context-preview"), null); }); +test("supports a subtle icon-only disclosure without losing its accessible name", async () => { + const { fireEvent, screen } = await import("@testing-library/react"); + await renderPreview("Exact context", { + iconOnly: true, + triggerLabel: "Preview message context", + }); + + const trigger = screen.getByRole("button", { + name: "Preview message context", + }); + assert.equal(trigger.textContent?.trim(), ""); + fireEvent.click(trigger); + assert.equal( + screen.getByTestId("agent-context-preview-payload").textContent, + "Exact context", + ); +}); + test("renders nothing when there is no payload to append", async () => { const { screen } = await import("@testing-library/react"); await renderPreview(""); diff --git a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx index d6db47b8686..550cadd86aa 100644 --- a/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx +++ b/desktop/src/features/projects/ui/AgentContextPayloadPreview.tsx @@ -1,6 +1,7 @@ import { Info } from "lucide-react"; import * as React from "react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; /** @@ -13,9 +14,11 @@ import { Button } from "@/shared/ui/button"; * inspects here is byte-identical to what gets signed under their key. */ export function AgentContextPayloadPreview({ + iconOnly = false, payload, triggerLabel, }: { + iconOnly?: boolean; payload: string; triggerLabel: string; }) { @@ -25,8 +28,14 @@ export function AgentContextPayloadPreview({ return (
{open ? (
{ + const byChannel = new Map(); + for (const hit of hits) { + if (hit.channelId && !byChannel.has(hit.channelId)) { + byChannel.set(hit.channelId, hit); + } + } + return byChannel; + }, [hits]); +} + +/** + * Shared row-click behavior: land on the latest matching message — in the + * side conversation panel when one is mounted — instead of jumping straight + * to the channel. Forum content opens in place (the panel renders chat + * threads only), and channels with no quotable hit fall back to plain + * channel navigation. + */ +function openDiscussionHit({ + channelId, + goChannel, + latestHit, + openSearchHit, + panel, +}: { + channelId: string; + goChannel: (channelId: string) => unknown; + latestHit: SearchHit | undefined; + openSearchHit: (hit: SearchHit) => unknown; + panel: { openConversation: (hit: SearchHit) => void } | null; +}) { + if (!latestHit) { + void goChannel(channelId); + return; + } + const opensForum = + latestHit.kind === KIND_FORUM_POST || latestHit.kind === KIND_FORUM_COMMENT; + if (!panel || opensForum) { + void openSearchHit(latestHit); + return; + } + panel.openConversation(latestHit); +} + /** Channel display name, preferring the hit's name, then bounded metadata, * then a short id so inaccessible/renamed channels still render something. */ function useChannelNameLookup(channelIds: readonly string[]) { @@ -141,19 +188,10 @@ export function DiscussedInChannels({ { enabled: visible.length > 0 }, ); const profiles = profilesQuery.data?.profiles; - // Hits are sorted newest first, so the first hit per channel is the one a - // click should land on (and the one worth quoting). The origin channel has - // no such hit: the `h` tag proves only the channel, so its row navigates - // to the channel without claiming any particular message. - const latestHitByChannel = React.useMemo(() => { - const byChannel = new Map(); - for (const hit of hits) { - if (hit.channelId && !byChannel.has(hit.channelId)) { - byChannel.set(hit.channelId, hit); - } - } - return byChannel; - }, [hits]); + // The origin channel has no quotable hit: the `h` tag proves only the + // channel, so its row navigates to the channel without claiming any + // particular message. + const latestHitByChannel = useLatestHitByChannel(hits); if (channels.length === 0) return null; const hiddenCount = channels.length - visible.length; @@ -173,21 +211,14 @@ export function DiscussedInChannels({ {visible.map((channel) => { const latestHit = latestHitByChannel.get(channel.id); const name = channelName(channel.id, channel.name); - const opensForum = - latestHit != null && - (latestHit.kind === KIND_FORUM_POST || - latestHit.kind === KIND_FORUM_COMMENT); - const openConversation = () => { - if (!latestHit) { - void goChannel(channel.id); - return; - } - if (!projectConversationPanel || opensForum) { - void openSearchHit(latestHit); - return; - } - projectConversationPanel.openConversation(latestHit); - }; + const openConversation = () => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }); return (
@@ -336,7 +367,9 @@ function DiscussionNameList({ /** * Full-width channel list for the workspace "Channels" tab: every channel * where the repository (or its PRs/issues) is linked in chat, with the - * people who discussed it there. + * people who discussed it there. Clicking a row opens the latest matching + * conversation in the side panel (whose header still jumps to the channel) + * rather than leaving the project view. */ export function DiscussionChannelsPanel({ query, @@ -345,13 +378,17 @@ export function DiscussionChannelsPanel({ query: string; repositoryName: string; }) { - const { channels, isLoading, isTruncated } = useDiscussionChannels(query); - const { goChannel } = useAppNavigation(); + const { channels, hits, isLoading, isTruncated } = + useDiscussionChannels(query); + const { goChannel, openSearchHit } = useAppNavigation(); + const projectConversationPanel = useProjectConversationPanel(); + const latestHitByChannel = useLatestHitByChannel(hits); const channelIds = React.useMemo( () => channels.map((channel) => channel.id), [channels], ); const channelName = useChannelNameLookup(channelIds); + const profilesQuery = useUsersBatchQuery( channels.flatMap((channel) => channel.participants), { enabled: channels.length > 0 }, @@ -363,7 +400,10 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

+

No channels reference this repository yet. Paste its link (or a review or task link) in a channel and it will show up here.

@@ -379,10 +419,11 @@ export function DiscussionChannelsPanel({ ); return ( -
+
    {channels.map((channel) => { const name = channelName(channel.id, channel.name); + const latestHit = latestHitByChannel.get(channel.id); return (
  • } - onClick={() => void goChannel(channel.id)} + onClick={() => + openDiscussionHit({ + channelId: channel.id, + goChannel, + latestHit, + openSearchHit, + panel: projectConversationPanel, + }) + } people={channel.participants} peopleTestId="project-channel-participants" profiles={profiles} @@ -411,7 +460,11 @@ export function DiscussionChannelsPanel({ }} testId="project-channel-row" title={`#${name}`} - titleAttr={`Open #${name}`} + titleAttr={ + latestHit + ? `Open the latest conversation in #${name}` + : `Open #${name}` + } />
  • ); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 4da5123814a..74130447270 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -267,7 +267,9 @@ export function IssueAssigneesRow({ {canSelfAssign && viewer ? ( + {open ? ( +
    + {payload} +
    + ) : null} +
+ ); + }, +); diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index b8c3ffd273d..68e067e0d1b 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -20,10 +20,8 @@ import type { ProjectActivitySummary, } from "@/features/projects/hooks"; import { - formatExactTimestamp, getProjectUpdatedAt, listRowDescription, - relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability"; import { projectShareLink } from "@/features/projects/lib/projectShareLinks"; @@ -55,39 +53,6 @@ import { ProjectEntityListRow } from "./ProjectEntityListRow"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; -function ProjectUpdatedLabel({ - profiles, - project, - summary, -}: { - profiles?: UserProfileLookup; - project: Project; - summary: ProjectActivitySummary | undefined; -}) { - const updatedAt = getProjectUpdatedAt(project, summary); - const latestCommit = summary?.latestCommit; - const authorLabel = latestCommit?.author - ? resolveUserLabel({ profiles, pubkey: latestCommit.author }) - : null; - - return ( - - - - {relativeTime(updatedAt)} - - - - {latestCommit - ? `${latestCommit.title || latestCommit.commit.slice(0, 7)}${ - authorLabel ? ` · ${authorLabel}` : "" - } · ${formatExactTimestamp(latestCommit.createdAt)}` - : `Created ${formatExactTimestamp(project.createdAt)}`} - - - ); -} - export function ProjectPeopleStack({ pubkeys, profiles, @@ -540,12 +505,7 @@ export function ProjectGridCard({
-
- +
{repositoryCount} } + affiliationClassName="w-auto" affiliationTestId="projects-row-context" affiliationTitle={`${repositoryCount} ${ repositoryCount === 1 ? "repository" : "repositories" diff --git a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx index 0930146e2db..faadaf5d2c4 100644 --- a/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx @@ -15,12 +15,16 @@ export function CopyTextButton({ text: string; }) { const [copied, setCopied] = React.useState(false); - const handleCopy = React.useCallback(() => { - void writeTextToClipboard(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2_000); - }); - }, [text]); + const handleCopy = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + void writeTextToClipboard(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }); + }, + [text], + ); return ( - {open ?
{children}
: null} + {open ? ( +
{children}
+ ) : null} ); } diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index 10cd1f87a41..d0769e31e50 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -131,6 +131,7 @@ export function ProjectEntitySelectControl({ export function ProjectEntityListRow({ affiliation, + affiliationClassName, affiliationTestId, affiliationTitle, beforeDate, @@ -158,6 +159,7 @@ export function ProjectEntityListRow({ trailing, }: { affiliation?: React.ReactNode; + affiliationClassName?: string; affiliationTestId?: string; affiliationTitle?: string; beforeDate?: React.ReactNode; @@ -292,7 +294,10 @@ export function ProjectEntityListRow({ ) : null} {affiliation ? ( {count != null ? ( - + {count} {countSuffix} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index 105d20f10ee..72884a33ed3 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -755,8 +755,10 @@ export function ProjectPullRequestFilesChangedPanel({ } export function ProjectDiffFilesPanel({ + className, error, diff, + fileTreeClassName, isLoading, embedded = false, focusedAnchor, @@ -764,6 +766,10 @@ export function ProjectDiffFilesPanel({ inlineComments, subjectLabel, }: { + /** Extra classes for the file-tree/diff grid container. */ + className?: string; + /** Overrides the file tree's default `max-h-96` cap, e.g. for full-height layouts. */ + fileTreeClassName?: string; error: unknown; diff: ProjectRepoDiff | null | undefined; isLoading: boolean; @@ -814,11 +820,11 @@ export function ProjectDiffFilesPanel({ } }, [filteredFiles, selectedPath]); - if (isLoading) { + if (isLoading && !diff) { return ; } - if (error) { + if (error && !diff) { const message = errorMessage(error); return (
@@ -876,7 +883,12 @@ export function ProjectDiffFilesPanel({ />
-