| author | |
| committer | |
| log | fec606f9ae524277ef1f6886e614f32d9e15e36e |
| tree | 7cf83521250c0fb16339721af6c33ebc844bbac6 |
| parent | d6e29dc9dee943b34d6b0d11441fa944c7bff7c9 |
| signature | Signed by PGP key B5690EEEBB952194 |
Build one ordered terminal metadata index per immutable snapshot. Reuse unchanged session wrappers and thread groups across consumers, with separate environment targets.
Keep numeric ordering, picker ties, attach state, and subscription lifetimes unchanged. Let retired snapshots and groups be collected.
Created with GPT-6 Astra (preview) in Codex.2 files changed, 292 insertions(+), 27 deletions(-)
apps/web/src/state/terminalSessions.test.ts created+171| ... | @@ -0,0 +1,171 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; | ||
| 3 | import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal"; | ||
| 4 | |||
| 5 | import { selectKnownTerminalSessions } from "./terminalSessions"; | ||
| 6 | |||
| 7 | vi.mock("./terminal", () => ({ terminalEnvironment: {} })); | ||
| 8 | |||
| 9 | const environmentA = EnvironmentId.make("environment-a"); | ||
| 10 | const environmentB = EnvironmentId.make("environment-b"); | ||
| 11 | const threadA = ThreadId.make("thread-a"); | ||
| 12 | const threadB = ThreadId.make("thread-b"); | ||
| 13 | |||
| 14 | function summary( | ||
| 15 | threadId: string, | ||
| 16 | terminalId: string, | ||
| 17 | changes: Partial<TerminalSummary> = {}, | ||
| 18 | ): TerminalSummary { | ||
| 19 | return { | ||
| 20 | threadId, | ||
| 21 | terminalId, | ||
| 22 | cwd: "/repo", | ||
| 23 | worktreePath: null, | ||
| 24 | status: "running", | ||
| 25 | pid: 123, | ||
| 26 | exitCode: null, | ||
| 27 | exitSignal: null, | ||
| 28 | hasRunningSubprocess: true, | ||
| 29 | label: "Terminal", | ||
| 30 | updatedAt: "2026-09-04T00:00:00.000Z", | ||
| 31 | ...changes, | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | describe("selectKnownTerminalSessions", () => { | ||
| 36 | it("preserves numeric terminal order and stable picker ties across threads", () => { | ||
| 37 | const metadata = [ | ||
| 38 | summary(threadB, "terminal-2"), | ||
| 39 | summary(threadA, "terminal-10"), | ||
| 40 | summary(threadB, "terminal-1"), | ||
| 41 | summary(threadA, "terminal-1"), | ||
| 42 | summary(threadA, "terminal-01"), | ||
| 43 | ]; | ||
| 44 | const all = selectKnownTerminalSessions(metadata, environmentA, null); | ||
| 45 | const selected = selectKnownTerminalSessions(metadata, environmentA, threadB); | ||
| 46 | expect(all.map((session) => `${session.target.threadId}/${session.target.terminalId}`)).toEqual( | ||
| 47 | [ | ||
| 48 | "thread-b/terminal-1", | ||
| 49 | "thread-a/terminal-1", | ||
| 50 | "thread-a/terminal-01", | ||
| 51 | "thread-b/terminal-2", | ||
| 52 | "thread-a/terminal-10", | ||
| 53 | ], | ||
| 54 | ); | ||
| 55 | expect(selected.map((session) => session.target.terminalId)).toEqual([ | ||
| 56 | "terminal-1", | ||
| 57 | "terminal-2", | ||
| 58 | ]); | ||
| 59 | const repeated = [...metadata]; | ||
| 60 | expect(selectKnownTerminalSessions(repeated, environmentA, null)).toBe(all); | ||
| 61 | expect(selectKnownTerminalSessions(repeated, environmentA, threadB)).toBe(selected); | ||
| 62 | |||
| 63 | const reordered = metadata.toReversed(); | ||
| 64 | expect(selectKnownTerminalSessions(reordered, environmentA, threadB)).toBe(selected); | ||
| 65 | expect( | ||
| 66 | selectKnownTerminalSessions(reordered, environmentA, null).map( | ||
| 67 | (session) => `${session.target.threadId}/${session.target.terminalId}`, | ||
| 68 | ), | ||
| 69 | ).toEqual([ | ||
| 70 | "thread-a/terminal-01", | ||
| 71 | "thread-a/terminal-1", | ||
| 72 | "thread-b/terminal-1", | ||
| 73 | "thread-b/terminal-2", | ||
| 74 | "thread-a/terminal-10", | ||
| 75 | ]); | ||
| 76 | }); | ||
| 77 | |||
| 78 | it("changes only the affected group and preserves earlier snapshots", () => { | ||
| 79 | const first = summary(threadA, "terminal-1"); | ||
| 80 | const second = summary(threadA, "terminal-2"); | ||
| 81 | const other = summary(threadB, "terminal-1"); | ||
| 82 | const metadata = [first, second, other]; | ||
| 83 | const beforeA = selectKnownTerminalSessions(metadata, environmentA, threadA); | ||
| 84 | const beforeB = selectKnownTerminalSessions(metadata, environmentA, threadB); | ||
| 85 | Object.freeze(beforeA); | ||
| 86 | Object.freeze(beforeB); | ||
| 87 | const exited = { | ||
| 88 | ...second, | ||
| 89 | status: "exited" as const, | ||
| 90 | pid: null, | ||
| 91 | exitCode: 0, | ||
| 92 | hasRunningSubprocess: false, | ||
| 93 | label: "Finished", | ||
| 94 | updatedAt: "2026-09-04T00:00:01.000Z", | ||
| 95 | }; | ||
| 96 | const next = [first, other, exited]; | ||
| 97 | const afterA = selectKnownTerminalSessions(next, environmentA, threadA); | ||
| 98 | expect(selectKnownTerminalSessions(next, environmentA, threadB)).toBe(beforeB); | ||
| 99 | expect(afterA).not.toBe(beforeA); | ||
| 100 | expect(afterA[0]).toBe(beforeA[0]); | ||
| 101 | expect(afterA[1]?.state).toMatchObject({ | ||
| 102 | status: "exited", | ||
| 103 | hasRunningSubprocess: false, | ||
| 104 | summary: { label: "Finished", pid: null, exitCode: 0 }, | ||
| 105 | }); | ||
| 106 | expect(selectRunningSubprocessTerminalIds(afterA)).toEqual(["terminal-1"]); | ||
| 107 | expect(beforeA[1]?.state).toMatchObject({ | ||
| 108 | status: "running", | ||
| 109 | hasRunningSubprocess: true, | ||
| 110 | summary: { label: "Terminal", pid: 123 }, | ||
| 111 | }); | ||
| 112 | expect(selectKnownTerminalSessions(metadata, environmentA, threadA)).toBe(beforeA); | ||
| 113 | |||
| 114 | const renamed = [first, exited, { ...other, label: "Renamed" }]; | ||
| 115 | expect(selectKnownTerminalSessions(renamed, environmentA, threadA)).toBe(afterA); | ||
| 116 | expect( | ||
| 117 | selectKnownTerminalSessions(renamed, environmentA, threadB)[0]?.state.summary?.label, | ||
| 118 | ).toBe("Renamed"); | ||
| 119 | expect(selectKnownTerminalSessions([other], environmentA, threadA)).toEqual([]); | ||
| 120 | expect(selectKnownTerminalSessions([other], environmentA, threadB)).toBe(beforeB); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("keeps environment targets separate when snapshots share the same objects", () => { | ||
| 124 | const metadata = [summary(threadA, "terminal-1")]; | ||
| 125 | const first = selectKnownTerminalSessions(metadata, environmentA, threadA); | ||
| 126 | const second = selectKnownTerminalSessions(metadata, environmentB, threadA); | ||
| 127 | expect(first[0]?.target.environmentId).toBe(environmentA); | ||
| 128 | expect(second[0]?.target.environmentId).toBe(environmentB); | ||
| 129 | expect(first).not.toBe(second); | ||
| 130 | expect(first[0]).not.toBe(second[0]); | ||
| 131 | expect(selectKnownTerminalSessions([...metadata], environmentA, threadA)).toBe(first); | ||
| 132 | expect(selectKnownTerminalSessions([...metadata], environmentB, threadA)).toBe(second); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("returns stable empty results without a connected environment or matching sessions", () => { | ||
| 136 | const metadata = [summary(threadA, "terminal-1")]; | ||
| 137 | const empty = selectKnownTerminalSessions(null, environmentA, threadA); | ||
| 138 | expect(selectKnownTerminalSessions(metadata, null, threadA)).toBe(empty); | ||
| 139 | expect(selectKnownTerminalSessions([], environmentA, null)).toBe(empty); | ||
| 140 | expect(selectKnownTerminalSessions(metadata, environmentA, threadB)).toBe(empty); | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("does not materialize unrelated raw thread identifiers", () => { | ||
| 144 | const metadata = [summary(" untrimmed-thread ", "terminal-1"), summary(threadA, "terminal-2")]; | ||
| 145 | expect( | ||
| 146 | selectKnownTerminalSessions(metadata, environmentA, threadA).map( | ||
| 147 | (session) => session.target.threadId, | ||
| 148 | ), | ||
| 149 | ).toEqual([threadA]); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it("reads the metadata array once for all thread selectors", () => { | ||
| 153 | const source = Array.from({ length: 40 }, (_, index) => | ||
| 154 | summary(`thread-${index}`, "terminal-1"), | ||
| 155 | ); | ||
| 156 | let reads = 0; | ||
| 157 | const metadata = new Proxy(source, { | ||
| 158 | get(target, property, receiver) { | ||
| 159 | if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) reads += 1; | ||
| 160 | return Reflect.get(target, property, receiver); | ||
| 161 | }, | ||
| 162 | }); | ||
| 163 | for (const item of source) { | ||
| 164 | expect( | ||
| 165 | selectKnownTerminalSessions(metadata, environmentA, ThreadId.make(item.threadId)), | ||
| 166 | ).toHaveLength(1); | ||
| 167 | } | ||
| 168 | expect(selectKnownTerminalSessions(metadata, environmentA, null)).toHaveLength(source.length); | ||
| 169 | expect(reads).toBe(source.length); | ||
| 170 | }); | ||
| 171 | }); | ||
apps/web/src/state/terminalSessions.ts+121-27| ... | @@ -6,12 +6,121 @@ import { | ... | @@ -6,12 +6,121 @@ import { |
| 6 | type KnownTerminalSession, | 6 | type KnownTerminalSession, |
| 7 | type TerminalSessionState, | 7 | type TerminalSessionState, |
| 8 | } from "@t3tools/client-runtime/state/terminal"; | 8 | } from "@t3tools/client-runtime/state/terminal"; |
| 9 | import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; | 9 | import { |
| 10 | ThreadId, | ||
| 11 | type EnvironmentId, | ||
| 12 | type TerminalAttachInput, | ||
| 13 | type TerminalSummary, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 10 | import { useMemo } from "react"; | 15 | import { useMemo } from "react"; |
| 11 | 16 | ||
| 12 | import { useEnvironmentQuery } from "./query"; | 17 | import { useEnvironmentQuery } from "./query"; |
| 13 | import { terminalEnvironment } from "./terminal"; | 18 | import { terminalEnvironment } from "./terminal"; |
| 14 | 19 | ||
| 20 | const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]); | ||
| 21 | |||
| 22 | interface TerminalMetadataIndex { | ||
| 23 | readonly all: ReadonlyArray<TerminalSummary>; | ||
| 24 | readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>; | ||
| 25 | readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>; | ||
| 26 | } | ||
| 27 | |||
| 28 | const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>(); | ||
| 29 | const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>(); | ||
| 30 | // Reuse groups a consumer still holds without keeping old group members alive | ||
| 31 | // merely because their first summary remains in a newer metadata snapshot. | ||
| 32 | const groupsByAnchor = new WeakMap< | ||
| 33 | TerminalSummary, | ||
| 34 | Map< | ||
| 35 | EnvironmentId, | ||
| 36 | { | ||
| 37 | readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>; | ||
| 38 | readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>; | ||
| 39 | } | ||
| 40 | > | ||
| 41 | >(); | ||
| 42 | |||
| 43 | function knownSession( | ||
| 44 | summary: TerminalSummary, | ||
| 45 | environmentId: EnvironmentId, | ||
| 46 | ): KnownTerminalSession { | ||
| 47 | let byEnvironment = sessionsBySummary.get(summary); | ||
| 48 | const previous = byEnvironment?.get(environmentId); | ||
| 49 | if (previous) return previous; | ||
| 50 | const session = { | ||
| 51 | target: { | ||
| 52 | environmentId, | ||
| 53 | threadId: ThreadId.make(summary.threadId), | ||
| 54 | terminalId: summary.terminalId, | ||
| 55 | }, | ||
| 56 | state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), | ||
| 57 | }; | ||
| 58 | if (!byEnvironment) { | ||
| 59 | byEnvironment = new Map(); | ||
| 60 | sessionsBySummary.set(summary, byEnvironment); | ||
| 61 | } | ||
| 62 | byEnvironment.set(environmentId, session); | ||
| 63 | return session; | ||
| 64 | } | ||
| 65 | |||
| 66 | function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex { | ||
| 67 | let index = metadataIndexes.get(metadata); | ||
| 68 | if (!index) { | ||
| 69 | const compare = new Intl.Collator(undefined, { numeric: true }).compare; | ||
| 70 | const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId)); | ||
| 71 | const byThreadId = new Map<string, TerminalSummary[]>(); | ||
| 72 | for (const summary of all) { | ||
| 73 | const group = byThreadId.get(summary.threadId); | ||
| 74 | if (group) group.push(summary); | ||
| 75 | else byThreadId.set(summary.threadId, [summary]); | ||
| 76 | } | ||
| 77 | index = { all, byThreadId, views: new Map() }; | ||
| 78 | metadataIndexes.set(metadata, index); | ||
| 79 | } | ||
| 80 | return index; | ||
| 81 | } | ||
| 82 | |||
| 83 | /** Share one ordered index per immutable snapshot without changing metadata subscriptions. */ | ||
| 84 | export function selectKnownTerminalSessions( | ||
| 85 | metadata: ReadonlyArray<TerminalSummary> | null, | ||
| 86 | environmentId: EnvironmentId | null, | ||
| 87 | threadId: ThreadId | null, | ||
| 88 | ): ReadonlyArray<KnownTerminalSession> { | ||
| 89 | if (environmentId === null || metadata === null || metadata.length === 0) { | ||
| 90 | return EMPTY_KNOWN_TERMINAL_SESSIONS; | ||
| 91 | } | ||
| 92 | const index = terminalMetadataIndex(metadata); | ||
| 93 | let views = index.views.get(environmentId); | ||
| 94 | const cached = views?.get(threadId); | ||
| 95 | if (cached) return cached; | ||
| 96 | const summaries = threadId === null ? index.all : index.byThreadId.get(threadId); | ||
| 97 | if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS; | ||
| 98 | |||
| 99 | const anchor = summaries[0]!; | ||
| 100 | const kind = threadId === null ? "all" : "thread"; | ||
| 101 | let groups = groupsByAnchor.get(anchor); | ||
| 102 | const previousGroups = groups?.get(environmentId); | ||
| 103 | const previous = previousGroups?.[kind]?.deref(); | ||
| 104 | const sessions = | ||
| 105 | previous?.length === summaries.length && | ||
| 106 | previous.every((session, index) => session.state.summary === summaries[index]) | ||
| 107 | ? previous | ||
| 108 | : summaries.map((summary) => knownSession(summary, environmentId)); | ||
| 109 | if (!groups) { | ||
| 110 | groups = new Map(); | ||
| 111 | groupsByAnchor.set(anchor, groups); | ||
| 112 | } | ||
| 113 | if (sessions !== previous) { | ||
| 114 | groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) }); | ||
| 115 | } | ||
| 116 | if (!views) { | ||
| 117 | views = new Map(); | ||
| 118 | index.views.set(environmentId, views); | ||
| 119 | } | ||
| 120 | views.set(threadId, sessions); | ||
| 121 | return sessions; | ||
| 122 | } | ||
| 123 | |||
| 15 | export function useAttachedTerminalSession(input: { | 124 | export function useAttachedTerminalSession(input: { |
| 16 | readonly environmentId: EnvironmentId | null; | 125 | readonly environmentId: EnvironmentId | null; |
| 17 | readonly terminal: TerminalAttachInput | null; | 126 | readonly terminal: TerminalAttachInput | null; |
| ... | @@ -38,11 +147,11 @@ export function useAttachedTerminalSession(input: { | ... | @@ -38,11 +147,11 @@ export function useAttachedTerminalSession(input: { |
| 38 | return EMPTY_TERMINAL_SESSION_STATE; | 147 | return EMPTY_TERMINAL_SESSION_STATE; |
| 39 | } | 148 | } |
| 40 | const summary = | 149 | const summary = |
| 41 | metadata.data?.find( | 150 | (metadata.data === null |
| 42 | (terminal) => | 151 | ? null |
| 43 | terminal.threadId === input.terminal?.threadId && | 152 | : terminalMetadataIndex(metadata.data) |
| 44 | terminal.terminalId === input.terminal?.terminalId, | 153 | .byThreadId.get(input.terminal.threadId) |
| 45 | ) ?? null; | 154 | ?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null; |
| 46 | const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); | 155 | const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); |
| 47 | return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; | 156 | return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; |
| 48 | }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); | 157 | }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); |
| ... | @@ -60,31 +169,16 @@ export function useKnownTerminalSessions(input: { | ... | @@ -60,31 +169,16 @@ export function useKnownTerminalSessions(input: { |
| 60 | input: null, | 169 | input: null, |
| 61 | }), | 170 | }), |
| 62 | ); | 171 | ); |
| 63 | return useMemo(() => { | 172 | return useMemo( |
| 64 | if (input.environmentId === null) { | 173 | () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId), |
| 65 | return []; | 174 | [input.environmentId, input.threadId, metadata.data], |
| 66 | } | 175 | ); |
| 67 | return (metadata.data ?? []) | ||
| 68 | .filter((summary) => input.threadId === null || summary.threadId === input.threadId) | ||
| 69 | .map((summary) => ({ | ||
| 70 | target: { | ||
| 71 | environmentId: input.environmentId!, | ||
| 72 | threadId: ThreadId.make(summary.threadId), | ||
| 73 | terminalId: summary.terminalId, | ||
| 74 | }, | ||
| 75 | state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), | ||
| 76 | })) | ||
| 77 | .sort((left, right) => | ||
| 78 | left.target.terminalId.localeCompare(right.target.terminalId, undefined, { | ||
| 79 | numeric: true, | ||
| 80 | }), | ||
| 81 | ); | ||
| 82 | }, [input.environmentId, input.threadId, metadata.data]); | ||
| 83 | } | 176 | } |
| 84 | 177 | ||
| 85 | export function useThreadRunningTerminalIds(input: { | 178 | export function useThreadRunningTerminalIds(input: { |
| 86 | readonly environmentId: EnvironmentId | null; | 179 | readonly environmentId: EnvironmentId | null; |
| 87 | readonly threadId: ThreadId | null; | 180 | readonly threadId: ThreadId | null; |
| 88 | }): ReadonlyArray<string> { | 181 | }): ReadonlyArray<string> { |
| 89 | return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input)); | 182 | const sessions = useKnownTerminalSessions(input); |
| 183 | return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]); | ||
| 90 | } | 184 | } |