diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 9e7730240d17..c37b4b9d604a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -36,6 +36,7 @@ import { getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, getLogDir, + getWindowFullscreenState, listLogFiles, openLogDir, openExternal, @@ -52,6 +53,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index b822af4985f1..61de3444c23a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,8 @@ export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; +export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index dfb4f7cc6856..7a621a822cec 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,12 +1,17 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as Electron from "electron"; + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { extractWslDistroFromEnvironmentId, getLocalEnvironmentBootstraps, + getWindowFullscreenState, resolveSelectedWslLinuxPath, resolveWslPickerDistro, } from "./window.ts"; @@ -187,3 +192,19 @@ describe("resolveSelectedWslLinuxPath", () => { ); }); }); + +describe("getWindowFullscreenState", () => { + it.effect("reads the current native window state", () => { + const window = { isFullScreen: () => true } as Electron.BrowserWindow; + + return Effect.gen(function* () { + assert.isTrue(yield* getWindowFullscreenState.handler()); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + currentMainOrFirst: Effect.succeed(Option.some(window)), + }), + ), + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 8fc26b4897a7..ae973b81b590 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -56,6 +56,16 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.getWindowFullscreenState")(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.currentMainOrFirst; + return Option.isSome(window) && window.value.isFullScreen(); + }), +}); + export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 66ea2e10d543..48a50cafaf47 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -120,6 +120,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + getWindowFullscreenState: () => + ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, + onWindowFullscreenStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, fullscreen: unknown) => { + if (typeof fullscreen !== "boolean") return; + listener(fullscreen); + }; + + ipcRenderer.on(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.WINDOW_FULLSCREEN_STATE_CHANNEL, wrappedListener); + }; + }, getUpdateState: () => ipcRenderer.invoke(IpcChannels.UPDATE_GET_STATE_CHANNEL), setUpdateChannel: (channel) => ipcRenderer.invoke(IpcChannels.UPDATE_SET_CHANNEL_CHANNEL, channel), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 24cfb459d51c..01b21936eff7 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -29,7 +29,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -47,6 +47,7 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), @@ -67,10 +68,13 @@ function makeFakeBrowserWindow() { close: vi.fn(), focus: vi.fn(), isDestroyed: vi.fn(() => false), + isFullScreen: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), loadURL: vi.fn(() => Promise.resolve()), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + windowListeners.set(eventName, listener); + }), once: vi.fn(), restore: vi.fn(), setBackgroundColor: vi.fn(), @@ -89,6 +93,7 @@ function makeFakeBrowserWindow() { send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, + windowListeners, }; } @@ -348,6 +353,37 @@ describe("DesktopWindow", () => { }), ); + it.effect("publishes native macOS fullscreen changes to the renderer", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const enterFullscreen = fakeWindow.windowListeners.get("enter-full-screen"); + const leaveFullscreen = fakeWindow.windowListeners.get("leave-full-screen"); + if (!enterFullscreen || !leaveFullscreen) { + return yield* Effect.die("fullscreen listeners were not registered"); + } + + enterFullscreen(); + leaveFullscreen(); + assert.deepEqual(fakeWindow.send.mock.calls, [ + [WINDOW_FULLSCREEN_STATE_CHANNEL, true], + [WINDOW_FULLSCREEN_STATE_CHANNEL, false], + ]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("recovers when the development renderer is temporarily unreachable", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index cd9c2b0db6fe..0848703914fd 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -15,7 +15,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; const TITLEBAR_HEIGHT = 40; @@ -367,6 +367,15 @@ export const make = Effect.gen(function* () { window.setTitle(environment.displayName); }); + if (environment.platform === "darwin") { + window.on("enter-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); + }); + window.on("leave-full-screen", () => { + window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); + }); + } + let developmentLoadRetryIndex = 0; let developmentLoadRetryFiber: Fiber.Fiber | undefined; const clearDevelopmentLoadRetry = () => { diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts new file mode 100644 index 000000000000..9e1480d1de93 --- /dev/null +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; +import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; + +const TYPESCRIPT_FILE: NativeReviewDiffFile = { + id: "file-1", + path: "example.ts", + language: "typescript", + additions: 0, + deletions: 0, +}; + +function makeLine( + input: Pick, +): NativeReviewDiffRow { + return { + kind: "line", + fileId: TYPESCRIPT_FILE.id, + ...input, + }; +} + +function makeHunk(id: string): NativeReviewDiffRow { + return { + kind: "hunk", + id, + fileId: TYPESCRIPT_FILE.id, + text: "@@", + }; +} + +function highlight( + rows: ReadonlyArray, + alreadyHighlightedRowIds?: ReadonlySet, +) { + return highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine: "javascript", + firstRowIndex: 0, + lastRowIndex: rows.length - 1, + overscanRows: 0, + maxRows: 100, + alreadyHighlightedRowIds, + }); +} + +describe("highlightNativeReviewDiffVisibleRows", () => { + it("does not carry grammar state across hunk boundaries", async () => { + const exportRow = makeLine({ + id: "export-row", + content: "export async function run() {}", + change: "add", + oldLineNumber: null, + newLineNumber: 100, + }); + const rows = [ + makeHunk("hunk-1"), + makeLine({ + id: "import-open", + content: "import {", + change: "context", + oldLineNumber: 1, + newLineNumber: 1, + }), + makeLine({ + id: "import-entry", + content: " Model,", + change: "context", + oldLineNumber: 2, + newLineNumber: 2, + }), + makeHunk("hunk-2"), + exportRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([makeHunk("standalone-hunk"), exportRow]), + ]); + + expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]); + }); + + it("keeps grammar state across inline comment rows", async () => { + const openingRow = makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const closingRow = makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }); + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const commentRow: NativeReviewDiffRow = { + kind: "comment", + id: "comment-1", + fileId: TYPESCRIPT_FILE.id, + commentText: "Review note", + }; + + const [withComment, contiguous] = await Promise.all([ + highlight([openingRow, commentRow, closingRow, trailingRow]), + highlight([openingRow, closingRow, trailingRow]), + ]); + + expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId); + }); + + it("does not join unhighlighted rows across cached gaps", async () => { + const trailingRow = makeLine({ + id: "trailing-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 3, + }); + const rows = [ + makeLine({ + id: "template-open", + content: "const message = `open", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }), + makeLine({ + id: "template-close", + content: "closed`;", + change: "add", + oldLineNumber: null, + newLineNumber: 2, + }), + trailingRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows, new Set(["template-close"])), + highlight([trailingRow]), + ]); + + expect(highlighted.tokensByRowId[trailingRow.id]).toEqual( + standalone.tokensByRowId[trailingRow.id], + ); + }); + + it("keeps deletion grammar state out of addition rows", async () => { + const additionRow = makeLine({ + id: "addition-row", + content: "export const answer = 42;", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + }); + const rows = [ + makeLine({ + id: "deletion-row", + content: "const removed = `open", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + }), + additionRow, + ]; + + const [highlighted, standalone] = await Promise.all([ + highlight(rows), + highlight([additionRow]), + ]); + + expect(highlighted.tokensByRowId[additionRow.id]).toEqual( + standalone.tokensByRowId[additionRow.id], + ); + }); +}); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 6c8c957f5410..14158e61c7d6 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -56,6 +56,11 @@ interface NativeReviewDiffLineRow extends NativeReviewDiffRow { readonly content: string; } +interface IndexedNativeReviewDiffLineRow { + readonly row: NativeReviewDiffLineRow; + readonly rowIndex: number; +} + export interface NativeReviewDiffTokenChunk { readonly chunkIndex: number; readonly fileId: string; @@ -308,6 +313,58 @@ function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDi return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string"; } +function hasConsecutiveLineNumbers( + previous: number | null | undefined, + next: number | null | undefined, +): boolean { + return typeof previous === "number" && typeof next === "number" && next === previous + 1; +} + +function hasOnlyCommentRowsBetween( + rows: ReadonlyArray, + previousRowIndex: number, + nextRowIndex: number, +): boolean { + for (let rowIndex = previousRowIndex + 1; rowIndex < nextRowIndex; rowIndex += 1) { + if (rows[rowIndex]?.kind !== "comment") { + return false; + } + } + return true; +} + +function canShareGrammarContext( + previous: IndexedNativeReviewDiffLineRow, + next: IndexedNativeReviewDiffLineRow, + rows: ReadonlyArray, +): boolean { + if ( + next.row.fileId !== previous.row.fileId || + !hasOnlyCommentRowsBetween(rows, previous.rowIndex, next.rowIndex) + ) { + return false; + } + + if (previous.row.change === "delete" || next.row.change === "delete") { + return ( + previous.row.change !== "add" && + next.row.change !== "add" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) + ); + } + + if (previous.row.change === "add" || next.row.change === "add") { + return hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber); + } + + return ( + previous.row.change === "context" && + next.row.change === "context" && + hasConsecutiveLineNumbers(previous.row.oldLineNumber, next.row.oldLineNumber) && + hasConsecutiveLineNumbers(previous.row.newLineNumber, next.row.newLineNumber) + ); +} + function groupLineRowsByFileId(rows: ReadonlyArray) { const rowsByFileId = new Map(); for (const row of rows) { @@ -360,7 +417,7 @@ export async function highlightNativeReviewDiffVisibleRows( const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS; const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows); const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows); - const selectedRows: NativeReviewDiffLineRow[] = []; + const selectedRows: IndexedNativeReviewDiffLineRow[] = []; for ( let rowIndex = startIndex; @@ -374,12 +431,12 @@ export async function highlightNativeReviewDiffVisibleRows( !input.alreadyHighlightedRowIds?.has(row.id) && fileMap.has(row.fileId) ) { - selectedRows.push(row); + selectedRows.push({ row, rowIndex }); } } const tokensByRowId: Record> = {}; - let segmentRows: NativeReviewDiffLineRow[] = []; + let segmentRows: IndexedNativeReviewDiffLineRow[] = []; let segmentFile: NativeReviewDiffFile | undefined; const flushSegment = () => { @@ -389,27 +446,34 @@ export async function highlightNativeReviewDiffVisibleRows( return; } - const code = segmentRows.map((row) => row.content).join("\n"); + const code = segmentRows.map(({ row }) => row.content).join("\n"); const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme }); - segmentRows.forEach((row, rowIndex) => { + segmentRows.forEach(({ row }, rowIndex) => { tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); }); segmentRows = []; segmentFile = undefined; }; - for (const row of selectedRows) { + for (const selectedRow of selectedRows) { + const { row } = selectedRow; const file = fileMap.get(row.fileId); if (!file) { continue; } - if (segmentFile && segmentFile.id !== file.id) { + const previousRow = segmentRows.at(-1); + if ( + segmentFile && + (segmentFile.id !== file.id || + (previousRow !== undefined && + !canShareGrammarContext(previousRow, selectedRow, input.rows))) + ) { flushSegment(); } segmentFile = file; - segmentRows.push(row); + segmentRows.push(selectedRow); } flushSegment(); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index f5ab794bce76..b59ded77f4f4 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -6,6 +6,7 @@ import type { ProjectId, ThreadId, } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import * as Effect from "effect/Effect"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; @@ -71,6 +72,30 @@ export function requireProjectAbsent(input: { ); } +export function requireActiveProjectWorkspaceRootAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly workspaceRoot: string; + readonly exceptProjectId?: ProjectId; +}): Effect.Effect { + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(input.workspaceRoot); + const existingProject = input.readModel.projects.find( + (project) => + project.deletedAt === null && + normalizeProjectPathForComparison(project.workspaceRoot) === normalizedWorkspaceRoot && + project.id !== input.exceptProjectId, + ); + if (existingProject === undefined) { + return Effect.void; + } + return Effect.fail( + invariantError( + input.command.type, + `Active project '${existingProject.id}' already exists for workspace root '${normalizedWorkspaceRoot}'.`, + ), + ); +} + export function requireThread(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 64ba159c740b..a0c068407339 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -94,6 +94,117 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { }), ); + it.effect("rejects project.create for an active workspace root that already exists", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const readModel = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-existing"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-existing"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.create", + commandId: CommandId.make("cmd-project-create-duplicate-root"), + projectId: asProjectId("project-duplicate-root"), + title: "Duplicate Project", + workspaceRoot: "/tmp/project/", + createdAt: now, + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-existing' already exists for workspace root '/tmp/project'.", + ); + }), + ); + + it.effect("rejects project.meta.update when moving onto another active workspace root", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + const withFirstProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create-first"), + aggregateKind: "project", + aggregateId: asProjectId("project-first"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-first"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-first"), + metadata: {}, + payload: { + projectId: asProjectId("project-first"), + title: "First", + workspaceRoot: "/tmp/project-first", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + const readModel = yield* projectEvent(withFirstProject, { + sequence: 2, + eventId: asEventId("evt-project-create-second"), + aggregateKind: "project", + aggregateId: asProjectId("project-second"), + type: "project.created", + occurredAt: now, + commandId: CommandId.make("cmd-project-create-second"), + causationEventId: null, + correlationId: CommandId.make("cmd-project-create-second"), + metadata: {}, + payload: { + projectId: asProjectId("project-second"), + title: "Second", + workspaceRoot: "/tmp/project-second", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const failure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-update-duplicate-root"), + projectId: asProjectId("project-second"), + workspaceRoot: "/tmp/project-first", + }, + readModel, + }), + ); + + expect(failure.message).toContain( + "Active project 'project-first' already exists for workspace root '/tmp/project-first'.", + ); + }), + ); + it.effect("emits user message and turn-start-requested events for thread.turn.start", () => Effect.gen(function* () { const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9c95b42269d6..1730494ecc6b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -12,6 +12,7 @@ import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, @@ -111,6 +112,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); return { ...(yield* withEventBase({ @@ -138,6 +145,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, projectId: command.projectId, }); + if (command.workspaceRoot !== undefined) { + yield* requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: command.workspaceRoot, + exceptProjectId: command.projectId, + }); + } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 0f1a8f9d4297..455cc9199f64 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; @@ -55,11 +55,35 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { + const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; + return isMacosDesktop && typeof getWindowFullscreenState === "function" + ? getWindowFullscreenState() + : false; + }); const macosWindowControlsStyle = - isElectron && isMacPlatform(navigator.platform) + isMacosDesktop && !isWindowFullscreen ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) : undefined; + useEffect(() => { + if (!isMacosDesktop) return; + const bridge = window.desktopBridge; + if (!bridge) return; + const { getWindowFullscreenState, onWindowFullscreenStateChange } = bridge; + if ( + typeof getWindowFullscreenState !== "function" || + typeof onWindowFullscreenStateChange !== "function" + ) { + return; + } + + const unsubscribe = onWindowFullscreenStateChange(setIsWindowFullscreen); + setIsWindowFullscreen(getWindowFullscreenState()); + return unsubscribe; + }, [isMacosDesktop]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 033818e4a422..3333bead4d34 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,7 +32,7 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; +import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -60,6 +60,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; +import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkMeta, @@ -165,6 +166,24 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, } satisfies Parameters[0]; +const CHAT_MARKDOWN_REMARK_PLUGINS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ + remarkGfm, + remarkNormalizeListItemIndentation, + remarkBreaks, + remarkPreserveCodeMeta, +] satisfies NonNullable; + +const CHAT_MARKDOWN_REHYPE_PLUGINS = [ + rehypeRaw, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], +] satisfies NonNullable; + function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; @@ -1601,11 +1620,9 @@ function ChatMarkdown({ > diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index b12acf1ba40d..0c22f1702e5e 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -17,7 +17,7 @@ export function ConnectionStatusDot({ {pingClassName ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d4be985566cb..b1c9f4fc6096 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -775,7 +775,9 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr /> } > - + {terminalStatus.label} @@ -2230,7 +2232,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec @@ -3188,8 +3190,9 @@ export default function Sidebar() { return buildPhysicalToLogicalProjectKeyMap({ projects: orderedProjects, settings: projectGroupingSettings, + primaryEnvironmentId, }); - }, [orderedProjects, projectGroupingSettings]); + }, [orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3e85920d1904..55f9fbfdc044 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -149,7 +149,7 @@ export function ThreadStatusLabel({ > @@ -170,7 +170,7 @@ export function ThreadStatusLabel({ > {status.label} @@ -276,7 +276,9 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {terminalStatus.label} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9a60e088ab4e..ca1518c3512d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1115,9 +1115,9 @@ function WorkingTimelineRow({ row }: { row: Extract
- - - + + + {row.createdAt ? ( diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 7f2d63e7ad2d..2ea10a68af25 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -1,5 +1,6 @@ import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { + type EnvironmentConnectionPresentation, RelayConnectionRegistration, RelayConnectionTarget, } from "@t3tools/client-runtime/connection"; @@ -10,7 +11,7 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as Option from "effect/Option"; -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { environmentCatalog } from "~/connection/catalog"; import { cn } from "~/lib/utils"; @@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; +import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; + +export interface SavedCloudEnvironmentConnection { + readonly environmentId: EnvironmentId; + readonly connection: EnvironmentConnectionPresentation; +} export function RemoteEnvironmentRowsSkeleton() { return ( @@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() { /** * The user's T3 Connect environments from relay discovery, each with a * Connect button. The primary environment is always excluded; already-saved - * environments are hidden unless `showSavedAsConnected` renders them as - * connected instead (used by onboarding, where the full device mesh should be - * visible). + * environments are hidden unless `showSavedEnvironments` renders them with + * their live connection state (used by onboarding, where the full device mesh + * should be visible). */ export function CloudEnvironmentConnectRows({ primaryEnvironmentId, - savedEnvironmentIds, - showSavedAsConnected = false, + savedEnvironments, + showSavedEnvironments = false, empty = null, }: { readonly primaryEnvironmentId: EnvironmentId | null; - readonly savedEnvironmentIds: ReadonlyArray; - readonly showSavedAsConnected?: boolean; + readonly savedEnvironments: ReadonlyArray; + readonly showSavedEnvironments?: boolean; readonly empty?: ReactNode; }) { const environmentsState = useRelayEnvironmentDiscovery(); @@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({ const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); - const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + const savedById = new Map( + savedEnvironments.map((environment) => [environment.environmentId, environment]), + ); useEffect(() => { void refreshRelayEnvironments(); @@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({ if (result._tag === "Success") { toastManager.add({ type: "success", - title: "Environment connected", - description: `${environment.label} is available through T3 Connect.`, + title: "Environment added", + description: `Connecting to ${environment.label} through T3 Connect.`, }); return; } @@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({ const visibleEnvironments = [...environmentsState.environments.values()].filter( ({ environment }) => environment.environmentId !== primaryEnvironmentId && - (showSavedAsConnected || !savedIds.has(environment.environmentId)), + (showSavedEnvironments || !savedById.has(environment.environmentId)), ); - const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0; + const standalone = showSavedEnvironments || savedEnvironments.length === 0; if ( standalone && @@ -163,31 +172,57 @@ export function CloudEnvironmentConnectRows({ } return visibleEnvironments.map(({ environment, availability, error }) => { - const alreadyConnected = savedIds.has(environment.environmentId); + const savedEnvironment = savedById.get(environment.environmentId); + const savedConnection = savedEnvironment + ? presentSavedCloudEnvironmentConnection(savedEnvironment.connection) + : null; + const dotClassName = savedConnection + ? savedConnection.tone === "connected" + ? "bg-success" + : savedConnection.tone === "connecting" + ? "bg-warning" + : savedConnection.tone === "error" + ? "bg-destructive" + : "bg-muted-foreground/35" + : availability === "online" + ? "bg-success" + : availability === "error" + ? "bg-destructive" + : availability === "checking" + ? "bg-warning" + : "bg-muted-foreground/35"; + const statusText = savedConnection + ? savedConnection.statusText + : availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable"); return (

{environment.label}

@@ -195,21 +230,20 @@ export function CloudEnvironmentConnectRows({

- {availability === "online" - ? "Available · Relay online" - : availability === "offline" - ? "Available · Relay offline" - : availability === "checking" - ? "Available · Checking relay status…" - : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} + {statusText}

- {alreadyConnected ? ( + {savedConnection && + (savedConnection.tone === "connected" || savedConnection.tone === "connecting") ? ( ) : ( )}
diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index ec03e3459a7d..2674def9e6d4 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -1,7 +1,7 @@ import { useAuth } from "@clerk/react"; import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts"; import { CheckIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY, @@ -412,20 +412,16 @@ function OnboardingToggleRow({ function DevicesStep() { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); - const savedEnvironmentIds = useMemo( - () => - environments - .filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget") - .map((environment) => environment.environmentId), - [environments], + const savedEnvironments = environments.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", ); return (
No other environments are published to your account yet. Publish one from another device diff --git a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts new file mode 100644 index 000000000000..654c2462a6f0 --- /dev/null +++ b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.test.ts @@ -0,0 +1,55 @@ +import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import { describe, expect, it } from "vite-plus/test"; + +import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation"; + +function connection( + phase: EnvironmentConnectionPresentation["phase"], + error: string | null = null, +): EnvironmentConnectionPresentation { + return { phase, error, traceId: null }; +} + +describe("saved cloud environment connection presentation", () => { + it("only labels a live connection as connected", () => { + expect(presentSavedCloudEnvironmentConnection(connection("connected"))).toEqual({ + buttonLabel: "Connected", + statusText: "Connected", + tone: "connected", + }); + + expect(presentSavedCloudEnvironmentConnection(connection("connecting"))).toEqual({ + buttonLabel: "Connecting…", + statusText: "Connecting...", + tone: "connecting", + }); + }); + + it("surfaces a failed attempt while the supervisor reconnects", () => { + expect( + presentSavedCloudEnvironmentConnection( + connection("reconnecting", "Relay environment endpoint is unavailable."), + ), + ).toEqual({ + buttonLabel: "Reconnecting…", + statusText: + "Failed to connect. Reconnecting... Reason: Relay environment endpoint is unavailable.", + tone: "connecting", + }); + }); + + it.each([ + ["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"], + ["offline", "Offline", "Offline", "idle"], + ["available", "Not connected", "Available", "idle"], + ] as const)( + "presents %s without claiming the environment is connected", + (phase, buttonLabel, statusText, tone) => { + expect( + presentSavedCloudEnvironmentConnection( + connection(phase, phase === "error" ? "Access denied." : null), + ), + ).toEqual({ buttonLabel, statusText, tone }); + }, + ); +}); diff --git a/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts new file mode 100644 index 000000000000..f2f3395f6d54 --- /dev/null +++ b/apps/web/src/components/cloud/cloudEnvironmentConnectionPresentation.ts @@ -0,0 +1,58 @@ +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; + +export interface SavedCloudEnvironmentConnectionPresentation { + readonly buttonLabel: string; + readonly statusText: string; + readonly tone: "connected" | "connecting" | "error" | "idle"; +} + +/** + * Present the live supervisor state for an environment that is already in the + * connection catalog. Catalog membership only means the environment is saved; + * it does not mean the connection attempt succeeded. + */ +export function presentSavedCloudEnvironmentConnection( + connection: EnvironmentConnectionPresentation, +): SavedCloudEnvironmentConnectionPresentation { + switch (connection.phase) { + case "connected": + return { + buttonLabel: "Connected", + statusText: connectionStatusText(connection), + tone: "connected", + }; + case "connecting": + return { + buttonLabel: "Connecting…", + statusText: connectionStatusText(connection), + tone: "connecting", + }; + case "reconnecting": + return { + buttonLabel: "Reconnecting…", + statusText: connectionStatusText(connection), + tone: "connecting", + }; + case "error": + return { + buttonLabel: "Connection failed", + statusText: connectionStatusText(connection), + tone: "error", + }; + case "offline": + return { + buttonLabel: "Offline", + statusText: connectionStatusText(connection), + tone: "idle", + }; + case "available": + return { + buttonLabel: "Not connected", + statusText: connectionStatusText(connection), + tone: "idle", + }; + } +} diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx index ca6c2ff72359..bc89daee4595 100644 --- a/apps/web/src/components/preview/AgentBrowserCursor.tsx +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -66,7 +66,7 @@ function AgentBrowserCursorEvent(props: { {event.phase === "click" ? ( ) : null} {recording ? ( - + ) : null} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index 54a020cbf655..c7b08ad2893d 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -36,7 +36,7 @@ function describeServer(server: PreviewableServer): string { function PulsingDot() { return ( - + ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b61f2d7aa956..ab68cda71c57 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1672,18 +1672,18 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable function CloudRemoteEnvironmentRows({ primaryEnvironmentId, - savedEnvironmentIds, + savedEnvironments, }: { readonly primaryEnvironmentId: EnvironmentId | null; - readonly savedEnvironmentIds: ReadonlyArray; + readonly savedEnvironments: ReadonlyArray; }) { return hasCloudPublicConfig() ? ( } /> - ) : savedEnvironmentIds.length === 0 ? ( + ) : savedEnvironments.length === 0 ? ( ) : null; } @@ -1713,10 +1713,6 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); - const savedEnvironmentIds = useMemo( - () => savedEnvironments.map((environment) => environment.environmentId), - [savedEnvironments], - ); const savedDesktopSshEnvironmentsByAlias = useMemo( () => savedEnvironments.reduce>( @@ -3352,7 +3348,7 @@ export function ConnectionsSettings() { ))} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index aeb8440127d8..6628937c99c1 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -223,7 +223,7 @@ function Sidebar({
@@ -629,7 +629,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
{ }), ).toBe("separate"); }); + + it("dedupes stale project rows with the same environment and workspace path", () => { + const duplicate = makeProject({ + id: ProjectId.make("project-duplicate"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const primary = makeProject({ + id: ProjectId.make("project-primary"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [primary, duplicate, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.groupedProjectCount).toBe(2); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + primary.id, + remote.id, + ]); + expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ + primary.id, + duplicate.id, + remote.id, + ]); + }); + + it("prefers the fresher project row when duplicate stale rows are ordered first", () => { + const staleDuplicate = makeProject({ + id: ProjectId.make("project-stale"), + workspaceRoot: "/tmp/shared-repo/", + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleDuplicate, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]); + expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ + staleDuplicate.id, + canonical.id, + ]); + expect(snapshots[0]?.id).toBe(canonical.id); + }); + + it("dedupes stale project rows before logical grouping", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const snapshots = buildSidebarProjectSnapshots({ + projects: [staleWithoutRepositoryIdentity, canonical, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === remoteEnvironmentId ? "remote" : "primary", + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]?.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([ + canonical.id, + remote.id, + ]); + expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ + staleWithoutRepositoryIdentity.id, + canonical.id, + remote.id, + ]); + }); + + it("routes duplicate physical project keys to the winning logical group", () => { + const staleWithoutRepositoryIdentity = makeProject({ + id: ProjectId.make("project-stale"), + repositoryIdentity: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("project-canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ + projects: [staleWithoutRepositoryIdentity, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + }); + + expect(physicalToLogicalKey.get(derivePhysicalProjectKey(staleWithoutRepositoryIdentity))).toBe( + repositoryIdentity.canonicalKey, + ); + }); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b32bdf816fd6..433f94a71691 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -36,6 +36,11 @@ @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; + /* Duty-cycled indicator animations: long opacity holds with short stepped + ramps, so the compositor only produces frames while the value changes + (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + --animate-status-pulse: status-pulse 2s infinite; + --animate-status-ping: status-ping 2s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -81,6 +86,36 @@ background-position: -200% 0; } } + @keyframes status-pulse { + 0%, + 40% { + opacity: 1; + animation-timing-function: steps(6); + } + 50%, + 90% { + opacity: 0.5; + animation-timing-function: steps(6); + } + 100% { + opacity: 1; + } + } + @keyframes status-ping { + /* Burst first (immediate feedback for click ripples), then hold + invisible for the rest of the cycle. Mirrors animate-ping's + 75%-scale start. */ + 0% { + opacity: 0.9; + scale: 0.75; + animation-timing-function: steps(8); + } + 40%, + 100% { + opacity: 0; + scale: 2; + } + } } @layer base { @@ -154,11 +189,18 @@ padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); } - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: color-mix(in srgb, var(--card) 20%, transparent); } + /* Mix from --background (not --card): the lower chrome is a full-width + strip over the main view, and a card-tinted wash reads as a lighter + seam against it when no content is scrolled underneath. The backdrop + blur, not the tint, carries the frosted effect. */ + .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 20%, transparent); + } + .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 28%), @@ -170,11 +212,14 @@ backdrop-filter: blur(16px); } - .dark .chat-composer-glass, - .dark .chat-composer-lower-chrome { + .dark .chat-composer-glass { background: color-mix(in srgb, var(--card) 45%, transparent); } + .dark .chat-composer-lower-chrome { + background: color-mix(in srgb, var(--background) 45%, transparent); + } + .dark .chat-composer-glass { box-shadow: 0 18px 48px -20px rgb(0 0 0 / 60%), @@ -195,10 +240,13 @@ } @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .chat-composer-lower-chrome { + .chat-composer-glass { background: var(--card); } + + .chat-composer-lower-chrome { + background: var(--background); + } } } @@ -218,6 +266,15 @@ padding-right: max(env(safe-area-inset-right), 0px); } +/* Grain texture for chrome surfaces that float above the body (see the + --surface-grain note). Layered over the element's background-color, so it + composes with bg-* utilities. */ +@utility surface-grain { + background-image: var(--surface-grain); + background-repeat: repeat; + background-size: 256px 256px; +} + /* Suppress all transitions during theme changes */ .no-transitions, .no-transitions *, @@ -332,13 +389,19 @@ body { padding-top: max(env(safe-area-inset-top), 0px); } -body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0.035; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +/* App-chrome grain. Baked into each surface's own background (behind + content) rather than a fixed overlay on top: a full-viewport overlay + forces the compositor to re-blend every frame any animation produces, + which multiplied idle GPU cost. The overlay's 0.035 opacity lives in the + SVG rect instead. Surfaces that float over the body (e.g. the inset main + card) must opt in via the surface-grain utility, or they lose the + grain's subtle brightening and stand out against the body. */ +:root { + --surface-grain: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"); +} + +body { + background-image: var(--surface-grain); background-repeat: repeat; background-size: 256px 256px; } diff --git a/apps/web/src/markdown-list-indentation.test.tsx b/apps/web/src/markdown-list-indentation.test.tsx new file mode 100644 index 000000000000..2c04ed5fa832 --- /dev/null +++ b/apps/web/src/markdown-list-indentation.test.tsx @@ -0,0 +1,82 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + + {markdown} + , + ); +} + +describe("remarkNormalizeListItemIndentation", () => { + it("renders same-line over-indented list content as list text", () => { + const html = renderMarkdown(`why did you do this? + +- for (const step of rest.steps) { +- if (step.request.body) { +- step.request.body = ""; +- } +- }`); + + expect(html).not.toContain("
");
+    expect(html).toContain("
  • for (const step of rest.steps) {
  • "); + expect(html).toContain("
  • if (step.request.body) {
  • "); + expect(html).toContain("
  • step.request.body = "<redacted>";
  • "); + }); + + it("parses inline markdown in recovered list content", () => { + const html = renderMarkdown( + "- **important** [docs](https://example.com) use `inline code`, not ~~plain text~~", + ); + + expect(html).toContain("important"); + expect(html).toContain('docs'); + expect(html).toContain("inline code"); + expect(html).toContain("plain text"); + expect(html).not.toContain("**important**"); + }); + + it("preserves every recovered block separated by blank lines", () => { + const html = renderMarkdown(`- **first block** + + [second block](https://example.com)`); + + expect(html).toContain("first block"); + expect(html).toContain('second block'); + }); + + it("recursively normalizes lists in recovered tail blocks", () => { + const html = renderMarkdown(`- first block + + - nested block`); + + expect(html).not.toContain("
    ");
    +    expect(html).toContain("
  • nested block
  • "); + }); + + it("preserves fenced code blocks within list items", () => { + const html = renderMarkdown(`- \`\`\`ts + const value = 1; + \`\`\``); + + expect(html).toContain('
    const value = 1;');
    +  });
    +
    +  it("preserves indented code blocks that start below a list marker", () => {
    +    const html = renderMarkdown(`-
    +      const value = 1;`);
    +
    +    expect(html).toContain("
    const value = 1;");
    +  });
    +
    +  it("preserves same-line code blocks without excess indentation", () => {
    +    const html = renderMarkdown("-     const value = 1;");
    +
    +    expect(html).toContain("
    const value = 1;");
    +  });
    +});
    diff --git a/apps/web/src/markdown-list-indentation.ts b/apps/web/src/markdown-list-indentation.ts
    new file mode 100644
    index 000000000000..bd1728e4d932
    --- /dev/null
    +++ b/apps/web/src/markdown-list-indentation.ts
    @@ -0,0 +1,143 @@
    +interface MarkdownPosition {
    +  readonly start?: {
    +    readonly line?: number;
    +    readonly offset?: number;
    +  };
    +}
    +
    +interface MarkdownAstNode {
    +  readonly type: string;
    +  readonly value?: unknown;
    +  readonly position?: MarkdownPosition;
    +  children?: MarkdownAstNode[];
    +}
    +
    +interface MarkdownFile {
    +  readonly value?: unknown;
    +}
    +
    +interface MarkdownParser {
    +  parse(markdown: string): unknown;
    +}
    +
    +interface RecoveredMarkdown {
    +  readonly blocks: MarkdownAstNode[];
    +  readonly source: string;
    +}
    +
    +const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
    +
    +function isSameLineOverIndentedCode(
    +  node: MarkdownAstNode,
    +  parent: MarkdownAstNode | undefined,
    +  markdown: string,
    +): boolean {
    +  if (
    +    node.type !== "code" ||
    +    parent?.type !== "listItem" ||
    +    typeof node.value !== "string" ||
    +    !/^[\t ]/.test(node.value)
    +  ) {
    +    return false;
    +  }
    +
    +  const nodeStart = node.position?.start;
    +  const parentStart = parent.position?.start;
    +  if (
    +    nodeStart?.line === undefined ||
    +    nodeStart.offset === undefined ||
    +    parentStart?.line === undefined ||
    +    nodeStart.line !== parentStart.line
    +  ) {
    +    return false;
    +  }
    +
    +  const sourceCharacter = markdown[nodeStart.offset];
    +  return sourceCharacter !== "`" && sourceCharacter !== "~";
    +}
    +
    +function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
    +  // A text prefix forces block-looking input into a paragraph while preserving
    +  // the processor's configured inline extensions (for example, GFM syntax).
    +  // Later root children are kept as blocks so blank-line-separated content is
    +  // never discarded.
    +  const source = `${INLINE_PARSE_PREFIX}${value}`;
    +  const document = parser.parse(source) as MarkdownAstNode;
    +  const blocks = document.children;
    +  const paragraph = blocks?.[0];
    +  const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
    +  const first = children?.[0];
    +  if (
    +    !blocks ||
    +    !children ||
    +    first?.type !== "text" ||
    +    typeof first.value !== "string" ||
    +    !first.value.startsWith(INLINE_PARSE_PREFIX)
    +  ) {
    +    return { blocks: [{ type: "text", value }], source };
    +  }
    +
    +  const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
    +  return {
    +    blocks: [
    +      {
    +        ...paragraph,
    +        type: "paragraph",
    +        children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
    +      },
    +      ...blocks.slice(1),
    +    ],
    +    source,
    +  };
    +}
    +
    +function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
    +  const value = typeof node.value === "string" ? node.value.trim() : "";
    +  const recovered = parseRecoveredMarkdown(value, parser);
    +  const first = recovered.blocks[0];
    +  return {
    +    ...recovered,
    +    blocks:
    +      first && node.position
    +        ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
    +        : recovered.blocks,
    +  };
    +}
    +
    +/**
    + * CommonMark treats four or more spaces after a list marker as an indented
    + * code block. In chat output, excessive spacing is commonly accidental
    + * alignment such as `-       text`, which otherwise produces a full code card
    + * for every bullet. Only normalize blocks that retain excess indentation and
    + * start on the marker's own line; explicit fences and conventional indented
    + * blocks remain code.
    + */
    +function attachListItemIndentationNormalizer(this: MarkdownParser) {
    +  return (tree: MarkdownAstNode, file: MarkdownFile) => {
    +    if (typeof file.value !== "string") {
    +      return;
    +    }
    +    const markdown = file.value;
    +
    +    const visit = (node: MarkdownAstNode, source: string) => {
    +      if (!node.children) {
    +        return;
    +      }
    +      node.children = node.children.flatMap((child) => {
    +        if (isSameLineOverIndentedCode(child, node, source)) {
    +          const recovered = blocksFromIndentedCode(child, this);
    +          for (const block of recovered.blocks) {
    +            visit(block, recovered.source);
    +          }
    +          return recovered.blocks;
    +        }
    +        visit(child, source);
    +        return [child];
    +      });
    +    };
    +
    +    visit(tree, markdown);
    +  };
    +}
    +
    +export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
    diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts
    index 599d43143df2..737f4e1c8f5d 100644
    --- a/apps/web/src/sidebarProjectGrouping.ts
    +++ b/apps/web/src/sidebarProjectGrouping.ts
    @@ -31,16 +31,77 @@ export interface SidebarProjectSnapshot extends Project {
       remoteEnvironmentLabels: readonly string[];
     }
     
    +interface SidebarProjectGroupCandidate {
    +  readonly logicalKey: string;
    +  readonly project: Project;
    +}
    +
    +function getProjectFreshnessTime(project: Project): number {
    +  const updatedAtTime = Date.parse(project.updatedAt);
    +  if (Number.isFinite(updatedAtTime)) {
    +    return updatedAtTime;
    +  }
    +  const createdAtTime = Date.parse(project.createdAt);
    +  return Number.isFinite(createdAtTime) ? createdAtTime : 0;
    +}
    +
    +function shouldReplaceDuplicateMember(input: {
    +  existingMember: Project;
    +  candidateMember: Project;
    +  primaryEnvironmentId: EnvironmentId | null;
    +}): boolean {
    +  if (
    +    input.primaryEnvironmentId !== null &&
    +    input.existingMember.environmentId !== input.primaryEnvironmentId &&
    +    input.candidateMember.environmentId === input.primaryEnvironmentId
    +  ) {
    +    return true;
    +  }
    +
    +  const existingFreshness = getProjectFreshnessTime(input.existingMember);
    +  const candidateFreshness = getProjectFreshnessTime(input.candidateMember);
    +  if (candidateFreshness !== existingFreshness) {
    +    return candidateFreshness > existingFreshness;
    +  }
    +
    +  return input.candidateMember.id > input.existingMember.id;
    +}
    +
    +function collectProjectWinnersByPhysicalKey(input: {
    +  projects: ReadonlyArray;
    +  settings: ProjectGroupingSettings;
    +  primaryEnvironmentId: EnvironmentId | null;
    +}): Map {
    +  const winnersByPhysicalKey = new Map();
    +  for (const project of input.projects) {
    +    const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
    +    const physicalProjectKey = derivePhysicalProjectKey(project);
    +    const existing = winnersByPhysicalKey.get(physicalProjectKey);
    +    if (!existing) {
    +      winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
    +      continue;
    +    }
    +    if (
    +      shouldReplaceDuplicateMember({
    +        existingMember: existing.project,
    +        candidateMember: project,
    +        primaryEnvironmentId: input.primaryEnvironmentId,
    +      })
    +    ) {
    +      winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
    +    }
    +  }
    +  return winnersByPhysicalKey;
    +}
    +
     export function buildPhysicalToLogicalProjectKeyMap(input: {
       projects: ReadonlyArray;
       settings: ProjectGroupingSettings;
    +  primaryEnvironmentId: EnvironmentId | null;
     }): Map {
       const mapping = new Map();
    -  for (const project of input.projects) {
    -    mapping.set(
    -      derivePhysicalProjectKey(project),
    -      deriveLogicalProjectKeyFromSettings(project, input.settings),
    -    );
    +  for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) {
    +    mapping.set(physicalProjectKey, winner.logicalKey);
       }
       return mapping;
     }
    @@ -56,17 +117,17 @@ export function buildSidebarProjectSnapshots(input: {
       // legacy behavior.
       isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
     }): SidebarProjectSnapshot[] {
    +  const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input);
       const groupedMembers = new Map();
    -  for (const project of input.projects) {
    -    const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
    +  for (const { logicalKey, project } of winnersByPhysicalKey.values()) {
         const member: SidebarProjectGroupMember = {
           ...project,
           physicalProjectKey: derivePhysicalProjectKey(project),
           environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
         };
    -    const existing = groupedMembers.get(logicalKey);
    -    if (existing) {
    -      existing.push(member);
    +    const existingMembers = groupedMembers.get(logicalKey);
    +    if (existingMembers) {
    +      existingMembers.push(member);
         } else {
           groupedMembers.set(logicalKey, [member]);
         }
    @@ -109,6 +170,13 @@ export function buildSidebarProjectSnapshots(input: {
           remoteMembers.length > 0 &&
           remoteMembers.every((member) => isDesktopLocal(member.environmentId));
     
    +    // Keep duplicate (non-winning) project ids in thread lookup refs so threads
    +    // still attached to stale rows remain visible under the winning sidebar row.
    +    const physicalKeysInGroup = new Set(members.map((member) => member.physicalProjectKey));
    +    const memberProjectRefs = input.projects
    +      .filter((project) => physicalKeysInGroup.has(derivePhysicalProjectKey(project)))
    +      .map((project) => scopeProjectRef(project.environmentId, project.id));
    +
         result.push({
           ...representative,
           projectKey: logicalKey,
    @@ -124,7 +192,7 @@ export function buildSidebarProjectSnapshots(input: {
             hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
           allRemoteMembersAreDesktopLocal,
           memberProjects: members,
    -      memberProjectRefs: members.map((member) => scopeProjectRef(member.environmentId, member.id)),
    +      memberProjectRefs,
           remoteEnvironmentLabels,
         });
       }
    diff --git a/packages/client-runtime/src/state/projects.ts b/packages/client-runtime/src/state/projects.ts
    index 82a43350650f..31f5ce111991 100644
    --- a/packages/client-runtime/src/state/projects.ts
    +++ b/packages/client-runtime/src/state/projects.ts
    @@ -3,16 +3,16 @@ import {
       isUncPath,
       isWindowsAbsolutePath,
       isWindowsDrivePath,
    +  normalizeProjectPathForComparison,
    +  normalizeProjectPathForDispatch,
     } from "@t3tools/shared/path";
     
    +export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
    +
     const isWindowsPlatform = (platform: string): boolean => {
       return /^win(dows)?/i.test(platform);
     };
     
    -function isRootPath(value: string): boolean {
    -  return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
    -}
    -
     function getAbsolutePathKind(value: string): "unix" | "windows" | null {
       if (isWindowsDrivePath(value) || isUncPath(value)) {
         return "windows";
    @@ -23,20 +23,6 @@ function getAbsolutePathKind(value: string): "unix" | "windows" | null {
       return null;
     }
     
    -function trimTrailingPathSeparators(value: string): string {
    -  if (value.length === 0 || isRootPath(value)) {
    -    return value;
    -  }
    -  const trimmed =
    -    getAbsolutePathKind(value) === "unix"
    -      ? value.replace(/\/+$/g, "")
    -      : value.replace(/[\\/]+$/g, "");
    -  if (trimmed.length === 0) {
    -    return value;
    -  }
    -  return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
    -}
    -
     function preferredPathSeparator(value: string): "/" | "\\" {
       const absolutePathKind = getAbsolutePathKind(value);
       if (absolutePathKind === "windows") return "\\";
    @@ -108,10 +94,6 @@ export function isUnsupportedWindowsProjectPath(value: string, platform: string)
       return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
     }
     
    -export function normalizeProjectPathForDispatch(value: string): string {
    -  return trimTrailingPathSeparators(value.trim());
    -}
    -
     export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
       const trimmedValue = value.trim();
       if (!isExplicitRelativePath(trimmedValue) || !cwd) {
    @@ -139,14 +121,6 @@ export function resolveProjectPathForDispatch(value: string, cwd?: string | null
       );
     }
     
    -export function normalizeProjectPathForComparison(value: string): string {
    -  const normalized = normalizeProjectPathForDispatch(value);
    -  if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
    -    return normalized.replaceAll("/", "\\").toLowerCase();
    -  }
    -  return normalized;
    -}
    -
     export function findProjectByPath(
       projects: ReadonlyArray,
       candidatePath: string,
    @@ -198,7 +172,7 @@ export function ensureBrowseDirectoryPath(currentPath: string): string {
     }
     
     export function getBrowseParentPath(currentPath: string): string | null {
    -  const trimmed = trimTrailingPathSeparators(currentPath);
    +  const trimmed = normalizeProjectPathForDispatch(currentPath);
       const absolutePath = splitAbsolutePath(trimmed);
       if (absolutePath) {
         if (absolutePath.segments.length === 0) return null;
    diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
    index e5a49f7e34e0..42e32e27121c 100644
    --- a/packages/contracts/src/ipc.ts
    +++ b/packages/contracts/src/ipc.ts
    @@ -994,6 +994,8 @@ export interface DesktopBridge {
       ) => Promise;
       openExternal: (url: string) => Promise;
       onMenuAction: (listener: (action: string) => void) => () => void;
    +  getWindowFullscreenState: () => boolean;
    +  onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void;
       getUpdateState: () => Promise;
       setUpdateChannel: (channel: DesktopUpdateChannel) => Promise;
       checkForUpdate: () => Promise;
    diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts
    index 2bb2ca0238db..66887d3f2ec3 100644
    --- a/packages/shared/src/path.ts
    +++ b/packages/shared/src/path.ts
    @@ -20,3 +20,32 @@ export function isExplicitRelativePath(value: string): boolean {
         value.startsWith("..\\")
       );
     }
    +
    +function isRootPath(value: string): boolean {
    +  return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
    +}
    +
    +function trimTrailingPathSeparators(value: string): string {
    +  if (value.length === 0 || isRootPath(value)) {
    +    return value;
    +  }
    +  const trimmed = value.startsWith("/")
    +    ? value.replace(/\/+$/g, "")
    +    : value.replace(/[\\/]+$/g, "");
    +  if (trimmed.length === 0) {
    +    return value;
    +  }
    +  return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
    +}
    +
    +export function normalizeProjectPathForDispatch(value: string): string {
    +  return trimTrailingPathSeparators(value.trim());
    +}
    +
    +export function normalizeProjectPathForComparison(value: string): string {
    +  const normalized = normalizeProjectPathForDispatch(value);
    +  if (isWindowsDrivePath(normalized) || isUncPath(normalized)) {
    +    return normalized.replaceAll("/", "\\").toLowerCase();
    +  }
    +  return normalized;
    +}