Skip to content

Commit 75af679

Browse files
committed
Merge remote-tracking branch 'github/main' into t3/mention-index
# Conflicts: # apps/mobile/src/features/settings/ThreadSettingsSections.tsx # apps/web/src/components/composerContextPresentation.tsx # apps/web/src/components/pullRequest/pullRequestDetail.logic.ts # apps/web/src/lib/composerContextRecords.test.ts # packages/contracts/src/settings.ts
2 parents aeab42c + 10af8ee commit 75af679

876 files changed

Lines changed: 45524 additions & 9119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/actions/setup-ci-deps/action.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@ runs:
123123
key: ${{ steps.pnpm_verified.outputs.cache-primary-key }}
124124

125125
# A filtered install that started from the current full store adds nothing
126-
# worth a second copy of it.
126+
# worth a second copy of it. A stacked PR, based on a branch other than the
127+
# default, saves a ~1 GB store only its own reruns can read, so it only restores.
127128
- name: Save pnpm store
128129
if: >-
129130
inputs.install == 'true' &&
131+
(github.event_name != 'pull_request' || github.base_ref == github.event.repository.default_branch) &&
130132
steps.pnpm_cache.outputs.cache-hit != 'true' &&
131133
(steps.pnpm_store.outputs.namespace == 'full' ||
132134
steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json')))

‎.github/workflows/ci.yml‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,41 @@ jobs:
192192
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
193193
run: node apps/desktop/scripts/verify-preload-bundle.mjs
194194

195+
# Browser tests run source modules; this boots the production chunk graph. The
196+
# desktop build above already produced apps/web/dist, and the client waits about
197+
# 15s for a session that never comes, so this runs here instead of in Test Web,
198+
# which gates the whole run. Chromium comes from the cache Test Web shares.
199+
- name: Resolve Playwright version
200+
id: playwright_version
201+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }}
202+
run: echo "version=$(node -p "require('./apps/web/node_modules/playwright/package.json').version")" >> "$GITHUB_OUTPUT"
203+
204+
- name: Cache Chromium
205+
id: chromium_cache
206+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }}
207+
uses: actions/cache@v6
208+
with:
209+
path: ~/.cache/ms-playwright
210+
key: playwright-chromium-shell-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright_version.outputs.version }}
211+
212+
- name: Install Chromium for the built web client boot check
213+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' && steps.chromium_cache.outputs.cache-hit != 'true' }}
214+
run: node apps/web/node_modules/playwright/cli.js install --only-shell chromium
215+
216+
- name: Install missing Chromium system dependencies
217+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }}
218+
run: |
219+
browser=$(find "$HOME/.cache/ms-playwright" -name chrome-headless-shell -type f -print -quit)
220+
test -n "$browser"
221+
if ldd "$browser" | grep '=> not found'; then
222+
node apps/web/node_modules/playwright/cli.js install-deps chromium
223+
fi
224+
225+
- name: Boot the built web client
226+
if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.changes.outputs.test_web != 'false' }}
227+
timeout-minutes: 3
228+
run: node apps/web/scripts/verify-built-boot.ts
229+
195230
- name: Typecheck
196231
if: ${{ !cancelled() && steps.typecheck.outcome == 'success' }}
197232
timeout-minutes: 5
@@ -340,12 +375,6 @@ jobs:
340375
- name: Test
341376
run: vp run --filter @t3tools/web test
342377

343-
# Browser tests run source modules; this boots the production chunk graph.
344-
- name: Boot the built web client
345-
run: |
346-
vp run --filter @t3tools/web build
347-
node apps/web/scripts/verify-built-boot.ts
348-
349378
# apps/server sets `fileParallelism: false`, so its test files run strictly
350379
# one at a time. Sharding spreads them over separate runners instead of
351380
# separate workers, so no two server test files ever share a machine and the

