Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 23 additions & 22 deletions apps/mobile/src/components/ProjectKnowledgeEntry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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");
});

Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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(
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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, "");
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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");
},
Expand All @@ -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;
Expand Down
65 changes: 35 additions & 30 deletions apps/mobile/src/components/ProjectKnowledgeEntry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,26 +79,40 @@ 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) {
const isFocused = useIsFocused();
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 &&
Expand All @@ -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 (
<>
<Button
label="Project memory"
icon="brain"
onPress={() => setOpen({ seed: null })}
tone="text"
accessibilityHint="Search and attach evidence from this project"
/>
{open && isFocused ? (
<ProjectKnowledgeSheet
key={`${props.environmentId}:${props.projectId}:${props.draftKey}:${open.seed?.messageId ?? ""}`}
{...props}
// Older servers lack readFact, status and supersede.
history={config.environment.capabilities.projectKnowledgeHistory === true}
assisted={config.environment.capabilities.projectKnowledgeAssistedLearning === true}
seedMessage={open.seed}
onClose={() => setOpen(null)}
/>
) : null}
</>
<ProjectKnowledgeSheet
key={`${props.environmentId}:${props.projectId}:${props.draftKey}:${open.seed?.messageId ?? ""}`}
{...props}
// Older servers lack readFact, status and supersede.
history={config.environment.capabilities.projectKnowledgeHistory === true}
assisted={config.environment.capabilities.projectKnowledgeAssistedLearning === true}
seedMessage={open.seed}
onClose={() => setOpen(null)}
/>
);
}

Expand Down Expand Up @@ -175,6 +179,7 @@ function ProjectKnowledgeSheet(
editorDirty.current = dirty;
}, []);
const selectionVersion = useRef(0);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- invalidates in-flight selection work when the sheet unmounts
useEffect(
() => () => {
selectionVersion.current += 1;
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/useRememberMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import { projectKnowledge } from "../state/projectKnowledge";
import { serverEnvironment } from "../state/server";
import { environmentThreadShells } from "../state/threads";
import { requestRememberMessage } from "./ProjectKnowledgeEntry";
import { requestProjectMemory } from "./ProjectKnowledgeEntry";

/** Returns a "Remember for this project" action, or null when this thread cannot save facts. */
export function useRememberMessage(
Expand All @@ -22,5 +22,5 @@ export function useRememberMessage(
if (config?.environment.capabilities.projectKnowledge !== true || !canMutate || !projectId)
return null;
return (message: { readonly threadId: ThreadId; readonly messageId: MessageId }) =>
requestRememberMessage({ environmentId, projectId, draftKey, message });
requestProjectMemory({ environmentId, projectId, draftKey, message });
}
10 changes: 10 additions & 0 deletions apps/mobile/src/features/threads/DraftThreadHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import { useEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { vcsActionManager, vcsEnvironment } from "../../state/vcs";
import { useVcsActionState } from "../../state/use-vcs-action-state";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { useOpenProjectMemory } from "../../components/ProjectKnowledgeEntry";
import { ThreadHeader } from "./ThreadHeader";

export function DraftThreadHeader(props: {
readonly thread: EnvironmentThreadShell;
readonly project: EnvironmentProject;
readonly cwd: string | null;
readonly draftKey: string | null | undefined;
}) {
const navigation = useNavigation();
const { themeVariables } = useAppearancePreferences();
Expand All @@ -44,6 +47,12 @@ export function DraftThreadHeader(props: {
projectName: props.project.title,
});
};
const openProjectMemory = useOpenProjectMemory(
environmentId,
props.project.id,
// ThreadComposer falls back to the thread's own key the same way.
props.draftKey ?? scopedThreadKey(environmentId, props.thread.id),
);
const requiresThread = () =>
Dialog.alert("Available after the first message", "This action needs a saved thread.");
return (
Expand All @@ -60,6 +69,7 @@ export function DraftThreadHeader(props: {
onToggleInspector={openFiles}
onOpenFilesInspector={openFiles}
onOpenGitInspector={requiresThread}
onOpenProjectMemory={openProjectMemory}
gitControls={{
environmentId,
threadId: props.thread.id,
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,12 @@ export function NewTaskDraftScreen(props: {
<ThreadScreenBinding
environmentId={selectedProject.environmentId}
header={
<DraftThreadHeader thread={thread} project={selectedProject} cwd={composerWorkspaceCwd} />
<DraftThreadHeader
thread={thread}
project={selectedProject}
cwd={composerWorkspaceCwd}
draftKey={flow.draftKey}
/>
}
draftMode={{
content: heroViewport,
Expand Down
Loading
Loading