From 19ff7e1e1f3c5292a631217eb0fde30cd3a27447 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:16:17 -0700 Subject: [PATCH 1/2] fix(web): project memory is a standard thread details row Project memory was a bare ghost button between the card's sections, so it had no icon, no row styling, and none of the section padding. It is now a row in the workspace section beside Code index and Conversation summaries, and it can be hidden from Choose what to show. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/ThreadDetailsPanel.tsx | 12 +++++++++--- .../src/components/chat/threadDetailsItems.ts | 1 + .../projectKnowledge/ProjectMemoryPanel.tsx | 17 ++++++++++++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 393c4b1c80483..091e555b43367 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -152,6 +152,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { environment: density === "full" && visible.environment, openIn: density !== "essential" && visible.openIn && props.showOpenInPicker, scripts: visible.scripts && props.activeProjectScripts !== undefined, + memory: visible.memory, branch: props.gitCwd !== null && props.isGitRepo && visible.branch, git: props.gitCwd !== null && @@ -162,7 +163,7 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { return ( <>
- {show.environment || show.openIn || show.scripts || showIndexRows ? ( + {show.environment || show.openIn || show.scripts || show.memory || showIndexRows ? ( ) : null} + + {show.memory ? ( + + ) : null}
) : null} @@ -254,8 +262,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { ) : null} - - {density === "full" && !props.draftId && visible.servers ? ( ) : null} diff --git a/apps/web/src/components/chat/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts index 7502a4c0b1dce..8d4cdbaf17166 100644 --- a/apps/web/src/components/chat/threadDetailsItems.ts +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -5,6 +5,7 @@ export const THREAD_DETAILS_ITEMS = [ { id: "environment", label: "Environment and worktree", defaultVisible: true }, { id: "openIn", label: "Open in editor", defaultVisible: true }, { id: "scripts", label: "Project scripts", defaultVisible: true }, + { id: "memory", label: "Project memory", defaultVisible: true }, { id: "branch", label: "Branch and pull request", defaultVisible: true }, { id: "gitActions", label: "Git actions", defaultVisible: true }, { id: "changes", label: "Changes", defaultVisible: true }, diff --git a/apps/web/src/components/projectKnowledge/ProjectMemoryPanel.tsx b/apps/web/src/components/projectKnowledge/ProjectMemoryPanel.tsx index 511ff73fe0132..3419a9a9b2b58 100644 --- a/apps/web/src/components/projectKnowledge/ProjectMemoryPanel.tsx +++ b/apps/web/src/components/projectKnowledge/ProjectMemoryPanel.tsx @@ -22,7 +22,13 @@ import { import { projectKnowledgeContextRecord } from "@t3tools/client-runtime/projectKnowledge"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { BrainIcon } from "lucide-react"; import { Button } from "../ui/button"; +import { ThreadDetailsControl } from "../chat/ThreadDetailsControl"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LABEL_CLASS, +} from "../chat/threadDetailsPanelStyles"; import { Checkbox } from "../ui/checkbox"; import { Input } from "../ui/input"; import { ScrollArea } from "../ui/scroll-area"; @@ -120,15 +126,19 @@ export function ProjectMemoryPanel(props: ProjectScope) { const open = useOpenProjectMemory(); if (!available) return null; return ( - + open(props)}> + + + Project memory + + ); } export function ProjectMemoryDialogHost(props: Scope) { const available = useProjectMemoryAvailable(props.environmentId); const [request, setRequest] = useAtom(openRequestAtom); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the shared open request this host holds until unmount useEffect(() => () => setRequest(null), [setRequest]); const active = available && @@ -226,6 +236,7 @@ function MemoryContents( } | null>(null); const rootRef = useRef(null); const selectionVersion = useRef(0); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates in-flight selection work when the dialog unmounts useEffect( () => () => { selectionVersion.current += 1; From d4014e61329379d6b05869267b3cd56bb9dc7ab6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:34:55 -0700 Subject: [PATCH 2/2] fix(mobile): project memory opens from the thread actions menu The composer drew a bare "Project memory" text button above itself, so it floated over the last message in the feed. It now sits in the thread actions menu beside Conversation summaries, on threads and new-thread drafts alike, and the composer only hosts the sheet. Co-Authored-By: Claude Opus 5.5 --- .../components/ProjectKnowledgeEntry.test.tsx | 45 ++++++------- .../src/components/ProjectKnowledgeEntry.tsx | 65 ++++++++++--------- .../src/components/useRememberMessage.ts | 4 +- .../features/threads/DraftThreadHeader.tsx | 10 +++ .../features/threads/NewTaskDraftScreen.tsx | 7 +- .../src/features/threads/ThreadHeader.tsx | 30 +++++---- .../features/threads/ThreadRouteScreen.tsx | 17 +++++ .../threads/useThreadHeaderOptions.tsx | 59 ++++++++++------- 8 files changed, 144 insertions(+), 93 deletions(-) diff --git a/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx index 98937778754b1..66c63f17dad4a 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx @@ -227,7 +227,7 @@ vi.mock("../features/settings/appearance/AppearancePreferencesProvider", () => ( })); vi.mock("../features/terminal/terminalTheme", () => ({ getMobileTerminalTheme: () => ({}) })); import { ComposerContextSheet } from "./ComposerContextSheet"; -import { ProjectKnowledgeEntry, requestRememberMessage } from "./ProjectKnowledgeEntry"; +import { ProjectKnowledgeEntry, requestProjectMemory } from "./ProjectKnowledgeEntry"; const scope = { environmentId: EnvironmentId.make("mobile-memory"), @@ -330,6 +330,7 @@ function setup(entries = [scope]) { ), ), rerender: () => act(() => root.render(renderEntries())), + openMemory: () => act(() => requestProjectMemory({ ...entries[0]!, message: null })), click: async (label: string) => { const button = [...container.querySelectorAll("button")].find( (item) => item.textContent === label || item.getAttribute("aria-label") === label, @@ -400,7 +401,7 @@ it.each(["selection", "archive", "close", "unmount", "removal"] as const)( complete = resolve; }), ); - await screen.click("Project memory"); + screen.openMemory(); if (change !== "removal") await screen.click("Select for draft"); await screen.click("Attach to draft"); if (change === "selection") await screen.click("Selected for draft"); @@ -410,7 +411,7 @@ it.each(["selection", "archive", "close", "unmount", "removal"] as const)( ); if (change === "close") { await screen.click("Close"); - await screen.click("Project memory"); + screen.openMemory(); } if (change === "unmount") screen.unmount(); if (change === "removal") await screen.click("Remove from draft"); @@ -435,7 +436,7 @@ it("flags a fact whose citation is unavailable in the list", async () => { nextCursor: null, unavailableSources: [{ factId: fact.id, reason: "changed" }], }; - await screen.click("Project memory"); + screen.openMemory(); expect(screen.container.textContent).toContain("Source changed"); }); @@ -457,7 +458,7 @@ it("saves a remembered feed message with a citation to that message", async () = archived: false, }; act(() => - requestRememberMessage({ + requestProjectMemory({ environmentId: scope.environmentId, projectId: scope.projectId, draftKey: scope.draftKey, @@ -487,7 +488,7 @@ it("shows user origin once without treating the assertion as a citation", async sources: [{ kind: "user" }], }; state.list = { facts: [asserted], nextCursor: null }; - await screen.click("Project memory"); + screen.openMemory(); await screen.click("Inspect fact: " + asserted.content); expect(screen.container.textContent?.match(/User assertion/g)).toHaveLength(1); expect(screen.container.textContent).toContain("No cited source"); @@ -496,7 +497,7 @@ it("shows user origin once without treating the assertion as a citation", async it("attaches the current selection", async () => { using screen = setup(); - await screen.click("Project memory"); + screen.openMemory(); await screen.click("Select for draft"); await screen.click("Attach to draft"); expect(state.insert).toHaveBeenCalledWith( @@ -513,7 +514,7 @@ it("attaches the current selection", async () => { it("edits a citationless agent observation as an explicit user assertion", async () => { using screen = setup(); - await screen.click("Project memory"); + screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); await screen.click("Edit"); await screen.click("Save"); @@ -558,7 +559,7 @@ it("pages both result streams without restarting the other stream", async () => ], }, })); - await screen.click("Project memory"); + screen.openMemory(); const input = screen.container.querySelector( '[aria-label="Search project knowledge"]', ) as HTMLInputElement; @@ -620,7 +621,7 @@ it.each([ ] as const)("sends %s feedback against the fact's revision", async (label, helpful) => { using screen = setup(); state.list = { facts: [{ ...fact, revision: 3 }], nextCursor: null }; - await screen.click("Project memory"); + screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); await screen.click(label); expect(state.mutate).toHaveBeenCalledWith({ @@ -638,7 +639,7 @@ it.each([ it("forgets a fact only after the permanent-erase warning is confirmed", async () => { using screen = setup(); - await screen.click("Project memory"); + screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); await screen.click("Forget"); @@ -673,7 +674,7 @@ it("marks a superseded fact read-only and lists superseded facts on request", as facts: [{ ...fact, supersededBy: ProjectFactId.make("replacement") }], nextCursor: null, }; - await screen.click("Project memory"); + screen.openMemory(); expect(screen.container.textContent).toContain("Superseded"); await screen.click(`Inspect fact: ${fact.content}`); const labels = [...screen.container.querySelectorAll("button")].map((item) => item.textContent); @@ -709,7 +710,7 @@ it("supersedes a fact with a new replacement and shows the returned conflict hin ], }, }); - await screen.click("Project memory"); + screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); await screen.click("Supersede"); await screen.click("Save"); @@ -737,7 +738,7 @@ it("treats a changed message source as the same selection and shows it as change facts: { state: "available", matches: [], coverage: "complete", nextCursor: null }, }); state.search = match("a".repeat(64)); - await screen.click("Project memory"); + screen.openMemory(); const input = screen.container.querySelector( '[aria-label="Search project knowledge"]', ) as HTMLInputElement; @@ -790,7 +791,7 @@ it("replaces a changed source with its current version on request", async () => maxBytes: 48_000, }); state.draft = { text: reference.text, context: { records: [reference.record] } }; - await screen.click("Project memory"); + screen.openMemory(); const input = screen.container.querySelector( '[aria-label="Search project knowledge"]', ) as HTMLInputElement; @@ -818,7 +819,7 @@ it("removes the attached draft reference when the selection changes and says so" state.setText.mockImplementation((_key: string, text: string) => { state.draft = { text }; }); - await screen.click("Project memory"); + screen.openMemory(); expect(screen.container.textContent).toContain("is attached to this draft"); await screen.click("Selected for draft"); expect(state.setText).toHaveBeenCalledWith(scope.draftKey, "See"); @@ -836,7 +837,7 @@ it("drops a revised fact from the selection and the draft after saving", async ( state.setText.mockImplementation((_key: string, text: string) => { state.draft = { text }; }); - await screen.click("Project memory"); + screen.openMemory(); await screen.click(`Inspect fact: ${fact.content}`); await screen.click("Confirm"); expect(state.setText).toHaveBeenCalledWith(scope.draftKey, ""); @@ -862,7 +863,7 @@ it("opens only the destination composer for a remembered message from another th archived: false, }; act(() => - requestRememberMessage({ + requestProjectMemory({ environmentId: scope.environmentId, projectId: scope.projectId, draftKey: "other-thread-draft", @@ -883,7 +884,7 @@ it("ignores remember requests while the destination entry is unfocused", () => { state.focused = false; screen.rerender(); act(() => - requestRememberMessage({ + requestProjectMemory({ environmentId: scope.environmentId, projectId: scope.projectId, draftKey: scope.draftKey, @@ -901,7 +902,7 @@ it.each(["Hardware Back", "Header Back"])( async (back) => { using screen = setup(); state.androidSheet = true; - await screen.click("Project memory"); + screen.openMemory(); await screen.click("Select for draft"); await screen.click("Inspect fact: " + fact.content); await screen.click(back); @@ -917,7 +918,7 @@ it.each(["Hardware Back", "Header Back"])( async (back) => { using screen = setup(); state.androidSheet = true; - await screen.click("Project memory"); + screen.openMemory(); await screen.click(back); expect(screen.container.textContent).not.toContain("Search"); }, @@ -928,7 +929,7 @@ it.each(["Hardware Back", "Header Back", "Back", "Cancel"])( async (back) => { using screen = setup(); state.androidSheet = true; - await screen.click("Project memory"); + screen.openMemory(); await screen.click("Inspect fact: " + fact.content); await screen.click("Edit"); const input = screen.container.querySelector('[aria-label="Fact content"]') as HTMLInputElement; diff --git a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx index 0126a863457ed..a664c574a46a3 100644 --- a/apps/mobile/src/components/ProjectKnowledgeEntry.tsx +++ b/apps/mobile/src/components/ProjectKnowledgeEntry.tsx @@ -79,17 +79,30 @@ type Page = | { readonly kind: "source"; readonly ref: SourceRef } | { readonly kind: "remember"; readonly ref: SourceRef } | { readonly kind: "editor"; readonly mode: FactEditorMode }; -type RememberRequest = { +type OpenRequest = { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; readonly draftKey: string; - readonly message: MessageCitation; + readonly message: MessageCitation | null; }; -// The feed asks the composer's entry to open, since the sheet writes to that draft. -const rememberListeners = new Set<(request: RememberRequest) => void>(); -export function requestRememberMessage(request: RememberRequest): void { - for (const listener of rememberListeners) listener(request); +// The header and feed ask the composer's entry to open, since the sheet writes to that draft. +const openListeners = new Set<(request: OpenRequest) => void>(); +export function requestProjectMemory(request: OpenRequest): void { + for (const listener of openListeners) listener(request); +} + +/** Opens project memory for a composer draft; undefined when the server has no project memory. */ +export function useOpenProjectMemory( + environmentId: EnvironmentId, + projectId: ProjectId | undefined, + draftKey: string, +) { + const config = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const open = useCallback(() => { + if (projectId) requestProjectMemory({ environmentId, projectId, draftKey, message: null }); + }, [environmentId, projectId, draftKey]); + return config?.environment.capabilities.projectKnowledge === true && projectId ? open : undefined; } export function ProjectKnowledgeEntry(props: Scope) { @@ -97,8 +110,9 @@ export function ProjectKnowledgeEntry(props: Scope) { const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); const [open, setOpen] = useState<{ readonly seed: MessageCitation | null } | null>(null); const { environmentId, projectId, draftKey } = props; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers this entry with the header and feed open requests while mounted useEffect(() => { - const listener = (request: RememberRequest) => { + const listener = (request: OpenRequest) => { if ( isFocused && request.environmentId === environmentId && @@ -107,33 +121,23 @@ export function ProjectKnowledgeEntry(props: Scope) { ) setOpen({ seed: request.message }); }; - rememberListeners.add(listener); + openListeners.add(listener); return () => { - rememberListeners.delete(listener); + openListeners.delete(listener); }; }, [environmentId, projectId, draftKey, isFocused]); - if (config?.environment.capabilities.projectKnowledge !== true) return null; + if (config?.environment.capabilities.projectKnowledge !== true || !open || !isFocused) + return null; return ( - <> -