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
17 changes: 10 additions & 7 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Rules:

- `ChatTranscriptPane` owns the transcript stage through `.app-transcript-stage` and renders `MessagesTimeline` with scroll-to-bottom chrome using `--app-scroll-button-*`.
- `MessagesTimeline` renders assistant messages in `.app-assistant-message`, user messages in `.app-user-message`, and file-change/activity summaries through `--app-work-row-*`, `--app-diff-card-*`, and metadata tokens.
- `.message-action-group` is the shared visibility pattern for user and assistant message actions. Actions remain discoverable at `0.6` opacity at rest and transition to full opacity over `200ms` on message hover or `focus-within`, including keyboard focus. Preserve their existing hit targets, accessible names/tooltips, and focus styling; do not introduce per-message opacity variants.
- `ChatMarkdown` owns markdown scannability: headings, links, inline code role classes, code blocks, copy buttons, tables, local generated images, and lazy image rendering.
- `ComposerPlanModeBlockedReason` renders complete, wrapped Plan recovery guidance above the compact composer toolbar so narrow panes keep the required action visible.
- Chat-output semantic roles must stay conservative: file paths, commands, theme tokens, success/warning/error states can be colored; ordinary text stays neutral.
Expand Down Expand Up @@ -125,18 +126,20 @@ Rules:

## 8. Motion

| Motion | Token/Pattern | Rule |
| ----------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Chat pane entry | `.chat-pane-enter`, `220ms cubic-bezier(0.22, 1, 0.36, 1)` | Use for empty/transcript pane swaps; respect reduced motion. |
| Terminal running dot | `.terminal-running-indicator__dot`, `640ms ease-in-out`, opacity/scale only | Keep as CSS animation to avoid JS timers across many terminals. |
| Generated image shimmer | `chat-generated-image-shimmer`, `1.6s ease-in-out` | Loading feedback for generated images only. |
| Micro-interactions | Tailwind `transition-colors`, `transition-opacity`, `duration-120/140/150/200` patterns | Prefer color/opacity/transform transitions. |
| Ultrathink | `ultrathink-*` spectrum animations, 10s linear | Existing special mode only; do not use as general decoration. |
| Motion | Token/Pattern | Rule |
| ----------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Chat pane entry | `.chat-pane-enter`, `220ms cubic-bezier(0.22, 1, 0.36, 1)` | Use for empty/transcript pane swaps; respect reduced motion. |
| Persistent status pulse | `.status-pulse`, `2s` stepped opacity-only duty cycle | Use only for running terminal dots, the pulsing Sidebar project-status dot, and the timeline working ellipsis; keep it in CSS without JS timers. |
| Generated image shimmer | `chat-generated-image-shimmer`, `1.6s ease-in-out` | Loading feedback for generated images only. |
| Micro-interactions | Tailwind `transition-colors`, `transition-opacity`, `duration-120/140/150/200` patterns | Prefer color/opacity/transform transitions. |
| Ultrathink | `ultrathink-*` spectrum animations, 10s linear | Existing special mode only; do not use as general decoration. |

Rules:

- Animate `opacity`, `transform`, and color/filter changes only. Do not animate layout properties for ordinary UI.
- Respect `prefers-reduced-motion` for non-essential animation.
- Preserve `.status-pulse` per-dot staggers: terminal dots use `0/160/320/480ms`, and timeline working dots use `0/200/400ms`. Under reduced motion, the indicator stays visible at full opacity with no animation or transform.
- Completion or a non-running state must remove or stop `.status-pulse` and render the settled/static state. Finite spinners, loading skeletons, generated-image shimmer, and ultrathink motion are explicit exclusions and retain their own patterns.
- Do not add decorative motion to transcript, diff, terminal, settings, or browser surfaces unless it improves state comprehension.

## 9. Implementation Rules
Expand Down
151 changes: 151 additions & 0 deletions apps/desktop/src/contextMenuPopup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

import {
popupContextMenu,
type ContextMenuPopup,
type ContextMenuPopupOptions,
type ContextMenuWindowResolver,
} from "./contextMenuPopup";

type TestWindow = {
readonly name: string;
readonly webContents: {
readonly getZoomFactor: ReturnType<typeof vi.fn<() => number>>;
};
};

function makeWindow(name: string, zoomFactor: number): TestWindow {
return {
name,
webContents: {
getZoomFactor: vi.fn(() => zoomFactor),
},
};
}

