diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4ad9893daa504..fd5ec50f96668 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -66,6 +66,7 @@ import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadPullRequestsSheet } from "./features/threads/ThreadPullRequestsSheet"; import { ConversationSummariesRouteScreen } from "./features/conversation-summaries/ConversationSummariesRouteScreen"; +import { ExternalHistoryRouteScreen } from "./features/external-history/ExternalHistoryRouteScreen"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadBackgroundWorkSheet } from "./features/threads/ThreadBackgroundWorkSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; @@ -304,6 +305,11 @@ const SETTINGS_SCREENS = { linking: "settings/thread-preferences", options: { ...SETTINGS_PAGE_OPTIONS, title: "Threads" }, }), + SettingsExternalHistory: createNativeStackScreen({ + screen: ExternalHistoryRouteScreen, + linking: "settings/environments/:environmentId/external-history", + options: { ...SETTINGS_PAGE_OPTIONS, title: "External history" }, + }), SettingsAbout: createNativeStackScreen({ screen: SettingsAboutRouteScreen, linking: "settings/about", diff --git a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx index f65373b1cab53..3d43e70756895 100644 --- a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx +++ b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.test.tsx @@ -10,18 +10,23 @@ import { ProviderInstanceId, RunId, ThreadId, + TurnItemId, + conversationSummarySourceKey, type ConversationSummary, type ConversationSummaryExpandInput, type ConversationSummaryList, type ConversationSummaryListInput, + type ConversationSummarySourceRef, } from "@t3tools/contracts"; const state = vi.hoisted(() => ({ supported: null as unknown, + searchSupported: null as unknown, focused: true, list: null as ConversationSummaryList | null, lists: [] as { environmentId: EnvironmentId; input: ConversationSummaryListInput }[], expansions: [] as { environmentId: EnvironmentId; input: ConversationSummaryExpandInput }[], + longSource: "", })); vi.mock("react-native", () => ({ View: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -36,6 +41,21 @@ vi.mock("../../components/ui/AppBar", () => ({ })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children?: ReactNode }) => {children}, + AppTextInput: ({ + accessibilityLabel, + value, + onChangeText, + }: { + accessibilityLabel: string; + value: string; + onChangeText: (value: string) => void; + }) => ( + onChangeText(event.target.value)} + /> + ), })); vi.mock("../../components/ui/ScreenContent", () => ({ ScreenContent: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -57,6 +77,7 @@ vi.mock("../../components/ui/Button", () => ({ })); vi.mock("../../state/conversationSummaries", () => ({ conversationSummariesSupportedAtom: () => state.supported, + conversationSummarySearchSupportedAtom: () => state.searchSupported, conversationSummaries: { list: (request: { environmentId: EnvironmentId; input: ConversationSummaryListInput }) => { state.lists.push(request); @@ -65,22 +86,36 @@ vi.mock("../../state/conversationSummaries", () => ({ expand: (request: { environmentId: EnvironmentId; input: ConversationSummaryExpandInput }) => { state.expansions.push(request); const offset = request.input.offset ?? 0; + const textOffset = request.input.textOffset ?? 0; + const maxChars = request.input.maxChars ?? 12_000; const summary = state.list?.summaries[0]; if (summary?.state !== "available") throw new Error("Expected summary"); + const firstPart = offset === 0 && textOffset === 0; return { summary, - sources: summary.summary.sources.slice(offset, offset + 6).map((ref, index) => ({ - ref, - result: - offset + index === 0 - ? { state: "unavailable", reason: "changed" } - : { - state: "available", - role: "assistant", - text: "Exact source " + (offset + index + 1) + ": 日本語 🧪\n unchanged.", - }, - })), - nextOffset: offset === 0 ? 6 : null, + sources: summary.summary.sources + .slice(offset, offset + (firstPart ? 1 : 6)) + .map((ref, index) => ({ + ref, + result: + offset + index === 0 + ? { + state: "available", + role: "assistant", + text: state.longSource.slice(textOffset, textOffset + maxChars), + textOffset, + totalChars: state.longSource.length, + } + : offset + index === 1 + ? { state: "unavailable", reason: "changed" } + : { + state: "available", + role: ref.kind === "turn_item" ? "tool" : "assistant", + text: "Exact source " + (offset + index + 1) + ": 日本語 🧪\n unchanged.", + }, + })), + nextOffset: firstPart ? 0 : offset === 0 ? 6 : null, + nextTextOffset: firstPart ? maxChars : 0, }; }, }, @@ -88,6 +123,9 @@ vi.mock("../../state/conversationSummaries", () => ({ vi.mock("../../state/query", () => ({ useEnvironmentQuery: (data: unknown) => ({ data, error: null, isPending: false }), })); +vi.mock("../external-history/ExternalHistoryRouteScreen", () => ({ + ExternalHistoryThreadButton: () => null, +})); import { ConversationSummariesRouteScreen } from "./ConversationSummariesRouteScreen"; @@ -95,10 +133,13 @@ function setup() { const supported = Atom.make(true); const registry = AtomRegistry.make(); state.supported = supported; + state.searchSupported = Atom.make(true); state.focused = false; state.lists = []; state.expansions = []; state.list = null; + state.longSource = + "Exact source 1: 日本語 🧪\n unchanged.\n" + "x".repeat(12_000) + "\nFinal evidence."; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const container = document.createElement("div"); const root = createRoot(container); @@ -141,20 +182,39 @@ it("reads only while open and supported, shows pass/citations/stale reasons and screen.setSupported(false); expect(state.lists).toEqual([]); expect(screen.container.textContent).toContain("Update the server"); + const sources = Array.from({ length: 10 }, (_, index): ConversationSummarySourceRef => + index === 9 + ? { + kind: "turn_item", + threadId: ThreadId.make("thread"), + itemId: TurnItemId.make("tool-output"), + digest: "a".repeat(64), + } + : { + kind: "message", + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message-" + (index < 2 ? 0 : index)), + digest: "a".repeat(64), + ...(index < 2 + ? { + range: { + start: index * state.longSource.length, + end: (index + 1) * state.longSource.length, + total: state.longSource.length * 2, + }, + } + : {}), + }, + ); const summary: ConversationSummary = { id: "summary:" + "a".repeat(64), projectId: ProjectId.make("project"), threadId: ThreadId.make("thread"), - sources: Array.from({ length: 10 }, (_, index) => ({ - kind: "message", - threadId: ThreadId.make("thread"), - messageId: MessageId.make("message-" + index), - digest: "a".repeat(64), - })), + sources, statements: [ { text: "Keep the canonical sources.", - citations: [MessageId.make("message-0"), MessageId.make("message-9")], + citations: [sources[0]!, sources[1]!, sources[9]!].map(conversationSummarySourceKey), }, ], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, @@ -190,9 +250,9 @@ it("reads only while open and supported, shows pass/citations/stale reasons and }); expect(state.expansions).toEqual([]); expect(screen.container.textContent).toContain( - "Last pass failed. No model 10 older messages wait", + "Last pass failed. No model 10 older sources wait", ); - expect(screen.container.textContent).toContain("Keep the canonical sources. [#1, #10]"); + expect(screen.container.textContent).toContain("Keep the canonical sources. [#1, #2, #10]"); expect(screen.container.textContent).toContain( "Outdated: source changed. The summary text is hidden", ); @@ -200,27 +260,45 @@ it("reads only while open and supported, shows pass/citations/stale reasons and expect(state.lists.at(-1)?.input.cursor).toBe(staleId); await screen.click("Earlier summaries"); expect(state.lists.at(-1)?.input.cursor).toBeUndefined(); - await screen.click("Show source messages"); + await screen.click("Show sources"); expect(state.expansions.at(-1)).toEqual({ environmentId: "remote", - input: { projectId: "project", threadId: "thread", summaryId: summary.id, offset: 0, limit: 6 }, + input: { + projectId: "project", + threadId: "thread", + summaryId: summary.id, + offset: 0, + textOffset: 0, + limit: 6, + maxChars: 12_000, + }, }); + expect(screen.container.textContent).toContain("Exact source 1: 日本語 🧪\n unchanged."); + expect(screen.container.textContent).not.toContain("Final evidence."); + await screen.click("Next sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 12_000 }); + expect(screen.container.textContent).toContain("Final evidence."); + expect(screen.container.textContent).not.toContain("Exact source 1:"); expect(screen.container.textContent).toContain("Source changed."); - expect(screen.container.textContent).toContain("Exact source 2: 日本語 🧪\n unchanged."); - await screen.click("Next messages"); + await screen.click("Previous sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 0 }); + expect(screen.container.textContent).toContain("Exact source 1:"); + await screen.click("Next sources"); + await screen.click("Next sources"); expect(state.expansions.at(-1)?.input.offset).toBe(6); expect(screen.container.textContent).toContain("#7 · Assistant"); - expect(screen.container.textContent).not.toContain("Exact source 2:"); + expect(screen.container.textContent).toContain("#10 · Tool"); + expect(screen.container.textContent).not.toContain("Exact source 1:"); expect( [...screen.container.querySelectorAll("button")].find( - (button) => button.textContent === "Next messages", + (button) => button.textContent === "Next sources", )?.disabled, ).toBe(true); - await screen.click("Previous messages"); - expect(state.expansions.at(-1)?.input.offset).toBe(0); + await screen.click("Previous sources"); + expect(state.expansions.at(-1)?.input).toMatchObject({ offset: 0, textOffset: 12_000 }); await screen.click("Back to summaries"); expect(screen.container.textContent).not.toContain("Exact source"); - await screen.click("Show source messages"); + await screen.click("Show sources"); const listsBeforeCapabilityLoss = state.lists.length; const expansionsBeforeCapabilityLoss = state.expansions.length; screen.setSupported(false); diff --git a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx index 5c2d6e32d5757..441722356fd72 100644 --- a/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx +++ b/apps/mobile/src/features/conversation-summaries/ConversationSummariesRouteScreen.tsx @@ -1,11 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { conversationSummaryPassLabel } from "@t3tools/client-runtime/conversationSummaries"; +import { + conversationSummaryPassLabel, + conversationSummarySourceRangeLabel, + type ConversationSummarySourceCursor, +} from "@t3tools/client-runtime/conversationSummaries"; import { KNOWLEDGE_SOURCE_UNAVAILABLE_LABEL } from "@t3tools/client-runtime/projectKnowledge"; import { EnvironmentId, ProjectId, ThreadId, + conversationSummarySourceKey, type ConversationSummaryId, type ConversationSummaryRead, } from "@t3tools/contracts"; @@ -13,14 +18,16 @@ import { useState } from "react"; import { ScrollView, View } from "react-native"; import { AppBar } from "../../components/ui/AppBar"; -import { AppText as Text } from "../../components/ui/AppText"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { conversationSummaries, conversationSummariesSupportedAtom, + conversationSummarySearchSupportedAtom, } from "../../state/conversationSummaries"; import { useEnvironmentQuery } from "../../state/query"; +import { ExternalHistoryThreadButton } from "../external-history/ExternalHistoryRouteScreen"; interface Scope { readonly environmentId: EnvironmentId; @@ -39,6 +46,7 @@ export function ConversationSummariesRouteScreen( const projectId = ProjectId.make(props.route.params.projectId); const threadId = ThreadId.make(props.route.params.threadId); const supported = useAtomValue(conversationSummariesSupportedAtom(environmentId)); + const searchSupported = useAtomValue(conversationSummarySearchSupportedAtom(environmentId)); const focused = useIsFocused(); const navigation = useNavigation(); return ( @@ -47,10 +55,11 @@ export function ConversationSummariesRouteScreen( {supported && focused ? ( ) : !supported ? ( @@ -71,7 +80,7 @@ function SummaryText({ read }: { readonly read: ConversationSummaryRead }) { ); const position = new Map( - read.summary.sources.map((source, index) => [source.messageId, index + 1]), + read.summary.sources.map((source, index) => [conversationSummarySourceKey(source), index + 1]), ); return ( @@ -88,29 +97,74 @@ function SummaryText({ read }: { readonly read: ConversationSummaryRead }) { ); } -function SummaryPages(props: Scope) { +function SummaryPages(props: Scope & { readonly searchSupported: boolean }) { const [cursors, setCursors] = useState>([]); const [selected, setSelected] = useState(null); + const [searchText, setSearchText] = useState(""); + const [query, setQuery] = useState(""); + const search = () => { + setQuery(searchText.trim()); + setCursors([]); + }; return ( + {selected !== null ? ( <> + ), +})); +vi.mock("../../components/ui/SelectionRow", () => ({ + SelectionRow: ({ title, onPress }: { title: string; onPress: () => void }) => ( + + ), +})); +vi.mock("../settings/components/SettingsSwitchRow", () => ({ + SettingsSwitchRow: ({ + label, + value, + disabled, + onValueChange, + }: { + label: string; + value: boolean; + disabled?: boolean; + onValueChange: (value: boolean) => void; + }) => ( + + ), +})); +vi.mock("../../state/entities", () => ({ useProject: () => null })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + configValueAtom: () => state.config, + updateSettings: { permissionAtom: () => state.allowed }, + }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.update })); +vi.mock("../../state/externalHistory", () => ({ + externalHistory: { query: ({ input }: { input: ExternalHistoryRequest }) => input }, +})); +const ref = { + sourceId: "codex-history", + threadId: "native-thread", + readCursor: "native-read-locator", + digest: "a".repeat(64), +}; +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: (request: ExternalHistoryRequest) => { + state.requests.push(request); + const data: ExternalHistoryResponse = + request.operation === "sources" + ? { + operation: "sources", + enabled: true, + sources: [{ id: "codex-history", label: "Native Codex", kind: "codex" }], + } + : request.operation === "search" + ? { + operation: "search", + examinedItems: 20, + matches: request.cursor + ? [ + { + ref, + threadTitle: "Compiler investigation", + itemId: "tool-output", + role: "tool", + excerpt: "First exact page", + textOffset: 0, + totalChars: 33, + }, + ] + : [], + nextCursor: request.cursor ? null : "more-native-history", + } + : { + operation: "read", + ref, + threadTitle: "Compiler investigation", + role: "tool", + text: request.textOffset ? "Second exact page" : "First exact page", + textOffset: request.textOffset ?? 0, + totalChars: 33, + nextTextOffset: request.textOffset ? null : 16, + }; + return { data, error: null, isPending: false, refresh: vi.fn() }; + }, +})); + +import { ExternalHistoryRouteScreen } from "./ExternalHistoryRouteScreen"; + +const configFor = (supported: boolean, enabled = true) => ({ + environment: { capabilities: supported ? { externalHistory: 1 as const } : {} }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + externalHistory: { enabled, chatGptExportPaths: ["/remote/export.json"] }, + }, +}); + +it("keeps remote history read-only without settings permission and stops reads when hidden, disabled or unsupported", async () => { + const registry = AtomRegistry.make(); + const config = Atom.make(configFor(true)); + state.config = config; + state.allowed = Atom.make(false); + state.focused = false; + state.requests = []; + state.update.mockReset(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + const root = createRoot(container); + const render = () => + act(() => + root.render( + + + , + ), + ); + const button = (label: string) => { + const value = [...container.querySelectorAll("button")].find( + (node) => node.textContent === label, + ); + if (!value) throw new Error("Missing button: " + label); + return value; + }; + const click = (label: string) => act(async () => button(label).click()); + try { + render(); + expect(state.requests).toEqual([]); + state.focused = true; + act(() => registry.set(config, configFor(false))); + expect(state.requests).toEqual([]); + expect(container.textContent).toContain("Update this server"); + act(() => registry.set(config, configFor(true))); + expect(button("Allow external history search").disabled).toBe(true); + expect( + container.querySelector('[aria-label="ChatGPT export paths"]')?.disabled, + ).toBe(true); + const input = container.querySelector( + '[aria-label="Search external history"]', + )!; + await act(async () => { + input.value = "exact"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await click("This project"); + await click("Search"); + expect(container.textContent).toContain("No matches on this page. Continue"); + await click("Continue search"); + expect(container.textContent).toContain("Compiler investigation"); + expect(state.requests).toContainEqual({ + operation: "search", + sourceId: ref.sourceId, + query: "exact", + filterCwd: "/remote/project", + includeArchived: false, + cursor: "more-native-history", + }); + await click("Open source"); + expect(container.textContent).toContain("First exact page"); + await click("More source text"); + expect(container.textContent).toContain("Second exact page"); + expect(container.textContent).not.toContain("First exact page"); + await click("Search"); + expect(container.textContent).toContain("No matches on this page. Continue"); + expect(container.textContent).not.toContain("Second exact page"); + expect(state.update).not.toHaveBeenCalled(); + act(() => registry.set(config, configFor(true, false))); + expect(container.textContent).toContain("External history search is off"); + const readsBeforeDisable = state.requests.length; + render(); + expect(state.requests).toHaveLength(readsBeforeDisable); + state.focused = false; + act(() => registry.set(config, configFor(true))); + render(); + expect(state.requests).toHaveLength(readsBeforeDisable); + } finally { + act(() => root.unmount()); + registry.dispose(); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx new file mode 100644 index 0000000000000..d4f8ce30e2980 --- /dev/null +++ b/apps/mobile/src/features/external-history/ExternalHistoryRouteScreen.tsx @@ -0,0 +1,456 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { externalHistoryRangeLabel } from "@t3tools/client-runtime/externalHistory"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + EnvironmentId, + type ExternalHistoryMatch, + type ExternalHistoryRequest, + type ExternalHistorySettings, + type ProjectId, +} from "@t3tools/contracts"; +import { useState } from "react"; +import { ScrollView, View } from "react-native"; + +import { AppBar } from "../../components/ui/AppBar"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { ScreenContent } from "../../components/ui/ScreenContent"; +import { SelectionRow } from "../../components/ui/SelectionRow"; +import { useProject } from "../../state/entities"; +import { externalHistory } from "../../state/externalHistory"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow"; + +type Search = Extract; +type Scope = { readonly environmentId: EnvironmentId; readonly currentProjectPath?: string }; + +export function ExternalHistoryThreadButton(props: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +}) { + const navigation = useNavigation(); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const project = useProject(props); + return config?.environment.capabilities.externalHistory === 1 ? ( + - + + ) : null} - + ); } function SummaryStatements(props: { readonly summary: ConversationSummary }) { const position = new Map( - props.summary.sources.map((source, index) => [source.messageId, index + 1]), + props.summary.sources.map((source, index) => [conversationSummarySourceKey(source), index + 1]), ); return (
    @@ -171,36 +190,147 @@ function SummaryStatements(props: { readonly summary: ConversationSummary }) { function SummaryCard(props: Scope & { readonly read: ConversationSummaryRead }) { const [showSources, setShowSources] = useState(false); + const [sourceCursors, setSourceCursors] = + useState | null>(null); const id = props.read.state === "available" ? props.read.summary.id : props.read.id; + const cursor = sourceCursors?.at(-1) ?? { offset: 0, textOffset: 0 }; + const expansion = useEnvironmentQuery( + sourceCursors === null + ? null + : conversationSummaries.expand({ + environmentId: props.environmentId, + input: { + projectId: props.projectId, + threadId: props.threadId, + summaryId: id, + ...cursor, + limit: SOURCE_PAGE_SIZE, + maxChars: SOURCE_PAGE_CHARS, + }, + }), + ); + const read = sourceCursors === null ? props.read : (expansion.data?.summary ?? null); + const headerRead = read ?? props.read; const sourceCount = - props.read.state === "available" ? props.read.summary.sources.length : props.read.sourceCount; + headerRead.state === "available" ? headerRead.summary.sources.length : headerRead.sourceCount; const createdAt = - props.read.state === "available" ? props.read.summary.createdAt : props.read.createdAt; + headerRead.state === "available" ? headerRead.summary.createdAt : headerRead.createdAt; return (

    - {sourceCount} messages · written {new Date(createdAt).toLocaleString()} + {sourceCount} sources · written {new Date(createdAt).toLocaleString()}

    - {props.read.state === "available" ? ( - + {read === null ? ( +

    + {expansion.error + ? "Summary unavailable until its sources can be checked." + : "Checking sources…"} +

    + ) : read.state === "available" ? ( + ) : (

    - Outdated: {KNOWLEDGE_SOURCE_UNAVAILABLE_LABEL[props.read.reason].toLowerCase()}. The - summary text is hidden and agents no longer receive it. + Outdated: {KNOWLEDGE_SOURCE_UNAVAILABLE_LABEL[read.reason].toLowerCase()}. The summary + text is hidden and agents no longer receive it.

    )}
    -
    - {showSources ? : null} + {showSources ? ( + expansion.error ? ( +

    + {expansion.error} +

    + ) : expansion.data === null ? ( +

    Loading sources…

    + ) : ( + 0} + onPrevious={() => setSourceCursors((current) => current?.slice(0, -1) ?? [])} + onNext={(nextCursor) => setSourceCursors((current) => [...(current ?? []), nextCursor])} + /> + ) + ) : null}
    ); } -function SummaryList(props: Scope) { +function SummaryList(props: Scope & { readonly searchSupported: boolean }) { const [cursors, setCursors] = useState>([]); + const [searchText, setSearchText] = useState(""); + const [query, setQuery] = useState(""); + return ( +
    + {props.searchSupported ? ( +
    { + event.preventDefault(); + setQuery(searchText.trim()); + setCursors([]); + }} + > + setSearchText(event.target.value)} + /> + + {searchText || query ? ( + + ) : null} +
    + ) : ( +

    Update this server to search summaries.

    + )} + setCursors((current) => current.slice(0, -1))} + onLater={(cursor) => setCursors((current) => [...current, cursor])} + /> +
    + ); +} + +function SummaryListPage( + props: Scope & { + readonly query: string; + readonly cursors: ReadonlyArray; + readonly onEarlier: () => void; + readonly onLater: (cursor: ConversationSummaryId) => void; + }, +) { + const cursor = props.cursors.at(-1); const list = useEnvironmentQuery( conversationSummaries.list({ environmentId: props.environmentId, @@ -208,7 +338,8 @@ function SummaryList(props: Scope) { projectId: props.projectId, threadId: props.threadId, limit: PAGE_SIZE, - ...(cursors.at(-1) === undefined ? {} : { cursor: cursors.at(-1)! }), + ...(cursor === undefined ? {} : { cursor }), + ...(props.query ? { query: props.query } : {}), }, }), ); @@ -231,7 +362,9 @@ function SummaryList(props: Scope) {

    ) : null} {summaries.length === 0 ? ( -

    This thread has no summaries yet.

    +

    + {props.query ? "No summaries match your search." : "This thread has no summaries yet."} +

    ) : ( summaries.map((read) => ( )) )} - {cursors.length > 0 || nextCursor !== null ? ( + {props.cursors.length > 0 || nextCursor !== null ? (
    @@ -256,7 +389,7 @@ function SummaryList(props: Scope) { size="sm" disabled={nextCursor === null} onClick={() => { - if (nextCursor !== null) setCursors((current) => [...current, nextCursor]); + if (nextCursor !== null) props.onLater(nextCursor); }} > Later @@ -270,21 +403,36 @@ function SummaryList(props: Scope) { export function ConversationSummariesDialog( props: Scope & { readonly open: boolean; readonly onOpenChange: (open: boolean) => void }, ) { + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + const project = useProject({ environmentId: props.environmentId, projectId: props.projectId }); + const searchSupported = config?.environment.capabilities.conversationSummarySourceFormat === 2; return ( Conversation summaries - Model-written summaries of this thread's older messages. When the thread moves to - another provider or is forked, agents receive them in place of messages that no longer - fit, and can open the exact messages behind each one. + Model-written summaries of this thread's older messages and tool output. When the thread + moves to another provider or is forked, agents receive them in place of messages that no + longer fit, and can open the exact sources behind each one.
    - {props.open ? : null} +
    + +
    + {props.open ? ( + + ) : null}
    diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx new file mode 100644 index 0000000000000..d6fe2b8d3047a --- /dev/null +++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.browser.test.tsx @@ -0,0 +1,206 @@ +import "../../index.css"; +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ExternalHistoryError, + type ExternalHistoryRequest, + type ExternalHistoryResponse, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ + config: null as unknown, + allowed: null as unknown, + sources: null as unknown, + first: null as unknown, + next: null as unknown, + read: null as unknown, + readNext: null as unknown, + requests: [] as ExternalHistoryRequest[], + update: vi.fn(), +})); +vi.mock( + import("../../state/server"), + () => + ({ + serverEnvironment: { + configValueAtom: () => state.config, + updateSettings: { permissionAtom: () => state.allowed }, + }, + }) as never, +); +vi.mock( + import("../../state/externalHistory"), + () => + ({ + externalHistory: { + query: ({ input }: { input: ExternalHistoryRequest }) => { + state.requests.push(input); + if (input.operation === "sources") return state.sources; + if (input.operation === "search") return input.cursor ? state.next : state.first; + return input.textOffset ? state.readNext : state.read; + }, + }, + }) as never, +); +vi.mock( + import("../../state/use-atom-command"), + () => ({ useAtomCommand: () => state.update }) as never, +); + +import { ExternalHistoryButton } from "./ExternalHistoryDialog"; + +const environmentId = EnvironmentId.make("native-history-browser"); +const ref = { + sourceId: "codex-source", + threadId: "native-thread", + readCursor: "exact-native-locator", + digest: "b".repeat(64), +}; +const configFor = (enabled: boolean, supported = true) => ({ + environment: { capabilities: supported ? { externalHistory: 1 as const } : {} }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + externalHistory: { enabled, chatGptExportPaths: ["/exports/conversations.json"] }, + }, +}); +const success = (data: ExternalHistoryResponse) => Atom.make(AsyncResult.success(data)); + +it("continues an empty bounded search, opens exact source pages, and hides revoked or changed evidence", async () => { + const registry = AtomRegistry.make(); + const config = Atom.make(configFor(true)); + const allowed = Atom.make(true); + state.config = config; + state.allowed = allowed; + state.requests = []; + state.update.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + state.sources = success({ + operation: "sources", + enabled: true, + sources: [{ id: ref.sourceId, label: "Local Codex history", kind: "codex" }], + }); + state.first = success({ + operation: "search", + matches: [], + examinedItems: 20, + nextCursor: "next-bounded-page", + }); + state.next = success({ + operation: "search", + matches: [ + { + ref, + threadTitle: "Native compiler investigation", + itemId: "native-command", + role: "tool", + excerpt: "remaining exact source text", + textOffset: 16, + totalChars: 43, + }, + ], + examinedItems: 10, + nextCursor: null, + }); + state.read = success({ + operation: "read", + ref, + threadTitle: "Native compiler investigation", + role: "tool", + text: "first exact page", + textOffset: 0, + totalChars: 43, + nextTextOffset: 16, + }); + const readNext = Atom.make< + AsyncResult.AsyncResult + >( + AsyncResult.success({ + operation: "read", + ref, + threadTitle: "Native compiler investigation", + role: "tool", + text: "remaining exact source text", + textOffset: 16, + totalChars: 43, + nextTextOffset: null, + }), + ); + state.readNext = readNext; + + await using _screen = await render( + + + , + ); + await page.getByRole("button", { name: "External history", exact: true }).click(); + await expect + .element(page.getByRole("textbox", { name: "ChatGPT export paths" })) + .toHaveValue("/exports/conversations.json"); + await page.getByRole("textbox", { name: "Search external history" }).fill("source"); + await page.getByRole("button", { name: "This project", exact: true }).click(); + await page.getByRole("switch", { name: "Include archived Codex threads" }).click(); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect + .element(page.getByText("No matches on this page. Continue to search the next page.")) + .toBeVisible(); + await page.getByRole("button", { name: "Continue search", exact: true }).click(); + await expect + .element(page.getByText("Native compiler investigation", { exact: true })) + .toBeVisible(); + expect(state.requests).toContainEqual({ + operation: "search", + sourceId: ref.sourceId, + query: "source", + filterCwd: "/current-project", + includeArchived: true, + cursor: "next-bounded-page", + }); + await page.getByRole("button", { name: "Open source", exact: true }).click(); + await expect.element(page.getByText("first exact page", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "More source text", exact: true }).click(); + await expect + .element(page.getByText("remaining exact source text", { exact: true })) + .toBeVisible(); + expect(state.requests).toContainEqual({ + operation: "read", + ref, + textOffset: 16, + maxChars: 12_000, + }); + registry.set( + readNext, + AsyncResult.failure(Cause.fail(new ExternalHistoryError({ reason: "changed" }))), + ); + await expect + .element(page.getByRole("alert")) + .toHaveTextContent("The source changed. Search again before opening this evidence."); + await expect + .element(page.getByText("remaining exact source text", { exact: true })) + .not.toBeInTheDocument(); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect + .element(page.getByText("No matches on this page. Continue to search the next page.")) + .toBeVisible(); + await expect.element(page.getByRole("alert")).not.toBeInTheDocument(); + registry.set(allowed, false); + await expect + .element(page.getByRole("switch", { name: "Allow external history search" })) + .toBeDisabled(); + await expect.element(page.getByRole("textbox", { name: "ChatGPT export paths" })).toBeDisabled(); + registry.set(config, configFor(false)); + await expect + .element(page.getByText("External history search is off for this environment.")) + .toBeVisible(); + await expect + .element(page.getByRole("textbox", { name: "Search external history" })) + .not.toBeInTheDocument(); + registry.set(config, configFor(false, false)); + await expect + .element(page.getByRole("button", { name: "External history", exact: true })) + .not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx new file mode 100644 index 0000000000000..b7b0a3c50a2bc --- /dev/null +++ b/apps/web/src/components/externalHistory/ExternalHistoryDialog.tsx @@ -0,0 +1,489 @@ +import { useAtomValue } from "@effect/atom-react"; +import { externalHistoryRangeLabel } from "@t3tools/client-runtime/externalHistory"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { + EnvironmentId, + ExternalHistoryMatch, + ExternalHistoryRequest, + ExternalHistorySettings, +} from "@t3tools/contracts"; +import { useState } from "react"; + +import { externalHistory } from "../../state/externalHistory"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; + +type Search = Extract; + +export function ExternalHistoryButton(props: { + readonly environmentId: EnvironmentId; + readonly currentProjectPath?: string; + readonly label?: string; +}) { + const [open, setOpen] = useState(false); + const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); + if (config?.environment.capabilities.externalHistory !== 1) return null; + return ( + <> + + + + + External history + + Search Codex conversations and configured ChatGPT exports on this environment. Open + the original evidence behind each match. + + + + {open ? : null} + + + + + ); +} + +function ExternalHistorySettingsEditor(props: { + readonly environmentId: EnvironmentId; + readonly settings: ExternalHistorySettings; +}) { + const canWrite = useAtomValue( + serverEnvironment.updateSettings.permissionAtom(props.environmentId), + ); + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); + const [paths, setPaths] = useState(props.settings.chatGptExportPaths.join("\n")); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const save = async (settings: ExternalHistorySettings) => { + if (pending || !canWrite) return; + setPending(true); + setError(null); + const result = await update({ + environmentId: props.environmentId, + input: { patch: { externalHistory: settings } }, + }); + setPending(false); + if (result._tag === "Failure") setError(String(squashAtomCommandFailure(result))); + }; + return ( +
    +
    + Allow external history search + void save({ ...props.settings, enabled })} + /> +
    +

    + Makes these sources available to this environment's connected clients and agents with thread + access. Source files stay in their existing locations. +

    +