Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ T4 Code needs an OMP build with desktop appserver support. For v0.1.23, use the

T4 Code v0.1.23 was verified with OMP 17.0.4 built from [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), tagged [`t4code-17.0.4-appserver-5`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.4-appserver-5). That public integration is based on the official upstream [`v17.0.4`](https://github.com/can1357/oh-my-pi/tree/v17.0.4) tag at [`3fdd85ab`](https://github.com/can1357/oh-my-pi/commit/3fdd85ab6c6bab6c0cdee80abbbec0981740a5c0). It adds redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, macOS system-temp aliases, workspace-native build artifacts, retry-safe release metadata, lock-aware session observation, complete transcript reconciliation, missing-lock-only promotion, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.4 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.5.10 from integration commit [`d57dcd85`](https://github.com/lyc-aon/oh-my-pi/commit/d57dcd855006c673d8d530237d474fe5ba5645c4), source tree `5cf488966e3c233764780d3ca7a8d8ea1e3a1f68`.

The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.5.10 from integration commit [`93f48ab6`](https://github.com/lyc-aon/oh-my-pi/commit/93f48ab62e2002b48a0dc2734de33d5328ea76d6), source tree `ea8608496731f29addc95d43ea68e44c5c42cb22`. The published v0.1.23 package remains pinned to the `d57dcd85` contract above.
The current source tree advances the vendored contract to `@oh-my-pi/app-wire` 0.6.1 from integration commit [`f57d5c96`](https://github.com/lyc-aon/oh-my-pi/commit/f57d5c96b46adc1c43b76710bb019ba4a3e17a75), source tree `41ef665e518cc914392bb1fea2e7922fca9664f5`. This adds the bounded cross-session transcript search and historical context contract. The published v0.1.23 package remains pinned to the `d57dcd85` contract above.

| Platform | Arch | Package |
| -------- | --------------------- | ---------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ T3 Code is selectively referenced for future ports from https://github.com/pingd

## Oh My Pi

Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.5.10` package is packed from the public `lyc-aon/oh-my-pi` integration commit `93f48ab62e2002b48a0dc2734de33d5328ea76d6`, source tree `ea8608496731f29addc95d43ea68e44c5c42cb22`; tarball SHA-256 `d30da820ff2bb8a7efa024fc829b654a2dfaf2600688fa369abc64b053ae8ede`; golden corpus SHA-256 `63480a2359c1b2b4ec2f5cc8890683f0eefc13e92597d1464e442b563bc7375e`. Target integration commit is recorded in the Desktop commit history and compatibility matrix.
Future adaptations of OMP source use the OMP repository under its repository license. OMP remains runtime authority; adapted files retain OMP attribution and the applicable source license. The vendored `@oh-my-pi/app-wire@0.6.1` package is packed from the public `lyc-aon/oh-my-pi` integration commit `f57d5c96b46adc1c43b76710bb019ba4a3e17a75`, source tree `41ef665e518cc914392bb1fea2e7922fca9664f5`; tarball SHA-256 `a3918226a83de61a6c741b022e0dab0705842d75337ac32f446df9ef393207e2`; golden corpus SHA-256 `f407bb389a3a77e9db7ec046b9cd1cb19b4edb986694ecc4509ac21c7524c66d`. Target integration commit is recorded in the Desktop commit history and compatibility matrix.

## Oh My Pi icon

Expand Down
40 changes: 35 additions & 5 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
// focus (dialog primitive owns the focus contract).
import { cn, Dialog, DialogPopup, StatusPill } from "@t4-code/ui";
import { useNavigate } from "@tanstack/react-router";
import { CornerDownLeft } from "lucide-react";
import { CornerDownLeft, Search } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";

import type { ProjectGroup } from "../lib/session-tree.ts";
import { handoffTranscriptSearchQuery } from "../features/transcript-search/index.ts";
import { TRANSCRIPT_SEARCH_ROUTE } from "../features/transcript-search/route.ts";
import { useWorkspace, workspaceStore } from "../state/store-instance.ts";
import { resolveTheme } from "../theme/theme.ts";
interface PaletteItem {
Expand All @@ -21,6 +23,7 @@ function buildItems(
groups: readonly ProjectGroup[],
navigate: (sessionId: string) => void,
openInbox: () => void,
openTranscriptSearch: (query: string) => void,
openAgentView: () => void,
openSettings: () => void,
): PaletteItem[] {
Expand Down Expand Up @@ -66,6 +69,13 @@ function buildItems(
status: null,
run: openInbox,
},
{
id: "action:transcript-search",
label: "Open transcript search",
hint: "Prior decisions and code discussions",
status: <Search aria-hidden="true" className="size-3.5 text-muted-foreground" />,
run: () => openTranscriptSearch(""),
},
{
id: "action:agents",
label: "Open Agent View",
Expand Down Expand Up @@ -101,6 +111,10 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] })
() => {
void navigate({ to: "/inbox" });
},
(searchQuery) => {
handoffTranscriptSearchQuery(searchQuery);
void navigate({ to: TRANSCRIPT_SEARCH_ROUTE });
},
() => {
void navigate({ to: "/agents" });
},
Expand All @@ -112,10 +126,26 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] })
);

const needle = query.trim().toLowerCase();
const filtered =
const baseFiltered =
needle === ""
? items
: items.filter((item) => `${item.label} ${item.hint}`.toLowerCase().includes(needle));
const filtered =
needle.length < 2
? baseFiltered
: [
...baseFiltered,
{
id: "action:transcript-search-query",
label: "View all transcript results",
hint: `Search for “${query.trim()}”`,
status: <Search aria-hidden="true" className="size-3.5 text-muted-foreground" />,
run: () => {
handoffTranscriptSearchQuery(query.trim());
void navigate({ to: TRANSCRIPT_SEARCH_ROUTE });
},
},
];

useEffect(() => {
setHighlighted(0);
Expand Down Expand Up @@ -143,7 +173,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] })
return (
<Dialog onOpenChange={(next) => workspaceStore.getState().setPaletteOpen(next)} open={open}>
<DialogPopup
aria-label="Search sessions and commands"
aria-label="Search sessions, transcripts, and commands"
className="w-full max-w-lg overflow-hidden p-0"
showCloseButton={false}
>
Expand All @@ -166,7 +196,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] })
runItem(filtered[highlighted]);
}
}}
placeholder="Search sessions and commands"
placeholder="Search sessions, transcripts, and commands"
role="combobox"
type="text"
value={query}
Expand All @@ -180,7 +210,7 @@ export function CommandPalette({ groups }: { groups: readonly ProjectGroup[] })
>
{filtered.length === 0 && (
<li className="px-2.5 py-6 text-center text-muted-foreground text-sm">
Nothing matches "{query}". Try a session title or project name.
Nothing matches "{query}". Try a session title, project, or transcript phrase.
</li>
)}
{filtered.map((item, index) => (
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/components/Titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Linux window controls are injected by the desktop shell later.
import { Badge, BrandLockup, IconButton, Tooltip, TooltipPopup, TooltipTrigger } from "@t4-code/ui";
import { useNavigate } from "@tanstack/react-router";
import { Command, Moon, PanelLeft, Settings, Sun, UsersRound } from "lucide-react";
import { Moon, PanelLeft, Search, Settings, Sun, UsersRound } from "lucide-react";
import { useEffect } from "react";

import { updateIsAvailable } from "../features/updates/update-model.ts";
Expand Down Expand Up @@ -149,16 +149,16 @@ export function Titlebar({
<TooltipTrigger
render={
<IconButton
aria-label="Search sessions and commands"
aria-label="Search sessions and transcripts"
className="size-11 sm:size-7"
onClick={() => workspaceStore.getState().setPaletteOpen(true)}
size="icon-sm"
>
<Command />
<Search />
</IconButton>
}
/>
<TooltipPopup side="bottom">Search sessions and commands (Ctrl+K)</TooltipPopup>
<TooltipPopup side="bottom">Search sessions and transcripts (Ctrl+K)</TooltipPopup>
</Tooltip>
<ThemeToggle />
<SettingsButton />
Expand Down
211 changes: 211 additions & 0 deletions apps/web/src/features/transcript-search/LiveTranscriptSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

import { desktopRuntime } from "../../platform/desktop-runtime.ts";
import { useShellData } from "../../state/shell-data.ts";
import { fixtureTranscriptSearchSource } from "./fixtures.ts";
import { LatestTranscriptSearchExecutor } from "./execution.ts";
import type { HistoricContextState, TranscriptSearchPhase } from "./TranscriptSearchScreen.tsx";
import { TranscriptSearchScreen } from "./TranscriptSearchScreen.tsx";
import {
DEFAULT_TRANSCRIPT_SEARCH_FILTERS,
type TranscriptSearchFilters,
type TranscriptSearchResponse,
type TranscriptSearchResult,
transcriptSearchCanRun,
} from "./model.ts";
import {
setTranscriptSearchQuery,
TranscriptSearchHandoffTracker,
useTranscriptSearchMemory,
} from "./search-memory.ts";
import { clientTranscriptSearchSource } from "./source.ts";
import type { DesktopRuntimeController } from "@t4-code/client";
import type { WorkspaceData } from "../../lib/workspace-data.ts";

/** Browser-direct has a real controller; only the disconnected showcase uses fixtures. */
export function selectTranscriptSearchSource(
controller: DesktopRuntimeController | null,
data: WorkspaceData,
) {
return controller === null
? fixtureTranscriptSearchSource
: clientTranscriptSearchSource(controller, data);
}

function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== "") return error.message;
return "The host did not return a usable response.";
}

/** Live route adapter: ephemeral UI state in, client-owned host fan-out out. */
export function LiveTranscriptSearch() {
const navigate = useNavigate();
const shellData = useShellData();
const controller = desktopRuntime();
const searchMemory = useTranscriptSearchMemory();
const query = searchMemory.query;
const [filters, setFilters] = useState<TranscriptSearchFilters>(
DEFAULT_TRANSCRIPT_SEARCH_FILTERS,
);
const [phase, setPhase] = useState<TranscriptSearchPhase>("idle");
const [response, setResponse] = useState<TranscriptSearchResponse | null>(null);
const [error, setError] = useState<string>();
const [historicContext, setHistoricContext] = useState<HistoricContextState>(null);
const [loadingMoreHostId, setLoadingMoreHostId] = useState<string | null>(null);
const [loadMoreError, setLoadMoreError] = useState<string>();
const searchExecutor = useRef(new LatestTranscriptSearchExecutor()).current;
const contextAbort = useRef<AbortController | null>(null);
const loadMoreAbort = useRef<AbortController | null>(null);
const disposeTimer = useRef<number | null>(null);
const handoffTracker = useRef(new TranscriptSearchHandoffTracker()).current;

const source = useMemo(
() => selectTranscriptSearchSource(controller, shellData),
[controller, shellData],
);
const projects = useMemo(
() =>
[...shellData.projects]
.map((project) => ({ id: project.id, label: project.name }))
.sort((left, right) => left.label.localeCompare(right.label)),
[shellData.projects],
);

const runSearch = useCallback(
async (
nextFilters: TranscriptSearchFilters = filters,
nextQuery: string = query,
) => {
if (!transcriptSearchCanRun(nextQuery)) return;
loadMoreAbort.current?.abort();
await searchExecutor.run(
source,
{ query: nextQuery.trim(), filters: nextFilters },
{
onStart: () => {
setPhase("searching");
setError(undefined);
setLoadingMoreHostId(null);
setLoadMoreError(undefined);
},
onSuccess: (next) => {
setResponse(next);
setPhase("complete");
},
onError: (caught) => {
setResponse(null);
setError(errorMessage(caught));
setPhase("error");
},
},
);
},
[filters, query, searchExecutor, source],
);

useLayoutEffect(() => {
const handoff = handoffTracker.take(searchMemory);
if (handoff === null) return;
searchExecutor.cancel();
contextAbort.current?.abort();
loadMoreAbort.current?.abort();
setHistoricContext(null);
setResponse(null);
setError(undefined);
setLoadMoreError(undefined);
setLoadingMoreHostId(null);
setPhase("idle");
if (transcriptSearchCanRun(handoff.query)) {
void runSearch(filters, handoff.query);
}
}, [filters, handoffTracker, runSearch, searchExecutor, searchMemory]);

useEffect(() => {
// StrictMode immediately runs setup → cleanup → setup in development.
// Defer disposal one tick so that probe cannot cancel the palette handoff.
if (disposeTimer.current !== null) window.clearTimeout(disposeTimer.current);
return () => {
disposeTimer.current = window.setTimeout(() => {
searchExecutor.cancel();
contextAbort.current?.abort();
loadMoreAbort.current?.abort();
}, 0);
};
}, [searchExecutor]);

const changeFilters = (next: TranscriptSearchFilters) => {
setFilters(next);
if (response !== null && transcriptSearchCanRun(query)) void runSearch(next);
};
Comment thread
wolfiesch marked this conversation as resolved.

const openResult = async (result: TranscriptSearchResult) => {
contextAbort.current?.abort();
const abort = new AbortController();
contextAbort.current = abort;
setHistoricContext({ result, phase: "loading" });
try {
const context = await source.context(result, abort.signal);
if (!abort.signal.aborted) setHistoricContext({ result, phase: "ready", context });
} catch (caught) {
if (!abort.signal.aborted) {
setHistoricContext({ result, phase: "error", error: errorMessage(caught) });
}
}
};

return (
<TranscriptSearchScreen
error={error}
filters={filters}
historicContext={historicContext}
onCloseHistoricContext={() => {
contextAbort.current?.abort();
setHistoricContext(null);
}}
onFiltersChange={changeFilters}
onOpenLiveTail={(result) => {
contextAbort.current?.abort();
void navigate({ params: { sessionId: result.sessionViewId }, to: "/sessions/$sessionId" });
}}
loadingMoreHostId={loadingMoreHostId}
loadMoreError={loadMoreError}
onLoadMoreHost={
source.loadMore === undefined
? undefined
: (hostId) => {
loadMoreAbort.current?.abort();
const abort = new AbortController();
loadMoreAbort.current = abort;
setLoadingMoreHostId(hostId);
setLoadMoreError(undefined);
void source
.loadMore?.(hostId, abort.signal)
.then((next) => {
if (!abort.signal.aborted) setResponse(next);
})
.catch((caught) => {
if (!abort.signal.aborted) setLoadMoreError(errorMessage(caught));
})
.finally(() => {
if (!abort.signal.aborted) setLoadingMoreHostId(null);
});
}
}
onOpenResult={(result) => void openResult(result)}
onQueryChange={(next) => {
setTranscriptSearchQuery(next);
if (phase !== "idle") {
searchExecutor.cancel();
setResponse(null);
setPhase("idle");
}
}}
onSubmit={() => void runSearch()}
phase={phase}
projects={projects}
query={query}
response={response}
/>
);
}
Loading
Loading