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
14 changes: 12 additions & 2 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
);

const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
({
id: 7,
webContents: { getZoomFactor: () => zoomFactor },
}) as unknown as Electron.BrowserWindow;

describe("ElectronMenu", () => {
beforeEach(() => {
buildFromTemplateMock.mockReset();
Expand Down Expand Up @@ -70,7 +76,7 @@ describe("ElectronMenu", () => {

const electronMenu = yield* ElectronMenu.ElectronMenu;
const selectedItemId = yield* electronMenu.showContextMenu({
window: {} as Electron.BrowserWindow,
window: makeWindow(),
items: [{ id: "copy", label: "Copy" }],
position: Option.none(),
});
Expand All @@ -81,20 +87,24 @@ describe("ElectronMenu", () => {

it.effect("resolves with none when the menu closes without a click", () =>
Effect.gen(function* () {
let popupOptions: Electron.PopupOptions | undefined;
buildFromTemplateMock.mockImplementation(() => ({
popup: (options: Electron.PopupOptions) => {
popupOptions = options;
options.callback?.();
},
}));

const electronMenu = yield* ElectronMenu.ElectronMenu;
const selectedItemId = yield* electronMenu.showContextMenu({
window: {} as Electron.BrowserWindow,
window: makeWindow(2),
items: [{ id: "copy", label: "Copy" }],
position: Option.some({ x: 10.8, y: 20.2 }),
});

assert.isTrue(Option.isNone(selectedItemId));
assert.equal(popupOptions?.x, 21);
assert.equal(popupOptions?.y, 40);
assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
label: "Copy",
enabled: true,
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
return normalizedItems;
}

// Renderer positions arrive in CSS pixels; popup() expects window points, so
// page zoom must be factored in or menus drift proportionally to their
// distance from the window origin.
const normalizePosition = (
position: Option.Option<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
Option.filter(
position,
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));
({ x, y }) =>
Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
).pipe(
Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
);

export const make = Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
Expand Down Expand Up @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {

try {
const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupPosition = normalizePosition(
input.position,
input.window.webContents.getZoomFactor(),
);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAtomValue } from "@effect/atom-react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { useLocation, useNavigate } from "@tanstack/react-router";

import { isElectron } from "../env";
Expand Down Expand Up @@ -74,8 +74,6 @@ function SidebarControl() {
export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const pathname = useLocation({ select: (location) => location.pathname });
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
const isMacosDesktop = isElectron && isMacPlatform(navigator.platform);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => {
const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState;
Expand Down Expand Up @@ -113,7 +111,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {

const unsubscribe = onMenuAction((action) => {
if (action === "open-settings") {
const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current);
const isSettingsRoute = /^\/settings(\/|$)/.test(pathname);
if (!isSettingsRoute) {
void navigate({ to: "/settings" });
}
Expand All @@ -123,7 +121,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
return () => {
unsubscribe?.();
};
}, [navigate]);
}, [navigate, pathname]);

return (
<SidebarProvider className="h-dvh! min-h-0!" defaultOpen style={macosWindowControlsStyle}>
Expand Down
57 changes: 56 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts";
import {
EnvironmentId,
MessageId,
ProjectId,
ProviderInstanceId,
ThreadId,
TurnId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import type { Thread } from "../types";
Expand Down Expand Up @@ -359,6 +366,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "ready",
latestTurn: completedTurn,
latestUserMessageId: localDispatch.latestUserMessageId,
session: readySession,
hasPendingApproval: false,
hasPendingUserInput: false,
Expand All @@ -384,6 +392,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "ready",
latestTurn: newerTurn,
latestUserMessageId: localDispatch.latestUserMessageId,
session: { ...readySession, updatedAt: newerTurn.completedAt },
hasPendingApproval: false,
hasPendingUserInput: false,
Expand All @@ -410,6 +419,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "running",
latestTurn: runningTurn,
latestUserMessageId: localDispatch.latestUserMessageId,
session: {
...readySession,
status: "running",
Expand All @@ -425,6 +435,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
localDispatch,
phase: "running",
latestTurn: runningTurn,
latestUserMessageId: localDispatch.latestUserMessageId,
session: {
...readySession,
status: "running",
Expand All @@ -437,12 +448,56 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
).toBe(true);
});

it("acknowledges a steering message projected onto the current running turn", () => {
const runningTurn = {
...completedTurn,
state: "running" as const,
completedAt: null,
};
const runningSession = {
...readySession,
status: "running" as const,
activeTurnId: runningTurn.turnId,
};
const localDispatch = createLocalDispatchSnapshot(
makeThread({
latestTurn: runningTurn,
session: runningSession,
messages: [
{
id: MessageId.make("message-before-steer"),
role: "user",
text: "Initial prompt",
turnId: runningTurn.turnId,
createdAt: runningTurn.requestedAt,
updatedAt: runningTurn.requestedAt,
streaming: false,
},
],
}),
);

expect(
hasServerAcknowledgedLocalDispatch({
localDispatch,
phase: "running",
latestTurn: runningTurn,
latestUserMessageId: MessageId.make("message-steer"),
session: runningSession,
hasPendingApproval: false,
hasPendingUserInput: false,
threadError: null,
}),
).toBe(true);
});

it("acknowledges pending user interaction and errors immediately", () => {
const localDispatch = createLocalDispatchSnapshot(makeThread());
const common = {
localDispatch,
phase: "ready" as const,
latestTurn: null,
latestUserMessageId: localDispatch.latestUserMessageId,
session: null,
hasPendingApproval: false,
hasPendingUserInput: false,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export async function waitForStartedServerThread(
export interface LocalDispatchSnapshot {
startedAt: string;
preparingWorktree: boolean;
latestUserMessageId: ChatMessage["id"] | null;
latestTurnTurnId: TurnId | null;
latestTurnRequestedAt: string | null;
latestTurnStartedAt: string | null;
Expand All @@ -401,9 +402,11 @@ export function createLocalDispatchSnapshot(
): LocalDispatchSnapshot {
const latestTurn = activeThread?.latestTurn ?? null;
const session = activeThread?.session ?? null;
const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user");
return {
startedAt: new Date().toISOString(),
preparingWorktree: Boolean(options?.preparingWorktree),
latestUserMessageId: latestUserMessage?.id ?? null,
latestTurnTurnId: latestTurn?.turnId ?? null,
latestTurnRequestedAt: latestTurn?.requestedAt ?? null,
latestTurnStartedAt: latestTurn?.startedAt ?? null,
Expand All @@ -417,6 +420,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
localDispatch: LocalDispatchSnapshot | null;
phase: SessionPhase;
latestTurn: Thread["latestTurn"] | null;
latestUserMessageId: ChatMessage["id"] | null;
session: Thread["session"] | null;
hasPendingApproval: boolean;
hasPendingUserInput: boolean;
Expand All @@ -431,13 +435,22 @@ export function hasServerAcknowledgedLocalDispatch(input: {

const latestTurn = input.latestTurn ?? null;
const session = input.session ?? null;
const latestUserMessageChanged =
input.localDispatch.latestUserMessageId !== input.latestUserMessageId;
const latestTurnChanged =
input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) ||
input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) ||
input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) ||
input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null);

if (input.phase === "running") {
// Steering adds a user message to the current running turn without
// necessarily changing any of the turn timestamps. Treat that projected
// message as the server acknowledgment so the composer does not remain
// stuck in its local "Sending" state until the turn settles.
if (latestUserMessageChanged) {
return true;
}
if (!latestTurnChanged) {
return false;
}
Expand Down
Loading
Loading