‎.github/workflows/release-desktop.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ jobs:
449449
name: cli-${{ inputs.platform }}-${{ inputs.arch }}
450450
path: release-cli/*
451451
if-no-files-found: error
452+
# The archive is already gzip or zip; deflating it again saves under 2%.
453+
compression-level: 0
452454

453455
- name: Install Linux desktop build libraries
454456
if: inputs.platform == 'linux'
@@ -761,6 +763,8 @@ jobs:
761763
name: desktop-${{ inputs.platform }}-${{ inputs.arch }}
762764
path: release-publish/*
763765
if-no-files-found: error
766+
# Installers, zips and blockmaps are already compressed; deflating them again saves under 0.3%.
767+
compression-level: 0
764768

765769
- name: Upload resource monitor
766770
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7

‎.macroscope/ignore.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,7 @@ native/libghostty-vt/include/**
279279
# it needs. Regenerated by apps/mobile/scripts/generate-highlights.mts; review
280280
# that script and cpp/t3_highlights.c instead.
281281
apps/mobile/modules/t3-highlights/Vendor/**
282+
283+
# Nitro bindings for T3Highlights. Regenerated by `vp run generate:nitro` in
284+
# apps/mobile from modules/t3-highlights/src/T3Highlights.nitro.ts.
285+
apps/mobile/modules/t3-highlights/nitrogen/generated/**

‎apps/desktop/src/app/DesktopApp.test.ts‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
22
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
33
import * as Deferred from "effect/Deferred";
44
import * as Effect from "effect/Effect";
5+
import * as Exit from "effect/Exit";
56
import * as Layer from "effect/Layer";
67
import * as FileSystem from "effect/FileSystem";
78
import * as Path from "effect/Path";
@@ -69,14 +70,19 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
6970
import * as MacPermissions from "../permissions/MacPermissions.ts";
7071
import * as PreviewManager from "../preview/Manager.ts";
7172

72-
it.effect(
73-
"starts the backend and window while Linux registration is stalled and cancels it on shutdown",
74-
() =>
73+
it.effect.each([false, true])(
74+
"stops backends together before flushing history and cancels Linux registration, stop defect=%s",
75+
(stopDefect) =>
7576
Effect.gen(function* () {
7677
const registrationStarted = yield* Deferred.make<void>();
7778
const releaseRegistration = yield* Deferred.make<void>();
7879
const registrationStopped = yield* Deferred.make<void>();
7980
const windowStarted = yield* Deferred.make<void>();
81+
const firstStopStarted = yield* Deferred.make<void>();
82+
const secondStopStarted = yield* Deferred.make<void>();
83+
const releaseStops = yield* Deferred.make<void>();
84+
const historyStarted = yield* Deferred.make<void>();
85+
const releaseHistory = yield* Deferred.make<void>();
8086
const quitting = yield* Ref.make(false);
8187
const events: string[] = [];
8288
const record = (event: string) =>
@@ -169,7 +175,21 @@ it.effect(
169175
start: record("backend"),
170176
currentConfig: Effect.succeedSome({ preflightFailure: Option.none() }),
171177
}),
172-
list: Effect.succeed([]),
178+
list: Effect.succeed([
179+
{
180+
stop: () =>
181+
Deferred.succeed(firstStopStarted, undefined).pipe(
182+
Effect.andThen(Deferred.await(releaseStops)),
183+
Effect.andThen(stopDefect ? Effect.die("stop defect") : Effect.void),
184+
),
185+
},
186+
{
187+
stop: () =>
188+
Deferred.succeed(secondStopStarted, undefined).pipe(
189+
Effect.andThen(Deferred.await(releaseStops)),
190+
),
191+
},
192+
]),
173193
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]),
174194
Effect.provideService(DesktopServerExposure.DesktopServerExposure, {
175195
configureFromSettings: () => Effect.succeed({ mode: "local-only" }),
@@ -182,7 +202,10 @@ it.effect(
182202
start: Effect.void,
183203
} as unknown as DesktopAppActivation.DesktopAppActivation["Service"]),
184204
Effect.provideService(DesktopRendererHistory.DesktopRendererHistory, {
185-
shutdown: Effect.void,
205+
shutdown: Deferred.succeed(historyStarted, undefined).pipe(
206+
Effect.andThen(Deferred.await(releaseHistory)),
207+
Effect.asVoid,
208+
),
186209
} as DesktopRendererHistory.DesktopRendererHistory["Service"]),
187210
);
188211
const app = yield* runApp.pipe(
@@ -226,8 +249,19 @@ it.effect(
226249
yield* Deferred.await(windowStarted);
227250
assert.deepEqual(events, ["identity", "ready", "identity", "backend", "window"]);
228251
assert.isFalse(yield* Deferred.isDone(registrationStopped));
229-
yield* (yield* DesktopShutdown.DesktopShutdown).request;
230-
yield* Fiber.join(app);
252+
const shutdown = yield* DesktopShutdown.DesktopShutdown;
253+
yield* shutdown.request;
254+
yield* Deferred.await(firstStopStarted);
255+
yield* Deferred.await(secondStopStarted);
256+
assert.isFalse(yield* Deferred.isDone(historyStarted));
257+
assert.isFalse(yield* shutdown.isComplete);
258+
yield* Deferred.succeed(releaseStops, undefined);
259+
yield* Deferred.await(historyStarted);
260+
assert.isFalse(yield* shutdown.isComplete);
261+
yield* Deferred.succeed(releaseHistory, undefined);
262+
const result = yield* Fiber.await(app);
263+
assert.equal(Exit.isFailure(result), stopDefect);
264+
assert.isTrue(yield* shutdown.isComplete);
231265
assert.isTrue(yield* Deferred.isDone(registrationStopped));
232266
}).pipe(Effect.provide(DesktopShutdown.layer)),
233267
);

‎apps/desktop/src/app/DesktopApp.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import * as Cause from "effect/Cause";
22
import * as Config from "effect/Config";
3-
import * as Duration from "effect/Duration";
43
import * as Effect from "effect/Effect";
54
import * as Option from "effect/Option";
65
import * as Ref from "effect/Ref";
@@ -19,6 +18,7 @@ import * as DesktopClerk from "./DesktopClerk.ts";
1918
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
2019
import * as DesktopWindow from "../window/DesktopWindow.ts";
2120
import * as PreviewPasskeys from "../preview/Passkeys.ts";
21+
import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
2222
import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
2323
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2424
import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
@@ -160,7 +160,7 @@ const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")(
160160
const instances = yield* pool.list;
161161
yield* Effect.forEach(
162162
instances,
163-
(instance) => instance.stop({ timeout: Duration.seconds(5) }),
163+
(instance) => instance.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }),
164164
{ concurrency: "unbounded" },
165165
);
166166
},
@@ -363,6 +363,7 @@ const scopedProgram = Effect.scoped(
363363
stopAllPoolInstances().pipe(
364364
Effect.ensuring(rendererHistory.shutdown),
365365
Effect.ensuring(shutdown.markComplete),
366+
Effect.withSpan("desktop.app.shutdown"),
366367
),
367368
);
368369

‎apps/desktop/src/backend/DesktopBackendManager.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,4 +720,58 @@ describe("DesktopBackendManager", () => {
720720
}).pipe(Effect.provide(TestClock.layer())),
721721
),
722722
);
723+
724+
it.effect("the quit stop deadline waits for a backend that only exits at the force kill", () =>
725+
Effect.scoped(
726+
Effect.gen(function* () {
727+
const spawned = yield* Deferred.make<void>();
728+
const teardownStarted = yield* Deferred.make<void>();
729+
const teardownFinished = yield* Deferred.make<void>();
730+
// The real spawner sends SIGTERM, waits the grace, then SIGKILLs. A backend that
731+
// ignores SIGTERM therefore finishes tearing down just after the grace.
732+
const instance = yield* makeTestInstance({
733+
spawnerLayer: Layer.succeed(
734+
ChildProcessSpawner.ChildProcessSpawner,
735+
ChildProcessSpawner.make(() =>
736+
Effect.gen(function* () {
737+
const scope = yield* Scope.Scope;
738+
yield* Deferred.succeed(spawned, undefined);
739+
yield* Scope.addFinalizer(
740+
scope,
741+
Deferred.succeed(teardownStarted, undefined).pipe(
742+
Effect.andThen(Effect.sleep(Duration.seconds(6))),
743+
Effect.andThen(Deferred.succeed(teardownFinished, undefined)),
744+
Effect.asVoid,
745+
),
746+
);
747+
return makeProcess({
748+
exitCode: Deferred.await(teardownFinished).pipe(
749+
Effect.as(ChildProcessSpawner.ExitCode(0)),
750+
),
751+
});
752+
}),
753+
),
754+
),
755+
httpClientLayer: layerHttpClient(() => Effect.never),
756+
});
757+
yield* instance.start;
758+
yield* Deferred.await(spawned);
759+
760+
// App quit and the updater stop every backend with this deadline.
761+
const quitDone = yield* Deferred.make<void>();
762+
const quitFiber = yield* instance
763+
.stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT })
764+
.pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild);
765+
766+
yield* Deferred.await(teardownStarted);
767+
// Past the SIGTERM grace but before the force kill lands: quit must still be waiting.
768+
yield* TestClock.adjust(Duration.millis(5_500));
769+
assert.isFalse(yield* Deferred.isDone(quitDone));
770+
771+
yield* TestClock.adjust(Duration.millis(500));
772+
yield* Fiber.join(quitFiber);
773+
assert.isTrue(yield* Deferred.isDone(teardownFinished));
774+
}).pipe(Effect.provide(TestClock.layer())),
775+
),
776+
);
723777
});

‎apps/desktop/src/backend/DesktopBackendManager.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
4141
const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
4242
// Match the service launcher's grace period so backends can stop provider sessions.
4343
const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5);
44+
// Allow SIGKILL and the spawner exit wait after the SIGTERM grace so quit cannot
45+
// orphan a backend whose shutdown hangs.
46+
export const BACKEND_QUIT_STOP_TIMEOUT = Duration.sum(
47+
DEFAULT_BACKEND_TERMINATE_GRACE,
48+
Duration.seconds(2),
49+
);
4450
const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
4551
const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
4652
// Three restarts without readiness is about 2 s of backoff: past a blip, early enough to help.
@@ -306,7 +312,10 @@ const closeRun = (
306312
onNone: () => Effect.void,
307313
onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid),
308314
});
309-
const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber));
315+
const close = Scope.close(run.scope, Exit.void).pipe(
316+
Effect.withSpan("desktop.backendInstance.closeScope"),
317+
Effect.andThen(waitForFiber.pipe(Effect.withSpan("desktop.backendInstance.awaitRun"))),
318+
);
310319
const timeout = options?.timeout;
311320

312321
if (!timeout) {
@@ -567,6 +576,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
567576
yield* probeReadiness().pipe(Effect.repeat({ while: (ready) => !ready }), Effect.forkScoped);
568577

569578
const exit = yield* handle.exitCode.pipe(
579+
Effect.withSpan("desktop.backendProcess.awaitExit"),
570580
Effect.map((exitCode) => ({
571581
code: Option.some(exitCode),
572582
reason: `code=${exitCode}`,
@@ -602,6 +612,7 @@ export const runBackendProcess = Effect.fn("runBackendProcess")(function* (
602612
}).pipe(
603613
Effect.timeout(options.outputDrainTimeout ?? DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT),
604614
Effect.ignore,
615+
Effect.withSpan("desktop.backendProcess.drainOutput"),
605616
);
606617
if (Exit.isFailure(exit)) {
607618
return yield* Effect.failCause(exit.cause);

‎apps/desktop/src/ipc/channels.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission"
121121

122122
export const PREVIEW_RECORDING_INPUT_CHANNEL = "desktop:preview-recording-input";
123123

124+
export const PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL = "desktop:preview-annotation-voice-event";
125+
export const PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL = "desktop:preview-annotation-voice-state";
126+
124127
export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider-auth-callback";
125128
export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback";
126129
export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage";

‎apps/desktop/src/ipc/methods/preview.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
DesktopPreviewAnnotationSendEnabledInputSchema,
33
DesktopPreviewAnnotationThemeInputSchema,
4+
DesktopPreviewAnnotationVoiceStateSchema,
5+
DesktopPreviewPickElementInputSchema,
46
DesktopPreviewArtifactInputSchema,
57
DesktopPreviewConfigInputSchema,
68
DesktopPreviewNavigateInputSchema,
@@ -363,11 +365,21 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({
363365

364366
const pickElement = DesktopIpc.makeIpcMethod({
365367
channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
366-
payload: DesktopPreviewTabInputSchema,
368+
payload: DesktopPreviewPickElementInputSchema,
367369
result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema),
368-
handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {
370+
handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId, voice }) {
371+
const manager = yield* PreviewManager.PreviewManager;
372+
return yield* manager.pickElement(tabId, voice);
373+
}),
374+
});
375+
376+
const updateAnnotationVoice = DesktopIpc.makeIpcMethod({
377+
channel: IpcChannels.PREVIEW_ANNOTATION_VOICE_STATE_CHANNEL,
378+
payload: DesktopPreviewAnnotationVoiceStateSchema,
379+
result: Schema.Void,
380+
handler: Effect.fn("desktop.ipc.preview.updateAnnotationVoice")(function* (state) {
369381
const manager = yield* PreviewManager.PreviewManager;
370-
return yield* manager.pickElement(tabId);
382+
yield* manager.updateAnnotationVoice(state);
371383
}),
372384
});
373385

@@ -428,6 +440,7 @@ const saveRecording = DesktopIpc.makeIpcMethod({
428440

429441
export const methods = [
430442
setForwardedShortcuts,
443+
updateAnnotationVoice,
431444
createTab,
432445
closeTab,
433446
registerWebview,

0 commit comments

Comments
 (0)