From ae72f7108011795f752581a85b2cf9fe6848e8e2 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:54:58 -0700 Subject: [PATCH 1/3] feat(web): match Codex rail organization --- FEATURE_MATRIX.md | 2 +- THIRD_PARTY_NOTICES.md | 2 +- apps/desktop/test/bundled-runtime.test.ts | 6 +- apps/desktop/test/doctor.test.ts | 4 +- apps/web/src/components/AppShell.tsx | 53 +-- apps/web/src/components/Rail.tsx | 437 ++++++++++++++++-- .../session-runtime/session-management.ts | 55 +++ apps/web/src/lib/session-tree.ts | 51 +- apps/web/src/state/workspace-store.ts | 65 ++- apps/web/test/mobile-touch-targets.test.tsx | 15 +- apps/web/test/session-management.test.ts | 67 ++- apps/web/test/session-tree.test.ts | 41 +- apps/web/test/workspace-store.test.ts | 42 ++ compat/omp-app-matrix.json | 21 +- docs/CODEX_RAIL_PARITY.md | 35 ++ e2e/built-web-server.ts | 120 +++++ e2e/pwa-install.spec.ts | 128 +---- e2e/rail-parity.spec.ts | 108 +++++ packages/fixture-server/src/engine.ts | 3 + packages/fixture-server/test/engine.test.ts | 2 + packages/protocol/package.json | 2 +- packages/protocol/test/distribution.test.ts | 14 +- pnpm-lock.yaml | 12 +- .../t3code/imports/f1-shell-20260711.json | 4 +- scripts/check-release-consistency.test.mjs | 7 +- vendor/app-wire/manifest.json | 12 +- vendor/app-wire/oh-my-pi-app-wire-0.6.2.tgz | Bin 0 -> 48654 bytes 27 files changed, 1054 insertions(+), 254 deletions(-) create mode 100644 docs/CODEX_RAIL_PARITY.md create mode 100644 e2e/built-web-server.ts create mode 100644 e2e/rail-parity.spec.ts create mode 100644 vendor/app-wire/oh-my-pi-app-wire-0.6.2.tgz diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 7e3b124..ff530f1 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -31,7 +31,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | Capability | Source command/state | Desktop behavior | T3 reference | Priority | |---|---|---|---|---| -| List/recent/search/filter | session store and metadata | Bounded left rail; folder or flat organization; Priority, Last updated, or Manual order; title/folder/host search; attention/running/unread/error filters; pinned shortcuts; running/waiting/failed/unread badges | `Sidebar.tsx`, sidebar logic/tests | Launch | +| List/recent/search/filter | session store and metadata | Codex-parity left rail: By project or In one list; Priority, Last updated, or Manual order; real project/session dragging with keyboard fallbacks; title/project/host search; attention/running/unread/error filters; pinned shortcuts; five-row Show more; project aliases; reversible hidden projects; bulk read/archive; local-only Finder reveal; direct pin/archive controls | `Rail.tsx`, `session-tree.ts`, workspace store, management helpers, browser tests | Launch | | New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch | | Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch | | Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3ac7a34..41bc2fb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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.6.1` package is packed from the public `lyc-aon/oh-my-pi` integration commit `e3e15c03ae95ebbda5f26495cd21213cc53518b1`, source tree `e0f32b279eb4b8cbc403e47d765a226bee99c99f`; tarball SHA-256 `78ec6223e7ad0f4f9e14526a822eaa45b363c856065c08196b2817a2f42740b9`; golden corpus SHA-256 `d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870`. 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.2` package is packed from the public `lyc-aon/oh-my-pi` integration commit `04229b1f46547ac7c0617e55a993496ec9725f46`, source tree `8400a3af618e8af11cccf6b20aadcf3a22baf9a1`; tarball SHA-256 `0f13659ebaabd284595558f023731723b928231bc82245f54ea25e79d00b6a6f`; golden corpus SHA-256 `d5e674095de3d9b3b56a5668bc91cbbf1904b409ea9ea6456c2eabdf272e7870`. Target integration commit is recorded in the Desktop commit history and compatibility matrix. ## Oh My Pi icon diff --git a/apps/desktop/test/bundled-runtime.test.ts b/apps/desktop/test/bundled-runtime.test.ts index 16ccba2..fc9b2dc 100644 --- a/apps/desktop/test/bundled-runtime.test.ts +++ b/apps/desktop/test/bundled-runtime.test.ts @@ -16,7 +16,7 @@ describe("bundled OMP runtime", () => { const sha256 = createHash("sha256").update(bytes).digest("hex"); await writeFile(join(runtimeRoot, "omp"), bytes); await writeFile(join(runtimeRoot, "manifest.json"), JSON.stringify({ - version: 1, tag: "t4code-17.0.5-appserver-6", platform: "darwin", arch: "arm64", + version: 1, tag: "t4code-17.0.5-appserver-7", platform: "darwin", arch: "arm64", executable: "omp", size: bytes.length, sha256, })); @@ -35,7 +35,7 @@ describe("bundled OMP runtime", () => { await mkdir(runtimeRoot, { recursive: true }); await writeFile(join(runtimeRoot, "omp"), "wrong"); await writeFile(join(runtimeRoot, "manifest.json"), JSON.stringify({ - version: 1, tag: "t4code-17.0.5-appserver-6", platform: "darwin", arch: "arm64", + version: 1, tag: "t4code-17.0.5-appserver-7", platform: "darwin", arch: "arm64", executable: "omp", size: 5, sha256: "0".repeat(64), })); @@ -58,7 +58,7 @@ describe("bundled OMP runtime", () => { await writeFile(join(runtimeRoot, "omp"), signedBytes); await writeFile(join(runtimeRoot, "manifest.json"), JSON.stringify({ version: 1, - tag: "t4code-17.0.5-appserver-6", + tag: "t4code-17.0.5-appserver-7", platform: "darwin", arch: "arm64", executable: "omp", diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index d0dd42d..0f345a3 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -41,9 +41,9 @@ describe("T4 setup doctor", () => { const source = await readSourceContract(); expect(source.ompVersion).toBe("17.0.5"); - expect(source.ompTag).toBe("t4code-17.0.5-appserver-6"); + expect(source.ompTag).toBe("t4code-17.0.5-appserver-7"); expect(source.ompUrl).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-6", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-7", ); }); diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index c4cea9e..c3c6bb1 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -41,7 +41,8 @@ export function AppShell() { (state) => state.sessionManualOrderByProjectId, ); const projectExpandedById = useWorkspace((state) => state.projectExpandedById); - const dismissedEmptyProjectIds = useWorkspace((state) => state.dismissedEmptyProjectIds); + const hiddenProjectIds = useWorkspace((state) => state.hiddenProjectIds); + const projectAliasById = useWorkspace((state) => state.projectAliasById); const lastVisitedAtBySessionId = useWorkspace((state) => state.lastVisitedAtBySessionId); const lastSeenAttentionOutcomeBySessionKey = useWorkspace( (state) => state.lastSeenAttentionOutcomeBySessionKey, @@ -58,20 +59,22 @@ export function AppShell() { projectExpandedById, lastVisitedAtBySessionId, "current", - dismissedEmptyProjectIds, + hiddenProjectIds, { filter: railFilter, query: railQuery, sort: railSort, projectManualOrder, sessionManualOrderByProjectId, + projectAliasById, }, ), [ shellData, projectExpandedById, lastVisitedAtBySessionId, - dismissedEmptyProjectIds, + hiddenProjectIds, + projectAliasById, railFilter, railQuery, railSort, @@ -93,6 +96,7 @@ export function AppShell() { sort: railSort, projectManualOrder, sessionManualOrderByProjectId, + projectAliasById, }, ), [ @@ -104,6 +108,7 @@ export function AppShell() { railSort, projectManualOrder, sessionManualOrderByProjectId, + projectAliasById, ], ); const allCurrentGroups = useMemo( @@ -113,14 +118,14 @@ export function AppShell() { projectExpandedById, lastVisitedAtBySessionId, "current", - dismissedEmptyProjectIds, - { sort: railSort, projectManualOrder, sessionManualOrderByProjectId }, + {}, + { sort: railSort, projectManualOrder, sessionManualOrderByProjectId, projectAliasById }, ), [ shellData, projectExpandedById, lastVisitedAtBySessionId, - dismissedEmptyProjectIds, + projectAliasById, railSort, projectManualOrder, sessionManualOrderByProjectId, @@ -134,7 +139,7 @@ export function AppShell() { lastVisitedAtBySessionId, "archived", {}, - { sort: railSort, projectManualOrder, sessionManualOrderByProjectId }, + { sort: railSort, projectManualOrder, sessionManualOrderByProjectId, projectAliasById }, ), [ shellData, @@ -143,6 +148,7 @@ export function AppShell() { railSort, projectManualOrder, sessionManualOrderByProjectId, + projectAliasById, ], ); const allSessionGroups = useMemo( @@ -163,24 +169,10 @@ export function AppShell() { }).urgentCount, [lastSeenAttentionOutcomeBySessionKey, runtimeSnapshot], ); - const hiddenEmptyProjectIds = useMemo(() => { - const currentProjectIds = new Set( - shellData.sessions - .filter((session) => session.archivedAt === undefined) - .map((session) => session.projectId), - ); - const hostById = new Map(shellData.hosts.map((host) => [host.id, host])); - return new Set( - shellData.projects - .filter( - (project) => - dismissedEmptyProjectIds[project.id] === true && - !currentProjectIds.has(project.id) && - hostById.get(project.hostId)?.sessionInventoryTruncated !== true, - ) - .map((project) => project.id), - ); - }, [dismissedEmptyProjectIds, shellData]); + const hiddenProjectIdSet = useMemo( + () => new Set(Object.keys(hiddenProjectIds).filter((id) => hiddenProjectIds[id] === true)), + [hiddenProjectIds], + ); // Desktop mode: start the runtime once. StrictMode's doubled effect and // HMR remounts are safe — start is idempotent on a global singleton. @@ -250,13 +242,14 @@ export function AppShell() { state.projectExpandedById, state.lastVisitedAtBySessionId, state.sessionListView, - state.dismissedEmptyProjectIds, + state.hiddenProjectIds, { filter: state.railFilter, query: state.railQuery, sort: state.railSort, projectManualOrder: state.projectManualOrder, sessionManualOrderByProjectId: state.sessionManualOrderByProjectId, + projectAliasById: state.projectAliasById, }, ), ); @@ -319,7 +312,9 @@ export function AppShell() {
!hiddenProjectIdSet.has(group.project.id), + )} onExpand={(projectId) => { const state = workspaceStore.getState(); state.setRailCollapsed(false); @@ -335,7 +330,7 @@ export function AppShell() { archivedCount={archivedCount} currentCount={currentCount} groups={groups} - hiddenEmptyProjectIds={hiddenEmptyProjectIds} + hiddenProjectIds={hiddenProjectIdSet} nowMs={nowMs} pinnedSessionGroups={allSessionGroups} view={sessionListView} @@ -391,7 +386,7 @@ export function AppShell() { archivedCount={archivedCount} currentCount={currentCount} groups={groups} - hiddenEmptyProjectIds={hiddenEmptyProjectIds} + hiddenProjectIds={hiddenProjectIdSet} nowMs={nowMs} pinnedSessionGroups={allSessionGroups} view={sessionListView} diff --git a/apps/web/src/components/Rail.tsx b/apps/web/src/components/Rail.tsx index 17591cd..a1d7c1f 100644 --- a/apps/web/src/components/Rail.tsx +++ b/apps/web/src/components/Rail.tsx @@ -28,6 +28,9 @@ import { ChevronDown, ChevronRight, CircleStop, + CheckCheck, + EyeOff, + FolderSearch, Folder, Inbox, LayoutList, @@ -59,6 +62,7 @@ import { flattenProjectGroups, formatRelativeTime, moveIdInManualOrder, + moveIdToManualIndex, type ProjectGroup, type RailFilter, type RailOrganization, @@ -71,6 +75,8 @@ import { archiveLiveSession, deleteLiveSession, managementCommandSupport, + projectRevealSupport, + revealLiveProject, renameLiveSession, restoreLiveSession, sessionCreateSupport, @@ -112,6 +118,7 @@ function SessionRowItem({ canMoveUp, canMoveDown, onMove, + onDrop, }: { row: SessionRow; active: boolean; @@ -123,6 +130,7 @@ function SessionRowItem({ canMoveUp?: boolean; canMoveDown?: boolean; onMove?: (direction: -1 | 1) => void; + onDrop?: (sourceId: string) => void; }) { const navigate = useNavigate(); const snapshot = useDesktopRuntimeSnapshot(); @@ -266,10 +274,34 @@ function SessionRowItem({ ); return ( -
+
{ + if (!manual) return; + event.stopPropagation(); + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDragStart={(event) => { + if (!manual) return; + event.stopPropagation(); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", `session:${session.id}`); + }} + onDrop={(event) => { + if (!manual || onDrop === undefined) return; + event.stopPropagation(); + event.preventDefault(); + const value = event.dataTransfer.getData("text/plain"); + if (value.startsWith("session:")) onDrop(value.slice("session:".length)); + }} + >
+
+ + { + workspaceStore.getState().setSessionPinned(session.id, !pinned); + onAnnounce(`${session.title} ${pinned ? "unpinned" : "pinned"}.`); + }} + type="button" + > + {pinned ? ( + + + { + const available = archived + ? restoreSupport.supported + : archiveSupport.supported; + if (!available || pending !== null) return; + void runAction(archived ? "restore" : "archive"); + }} + title={(archived ? restoreSupport.reason : archiveSupport.reason) ?? undefined} + type="button" + > + {pending === (archived ? "restore" : "archive") ? ( + + ) : archived ? ( + +
void; + onDrop: (sourceId: string) => void; onPin: () => void; + onAnnounce: (message: string) => void; view: SessionListView; }) { const navigate = useNavigate(); const snapshot = useDesktopRuntimeSnapshot(); const controller = desktopRuntime(); const [pending, setPending] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [renameValue, setRenameValue] = useState(group.displayName); const [menuOpen, setMenuOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [error, setError] = useState(null); @@ -692,6 +793,10 @@ function ProjectHeaderRow({ snapshot !== null && address !== null && snapshot.targets.get(address.targetId)?.kind === "local"; + const revealSupport = + snapshot !== null && address !== null + ? projectRevealSupport(snapshot, address) + : { supported: false, reason: "Connect to this host to reveal the project" }; const configuredLocalProfiles = projectIsLocal && snapshot !== null ? [...snapshot.targets.values()].filter((target) => target.kind === "local") @@ -743,6 +848,63 @@ function ProjectHeaderRow({ const inventoryTruncated = group.host.sessionInventoryTruncated === true; const showShortcutAction = emptyCurrentProject || (view === "archived" && shortcutHidden); + const markAllRead = () => { + const visits = Object.fromEntries( + group.sessions.map(({ session }) => [ + session.id, + session.latestTurnCompletedAt ?? session.updatedAt, + ]), + ); + workspaceStore.getState().markSessionsVisited(visits); + onAnnounce(`Marked all sessions in ${group.displayName} as read.`); + setMenuOpen(false); + }; + + const archiveAll = async () => { + if (controller === null || snapshot === null || pending) return; + const candidates = group.sessions.flatMap(({ session }) => { + const sessionAddress = resolveLiveSession(snapshot, session.id); + if (sessionAddress === null) return []; + const support = managementCommandSupport(snapshot, sessionAddress, "session.archive"); + return support.supported ? [{ session, address: sessionAddress }] : []; + }); + if (candidates.length === 0) { + setError("No sessions in this project can be archived right now."); + return; + } + setPending(true); + setError(null); + let completed = 0; + try { + for (const candidate of candidates) { + await archiveLiveSession(controller, candidate.address); + completed += 1; + } + onAnnounce(`Archived ${completed} sessions in ${group.displayName}.`); + setMenuOpen(false); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Project archive failed."; + setError(`${completed} archived before the operation stopped. ${message}`); + } finally { + setPending(false); + } + }; + + const revealProject = async () => { + if (controller === null || address === null || pending || !revealSupport.supported) return; + setPending(true); + setError(null); + try { + await revealLiveProject(controller, address); + onAnnounce(`Revealed ${group.displayName} in Finder.`); + setMenuOpen(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Project reveal failed."); + } finally { + setPending(false); + } + }; + const handleCreate = useCallback( async (targetAddress: NonNullable) => { if (!canCreate || controller === null) return; @@ -763,14 +925,35 @@ function ProjectHeaderRow({ ); return ( -
+
{ + if (!manual) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDragStart={(event) => { + if (!manual) return; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", `project:${group.project.id}`); + }} + onDrop={(event) => { + if (!manual) return; + event.preventDefault(); + const value = event.dataTransfer.getData("text/plain"); + if (value.startsWith("project:")) onDrop(value.slice("project:".length)); + }} + >
0 ? `, ${group.unreadCount} unread` : ""}`} + aria-label={`${group.displayName}, ${group.sessions.length} ${group.sessions.length === 1 ? "session" : "sessions"}${group.unreadCount > 0 ? `, ${group.unreadCount} unread` : ""}`} className="flex min-h-11 min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-1 text-left outline-none transition-colors duration-(--motion-duration-fast) hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background sm:min-h-0" data-project-disclosure={group.project.id} onClick={() => @@ -787,7 +970,7 @@ function ProjectHeaderRow({ )} /> - {group.project.name} + {group.displayName} {group.host.kind === "remote" && (
); } @@ -1112,9 +1418,11 @@ const RAIL_FILTERS: ReadonlyArray<{ readonly value: RailFilter; readonly label: ]; function RailOptionsMenu({ + hiddenGroups, organization, sort, }: { + hiddenGroups: readonly ProjectGroup[]; organization: RailOrganization; sort: RailSort; }) { @@ -1154,7 +1462,7 @@ function RailOptionsMenu({ Organize sidebar - {option(organization === "by-project", "By working folder", () => + {option(organization === "by-project", "By project", () => workspaceStore.getState().setRailOrganization("by-project"), )} {option(organization === "flat", "In one list", () => @@ -1171,6 +1479,28 @@ function RailOptionsMenu({ {option(sort === "manual", "Manual order", () => workspaceStore.getState().setRailSort("manual"), )} + {hiddenGroups.length > 0 && ( + <> +
+

+ Hidden projects +

+ {hiddenGroups.map((group) => ( + + ))} + + )} @@ -1181,7 +1511,7 @@ function RailOptionsMenu({ export function Rail({ allGroups, groups, - hiddenEmptyProjectIds, + hiddenProjectIds, nowMs, pinnedSessionGroups, view, @@ -1191,7 +1521,7 @@ export function Rail({ }: { allGroups: readonly ProjectGroup[]; groups: readonly ProjectGroup[]; - hiddenEmptyProjectIds: ReadonlySet; + hiddenProjectIds: ReadonlySet; nowMs: number; pinnedSessionGroups: readonly ProjectGroup[]; view: SessionListView; @@ -1232,8 +1562,12 @@ export function Rail({ }); }, [pinnedSessionIds, pinnedSourceEntries]); const pinnedGroups = useMemo( - () => allGroups.filter((group) => pinnedProjectIds[group.project.id] === true), - [allGroups, pinnedProjectIds], + () => + allGroups.filter( + (group) => + pinnedProjectIds[group.project.id] === true && !hiddenProjectIds.has(group.project.id), + ), + [allGroups, hiddenProjectIds, pinnedProjectIds], ); const matchCount = flatEntries.length; @@ -1265,6 +1599,34 @@ export function Rail({ ); }; + const dropProject = (sourceId: string, targetId: string) => { + const visibleIds = groups.map((group) => group.project.id); + workspaceStore + .getState() + .setProjectManualOrder( + moveIdToManualIndex(projectManualOrder, visibleIds, sourceId, targetId), + ); + }; + + const dropSession = ( + projectId: string, + visibleIds: readonly string[], + sourceId: string, + targetId: string, + ) => { + workspaceStore + .getState() + .setSessionManualOrder( + projectId, + moveIdToManualIndex( + sessionManualOrderByProjectId[projectId] ?? [], + visibleIds, + sourceId, + targetId, + ), + ); + }; + const dismissProject = (group: ProjectGroup) => { const disclosures = [ ...(navRef.current?.querySelectorAll("[data-project-disclosure]") ?? []), @@ -1274,9 +1636,9 @@ export function Rail({ ); const focusTarget = disclosures[currentIndex + 1] ?? disclosures[currentIndex - 1] ?? navRef.current; - workspaceStore.getState().setEmptyProjectDismissed(group.project.id, true); + workspaceStore.getState().setProjectHidden(group.project.id, true); setAnnouncement( - `Removed ${group.project.name} from Working folders. The folder and OMP sessions are unchanged.`, + `Removed ${group.displayName} from Projects. The folder and OMP sessions are unchanged.`, ); requestAnimationFrame(() => { const target = focusTarget?.isConnected ? focusTarget : navRef.current; @@ -1297,7 +1659,11 @@ export function Rail({

Sessions

{matchCount} matches - + hiddenProjectIds.has(group.project.id))} + organization={organization} + sort={sort} + />