Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
8 changes: 6 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
const wrappedListener = (
_event: Electron.IpcRendererEvent,
action: unknown,
sourceTabId: unknown,
) => {
if (typeof action !== "string") return;
listener(action);
listener(action, typeof sourceTabId === "string" ? sourceTabId : undefined);
};

ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener);
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,11 @@ describe("PreviewManager", () => {
]);
beforeInput({ preventDefault } as never, { ...key, key: "l", shift: false } as never);
expect(focus).toHaveBeenCalledOnce();
expect(send).toHaveBeenLastCalledWith("desktop:menu-action", "preview.focusUrl");
expect(send).toHaveBeenLastCalledWith(
"desktop:menu-action",
"preview.focusUrl",
"tab_older",
);
expect(focus.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]!);
beforeInput(
{ preventDefault } as never,
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,7 +1484,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
// DOM focus in the renderer cannot by itself release the guest's native keyboard focus.
host.focus();
}
host.send(MENU_ACTION_CHANNEL, forwarded.command);
if (forwarded.command === "preview.focusUrl") {
host.send(MENU_ACTION_CHANNEL, forwarded.command, tabId);
} else {
host.send(MENU_ACTION_CHANNEL, forwarded.command);
}
}
return;
}
Expand Down
94 changes: 94 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RightPanelSurface } from "../rightPanelStore";
import {
boundAttachmentIds,
buildExpiredTerminalContextToastCopy,
Expand Down Expand Up @@ -46,6 +47,7 @@ import {
resetHeldThreadTimeline,
resolveThreadSwitchTimeline,
timelineHasEphemeralPreviewUrls,
shouldRenderPreviewMiniPlayerInColumns,
createLocalDispatchSnapshot,
deriveCommittedServerUserMessageIds,
deriveComposerSendState,
Expand Down Expand Up @@ -403,6 +405,98 @@ describe("deriveCommittedServerUserMessageIds", () => {
});
});

describe("floating preview in scrolling columns", () => {
const browserColumn = { id: "browser:one", kind: "preview", resourceId: "tab-1" } as const;
const deviceColumn = {
id: "device:one",
kind: "device",
target: { hostId: "host-1", deviceId: "device-1", platform: "ios", name: "iPhone" },
} as const;
const diffColumn = { id: "diff", kind: "diff" } as const;
const surfaces: RightPanelSurface[] = [diffColumn, browserColumn, deviceColumn];

it("returns to the floating player when the selected source column scrolls away", () => {
const source = { kind: "browser", tabId: "tab-1" } as const;
// Scrolled over to the selected browser column: the column owns the view.
expect(
shouldRenderPreviewMiniPlayerInColumns({
source,
chatUnclipped: false,
surfaces,
unclippedSurfaceIds: new Set([browserColumn.id]),
}),
).toBe(false);
// Back on the chat with the browser column still selected but offscreen.
expect(
shouldRenderPreviewMiniPlayerInColumns({
source,
chatUnclipped: true,
surfaces,
unclippedSurfaceIds: new Set([diffColumn.id]),
}),
).toBe(true);
// Chat and the browser column both wholly in view: the column shows it.
expect(
shouldRenderPreviewMiniPlayerInColumns({
source,
chatUnclipped: true,
surfaces,
unclippedSurfaceIds: new Set([browserColumn.id]),
}),
).toBe(false);
});

it("matches device sources by host and device", () => {
expect(
shouldRenderPreviewMiniPlayerInColumns({
source: {
kind: "device",
hostId: "host-1",
deviceId: "device-1",
platform: "ios",
name: "iPhone",
},
chatUnclipped: true,
surfaces,
unclippedSurfaceIds: new Set([deviceColumn.id]),
}),
).toBe(false);
expect(
shouldRenderPreviewMiniPlayerInColumns({
source: {
kind: "device",
hostId: "host-1",
deviceId: "device-1",
platform: "ios",
name: "iPhone",
},
chatUnclipped: true,
surfaces,
unclippedSurfaceIds: new Set(),
}),
).toBe(true);
});

it("stays hidden while the chat column is clipped or there is no source", () => {
expect(
shouldRenderPreviewMiniPlayerInColumns({
source: { kind: "browser", tabId: "tab-1" },
chatUnclipped: false,
surfaces,
unclippedSurfaceIds: new Set(),
}),
).toBe(false);
expect(
shouldRenderPreviewMiniPlayerInColumns({
source: null,
chatUnclipped: true,
surfaces,
unclippedSurfaceIds: new Set(),
}),
).toBe(false);
});
});

describe("resolveThreadSwitchTimeline", () => {
afterEach(() => {
resetHeldThreadTimeline();
Expand Down
47 changes: 36 additions & 11 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,46 @@ export function agentControlledBrowserCloseConfirmation(
].join("\n");
}

/** Whether a right panel surface shows the same browser tab or device as the floating player. */
export function surfaceShowsPreviewSource(
source: PreviewMiniPlayerSource,
surface: RightPanelSurface | null,
): boolean {
if (source.kind === "browser") {
return surface?.kind === "preview" && surface.resourceId === source.tabId;
}
return (
surface?.kind === "device" &&
surface.target?.hostId === source.hostId &&
surface.target.deviceId === source.deviceId
);
}

/** The floating player hides only while the same source is rendered in the panel. */
export function shouldRenderPreviewMiniPlayer(
source: PreviewMiniPlayerSource | null,
renderedRightPanelSurface: RightPanelSurface | null,
): boolean {
if (source === null) return false;
if (source.kind === "browser") {
return !(
renderedRightPanelSurface?.kind === "preview" &&
renderedRightPanelSurface.resourceId === source.tabId
);
}
return !(
renderedRightPanelSurface?.kind === "device" &&
renderedRightPanelSurface.target?.hostId === source.hostId &&
renderedRightPanelSurface.target.deviceId === source.deviceId
return source !== null && !surfaceShowsPreviewSource(source, renderedRightPanelSurface);
}

/**
* In the scrolling columns layout the floating player floats over the chat, so
* it shows only while the chat column is wholly in view, and hides only while a
* column that is wholly in view shows the same source. A selected source column
* scrolled out of view does not count: its native view is hidden there too.
*/
export function shouldRenderPreviewMiniPlayerInColumns(input: {
source: PreviewMiniPlayerSource | null;
chatUnclipped: boolean;
surfaces: readonly RightPanelSurface[];
unclippedSurfaceIds: ReadonlySet<string>;
}): boolean {
const { source } = input;
if (source === null || !input.chatUnclipped) return false;
return !input.surfaces.some(
(surface) =>
input.unclippedSurfaceIds.has(surface.id) && surfaceShowsPreviewSource(source, surface),
);
}

Expand Down
Loading
Loading