describe("popupContextMenu", () => {
const callback = vi.fn();
const popup = vi.fn<(options: ContextMenuPopupOptions<TestWindow>) => void>();
const menu: ContextMenuPopup<TestWindow> = { popup };

beforeEach(() => {
callback.mockReset();
popup.mockReset();
});

it("uses sender-owner zoom and binds the popup to the sender owner", () => {
// Given a sender owner whose zoom conflicts with the focused and main windows
const senderOwner = makeWindow("sender", 2);
const focusedWindow = makeWindow("focused", 1.25);
const mainWindow = makeWindow("main", 0.8);
const windows: ContextMenuWindowResolver<TestWindow> = {
getSenderWindow: vi.fn(() => senderOwner),
getFocusedWindow: vi.fn(() => focusedWindow),
getMainWindow: vi.fn(() => mainWindow),
};

// When the popup is opened for fractional renderer coordinates
const didPopup = popupContextMenu({ menu, position: { x: 10.8, y: 20.2 }, callback }, windows);

// Then only the sender owner supplies the zoom and exact popup options
expect(didPopup).toBe(true);
expect(windows.getSenderWindow).toHaveBeenCalledOnce();
expect(windows.getFocusedWindow).not.toHaveBeenCalled();
expect(windows.getMainWindow).not.toHaveBeenCalled();
expect(senderOwner.webContents.getZoomFactor).toHaveBeenCalledOnce();
expect(focusedWindow.webContents.getZoomFactor).not.toHaveBeenCalled();
expect(mainWindow.webContents.getZoomFactor).not.toHaveBeenCalled();
expect(popup).toHaveBeenCalledWith({
window: senderOwner,
x: 21,
y: 40,
callback,
});
});

it("falls back to the focused window before the main window", () => {
// Given no sender owner and both focused and main windows
const focusedWindow = makeWindow("focused", 1.5);
const mainWindow = makeWindow("main", 2);
const windows: ContextMenuWindowResolver<TestWindow> = {
getSenderWindow: vi.fn(() => null),
getFocusedWindow: vi.fn(() => focusedWindow),
getMainWindow: vi.fn(() => mainWindow),
};

// When the popup is opened
const didPopup = popupContextMenu({ menu, position: { x: 8, y: 10 }, callback }, windows);

// Then the focused window owns the popup and main fallback is not consulted
expect(didPopup).toBe(true);
expect(windows.getFocusedWindow).toHaveBeenCalledOnce();
expect(windows.getMainWindow).not.toHaveBeenCalled();
expect(popup).toHaveBeenCalledWith({
window: focusedWindow,
x: 12,
y: 15,
callback,
});
});

it("falls back to the main window when sender and focused owners are unavailable", () => {
// Given only the main window is available
const mainWindow = makeWindow("main", 1.25);
const windows: ContextMenuWindowResolver<TestWindow> = {
getSenderWindow: vi.fn(() => null),
getFocusedWindow: vi.fn(() => null),
getMainWindow: vi.fn(() => mainWindow),
};

// When the popup is opened
const didPopup = popupContextMenu({ menu, position: { x: 8, y: 12 }, callback }, windows);

// Then the main window owns the popup
expect(didPopup).toBe(true);
expect(windows.getMainWindow).toHaveBeenCalledOnce();
expect(popup).toHaveBeenCalledWith({
window: mainWindow,
x: 10,
y: 15,
callback,
});
});

it.each([
{ label: "missing position", position: undefined, zoomFactor: 1.5 },
{ label: "invalid coordinates", position: { x: -1, y: 10 }, zoomFactor: 1.5 },
{ label: "invalid zoom", position: { x: 10, y: 20 }, zoomFactor: Number.NaN },
])("omits x and y for $label without throwing", ({ position, zoomFactor }) => {
// Given an owner and popup input that cannot be explicitly positioned
const senderOwner = makeWindow("sender", zoomFactor);
const windows: ContextMenuWindowResolver<TestWindow> = {
getSenderWindow: vi.fn(() => senderOwner),
getFocusedWindow: vi.fn(() => null),
getMainWindow: vi.fn(() => null),
};

// When the native popup is opened
const openPopup = () => popupContextMenu({ menu, position, callback }, windows);

// Then it uses native cursor placement without throwing
expect(openPopup).not.toThrow();
expect(senderOwner.webContents.getZoomFactor).toHaveBeenCalledOnce();
expect(popup).toHaveBeenCalledWith({ window: senderOwner, callback });
});

it("does not popup when no owner window is available", () => {
// Given no sender, focused, or main owner
const windows: ContextMenuWindowResolver<TestWindow> = {
getSenderWindow: vi.fn(() => null),
getFocusedWindow: vi.fn(() => null),
getMainWindow: vi.fn(() => null),
};

// When the popup is requested
const didPopup = popupContextMenu({ menu, position: { x: 10, y: 20 }, callback }, windows);

// Then native popup is skipped cleanly
expect(didPopup).toBe(false);
expect(popup).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions apps/desktop/src/contextMenuPopup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { resolveMenuPopupPosition, type MenuPopupPosition } from "./menuCoordinates";

type ZoomedMenuWindow = {
readonly webContents: {
readonly getZoomFactor: () => number;
};
};

export type ContextMenuPopupOptions<TWindow extends ZoomedMenuWindow> = {
readonly window: TWindow;
readonly x?: number;
readonly y?: number;
readonly callback: () => void;
};

export type ContextMenuPopup<TWindow extends ZoomedMenuWindow> = {
readonly popup: (options: ContextMenuPopupOptions<TWindow>) => void;
};

export type ContextMenuWindowResolver<TWindow extends ZoomedMenuWindow> = {
readonly getSenderWindow: () => TWindow | null;
readonly getFocusedWindow: () => TWindow | null;
readonly getMainWindow: () => TWindow | null;
};

type ContextMenuPopupRequest<TWindow extends ZoomedMenuWindow> = {
readonly menu: ContextMenuPopup<TWindow>;
readonly position: MenuPopupPosition | undefined;
readonly callback: () => void;
};

export function popupContextMenu<TWindow extends ZoomedMenuWindow>(
request: ContextMenuPopupRequest<TWindow>,
windows: ContextMenuWindowResolver<TWindow>,
): boolean {
const owner = windows.getSenderWindow() ?? windows.getFocusedWindow() ?? windows.getMainWindow();
if (!owner) {
return false;
}

const zoomFactor = owner.webContents.getZoomFactor();
const popupPosition = request.position
? resolveMenuPopupPosition(request.position, zoomFactor)
: undefined;
request.menu.popup({
window: owner,
...popupPosition,
callback: request.callback,
});
return true;
}
78 changes: 78 additions & 0 deletions apps/desktop/src/fullscreenBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";

import {
DESKTOP_FULLSCREEN_CHANGE_CHANNEL,
DESKTOP_IS_FULLSCREEN_CHANNEL,
} from "./fullscreenWindow";
import {
createFullscreenBridge,
type FullscreenIpcRenderer,
type FullscreenRendererListener,
} from "./fullscreenBridge";

class FakeIpcRenderer implements FullscreenIpcRenderer {
readonly sendSync = vi.fn((_channel: string): unknown => false);
readonly onCalls = vi.fn();
readonly removeCalls = vi.fn();
private listener: FullscreenRendererListener | null = null;

on(channel: string, listener: FullscreenRendererListener): this {
this.onCalls(channel, listener);
this.listener = listener;
return this;
}

removeListener(channel: string, listener: FullscreenRendererListener): this {
this.removeCalls(channel, listener);
if (this.listener === listener) {
this.listener = null;
}
return this;
}

emit(payload: unknown): void {
this.listener?.({}, payload);
}
}

describe("desktop fullscreen preload bridge", () => {
it("performs a synchronous read and accepts only literal true", () => {
const renderer = new FakeIpcRenderer();
const bridge = createFullscreenBridge(renderer);

renderer.sendSync.mockReturnValueOnce(true).mockReturnValueOnce(1).mockReturnValueOnce("true");

expect(bridge.getIsFullscreen()).toBe(true);
expect(bridge.getIsFullscreen()).toBe(false);
expect(bridge.getIsFullscreen()).toBe(false);
expect(renderer.sendSync).toHaveBeenCalledTimes(3);
expect(renderer.sendSync).toHaveBeenNthCalledWith(1, DESKTOP_IS_FULLSCREEN_CHANNEL);
});

it("forwards boolean events and rejects malformed payloads", () => {
const renderer = new FakeIpcRenderer();
const bridge = createFullscreenBridge(renderer);
const listener = vi.fn();

const unsubscribe = bridge.onFullscreenChange(listener);
renderer.emit(true);
renderer.emit(false);
renderer.emit(1);
renderer.emit("false");
renderer.emit(null);

expect(listener.mock.calls).toEqual([[true], [false]]);
expect(renderer.onCalls).toHaveBeenCalledExactlyOnceWith(
DESKTOP_FULLSCREEN_CHANGE_CHANNEL,
expect.any(Function),
);

unsubscribe();
renderer.emit(true);
expect(listener).toHaveBeenCalledTimes(2);
expect(renderer.removeCalls).toHaveBeenCalledExactlyOnceWith(
DESKTOP_FULLSCREEN_CHANGE_CHANNEL,
expect.any(Function),
);
});
});
33 changes: 33 additions & 0 deletions apps/desktop/src/fullscreenBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { DesktopBridge } from "@jcode/contracts";

import {
DESKTOP_FULLSCREEN_CHANGE_CHANNEL,
DESKTOP_IS_FULLSCREEN_CHANNEL,
} from "./fullscreenWindow";

export type FullscreenRendererListener = (event: unknown, isFullscreen: unknown) => void;

export interface FullscreenIpcRenderer {
sendSync(channel: string): unknown;
on(channel: string, listener: FullscreenRendererListener): this;
removeListener(channel: string, listener: FullscreenRendererListener): this;
}

type FullscreenBridge = Required<Pick<DesktopBridge, "getIsFullscreen" | "onFullscreenChange">>;

export function createFullscreenBridge(ipcRenderer: FullscreenIpcRenderer): FullscreenBridge {
return {
getIsFullscreen: () => ipcRenderer.sendSync(DESKTOP_IS_FULLSCREEN_CHANNEL) === true,
onFullscreenChange: (listener) => {
const wrappedListener: FullscreenRendererListener = (_event, isFullscreen) => {
if (typeof isFullscreen !== "boolean") return;
listener(isFullscreen);
};

ipcRenderer.on(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(DESKTOP_FULLSCREEN_CHANGE_CHANNEL, wrappedListener);
};
},
};
}
Loading
Loading