diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3cce8fbe0b21..335f563334ba 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -31,6 +31,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.findThread]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index f38e1aeff1d9..fb1e0d5ac2e3 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -20,6 +20,7 @@ const RPC_AGGREGATES = { [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", + [ORCHESTRATION_V2_WS_METHODS.findThread]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration", [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2", diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 684722111384..ad7e56dd2226 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -4,8 +4,14 @@ import { MessageId, ProjectId, ProviderInstanceId, + RunId, + RuntimeRequestId, ThreadId, + TurnItemId, + type OrchestrationV2AppThread, type OrchestrationV2DomainEvent, + type OrchestrationV2Notification, + type OrchestrationV2UserMessageInputIntent, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -54,7 +60,9 @@ const createProject = (projectId: ProjectId) => const thread = ( threadId: ThreadId, projectId: ProjectId, - overrides: { readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc } = {}, + overrides: Partial< + Pick + > = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`created:${threadId}`), type: "thread.created", @@ -83,6 +91,7 @@ const thread = ( settledAt: null, lastVisitedAt: null, deletedAt: overrides.deletedAt ?? null, + ...overrides, }, }); @@ -91,28 +100,80 @@ const message = ( id: string, role: "user" | "assistant" | "system", text: string, - options: { readonly minute?: number; readonly streaming?: boolean } = {}, -): OrchestrationV2DomainEvent => ({ - id: EventId.make(`message:${id}`), - type: "message.updated", - threadId, - providerInstanceId, - occurredAt: at(options.minute ?? 1), - payload: { - createdBy: role === "user" ? "user" : "agent", - creationSource: role === "user" ? "web" : "provider", - id: MessageId.make(id), + options: { + readonly minute?: number; + readonly streaming?: boolean; + readonly notification?: OrchestrationV2Notification; + readonly runId?: RunId; + readonly ordinal?: number; + readonly inputIntent?: OrchestrationV2UserMessageInputIntent; + } = {}, +): OrchestrationV2DomainEvent => { + const event: Extract = { + id: EventId.make(`message:${id}`), + type: "message.updated", threadId, - runId: null, - nodeId: null, - role, - text, - attachments: [], - streaming: options.streaming ?? false, - createdAt: at(options.minute ?? 1), - updatedAt: at(options.minute ?? 1), - }, -}); + providerInstanceId, + occurredAt: at(options.minute ?? 1), + payload: { + createdBy: role === "user" ? "user" : "agent", + creationSource: role === "user" ? "web" : "provider", + id: MessageId.make(id), + threadId, + runId: options.runId ?? null, + nodeId: null, + role, + text, + attachments: [], + streaming: options.streaming ?? false, + createdAt: at(options.minute ?? 1), + updatedAt: at(options.minute ?? 1), + ...(options.notification ? { notification: options.notification } : {}), + }, + }; + if (options.ordinal === undefined || role === "system") return event; + const value = event.payload; + return { + id: EventId.make(`item:${value.id}`), + type: "turn-item.updated", + threadId: event.threadId, + occurredAt: event.occurredAt, + payload: { + id: TurnItemId.make(`item:${value.id}`), + threadId: value.threadId, + runId: value.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: options.ordinal, + status: "completed", + title: null, + startedAt: value.createdAt, + completedAt: value.createdAt, + updatedAt: value.updatedAt, + ...(value.notification + ? { type: "notification", ...value.notification } + : value.role === "user" + ? { + type: "user_message", + createdBy: value.createdBy, + creationSource: value.creationSource, + messageId: value.id, + inputIntent: options.inputIntent ?? "turn_start", + text: value.text, + attachments: [], + } + : { + type: "assistant_message", + messageId: value.id, + text: value.text, + streaming: value.streaming, + }), + }, + }; +}; it.layer(layerTest)("ThreadSearch", (it) => { it.effect("returns one finished user or assistant match per active thread", () => @@ -175,6 +236,263 @@ it.layer(layerTest)("ThreadSearch", (it) => { }), ); + it.effect("finds every literal occurrence across persisted conversation messages", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:find"); + const threadId = ThreadId.make("thread:find"); + const other = ThreadId.make("thread:other"); + yield* createProject(project); + yield* projections.apply(thread(threadId, project, { archivedAt: at(5) })); + yield* projections.apply(thread(other, project)); + const messages = [ + message(threadId, "old", "user", "Needle needle", { minute: 1, ordinal: 0 }), + message(threadId, "new", "assistant", "needle %_ needle", { minute: 2, ordinal: 1 }), + message(threadId, "ignored", "system", "needle"), + message(threadId, "notification", "user", "needle notification-only", { + ordinal: 2, + notification: { + source: { kind: "background_task" }, + outcome: "completed", + summary: "needle notification-only", + }, + }), + message(other, "other", "assistant", "needle", { ordinal: 0 }), + ]; + yield* Effect.forEach(messages, projections.apply, { discard: true }); + assert.deepEqual(yield* search.find({ threadId, query: "NEEDLE" }), { + total: 4, + match: { messageId: MessageId.make("old"), occurrence: 0 }, + }); + assert.deepEqual(yield* search.find({ threadId, query: "needle", index: 1 }), { + total: 4, + match: { messageId: MessageId.make("old"), occurrence: 1 }, + }); + assert.deepEqual(yield* search.find({ threadId, query: "needle", index: 3 }), { + total: 4, + match: { messageId: MessageId.make("new"), occurrence: 1 }, + }); + assert.deepEqual(yield* search.find({ threadId, query: "needle", index: 4 }), { + total: 4, + match: null, + }); + assert.deepEqual(yield* search.find({ threadId, query: "%_" }), { + total: 1, + match: { messageId: MessageId.make("new"), occurrence: 0 }, + }); + assert.deepEqual(yield* search.find({ threadId, query: "missing" }), { + total: 0, + match: null, + }); + assert.deepEqual(yield* search.find({ threadId, query: "notification-only" }), { + total: 0, + match: null, + }); + yield* projections.apply( + message(threadId, "async-answer:answered", "user", "reply-only answered", { ordinal: 3 }), + ); + yield* projections.apply( + message(threadId, "async-answer:orphan", "user", "reply-only orphan", { ordinal: 4 }), + ); + const request = { + id: EventId.make("question:answered"), + type: "turn-item.updated", + threadId, + occurredAt: at(3), + payload: { + id: TurnItemId.make("question:answered"), + type: "user_input_request", + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "completed", + title: null, + startedAt: at(3), + completedAt: at(3), + updatedAt: at(3), + requestId: RuntimeRequestId.make("answered"), + questions: [], + }, + } satisfies OrchestrationV2DomainEvent; + yield* projections.apply(request); + assert.deepEqual(yield* search.find({ threadId, query: "reply-only" }), { + total: 2, + match: { messageId: MessageId.make("async-answer:answered"), occurrence: 0 }, + }); + yield* projections.apply({ + ...request, + payload: { + ...request.payload, + questionAnswer: { requestId: "answered", answers: {}, attachmentsByQuestionId: {} }, + }, + }); + assert.deepEqual(yield* search.find({ threadId, query: "reply-only" }), { + total: 1, + match: { messageId: MessageId.make("async-answer:orphan"), occurrence: 0 }, + }); + }), + ); + + it.effect( + "finds the visible nested fork history and preserves inherited rollback snapshots", + () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + const search = yield* ThreadSearch.ThreadSearch; + const project = ProjectId.make("project:find-fork"); + const source = ThreadId.make("thread:find-source"); + const branch = ThreadId.make("thread:find-branch"); + const target = ThreadId.make("thread:find-target"); + const sourceCutoff = RunId.make("run:source-cutoff"); + const sourceLater = RunId.make("run:source-later"); + const branchEarly = RunId.make("run:branch-early"); + const branchCutoff = RunId.make("run:branch-cutoff"); + const branchLater = RunId.make("run:branch-later"); + const localRollback = RunId.make("run:local-rollback"); + const localQueued = RunId.make("run:local-queued"); + const localCancelled = RunId.make("run:local-cancelled"); + yield* createProject(project); + yield* Effect.forEach( + [ + thread(source, project, { + historyOrigin: "v1_import", + forkedFrom: { type: "run", threadId: target, runId: localCancelled }, + }), + thread(branch, project, { + forkedFrom: { type: "run", threadId: source, runId: sourceCutoff }, + }), + thread(target, project, { + forkedFrom: { type: "run", threadId: branch, runId: branchCutoff }, + }), + ], + projections.apply, + { discard: true }, + ); + for (const [threadId, id, ordinal, status] of [ + [source, sourceCutoff, 1, "rolled_back"], + [source, sourceLater, 2, "completed"], + [branch, branchEarly, 1, "completed"], + [branch, branchCutoff, 2, "completed"], + [branch, branchLater, 3, "completed"], + [target, localRollback, 1, "rolled_back"], + [target, localQueued, 2, "cancelled"], + [target, localCancelled, 3, "cancelled"], + ] as const) { + yield* projections.apply({ + id: EventId.make(`created:${id}`), + type: "run.created", + threadId, + occurredAt: at(ordinal), + payload: { + id, + threadId, + ordinal, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`message:${id}`), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: at(ordinal), + startedAt: at(ordinal), + completedAt: at(ordinal), + checkpointId: null, + contextHandoffId: null, + }, + }); + } + const messages = [ + message(source, "source-legacy", "user", "needle legacy", { minute: 9, ordinal: 0 }), + message(source, "source-snapshot", "assistant", "needle snapshot", { + runId: sourceCutoff, + minute: 8, + ordinal: 1, + }), + message(source, "source-later", "assistant", "needle later", { + runId: sourceLater, + ordinal: 2, + }), + message(branch, "branch-runless", "user", "needle runless", { ordinal: 0 }), + message(branch, "branch-early", "user", "needle branch", { + runId: branchEarly, + ordinal: 1, + }), + message(branch, "branch-cutoff", "assistant", "needle cutoff", { + runId: branchCutoff, + ordinal: 2, + }), + message(branch, "branch-later", "assistant", "needle later", { + runId: branchLater, + ordinal: 3, + }), + message(target, "target-local", "assistant", "needle local", { ordinal: 1 }), + message(target, "target-rollback", "assistant", "needle undone", { + runId: localRollback, + ordinal: 2, + }), + message(target, "target-queued", "user", "needle queued", { + runId: localQueued, + ordinal: 3, + inputIntent: "queued_turn", + }), + message(target, "target-cancelled", "user", "needle cancelled", { + runId: localCancelled, + ordinal: 4, + }), + ]; + yield* Effect.forEach(messages, projections.apply, { discard: true }); + // Queued records without a committed turn item are composer state. + yield* projections.apply(message(target, "target-uncommitted", "user", "needle pending")); + const visibleIds = (yield* projections.getThreadProjection( + target, + )).visibleTurnItems.flatMap(({ item }) => + item.type === "user_message" || item.type === "assistant_message" ? [item.messageId] : [], + ); + assert.deepEqual(visibleIds, [ + "source-legacy", + "source-snapshot", + "branch-early", + "branch-cutoff", + "target-local", + "target-cancelled", + ]); + for (const [index, messageId] of visibleIds.entries()) { + assert.deepEqual(yield* search.find({ threadId: target, query: "needle", index }), { + total: visibleIds.length, + match: { messageId, occurrence: 0 }, + }); + } + assert.deepEqual(yield* search.find({ threadId: target, query: "undone" }), { + total: 0, + match: null, + }); + assert.deepEqual(yield* search.find({ threadId: source, query: "snapshot" }), { + total: 0, + match: null, + }); + const missing = ThreadId.make("thread:find-boundary-missing"); + yield* projections.apply( + thread(missing, project, { + forkedFrom: { type: "run", threadId: source, runId: RunId.make("run:missing") }, + }), + ); + yield* projections.apply( + message(missing, "boundary-missing", "user", "needle missing", { ordinal: 0 }), + ); + assert.deepEqual(yield* search.find({ threadId: missing, query: "needle" }), { + total: 1, + match: { messageId: MessageId.make("boundary-missing"), occurrence: 0 }, + }); + }), + ); + it.effect("reports an unreadable match as a decode failure", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 855556dd3d19..e774a2bb80d5 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -1,5 +1,8 @@ import { IsoDateTime, + MessageId, + type OrchestrationFindThreadInput, + type OrchestrationFindThreadResult, OrchestrationThreadSearchSource, type OrchestrationSearchThreadsInput, type OrchestrationSearchThreadsResult, @@ -68,6 +71,9 @@ function buildSearchSnippet(text: string, query: string): string { export class ThreadSearch extends Context.Service< ThreadSearch, { + readonly find: ( + input: OrchestrationFindThreadInput, + ) => Effect.Effect; readonly search: ( input: OrchestrationSearchThreadsInput, ) => Effect.Effect; @@ -162,7 +168,106 @@ export const make = Effect.gen(function* () { }, ); - return ThreadSearch.of({ search }); + // Inherited items are a fork snapshot: later source rollbacks do not hide them. + // Only the requested thread applies live visibility; ancestors stop at their fork run. + const findRow = SqlSchema.findAll({ + Request: Schema.Struct({ threadId: ThreadId, query: Schema.String, index: Schema.Int }), + Result: Schema.Struct({ + total: Schema.Int, + messageId: Schema.NullOr(MessageId), + occurrence: Schema.NullOr(Schema.Int), + }), + execute: ({ threadId, query, index }) => sql` + WITH RECURSIVE history(thread_id, payload_json, cutoff, depth, visited) AS ( + SELECT threads.thread_id, threads.payload_json, NULL, 0, json_array() + FROM orchestration_v2_projection_threads AS threads + INNER JOIN projection_projects AS projects ON projects.project_id = threads.project_id + WHERE threads.thread_id = ${threadId} + AND threads.deleted_at IS NULL AND projects.deleted_at IS NULL + UNION ALL + SELECT source.thread_id, source.payload_json, fork_run.ordinal, history.depth + 1, + json_insert(history.visited, '$[#]', history.thread_id) + FROM history + INNER JOIN orchestration_v2_projection_threads AS source + ON source.thread_id = json_extract(history.payload_json, '$.forkedFrom.threadId') + INNER JOIN orchestration_v2_projection_runs AS fork_run + ON fork_run.thread_id = source.thread_id + AND fork_run.run_id = json_extract(history.payload_json, '$.forkedFrom.runId') + WHERE json_extract(history.payload_json, '$.forkedFrom.type') = 'run' + AND NOT EXISTS (SELECT 1 FROM json_each(history.visited) WHERE value = source.thread_id) + ), visible_items AS ( + SELECT item.payload_json, item.type, history.depth, item.ordinal, item.turn_item_id + FROM history + INNER JOIN orchestration_v2_projection_turn_items AS item ON item.thread_id = history.thread_id + LEFT JOIN orchestration_v2_projection_runs AS run + ON run.run_id = item.run_id AND run.thread_id = item.thread_id + WHERE item.type IN ('user_message', 'assistant_message', 'user_input_request') + AND ( + (history.depth = 0 + AND (run.status IS NULL OR run.status <> 'rolled_back') + AND NOT (item.type = 'user_message' + AND json_extract(item.payload_json, '$.inputIntent') IS 'queued_turn' + AND run.status IS 'cancelled')) + OR (history.depth > 0 AND ( + run.ordinal <= history.cutoff + OR (item.run_id IS NULL AND json_extract(history.payload_json, '$.historyOrigin') = 'v1_import') + )) + ) + ), messages AS ( + SELECT json_extract(item.payload_json, '$.messageId') AS message_id, + item.depth, item.ordinal, item.turn_item_id, + lower(json_extract(item.payload_json, '$.text')) AS text + FROM visible_items AS item + WHERE item.type IN ('user_message', 'assistant_message') + AND NOT (item.type = 'user_message' AND json_extract(item.payload_json, '$.messageId') IN ( + SELECT 'async-answer:' || json_extract(request.payload_json, '$.questionAnswer.requestId') + FROM visible_items AS request + WHERE request.type = 'user_input_request' + AND json_extract(request.payload_json, '$.questionAnswer.requestId') IS NOT NULL + )) + ), counts AS ( + SELECT message_id, depth, ordinal, turn_item_id, + (length(text) - length(replace(text, lower(${query}), ''))) / length(${query}) AS count + FROM messages + ), positions AS ( + SELECT message_id, count, + sum(count) OVER (ORDER BY depth DESC, ordinal, turn_item_id ROWS UNBOUNDED PRECEDING) AS end_index + FROM counts WHERE count > 0 + ), total AS ( + SELECT coalesce(sum(count), 0) AS count FROM counts + ) + SELECT total.count AS total, positions.message_id AS "messageId", + ${index} - (positions.end_index - positions.count) AS occurrence + FROM total LEFT JOIN positions + ON ${index} >= positions.end_index - positions.count AND ${index} < positions.end_index + `, + }); + + const find: ThreadSearch["Service"]["find"] = Effect.fn("ThreadSearch.find")(function* (input) { + const rows = yield* findRow({ + threadId: input.threadId, + query: input.query, + index: input.index ?? 0, + }).pipe( + Effect.mapError( + (cause) => + new ThreadSearchError({ + operation: Schema.isSchemaError(cause) ? "decode" : "query", + cause, + }), + ), + ); + const row = rows[0]; + return { + total: row?.total ?? 0, + match: + row?.messageId != null && row.occurrence !== null + ? { messageId: row.messageId, occurrence: row.occurrence } + : null, + }; + }); + + return ThreadSearch.of({ search, find }); }); export const layer = Layer.effect(ThreadSearch, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ad1b9815acc3..90bab4fde416 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1886,6 +1886,16 @@ const layerWsRpc = ( }), ), ), + [ORCHESTRATION_V2_WS_METHODS.findThread]: (input) => + threadSearch.find(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadsError({ + message: "Failed to find text in thread", + cause, + }), + ), + ), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => threadSearch.search(input).pipe( Effect.mapError( diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 7bf122c46817..58d41f67a936 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,4 +1,8 @@ -import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; +import type { + EnvironmentId, + PullRequestLinkedThreadsResult, + ScopedThreadRef, +} from "@t3tools/contracts"; export interface CommandPaletteLinkedThreads { readonly environmentId: EnvironmentId; @@ -37,3 +41,13 @@ export function isCommandPaletteOpen(): boolean { typeof document !== "undefined" && document.querySelector("[data-command-palette]") !== null ); } + +export function openThreadFind(threadRef: ScopedThreadRef): void { + window.dispatchEvent(new CustomEvent("t3code:find-thread", { detail: threadRef })); +} + +export function onOpenThreadFind(listener: (threadRef: ScopedThreadRef) => void): () => void { + const handler = (event: Event) => listener((event as CustomEvent).detail); + window.addEventListener("t3code:find-thread", handler); + return () => window.removeEventListener("t3code:find-thread", handler); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e705a42d5976..a96054e4b1c4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,7 +229,8 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { ThreadFind, type ThreadFindTarget } from "./chat/ThreadFind"; +import { isCommandPaletteOpen, onOpenThreadFind } from "../commandPaletteBus"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -1543,6 +1544,26 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const [findThreadKey, setFindThreadKey] = useState(null); + const [findTarget, setFindTarget] = useState(null); + useEffect(() => { + setFindThreadKey(null); + setFindTarget(null); + }, [routeThreadKey]); + useEffect( + () => + onOpenThreadFind((target) => { + if (routeKind === "server" && scopedThreadKey(target) === routeThreadKey) { + setFindThreadKey(routeThreadKey); + window.requestAnimationFrame(() => + document + .querySelector('input[aria-label="Find in conversation"]') + ?.focus(), + ); + } + }), + [routeKind, routeThreadKey], + ); const currentRouteThreadKeyRef = useRef(routeThreadKey); useLayoutEffect(() => { currentRouteThreadKeyRef.current = routeThreadKey; @@ -7818,6 +7839,17 @@ export default function ChatView(props: ChatViewProps) { }); if (!command) return; + if (command === "thread.find" && routeKind === "server") { + event.preventDefault(); + event.stopPropagation(); + setFindThreadKey(routeThreadKey); + window.requestAnimationFrame(() => + document + .querySelector('input[aria-label="Find in conversation"]') + ?.focus(), + ); + return; + } if (command === "thread.copyReference") { event.preventDefault(); event.stopPropagation(); @@ -8079,6 +8111,8 @@ export default function ChatView(props: ChatViewProps) { supportsSettlement, confirmAndUnpinThread, copyActiveThreadReference, + routeKind, + routeThreadKey, getShortcutContext, toggleRightPanel, toggleThreadPanel, @@ -11017,6 +11051,19 @@ export default function ChatView(props: ChatViewProps) { /> + {routeKind === "server" && findThreadKey === routeThreadKey ? ( + { + setFindThreadKey(null); + setFindTarget(null); + focusComposer(); + }} + /> + ) : null} + {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -11067,8 +11114,20 @@ export default function ChatView(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} , + shortcutCommand: "thread.find", + run: async () => { + openThreadFind(scopeThreadRef(activeThread.environmentId, activeThread.id)); + }, + }); + } + if ( activeThread !== null && threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7360650a3a99..ce5352313edc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -786,7 +786,7 @@ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): } /** A steer adds input to its existing turn, without creating a new header. */ -function timelineEntryStartsResponse(entry: TimelineEntry): boolean { +export function timelineEntryStartsResponse(entry: TimelineEntry): boolean { return ( (entry.kind === "message" && entry.message.role === "user" && diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7aeb679a87b..000dd56991e9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -252,6 +252,8 @@ function stubDomGlobals() { desktopBridge: undefined, }); vi.stubGlobal("document", { + addEventListener: () => {}, + removeEventListener: () => {}, documentElement: { classList, offsetHeight: 0, @@ -1461,6 +1463,87 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-timeline-row-kind="turn-fold"'); }); + it.each(["prompt", "notification", "steer"])( + "finds folded runless assistant text after a %s", + async (boundary) => { + const prompt = buildUserTimelineEntry("Investigate the imported response"); + const commentary = buildAssistantTimelineEntry("Hidden runless search match"); + const final = buildAssistantTimelineEntry("The final answer stays visible"); + const timelineEntries = [ + prompt, + ...(boundary === "notification" + ? [ + { + kind: "work" as const, + id: "wake-notification", + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "wake-notification", + runId: null, + createdAt: MESSAGE_CREATED_AT, + label: "Automatic wake", + tone: "info" as const, + itemType: "notification" as const, + }, + }, + ] + : boundary === "steer" + ? [ + { + ...prompt, + id: "steer-entry", + message: { + ...prompt.message, + id: MessageId.make("steer-message"), + text: "Keep investigating", + inputIntent: "steer" as const, + }, + }, + ] + : []), + { + ...commentary, + id: "hidden-commentary-entry", + message: { ...commentary.message, id: MessageId.make("hidden-commentary") }, + }, + { + ...final, + id: "final-answer-entry", + message: { ...final.message, id: MessageId.make("final-answer") }, + }, + ]; + const props = { ...buildProps(), timelineEntries }; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + expect( + renderer!.root.findAllByProps({ "data-message-id": "hidden-commentary" }), + ).toHaveLength(0); + await act(() => { + renderer!.update( + , + ); + }); + expect( + renderer!.root.findAllByProps({ "data-message-id": "hidden-commentary" }).length, + ).toBeGreaterThan(0); + expect(renderer!.root.findByProps({ "aria-expanded": true }).type).toBe("button"); + } finally { + await act(() => renderer?.unmount()); + } + }, + ); + it("shows a collapsed disclosure for superseded attempt output", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const runId = RunId.make("run-steered"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 89d44b6abf76..92006235c17b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -183,6 +183,8 @@ import { PierreEntryIcon } from "./PierreEntryIcon"; import { inferEntryKindFromPath } from "../../pierre-icons"; import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import type { ThreadFindTarget } from "./ThreadFind"; +import { useThreadFindTarget } from "./useThreadFindTarget"; import { AssistantCitationSource, type AssistantCitationRequest, @@ -296,6 +298,8 @@ import { // --------------------------------------------------------------------------- interface TimelineRowSharedState { + findTarget: ThreadFindTarget | null; + findSourceMatch: ReturnType["sourceMatch"]; citationRequest: AssistantCitationTarget | null; listRef: React.RefObject; timestampFormat: TimestampFormat; @@ -414,6 +418,7 @@ export interface MessagesTimelineHistoryControls { } interface MessagesTimelineProps { + findTarget?: ThreadFindTarget | null; citationRequest?: AssistantCitationRequest | null; citationHistoryLoading?: boolean; onCiteAssistantText?: ( @@ -504,6 +509,7 @@ interface MessagesTimelineProps { // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ + findTarget = null, citationRequest = null, citationHistoryLoading = false, onCiteAssistantText, @@ -606,6 +612,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const expandCitedRun = useCallback((runId: RunId) => { setExpandedRunIds((current) => (current.has(runId) ? current : new Set([...current, runId]))); }, []); + const expandFindAttempt = useCallback((attemptId: RunAttemptId) => { + setExpandedAttemptIds((current) => + current.has(attemptId) ? current : new Set([...current, attemptId]), + ); + }, []); // Nested tool state shares the bounded thread-position cache. const workGroupViewState = useMemo( () => @@ -948,8 +959,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onExpandTurn: expandCitedRun, onManualNavigation, }); + const findNavigation = useThreadFindTarget({ + target: findTarget, + entries: timelineEntries, + rows, + listRef, + viewport: timelineViewportElement, + historyLoading: citationHistoryLoading, + loadEarlier, + onExpandTurn: expandCitedRun, + onExpandAttempt: expandFindAttempt, + historyError: historyControls?.error ?? null, + onManualNavigation, + }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); - const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender; + const alwaysRender = findNavigation.alwaysRender ?? citationAlwaysRender ?? restoringAlwaysRender; const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( @@ -1151,6 +1175,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const sharedState = useMemo( () => ({ + findTarget, + findSourceMatch: findNavigation.sourceMatch, citationRequest: readyCitationRequest, listRef, timestampFormat, @@ -1187,6 +1213,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workGroupViewState, }), [ + findTarget, + findNavigation.sourceMatch, readyCitationRequest, listRef, timestampFormat, @@ -1347,14 +1375,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} + initialScrollAtEnd={ + findTarget === null && citationRequest === null && rememberedPosition?.atEnd !== false + } // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} + dataVersion={findNavigation.key ?? readyCitationRequest?.key ?? listIdentityKey} {...(alwaysRender ? { alwaysRender } : {})} onLoad={onCitationListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ + findNavigation.positioning || citationPositioning || (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || @@ -1366,6 +1397,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ + findNavigation.positioning || citationPositioning || (restoringThreadPosition && rememberedPosition?.atEnd === false) ? false @@ -1756,6 +1788,11 @@ type TimelineWorkEntry = Extract["grouped type TimelineRow = MessagesTimelineRow; const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const ctx = use(TimelineRowCtx); + const sourceMatch = + row.kind === "message" && ctx.findSourceMatch?.messageId === row.message.id + ? ctx.findSourceMatch + : null; const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isSubagentGroup = row.kind === "event" && row.projectedItem.item.type === "subagent"; const isWorkLogRow = @@ -1824,6 +1861,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} + {sourceMatch ? ( +
+

