Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.0.32",
"version": "0.0.33",
"private": true,
"type": "module",
"main": "dist-electron/main.cjs",
Expand Down
89 changes: 0 additions & 89 deletions apps/desktop/src/electron/ElectronDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,70 +99,6 @@ describe("ElectronDialog", () => {
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("returns false without opening a confirm dialog for empty messages", () =>
Effect.gen(function* () {
const dialog = yield* ElectronDialog.ElectronDialog;

const result = yield* dialog.confirm({
message: " ",
owner: Option.none(),
});

assert.isFalse(result);
assert.equal(showMessageBoxMock.mock.calls.length, 0);
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("opens a confirm dialog for the owner window", () =>
Effect.gen(function* () {
const owner = { id: 1 } as BrowserWindow;
showMessageBoxMock.mockResolvedValue({ response: 1 });
const dialog = yield* ElectronDialog.ElectronDialog;

const result = yield* dialog.confirm({
message: "Delete worktree?",
owner: Option.some(owner),
});

assert.isTrue(result);
assert.deepEqual(showMessageBoxMock.mock.calls[0], [
owner,
{
type: "question",
buttons: ["No", "Yes"],
defaultId: 0,
cancelId: 0,
noLink: true,
message: "Delete worktree?",
},
]);
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("opens an app-level confirm dialog when there is no owner window", () =>
Effect.gen(function* () {
showMessageBoxMock.mockResolvedValue({ response: 0 });
const dialog = yield* ElectronDialog.ElectronDialog;

const result = yield* dialog.confirm({
message: "Delete worktree?",
owner: Option.none(),
});

assert.isFalse(result);
assert.deepEqual(showMessageBoxMock.mock.calls[0], [
{
type: "question",
buttons: ["No", "Yes"],
defaultId: 0,
cancelId: 0,
noLink: true,
message: "Delete worktree?",
},
]);
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("preserves folder picker request context and cause", () =>
Effect.gen(function* () {
const cause = new Error("folder picker failed");
Expand All @@ -188,31 +124,6 @@ describe("ElectronDialog", () => {
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("preserves confirmation request context and cause", () =>
Effect.gen(function* () {
const cause = new Error("confirmation failed");
const owner = { id: 9 } as BrowserWindow;
showMessageBoxMock.mockRejectedValue(cause);
const dialog = yield* ElectronDialog.ElectronDialog;

const error = yield* Effect.flip(
dialog.confirm({
owner: Option.some(owner),
message: " Confirm removal? ",
}),
);

assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError);
assert.strictEqual(error.ownerWindowId, 9);
assert.strictEqual(error.promptLength, "Confirm removal?".length);
assert.notProperty(error, "promptMessage");
assert.strictEqual(error.cause, cause);
assert.include(error.message, "window 9");
assert.notInclude(error.message, "Confirm removal?");
assert.notInclude(error.message, cause.message);
}).pipe(Effect.provide(dialogLayer)),
);

it.effect("preserves message box request context and cause", () =>
Effect.gen(function* () {
const cause = new Error("message box failed");
Expand Down
58 changes: 0 additions & 58 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import type { DesktopApplicationSelection } from "@t3tools/contracts";

import * as MacApplicationIcon from "./MacApplicationIcon.ts";

const CONFIRM_BUTTON_INDEX = 1;

export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(
"ElectronDialogPickFolderError",
{
Expand Down Expand Up @@ -56,20 +54,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<Electr
}
}

export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(
"ElectronDialogConfirmError",
{
ownerWindowId: Schema.NullOr(Schema.Number),
promptLength: Schema.Number,
cause: Schema.Defect(),
},
) {
override get message(): string {
const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`;
}
}

export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(
"ElectronDialogShowMessageBoxError",
{
Expand Down Expand Up @@ -104,7 +88,6 @@ export const ElectronDialogError = Schema.Union([
ElectronDialogPickFolderError,
ElectronDialogPickApplicationError,
ElectronDialogPickFilesError,
ElectronDialogConfirmError,
ElectronDialogShowMessageBoxError,
ElectronDialogShowErrorBoxError,
]);
Expand All @@ -126,11 +109,6 @@ export interface ElectronDialogPickFilesInput {
readonly filters: readonly Electron.FileFilter[];
}

export interface ElectronDialogConfirmInput {
readonly owner: Option.Option<Electron.BrowserWindow>;
readonly message: string;
}

export class ElectronDialog extends Context.Service<
ElectronDialog,
{
Expand All @@ -146,9 +124,6 @@ export class ElectronDialog extends Context.Service<
readonly pickFiles: (
input: ElectronDialogPickFilesInput,
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
readonly confirm: (
input: ElectronDialogConfirmInput,
) => Effect.Effect<boolean, ElectronDialogConfirmError>;
readonly showMessageBox: (
options: Electron.MessageBoxOptions,
) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
Expand Down Expand Up @@ -267,39 +242,6 @@ export const make = Effect.gen(function* () {
iconDataUrl,
});
}),
confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {
const normalizedMessage = input.message.trim();
if (normalizedMessage.length === 0) {
return false;
}

const options = {
type: "question" as const,
buttons: ["No", "Yes"],
defaultId: 0,
cancelId: 0,
noLink: true,
message: normalizedMessage,
};
const ownerWindowId = Option.match(input.owner, {
onNone: () => null,
onSome: (owner) => owner.id,
});
const result = yield* Effect.tryPromise({
try: () =>
Option.match(input.owner, {
onNone: () => Electron.dialog.showMessageBox(options),
onSome: (owner) => Electron.dialog.showMessageBox(owner, options),
}),
catch: (cause) =>
new ElectronDialogConfirmError({
ownerWindowId,
promptLength: normalizedMessage.length,
cause,
}),
});
return result.response === CONFIRM_BUTTON_INDEX;
}),
showMessageBox: (options) =>
Effect.tryPromise({
try: () => Electron.dialog.showMessageBox(options),
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
setUpdateChannel,
} from "./methods/updates.ts";
import {
confirm,
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
Expand Down Expand Up @@ -86,7 +85,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"

yield* ipc.handle(pickFolder);
yield* ipc.handle(pickThemeFiles);
yield* ipc.handle(confirm);
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const CONFIRM_CHANNEL = "desktop:confirm";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
Expand Down
13 changes: 0 additions & 13 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
}),
});

export const confirm = DesktopIpc.makeIpcMethod({
channel: IpcChannels.CONFIRM_CHANNEL,
payload: Schema.String,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) {
const dialog = yield* ElectronDialog.ElectronDialog;
const electronWindow = yield* ElectronWindow.ElectronWindow;
return yield* electronWindow.focusedMainOrFirst.pipe(
Effect.flatMap((owner) => dialog.confirm({ owner, message })),
);
}),
});

export const setTheme = DesktopIpc.makeIpcMethod({
channel: IpcChannels.SET_THEME_CHANNEL,
payload: DesktopThemeSchema,
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,18 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin
function* (
config: ShellEnvironmentConfig,
): Effect.fn.Return<void, never, ChildProcessSpawner.ChildProcessSpawner> {
const noProfile = yield* readWindowsEnvironment(["PATH"], { loadProfile: false });
const profile = yield* readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, {
loadProfile: true,
});
// Concurrent, not sequential: these two probes are independent (only their
// results are combined below) and each spawns its own PowerShell. Run in
// series they sit at offset 0 of desktop.startup, before anything else, and
// launch traces measured them at 2718ms then 2066ms — the entire 4.8s
// startup span, of which desktop.bootstrap is ~30ms.
const [noProfile, profile] = yield* Effect.all(
[
readWindowsEnvironment(["PATH"], { loadProfile: false }),
readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }),
],
{ concurrency: 2 },
);
const mergedPath = mergePaths("win32", [
trimNonEmpty(profile.PATH),
trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.succeed(Option.none()),
pickApplication: () => Effect.succeed(Option.none()),
pickFiles: () => Effect.succeed([]),
confirm: () => Effect.succeed(false),
showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
showErrorBox: () => Effect.void,
} satisfies ElectronDialog.ElectronDialog["Service"]);
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reani
import { useThemeColor } from "../../lib/useThemeColor";
import { useFontFamily } from "../../lib/useFontFamily";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
import {
hasNativeSelectableMarkdownText,
SelectableMarkdownText,
Expand Down Expand Up @@ -878,6 +879,12 @@ function renderFeedEntry(
const attachments = message.attachments ?? [];
const previewAttachments = entry.previewAttachments ?? [];
const hasReviewCommentContext = message.text.includes("<review_comment");
// A bubble that sizes itself from its content cannot lay out a block whose
// intrinsic width overflows `maxWidth`: Android positions the bubble's
// children during the unclamped pass and never moves them once the width
// is clamped, so the paragraphs around the block end up drawn on top of
// each other. Pinning the width removes that pass.
const hasWideBlock = hasWideMarkdownBlock(message.text);
const assistantTurnStillInProgress =
message.role === "assistant" &&
props.unsettledTurnId !== null &&
Expand All @@ -900,7 +907,11 @@ function renderFeedEntry(
style={{
backgroundColor: userBubbleColor,
maxWidth: props.userBubbleMaxWidth,
...(hasReviewCommentContext ? { width: props.reviewCommentBubbleWidth } : null),
...(hasReviewCommentContext
? { width: props.reviewCommentBubbleWidth }
: hasWideBlock
? { width: props.userBubbleMaxWidth }
: null),
}}
>
{message.text.trim().length > 0 ? (
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ function toolDetailTextLooksLikeFailure(text: string): boolean {
normalized.includes("command not found") ||
(normalized.includes("cannot find path") && normalized.includes("because it does not exist")) ||
(normalized.includes("is not recognized") && normalized.includes("the term '")) ||
normalized.includes("is not recognized as the name of a cmdlet") ||
normalized.includes("a parameter cannot be found that matches parameter name") ||
/<exited with exit code\s+[1-9]\d*\s*>/i.test(text) ||
/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) ||
/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/src/lib/wideMarkdownBlocks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vite-plus/test";

import { hasWideMarkdownBlock } from "./wideMarkdownBlocks";

describe("hasWideMarkdownBlock", () => {
it("ignores prose, inline code, and emphasis", () => {
expect(hasWideMarkdownBlock("just a message")).toBe(false);
expect(hasWideMarkdownBlock("I found it in `secteurs_intervention` earlier")).toBe(false);
expect(hasWideMarkdownBlock("a | b in a sentence")).toBe(false);
expect(hasWideMarkdownBlock("an em dash — and a rule\n\n---\n")).toBe(false);
});

it("detects fenced code blocks", () => {
expect(hasWideMarkdownBlock("before\n```\ncode\n```\nafter")).toBe(true);
expect(hasWideMarkdownBlock("before\n```ts\ncode\n```")).toBe(true);
expect(hasWideMarkdownBlock("before\n~~~\ncode\n~~~")).toBe(true);
expect(hasWideMarkdownBlock(" ```\ncode\n```")).toBe(true);
});

it("detects GFM tables", () => {
expect(hasWideMarkdownBlock("| a | b |\n| --- | --- |\n| 1 | 2 |")).toBe(true);
expect(hasWideMarkdownBlock("a | b\n:-- | --:\n1 | 2")).toBe(true);
});
});
38 changes: 38 additions & 0 deletions apps/mobile/src/lib/wideMarkdownBlocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Detects markdown that the JS renderer draws as a standalone block View
* wrapping a horizontal ScrollView — fenced code blocks and GFM tables.
*
* Those blocks report an intrinsic width equal to their widest line, which is
* effectively unbounded. A user bubble sizes itself from its content
* (`maxWidth` with no `width`), so Android lays the bubble's children out
* during the unclamped intrinsic pass — where the surrounding paragraphs
* collapse to a single line — and never repositions them once the width is
* clamped back to `maxWidth`. The result is siblings drawn on top of each
* other inside an over-tall bubble. Pinning the bubble's width removes the
* intrinsic pass entirely, which is the same reason review-comment bubbles
* already carry an explicit width.
*
* Indented (four-space) code blocks are deliberately not detected: they are
* vanishingly rare in chat input and the check would fire on ordinary nested
* list continuations.
*/

const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;

function isTableDelimiterRow(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.includes("|") || !trimmed.includes("-")) {
return false;
}
return /^[|\-: \t]+$/.test(trimmed);
}

export function hasWideMarkdownBlock(text: string): boolean {
if (FENCED_CODE_BLOCK.test(text)) {
return true;
}
if (!text.includes("|")) {
return false;
}
return text.split("\n").some(isTableDelimiterRow);
}
Loading
Loading