Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a58e4ad
feat(web): redesign settings and setup surfaces
maria-rcks Oct 31, 2026
2918ea1
refactor(web): share diagnostics statistic tooltip
maria-rcks Oct 31, 2026
3ab4cfe
chore(web): remove obsolete lint ceiling entries
maria-rcks Oct 31, 2026
321ec49
fix(web): keep snooze fields inside narrow settings panes
maria-rcks Oct 31, 2026
b5c9dc9
fix(web): preserve room for snooze amounts on mobile
maria-rcks Oct 31, 2026
fb4c098
fix(web): give settings a floating panel shell
maria-rcks Oct 31, 2026
4880a05
fix(web): scope settings navigation surface and insets
maria-rcks Oct 31, 2026
13711b9
fix(web): match settings scrim and retain mobile scope labels
maria-rcks Oct 31, 2026
6fe11a9
fix(web): match settings content spacing and defer shortcut editors
maria-rcks Oct 31, 2026
2c534f1
perf(web): share shortcut tooltip and skip offscreen row layout
maria-rcks Oct 31, 2026
d989c1c
perf(web): defer offscreen shortcut controls until visible
maria-rcks Oct 31, 2026
82b848f
fix(web): align diagnostics overview cards with settings design
maria-rcks Oct 31, 2026
580b559
fix(web): retain shortcut subgrid alignment when deferring controls
maria-rcks Oct 31, 2026
d281326
fix(web): match settings page content to design frames
maria-rcks Oct 31, 2026
dab7bb8
fix(web): use compact project choices and browser selects
maria-rcks Oct 31, 2026
c7f1cc1
fix(server): let partial session scans search farther on refresh
maria-rcks Oct 31, 2026
958ae3c
fix(web): align system dialogs and restore guided setup
maria-rcks Oct 31, 2026
e3007c4
fix(web): keep all configured agents reachable during setup
maria-rcks Oct 31, 2026
e292d91
fix(web): keep setup actions visible and project names readable
maria-rcks Oct 31, 2026
67d5efc
fix(web): use provider tile size in publish dialog
maria-rcks Oct 31, 2026
3a22844
fix(web): align pull request linking dialog with design
maria-rcks Oct 31, 2026
dbe5dbd
fix(web): match link dialog title and action dimensions
maria-rcks Oct 31, 2026
ee347fa
fix(web): preserve intrinsic link dialog action widths
maria-rcks Oct 31, 2026
c65ca0f
fix(web): align settings rows and defer offscreen shortcut layout
maria-rcks Oct 31, 2026
2da9210
fix(web): retain the deferred shortcut grid layout
maria-rcks Oct 31, 2026
904d38c
fix(web): keep publish provider names readable in one row
maria-rcks Oct 31, 2026
1516f90
fix(web): position pull request linking within content
maria-rcks Oct 31, 2026
75e13a6
fix(web): account for both content margins when centering
maria-rcks Oct 31, 2026
a992fdb
fix(web): restore settings scope sentence and page breadcrumb
maria-rcks Oct 31, 2026
d58e18f
fix(web): wrap settings scope labels on narrow screens
maria-rcks Oct 31, 2026
26a7f9a
fix(web): settings stay dark in the Iris, Ember, Ocean and Grove them…
flamboh Nov 2, 2026
8a65ca5
fix(web): the current thread row in Lineage nests inside its card (#3…
SunkenInTime Nov 3, 2026
a96171a
fix(web): the project picker's computer icon lines up with the other …
SunkenInTime Nov 3, 2026
ad02f65
fix(web): the content pane sits evenly in the window and follows the …
SunkenInTime Nov 3, 2026
18517a5
fix(desktop): restore main titlebar insets after pane geometry
maria-rcks Nov 3, 2026
de5bfad
fix(web): keep working row hooks ahead of the setup card early return
maria-rcks Nov 5, 2026
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
21 changes: 14 additions & 7 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,23 @@ try {
// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
const clientPlatform = process.platform;

const macosMajorVersion =
clientPlatform === "darwin" ? Number.parseInt(process.getSystemVersion()) : 0;

if (clientPlatform === "darwin") {
// Native buttons ignore Chromium zoom; reserve native points even after reload.
const syncWindowControlInset = () => {
document.documentElement.style.setProperty(
"--desktop-window-controls-inset",
`${30 + 67 / webFrame.getZoomFactor()}px`,
);
const syncWindowGeometry = () => {
const rootStyle = document.documentElement.style;
const zoomFactor = webFrame.getZoomFactor();
rootStyle.setProperty("--desktop-window-controls-inset", `${90 / zoomFactor}px`);
// Measured from macOS 27 window captures; earlier releases draw other corners.
if (macosMajorVersion >= 27) {
rootStyle.setProperty("--desktop-window-corner-radius", `${31.5 / zoomFactor}px`);
rootStyle.setProperty("--desktop-window-corner-shape", "superellipse(2.12)");
}
};
window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true });
window.addEventListener("resize", syncWindowControlInset);
window.addEventListener("DOMContentLoaded", syncWindowGeometry, { once: true });
window.addEventListener("resize", syncWindowGeometry);
}

function unwrapEnsureSshEnvironmentResult(result: unknown) {
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ const clientSettings: ClientSettings = {
fontSizePrompt: 14,
fontSizeTerminal: 12,
fontSmoothing: true,
onboardingCompletedAt: null,
panelAnimationDurationMs: 0,
planModeEnabled: false,
proactivePanelsEnabled: true,
Expand Down Expand Up @@ -177,7 +176,7 @@ describe("DesktopClientSettings", () => {
const settings = yield* DesktopClientSettings.DesktopClientSettings;
const savedSettings = {
...clientSettings,
onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
fontSmoothing: false,
};
yield* settings.set(savedSettings);
const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope,
[WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope,
[WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope,
[WS_METHODS.agentSessionsImportWithProgress]: AuthOrchestrationOperateScope,
[WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope,
[WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope,
[WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope,
Expand Down
34 changes: 30 additions & 4 deletions apps/server/src/cloud/CliTokenManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary.
import * as NodeHttp from "node:http";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as Clock from "effect/Clock";
Expand All @@ -26,7 +28,10 @@ import * as HttpRouter from "effect/http/HttpRouter";
import * as HttpServerRequest from "effect/http/HttpServerRequest";
import * as HttpServerResponse from "effect/http/HttpServerResponse";

import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth";
import {
buildConnectAuthorizeRequestUrl,
connectAuthorizeRequestCode,
} from "@t3tools/shared/connectAuth";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ExternalLauncher from "../process/externalLauncher.ts";
Expand All @@ -49,16 +54,24 @@ const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5);
const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10);
const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;

function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
function formatLoopbackAuthorizationPrompt(authorizationUrl: string, code: string): string {
return [
"Open this URL to authorize T3 Connect:",
` ${authorizationUrl}`,
"",
`The browser shows this code: ${boldTerminalText(code)}`,
"",
`Press ${boldTerminalText("Enter")} to open it in your browser.`,
`No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`,
].join("\n");
}

/** "host · shell" label the hosted /connect page shows for this terminal. */
function describeCliDevice(): string {
const shell = process.env.SHELL ? NodePath.basename(process.env.SHELL) : "";
return [NodeOS.hostname(), shell].filter((part) => part.length > 0).join(" · ");
}

export type LoopbackAuthorizationResult =
| { readonly _tag: "AuthorizationCode"; readonly code: string }
| { readonly _tag: "HeadlessRequested" };
Expand Down Expand Up @@ -513,13 +526,23 @@ export const make = Effect.gen(function* () {
const metadata = yield* cloudCliOAuthConfig;
const hostedAppUrl = yield* hostedAppUrlConfig;
const { verifier, challenge, state } = yield* makePkceRequest;
const callback = yield* Deferred.make<string>();
const callback = yield* Deferred.make<string, CloudCliAuthorizationDeniedError>();
const layerCallbackRoute = HttpRouter.add(
"GET",
"/callback",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = new URL(request.originalUrl, metadata.redirectUri);
// Cancel on the hosted /connect page returns here with access_denied.
if (
url.searchParams.get("state") === state &&
url.searchParams.get("error") === "access_denied"
) {
yield* Deferred.fail(callback, new CloudCliAuthorizationDeniedError());
return HttpServerResponse.text(
"T3 Connect authorization was cancelled. You can close this tab.",
);
}
const code = url.searchParams.get("code");
if (url.searchParams.get("state") !== state || !code) {
return HttpServerResponse.text("Invalid T3 Connect authorization callback.", {
Expand Down Expand Up @@ -549,8 +572,11 @@ export const make = Effect.gen(function* () {
state,
challenge,
loopbackPort: metadata.loopbackPort,
device: describeCliDevice(),
});
yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
yield* Console.log(
formatLoopbackAuthorizationPrompt(authorizationUrl, connectAuthorizeRequestCode(state)),
);
const authorization = yield* waitForLoopbackAuthorization({
authorizationUrl,
callback: Deferred.await(callback).pipe(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export const make = Effect.gen(function* () {
threadActiveReorder: true,
threadAutoSettleOptOut: true,
threadProjectFromScratch: true,
agentSessionImportProgress: true,
threadTitleRegeneration: true,
commandOutputStreaming: true,
threadVisitedTracking: true,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.filesystemGetMetadata]: "workspace",
[WS_METHODS.agentSessionsScan]: "workspace",
[WS_METHODS.agentSessionsImport]: "workspace",
[WS_METHODS.agentSessionsImportWithProgress]: "workspace",
[WS_METHODS.assetsCreateUrl]: "workspace",
[WS_METHODS.assetsPersistChatAttachments]: "orchestration",
[WS_METHODS.attachmentsCreateUploadUrl]: "workspace",
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/project/AgentSessionImporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
type OrchestrationV2ProviderThread,
} from "@t3tools/contracts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as Clock from "effect/Clock";
import * as TestClock from "effect/testing/TestClock";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
Expand Down Expand Up @@ -248,7 +250,7 @@ const makeImporter = Effect.fn(function* (
),
);
const importer = Context.get(context, AgentSessionImporter.AgentSessionImporter);
return { run: importer.importRecentAgentThreads({ projectId }), published };
return { run: importer.importRecentAgentThreads({ projectId }), published, importer };
});

it.effect(
Expand Down Expand Up @@ -449,3 +451,29 @@ it.effect("imports under a policy ceiling in the project's default, kept under t
);
}).pipe(Effect.scoped, Effect.provide(persistence)),
);

it.effect("paces progress per import while preserving processed counts", () =>
Effect.gen(function* () {
const { importer } = yield* makeImporter(
Array.from({ length: 5 }, (_, index) => candidate(CODEX_MODEL_SELECTION, `paced-${index}`)),
TestClock.adjust(125),
);
const progress: Array<{ processedCount: number; at: number }> = [];
const report = (processedCount: number) =>
Clock.currentTimeMillis.pipe(
Effect.tap((at) =>
Effect.sync(() => {
progress.push({ processedCount, at });
}),
),
Effect.asVoid,
);
const result = yield* importer.importRecentAgentThreads({ projectId }, report);
expect(result).toEqual({ importedCount: 5, skippedCount: 0 });
expect(progress.map(({ processedCount }) => processedCount)).toEqual([1, 3, 5]);
expect(progress.slice(1).map(({ at }, index) => at - progress[index]!.at)).toEqual([250, 250]);
progress.length = 0;
yield* importer.importRecentAgentThreads({ projectId }, report);
expect(progress.map(({ processedCount }) => processedCount)).toEqual([1]);
}).pipe(Effect.scoped, Effect.provide(persistence)),
);
19 changes: 18 additions & 1 deletion apps/server/src/project/AgentSessionImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "@t3tools/contracts";
import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand All @@ -41,6 +42,8 @@ import * as ServerSettings from "../serverSettings.ts";
import * as AgentSessionScanner from "./AgentSessionScanner.ts";
import * as ProjectService from "./ProjectService.ts";

/** Onboarding import counts are a label; a few updates a second is plenty. */
const IMPORT_PROGRESS_INTERVAL_MS = 250;
const IMPORT_EVENT_PREFIX = "agent-session-import:v2";
const CLAUDE_SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
Expand Down Expand Up @@ -177,8 +180,12 @@ const make = Effect.gen(function* () {
const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository;
const settingsService = yield* ServerSettings.ServerSettingsService;
const ceiling = settingsService.managedPolicy?.limits?.maxRuntimeMode;
/**
* `onProgress` hears the running imported + skipped count at most every 250 ms.
*/
const importRecentAgentThreads = Effect.fn("importRecentAgentThreadsV2")(function* (
input: AgentSessionImportInput,
onProgress?: (processedCount: number) => Effect.Effect<void>,
) {
const project = yield* projects.getById(input.projectId).pipe(
Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })),
Expand Down Expand Up @@ -235,6 +242,16 @@ const make = Effect.gen(function* () {
const importedThreadIds = new Set<ThreadId>();
let importedCount = 0;
let skippedCount = 0;
let lastReportedAt = Number.NEGATIVE_INFINITY;
const reportProgress =
onProgress === undefined
? Effect.void
: Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
if (now - lastReportedAt < IMPORT_PROGRESS_INTERVAL_MS) return;
lastReportedAt = now;
yield* onProgress(importedCount + skippedCount);
});

yield* Stream.runForEach(outcomes, (outcome) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -446,7 +463,7 @@ const make = Effect.gen(function* () {
} else if (imported === false) {
skippedCount += 1;
}
}),
}).pipe(Effect.andThen(reportProgress)),
);

return { importedCount, skippedCount } satisfies AgentSessionImportResult;
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,59 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect(
"refreshes a partial scan with a larger record budget and keeps recovered projects",
() =>
Effect.gen(function* () {
const path = yield* Path.Path;
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const workspace = yield* makeTempDir("t3code-workspace-");
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
contents: '{"type":"summary"}\n'.repeat(1_001) + claudeSessionLine(workspace),
mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
});

yield* Effect.gen(function* () {
const scanner = yield* AgentSessionScanner.AgentSessionScanner;
const initial = yield* scanner.scan;
expect(initial.truncated).toBe(true);
expect(initial.candidates).toEqual([]);
const recovered = yield* scanner.scan;
expect(recovered.truncated).not.toBe(true);
expect(recovered.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
const refreshed = yield* scanner.scan;
expect(refreshed.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
}).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })));
}),
);

it.effect("refreshes a partial scan with a larger total byte budget", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const workspace = yield* makeTempDir("t3code-workspace-");
const record = claudeSessionLine(workspace);
const contents = " ".repeat(1024 * 1024 - record.length) + record;
for (let index = 0; index < 65; index += 1) {
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug", `${index}.jsonl`),
contents,
mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
});
}
yield* Effect.gen(function* () {
const scanner = yield* AgentSessionScanner.AgentSessionScanner;
expect((yield* scanner.scan).truncated).toBe(true);
const recovered = yield* scanner.scan;
expect(recovered.truncated).not.toBe(true);
expect(recovered.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
}).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })));
}),
);

it.effect("reads a complete transcript record at the exact chunk boundary", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down
Loading
Loading