Match in message source

+
+ {sourceMatch.before} + {sourceMatch.match} + {sourceMatch.after} +
+
+ ) : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( @@ -2252,8 +2299,11 @@ function UserTimelineRow({ row }: { row: Extract ) : null} -
+
ReactNode; skills: ReadonlyArray>; @@ -4373,6 +4424,9 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop const hasVisibleBody = props.text.trim().length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; + useEffect(() => { + if (props.expandRequestKey) setExpanded(true); + }, [props.expandRequestKey]); return (
diff --git a/apps/web/src/components/chat/ThreadFind.tsx b/apps/web/src/components/chat/ThreadFind.tsx new file mode 100644 index 000000000000..8c31577df293 --- /dev/null +++ b/apps/web/src/components/chat/ThreadFind.tsx @@ -0,0 +1,160 @@ +import type { OrchestrationFindThreadResult, ScopedThreadRef } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export interface ThreadFindTarget { + readonly messageId: NonNullable["messageId"]; + readonly occurrence: number; + readonly query: string; + readonly key: string; +} + +/** Queries persisted history, rather than the subset mounted by the virtual list. */ +export function ThreadFind({ + threadRef, + onTarget, + onClose, +}: { + threadRef: ScopedThreadRef; + onTarget: (target: ThreadFindTarget | null) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [index, setIndex] = useState(0); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const find = useAtomQueryRunner(orchestrationEnvironment.threadFind, { + refresh: true, + reportFailure: false, + }); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + useEffect(() => () => onTarget(null), [onTarget]); + useEffect(() => { + let cancelled = false; + setError(null); + setLoading(query.trim().length > 0); + if (!query.trim()) { + onTarget(null); + setTotal(0); + return; + } + const timer = window.setTimeout(() => { + void find({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, query: query.trim(), index }, + }).then((result) => { + if (cancelled) return; + setLoading(false); + if (result._tag === "Failure") { + onTarget(null); + setError("Could not search this conversation"); + setTotal(0); + return; + } + setTotal(result.value.total); + if (index >= result.value.total && result.value.total > 0) { + setIndex(0); + return; + } + onTarget( + result.value.match + ? { + ...result.value.match, + query: query.trim(), + key: `${threadRef.threadId}:${query.trim()}:${index}`, + } + : null, + ); + }); + }, 200); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [find, index, onTarget, query, threadRef]); + const move = (direction: -1 | 1) => { + if (total > 0 && !loading) setIndex((current) => (current + direction + total) % total); + }; + return ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + move(event.shiftKey ? -1 : 1); + } + }} + > +
+ { + onTarget(null); + setQuery(event.target.value); + setIndex(0); + setTotal(0); + }} + /> +
+ + {error ?? (loading ? "Searching…" : `${total ? index + 1 : 0} of ${total}`)} + + + + +
+ ); +} diff --git a/apps/web/src/components/chat/useThreadFindTarget.ts b/apps/web/src/components/chat/useThreadFindTarget.ts new file mode 100644 index 000000000000..58e2d6354d96 --- /dev/null +++ b/apps/web/src/components/chat/useThreadFindTarget.ts @@ -0,0 +1,316 @@ +import type { LegendListRef } from "@legendapp/list/react"; +import { RunId, type RunAttemptId } from "@t3tools/contracts"; +import { useEffect, useRef, useState, type RefObject } from "react"; +import type { TimelineEntry } from "../../session-logic"; +import { timelineEntryStartsResponse, type MessagesTimelineRow } from "./MessagesTimeline.logic"; +import type { CitationHistoryPage } from "./useAssistantCitationTarget"; +import type { ThreadFindTarget } from "./ThreadFind"; +import { toastManager } from "../ui/toast"; + +const fold = (text: string) => text.replace(/[A-Z]/g, (character) => character.toLowerCase()); +const MAX_POSITION_ATTEMPTS = 8; + +/** Load and unfold the same history used by citation navigation, then pin its virtual row. */ +export function useThreadFindTarget({ + target, + entries, + rows, + listRef, + viewport, + historyLoading, + loadEarlier, + onExpandTurn, + onExpandAttempt, + historyError, + onManualNavigation, +}: { + target: ThreadFindTarget | null; + entries: ReadonlyArray; + rows: ReadonlyArray; + listRef: RefObject; + viewport: HTMLElement | null; + historyLoading: boolean; + loadEarlier: CitationHistoryPage | null; + onExpandTurn: (id: RunId) => void; + onExpandAttempt: (id: RunAttemptId) => void; + historyError: string | null; + onManualNavigation: () => void; +}) { + const navigation = useRef<{ + key: string; + pages: Set; + finished: boolean; + positionAttempts: number; + } | null>(null); + const cancelScroll = useRef<(() => void) | null>(null); + const [finishedKey, setFinishedKey] = useState(null); + const [readyKey, setReadyKey] = useState(null); + const [sourceMatch, setSourceMatch] = useState<{ + key: string; + messageId: string; + before: string; + match: string; + after: string; + } | null>(null); + useEffect(() => { + if (!target) { + navigation.current = null; + setFinishedKey(null); + setReadyKey(null); + setSourceMatch(null); + return; + } + if (navigation.current?.key !== target.key) { + navigation.current = { + key: target.key, + pages: new Set(), + finished: false, + positionAttempts: 0, + }; + setFinishedKey(null); + setReadyKey(null); + onManualNavigation(); + } + const current = navigation.current; + if (historyLoading || current.finished) return; + const source = entries.find( + (entry) => entry.kind === "message" && entry.message.id === target.messageId, + ); + if (!source) { + if (!historyError && loadEarlier && !loadEarlier.loading) { + const cursor = loadEarlier.cursor ?? entries[0]?.id ?? "first"; + if (!current.pages.has(cursor)) { + current.pages.add(cursor); + loadEarlier.onLoadEarlier(); + } + return; + } else if (loadEarlier?.loading) return; + current.finished = true; + setFinishedKey(target.key); + toastManager.add({ + type: "warning", + title: "Could not load the matching message", + description: "Load earlier turns, then try the match again.", + }); + return; + } + const row = rows.find((row) => row.kind === "message" && row.message.id === target.messageId); + if (!row && source.kind === "message") { + let runId = source.message.runId; + if (runId == null) { + const boundary = entries + .slice(0, entries.indexOf(source)) + .findLast(timelineEntryStartsResponse); + const boundaryRunId = + boundary?.kind === "message" + ? boundary.message.runId + : boundary?.kind === "work" + ? boundary.entry.runId + : null; + if (boundary && boundaryRunId == null) runId = RunId.make(`runless:${boundary.id}`); + } + if (runId) onExpandTurn(runId); + if (source.attempt) onExpandAttempt(source.attempt.id); + return; + } + if (row) setReadyKey(target.key); + }, [ + entries, + historyLoading, + loadEarlier, + onExpandTurn, + onManualNavigation, + rows, + target, + onExpandAttempt, + historyError, + ]); + + useEffect(() => { + if (!target) return; + const cancelForNavigation = () => { + const current = navigation.current; + if (!current || current.key !== target.key || current.finished) return; + current.finished = true; + cancelScroll.current?.(); + setFinishedKey(target.key); + onManualNavigation(); + }; + const onWheel = (event: WheelEvent) => { + if (event.deltaY !== 0) cancelForNavigation(); + }; + const onScrollKey = (event: KeyboardEvent) => { + if ( + ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && + !( + event.target instanceof Element && + event.target.closest("input, textarea, [contenteditable=true]") + ) + ) + cancelForNavigation(); + }; + const ownerDocument = viewport?.ownerDocument ?? document; + ownerDocument.addEventListener("pointerdown", cancelForNavigation, true); + ownerDocument.addEventListener("keydown", onScrollKey); + viewport?.addEventListener("wheel", onWheel, { passive: true }); + viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true }); + return () => { + ownerDocument.removeEventListener("pointerdown", cancelForNavigation, true); + ownerDocument.removeEventListener("keydown", onScrollKey); + viewport?.removeEventListener("wheel", onWheel); + viewport?.removeEventListener("touchmove", cancelForNavigation); + }; + }, [onManualNavigation, target, viewport]); + + const row = + target && readyKey === target.key + ? rows.find((row) => row.kind === "message" && row.message.id === target.messageId) + : undefined; + useEffect(() => { + const list = listRef.current; + if (!target || !row || !list || !viewport) return; + let cancelled = false; + let scrolling = false; + let frame: number | null = null; + let observer: MutationObserver | null = null; + const resizeObserver = new ResizeObserver(() => schedule()); + let highlight: Highlight | null = null; + const position = () => { + if (cancelled) return; + const source = viewport.querySelector( + `[data-message-id="${CSS.escape(target.messageId)}"][data-message-role]`, + ); + if (!source) return; + resizeObserver.observe(source); + const body = + source.querySelector( + "[data-thread-find-text], [data-assistant-citation-source]", + ) ?? source; + const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); + const nodes: Text[] = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) nodes.push(node as Text); + const text = fold(nodes.map((node) => node.data).join("")); + const query = fold(target.query); + const rawText = row.kind === "message" ? row.message.text : ""; + const raw = fold(rawText); + const useSource = raw.split(query).length !== text.split(query).length; + const searchable = useSource ? raw : text; + let start = -1; + for (let occurrence = 0; occurrence <= target.occurrence; occurrence++) { + const next = searchable.indexOf(query, start + (start < 0 ? 1 : query.length)); + if (next < 0) { + start = -1; + break; + } + start = next; + } + let range: Range | null = null; + if (useSource && start >= 0) { + const before = rawText.slice(Math.max(0, start - 80), start); + const match = rawText.slice(start, start + query.length); + const after = rawText.slice(start + query.length, start + query.length + 80); + setSourceMatch((current) => + current?.key === target.key && + current.before === before && + current.match === match && + current.after === after + ? current + : { key: target.key, messageId: target.messageId, before, match, after }, + ); + const matchElement = source.querySelector( + `[data-thread-find-match="${CSS.escape(target.key)}"]`, + ); + if (!matchElement) return; + range = document.createRange(); + range.selectNodeContents(matchElement); + } else if (start >= 0) { + setSourceMatch(null); + range = document.createRange(); + let offset = 0; + for (const node of nodes) { + if (start >= offset && start < offset + node.length) range.setStart(node, start - offset); + if (start + query.length > offset && start + query.length <= offset + node.length) { + range.setEnd(node, start + query.length - offset); + break; + } + offset += node.length; + } + } + const scroll = list.getScrollableNode(); + if (!(scroll instanceof HTMLElement)) return; + const rect = (range ?? body).getBoundingClientRect(); + if (rect.height <= 0) return; + const current = navigation.current; + if (current?.key === target.key && !current.finished && !scrolling) { + // Keep the limit across row/layout changes that restart this effect. + if (current.positionAttempts >= MAX_POSITION_ATTEMPTS) { + current.finished = true; + setFinishedKey(target.key); + toastManager.add({ + type: "warning", + title: "Could not scroll to the matching message", + description: "Try the match again after the conversation finishes loading.", + }); + return; + } + const offset = Math.max( + 0, + scroll.scrollTop + + rect.top - + scroll.getBoundingClientRect().top - + Math.min(100, scroll.clientHeight / 3), + ); + scrolling = true; + current.positionAttempts++; + void list.scrollToOffset({ offset, animated: false }).then(() => { + scrolling = false; + if (cancelled || navigation.current !== current || current.finished) return; + const positioned = (range ?? body).getBoundingClientRect(); + const bounds = scroll.getBoundingClientRect(); + if (positioned.top < bounds.bottom && positioned.bottom > bounds.top) { + current.finished = true; + setFinishedKey(target.key); + } + if (!current.finished) schedule(); + }); + } + if (range && typeof Highlight !== "undefined" && CSS.highlights) { + highlight = new Highlight(range); + CSS.highlights.set("t3-thread-find", highlight); + } + }; + const schedule = () => { + if (cancelled) return; + if (frame !== null) cancelAnimationFrame(frame); + frame = requestAnimationFrame(position); + }; + const stopScrolling = () => { + cancelled = true; + if (frame !== null) cancelAnimationFrame(frame); + const scroll = list.getScrollableNode(); + // Supersede Legend's pending request before it can override the gesture. + if (scroll instanceof HTMLElement) + void list.scrollToOffset({ offset: scroll.scrollTop, animated: false }); + }; + cancelScroll.current = stopScrolling; + observer = new MutationObserver(schedule); + observer.observe(viewport, { childList: true, subtree: true, characterData: true }); + resizeObserver.observe(viewport); + schedule(); + return () => { + cancelled = true; + if (cancelScroll.current === stopScrolling) cancelScroll.current = null; + observer?.disconnect(); + resizeObserver.disconnect(); + if (frame !== null) cancelAnimationFrame(frame); + if (highlight && CSS.highlights?.get("t3-thread-find") === highlight) + CSS.highlights.delete("t3-thread-find"); + }; + }, [listRef, row, target, viewport]); + return { + sourceMatch: sourceMatch?.key === target?.key ? sourceMatch : null, + alwaysRender: row ? { keys: [row.id] } : undefined, + positioning: target !== null && finishedKey !== target.key, + key: row ? target?.key : undefined, + }; +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index fde8f3419c09..30013bd11701 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -364,6 +364,7 @@ export function commandLabel(command: KeybindingCommand): string { if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread"; if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer"; if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message"; + if (command === "thread.find") return "Thread: Find in Conversation"; if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID"; const usageMetric = METRIC_OPTIONS.find((option) => option.command === command); if (usageMetric) return `Usage: ${usageMetric.label}`; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 46b57325a3ff..fc0f6e9a9a9b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2307,3 +2307,8 @@ code { } } } + +::highlight(t3-thread-find) { + background-color: color-mix(in srgb, var(--primary) 35%, transparent); + color: inherit; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 58199ddf6289..8d05e2fb4d3c 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1367,6 +1367,33 @@ describe("composer and pull request shortcuts", () => { ); } + it.each(["MacIntel", "Win32", "Linux"])( + "finds the current conversation without taking terminal or preview find on %s", + (platform) => { + const input = event({ + key: "f", + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: false, previewFocus: false, draftThreadRoute: false }, + }), + "thread.find", + ); + for (const context of [ + { terminalFocus: true }, + { previewFocus: true }, + { draftThreadRoute: true }, + ]) { + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { platform, context }), + ); + } + }, + ); + it.each(["MacIntel", "Win32", "Linux"])( "edits the last queued message with Alt+ArrowUp from the composer on %s", (platform) => { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index aed09c042794..28fbd6f85795 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -177,6 +177,10 @@ On web and desktop, open the command palette with `Cmd/Ctrl+K` to search threads across connected environments. Message search starts after two characters and includes your messages and final agent responses. +Use `Cmd/Ctrl+F` or **Find in conversation** in the command palette to search the +current thread, including older messages. `Enter` moves to the next match, +`Shift+Enter` moves to the previous match, and `Escape` closes find. + Use **Settings → Keybindings** to find or customize shortcuts for searching files and copying a thread reference. A copied reference uses the thread's pull request link when available, otherwise its thread ID. See [keybindings](./keybindings.md) diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 9392ee2baab4..61d2ebc84b75 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -55,6 +55,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, }), + threadFind: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-find", + tag: ORCHESTRATION_V2_WS_METHODS.findThread, + staleTimeMs: 0, + idleTtlMs: 0, + }), threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:thread-search", tag: ORCHESTRATION_V2_WS_METHODS.searchThreads, diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 20bf0b901389..2f8d7b6e5d98 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -41,6 +41,7 @@ const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", "thread.copyReference", + "thread.find", "thread.settle", "thread.pin", "thread.undo", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..089043219433 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3137,6 +3137,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", + findThread: "orchestration.findThread", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f05935309235..29adf635f708 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -136,6 +136,8 @@ import { KeybindingsConfigError } from "./keybindings.ts"; import { OrchestrationSearchThreadsError, OrchestrationSearchThreadsInput, + OrchestrationFindThreadInput, + OrchestrationFindThreadResult, OrchestrationSearchThreadsResult, } from "./threadSearch.ts"; import { @@ -1523,6 +1525,12 @@ const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.s error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), }); +const WsOrchestrationV2FindThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.findThread, { + payload: OrchestrationFindThreadInput, + success: OrchestrationFindThreadResult, + error: Schema.Union([OrchestrationSearchThreadsError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2GetArchivedShellSnapshotRpc = Rpc.make( ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot, { @@ -1904,6 +1912,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, WsOrchestrationV2SearchThreadsRpc, + WsOrchestrationV2FindThreadRpc, WsOrchestrationV2GetArchivedShellSnapshotRpc, WsOrchestrationV2GetThreadProjectionRpc, WsOrchestrationV2LaunchThreadRpc, diff --git a/packages/contracts/src/threadSearch.ts b/packages/contracts/src/threadSearch.ts index d3b8f6badee0..3ac1087a8793 100644 --- a/packages/contracts/src/threadSearch.ts +++ b/packages/contracts/src/threadSearch.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, + MessageId, ProjectId, ThreadId, TrimmedNonEmptyString, @@ -40,3 +41,22 @@ export class OrchestrationSearchThreadsError extends Schema.TaggedError = [ { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, + { + key: "mod+f", + command: "thread.find", + when: "!terminalFocus && !previewFocus && !draftThreadRoute", + }, { key: "mod+u", command: "usage.open", when: "!terminalFocus" }, { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" }, { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" },