authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 13:47:30-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 13:47:30-08:00
log6e3726ec32bb7d93b6004fb941df20876485032f
treedbbf2c30313d169fff5853196262be7441826c60
parent7e5cd01d26b21093947cb9b988f5963b8ed6ce26
parent169d06f05b543433278a123b3512fddad05f9f2a

Merge remote-tracking branch 'origin/main' into juliusmarminge/git-integration

# Conflicts: # apps/web/src/store.ts # apps/web/src/wsNativeApi.ts # packages/contracts/src/ipc.ts

8 files changed, 150 insertions(+), 2 deletions(-)

apps/desktop/src/main.ts+21-1
...@@ -5,13 +5,14 @@ import net from "node:net";...@@ -5,13 +5,14 @@ import net from "node:net";
5import os from "node:os";5import os from "node:os";
6import path from "node:path";6import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain } from "electron";8import { app, BrowserWindow, dialog, ipcMain, Menu } from "electron";
99
10import { fixPath } from "./fixPath";10import { fixPath } from "./fixPath";
1111
12fixPath();12fixPath();
1313
14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";14const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
15const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
15const ROOT_DIR = path.resolve(__dirname, "../../..");16const ROOT_DIR = path.resolve(__dirname, "../../..");
16const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");17const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
17const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");18const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
...@@ -139,6 +140,25 @@ function registerIpcHandlers(): void {...@@ -139,6 +140,25 @@ function registerIpcHandlers(): void {
139 if (result.canceled) return null;140 if (result.canceled) return null;
140 return result.filePaths[0] ?? null;141 return result.filePaths[0] ?? null;
141 });142 });
143
144 ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
145 ipcMain.handle(CONTEXT_MENU_CHANNEL, async (_event, items: { id: string; label: string }[]) => {
146 const window = BrowserWindow.getFocusedWindow() ?? mainWindow;
147 if (!window) return null;
148
149 return new Promise<string | null>((resolve) => {
150 const menu = Menu.buildFromTemplate(
151 items.map((item) => ({
152 label: item.label,
153 click: () => resolve(item.id),
154 })),
155 );
156 menu.popup({
157 window,
158 callback: () => resolve(null),
159 });
160 });
161 });
142}162}
143163
144function createWindow(): BrowserWindow {164function createWindow(): BrowserWindow {
apps/desktop/src/preload.ts+3
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1import { contextBridge, ipcRenderer } from "electron";1import { contextBridge, ipcRenderer } from "electron";
22
3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
4const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;5const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
56
6contextBridge.exposeInMainWorld("desktopBridge", {7contextBridge.exposeInMainWorld("desktopBridge", {
7 getWsUrl: () => wsUrl,8 getWsUrl: () => wsUrl,
8 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,9 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
10 showContextMenu: (items: { id: string; label: string }[]) =>
11 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
9});12});
apps/web/src/components/Sidebar.tsx+32
...@@ -162,6 +162,31 @@ export default function Sidebar() {...@@ -162,6 +162,31 @@ export default function Sidebar() {
162 }162 }
163 };163 };
164164
165 const handleThreadContextMenu = useCallback(
166 async (threadId: string, position: { x: number; y: number }) => {
167 if (!api) return;
168 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
169 if (clicked !== "delete") return;
170
171 const thread = state.threads.find((t) => t.id === threadId);
172 if (!thread) return;
173
174 // Stop active session if running
175 if (thread.session?.sessionId) {
176 try {
177 await api.providers.stopSession({
178 sessionId: thread.session.sessionId,
179 });
180 } catch {
181 // Session may already be stopped
182 }
183 }
184
185 dispatch({ type: "DELETE_THREAD", threadId });
186 },
187 [api, dispatch, state.threads],
188 );
189
165 useEffect(() => {190 useEffect(() => {
166 const onWindowKeyDown = (event: KeyboardEvent) => {191 const onWindowKeyDown = (event: KeyboardEvent) => {
167 const isNewThreadShortcut =192 const isNewThreadShortcut =
...@@ -292,6 +317,13 @@ export default function Sidebar() {...@@ -292,6 +317,13 @@ export default function Sidebar() {
292 threadId: thread.id,317 threadId: thread.id,
293 })318 })
294 }319 }
320 onContextMenu={(e) => {
321 e.preventDefault();
322 void handleThreadContextMenu(thread.id, {
323 x: e.clientX,
324 y: e.clientY,
325 });
326 }}
295 >327 >
296 <div className="flex min-w-0 flex-1 items-center gap-1.5">328 <div className="flex min-w-0 flex-1 items-center gap-1.5">
297 {threadStatus && (329 {threadStatus && (
apps/web/src/contextMenuFallback.ts created+64
...@@ -0,0 +1,64 @@
1/**
2 * Imperative DOM-based context menu for non-Electron environments.
3 * Shows a positioned dropdown and returns a promise that resolves
4 * with the clicked item id, or null if dismissed.
5 */
6export function showContextMenuFallback<T extends string>(
7 items: readonly { id: T; label: string }[],
8 position?: { x: number; y: number },
9): Promise<T | null> {
10 return new Promise<T | null>((resolve) => {
11 const overlay = document.createElement("div");
12 overlay.style.cssText = "position:fixed;inset:0;z-index:9999";
13
14 const menu = document.createElement("div");
15 menu.className =
16 "fixed z-[10000] min-w-[140px] rounded-md border border-border bg-popover py-1 shadow-xl animate-in fade-in zoom-in-95";
17
18 const x = position?.x ?? 0;
19 const y = position?.y ?? 0;
20 menu.style.top = `${y}px`;
21 menu.style.left = `${x}px`;
22
23 function cleanup(result: T | null) {
24 document.removeEventListener("keydown", onKeyDown);
25 overlay.remove();
26 menu.remove();
27 resolve(result);
28 }
29
30 function onKeyDown(e: KeyboardEvent) {
31 if (e.key === "Escape") {
32 e.preventDefault();
33 cleanup(null);
34 }
35 }
36
37 overlay.addEventListener("mousedown", () => cleanup(null));
38 document.addEventListener("keydown", onKeyDown);
39
40 for (const item of items) {
41 const btn = document.createElement("button");
42 btn.type = "button";
43 btn.textContent = item.label;
44 btn.className =
45 "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default";
46 btn.addEventListener("click", () => cleanup(item.id));
47 menu.appendChild(btn);
48 }
49
50 document.body.appendChild(overlay);
51 document.body.appendChild(menu);
52
53 // Adjust if menu overflows viewport
54 requestAnimationFrame(() => {
55 const rect = menu.getBoundingClientRect();
56 if (rect.right > window.innerWidth) {
57 menu.style.left = `${window.innerWidth - rect.width - 4}px`;
58 }
59 if (rect.bottom > window.innerHeight) {
60 menu.style.top = `${window.innerHeight - rect.height - 4}px`;
61 }
62 });
63 });
64}
apps/web/src/store.ts+11-1
...@@ -39,7 +39,8 @@ type Action =...@@ -39,7 +39,8 @@ type Action =
39 branch: string | null;39 branch: string | null;
40 worktreePath: string | null;40 worktreePath: string | null;
41 }41 }
42 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };42 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }
43 | { type: "DELETE_THREAD"; threadId: string };
4344
44// ── State ────────────────────────────────────────────────────────────45// ── State ────────────────────────────────────────────────────────────
4546
...@@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -391,6 +392,15 @@ export function reducer(state: AppState, action: Action): AppState {
391 runtimeMode: action.mode,392 runtimeMode: action.mode,
392 };393 };
393394
395 case "DELETE_THREAD": {
396 const threads = state.threads.filter((t) => t.id !== action.threadId);
397 const activeThreadId =
398 state.activeThreadId === action.threadId
399 ? (threads[0]?.id ?? null)
400 : state.activeThreadId;
401 return { ...state, threads, activeThreadId };
402 }
403
394 default:404 default:
395 return state;405 return state;
396 }406 }
apps/web/src/vite-env.d.ts+1
...@@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts";...@@ -5,6 +5,7 @@ import type { NativeApi } from "@t3tools/contracts";
5interface DesktopBridge {5interface DesktopBridge {
6 getWsUrl: () => string | null;6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;7 pickFolder: () => Promise<string | null>;
8 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
8}9}
910
10declare global {11declare global {
apps/web/src/wsNativeApi.ts+12
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
22
3import { showContextMenuFallback } from "./contextMenuFallback";
3import { WsTransport } from "./wsTransport";4import { WsTransport } from "./wsTransport";
45
5let instance: { api: NativeApi; transport: WsTransport } | null = null;6let instance: { api: NativeApi; transport: WsTransport } | null = null;
...@@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi {...@@ -105,6 +106,17 @@ export function createWsNativeApi(): NativeApi {
105 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),106 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
106 init: (input) => transport.request(WS_METHODS.gitInit, input),107 init: (input) => transport.request(WS_METHODS.gitInit, input),
107 },108 },
109 contextMenu: {
110 show: async <T extends string>(
111 items: readonly { id: T; label: string }[],
112 position?: { x: number; y: number },
113 ): Promise<T | null> => {
114 if (window.desktopBridge) {
115 return window.desktopBridge.showContextMenu(items) as Promise<T | null>;
116 }
117 return showContextMenuFallback(items, position);
118 },
119 },
108 };120 };
109121
110 instance = { api, transport };122 instance = { api, transport };
packages/contracts/src/ipc.ts+6
...@@ -80,4 +80,10 @@ export interface NativeApi {...@@ -80,4 +80,10 @@ export interface NativeApi {
80 checkout: (input: GitCheckoutInput) => Promise<void>;80 checkout: (input: GitCheckoutInput) => Promise<void>;
81 init: (input: GitInitInput) => Promise<void>;81 init: (input: GitInitInput) => Promise<void>;
82 };82 };
83 contextMenu: {
84 show: <T extends string>(
85 items: readonly { id: T; label: string }[],
86 position?: { x: number; y: number },
87 ) => Promise<T | null>;
88 };
83}89}