From 94c4bba3cd6c3722dd85be6ba9125b8f707230dd Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 23 Dec 2025 21:56:35 -0800 Subject: [PATCH 1/5] feat(desktop): auto-update tab and workspace titles from terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listen for terminal title changes (OSC 0, 1, 2 escape sequences) and automatically update the tab title. Also updates the workspace name if it hasn't been customized (still using the default branch name). This allows shells and programs (vim, htop, ssh, etc.) that set the terminal title to have that reflected in the UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/trpc/routers/workspaces/workspaces.ts | 32 +++++++++++++++++++ .../TabsContent/Terminal/Terminal.tsx | 28 +++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 21705214431..c6b3cb90904 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -701,6 +701,38 @@ export const createWorkspacesRouter = () => { return { success: true }; }), + // Set workspace name only if it hasn't been customized (still equals branch name) + setAutoName: publicProcedure + .input( + z.object({ + id: z.string(), + name: z.string(), + }), + ) + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + return { success: false, reason: "not_found" }; + } + + // Only update if name still equals branch (not customized by user) + if (workspace.name !== workspace.branch) { + return { success: false, reason: "already_customized" }; + } + + localDb + .update(workspaces) + .set({ name: input.name }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true }; + }), + canDelete: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 14bfdda99a6..7597318b360 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1,7 +1,7 @@ -import "@xterm/xterm/css/xterm.css"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -103,17 +103,21 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const resizeMutation = trpc.terminal.resize.useMutation(); const detachMutation = trpc.terminal.detach.useMutation(); const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); + const setWorkspaceAutoNameMutation = + trpc.workspaces.setAutoName.useMutation(); const createOrAttachRef = useRef(createOrAttachMutation.mutate); const writeRef = useRef(writeMutation.mutate); const resizeRef = useRef(resizeMutation.mutate); const detachRef = useRef(detachMutation.mutate); const clearScrollbackRef = useRef(clearScrollbackMutation.mutate); + const setWorkspaceAutoNameRef = useRef(setWorkspaceAutoNameMutation.mutate); createOrAttachRef.current = createOrAttachMutation.mutate; writeRef.current = writeMutation.mutate; resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; clearScrollbackRef.current = clearScrollbackMutation.mutate; + setWorkspaceAutoNameRef.current = setWorkspaceAutoNameMutation.mutate; const registerClearCallbackRef = useRef( useTerminalCallbacksStore.getState().registerClearCallback, @@ -134,6 +138,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, 100), ); + const workspaceIdRef = useRef(workspaceId); + workspaceIdRef.current = workspaceId; + + const debouncedSetWorkspaceAutoNameRef = useRef( + debounce((id: string, name: string) => { + setWorkspaceAutoNameRef.current({ id, name }); + }, 100), + ); + const handleStreamData = (event: TerminalStreamEvent) => { // Queue events until terminal is ready to prevent data loss if (!xtermRef.current || !subscriptionEnabled) { @@ -337,6 +350,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const inputDisposable = xterm.onData(handleTerminalInput); const keyDisposable = xterm.onKey(handleKeyPress); + const titleDisposable = xterm.onTitleChange((title) => { + if (title) { + // Update tab title + if (parentTabIdRef.current) { + debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); + } + // Update workspace name if it hasn't been customized + debouncedSetWorkspaceAutoNameRef.current(workspaceIdRef.current, title); + } + }); + const handleClear = () => { xterm.clear(); clearScrollbackRef.current({ paneId }); @@ -382,6 +406,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { isUnmounted = true; inputDisposable.dispose(); keyDisposable.dispose(); + titleDisposable.dispose(); cleanupKeyboard(); cleanupClickToMove(); cleanupFocus?.(); @@ -390,6 +415,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); + debouncedSetWorkspaceAutoNameRef.current?.cancel?.(); // Detach instead of kill to keep PTY running for reattachment detachRef.current({ paneId }); setSubscriptionEnabled(false); From b8d04706fafe54b3a522c8f2703829d18f03662f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 23 Dec 2025 22:15:35 -0800 Subject: [PATCH 2/5] fix(desktop): invalidate workspace queries after auto-name update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added useSetWorkspaceAutoName hook that invalidates workspace queries after successfully updating the workspace name. This ensures the WorkspaceItem in the top bar reflects the updated name. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../renderer/react-query/workspaces/index.ts | 1 + .../workspaces/useSetWorkspaceAutoName.ts | 25 +++++++++++++++++++ .../TabsContent/Terminal/Terminal.tsx | 4 +-- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 60a9c29b75d..432bd08d2d0 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -5,4 +5,5 @@ export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; +export { useSetWorkspaceAutoName } from "./useSetWorkspaceAutoName"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts new file mode 100644 index 00000000000..cc700ef430d --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts @@ -0,0 +1,25 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for setting a workspace's auto-generated name. + * Only updates if the workspace name hasn't been customized (still equals branch name). + * Automatically invalidates all workspace queries on success. + */ +export function useSetWorkspaceAutoName( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.setAutoName.useMutation({ + ...options, + onSuccess: async (...args) => { + // Only invalidate if the update was actually applied + if (args[0].success) { + await utils.workspaces.invalidate(); + } + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 7597318b360..7e2809e687b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -6,6 +6,7 @@ import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; +import { useSetWorkspaceAutoName } from "renderer/react-query/workspaces"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; @@ -103,8 +104,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const resizeMutation = trpc.terminal.resize.useMutation(); const detachMutation = trpc.terminal.detach.useMutation(); const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); - const setWorkspaceAutoNameMutation = - trpc.workspaces.setAutoName.useMutation(); + const setWorkspaceAutoNameMutation = useSetWorkspaceAutoName(); const createOrAttachRef = useRef(createOrAttachMutation.mutate); const writeRef = useRef(writeMutation.mutate); From df9844e9474bd198a86d7ea113d6ee68b0f71d10 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 23 Dec 2025 22:21:24 -0800 Subject: [PATCH 3/5] fix(desktop): allow workspace name to always update from terminal title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the branch-name check that was preventing subsequent auto-updates. Now the workspace name always reflects the terminal title. Only skip update if the name hasn't actually changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../desktop/src/lib/trpc/routers/workspaces/workspaces.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index c6b3cb90904..e54102cda48 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -701,7 +701,7 @@ export const createWorkspacesRouter = () => { return { success: true }; }), - // Set workspace name only if it hasn't been customized (still equals branch name) + // Set workspace name from terminal title (auto-update) setAutoName: publicProcedure .input( z.object({ @@ -719,9 +719,9 @@ export const createWorkspacesRouter = () => { return { success: false, reason: "not_found" }; } - // Only update if name still equals branch (not customized by user) - if (workspace.name !== workspace.branch) { - return { success: false, reason: "already_customized" }; + // Skip if name hasn't changed + if (workspace.name === input.name) { + return { success: false, reason: "unchanged" }; } localDb From f565dee14029a683877e8f751897c3a8fde368a8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 23 Dec 2025 22:32:41 -0800 Subject: [PATCH 4/5] refactor(desktop): remove workspace name auto-update, keep only tab title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab and workspace name systems work differently: - Tab name: Zustand store (immediate local updates) - Workspace name: Database mutation (requires query invalidation) Keeping only tab title updates from terminal OSC sequences is simpler and more appropriate - tab shows "what's running", workspace shows "what this is for" (branch name). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../lib/trpc/routers/workspaces/workspaces.ts | 32 ------------------- .../renderer/react-query/workspaces/index.ts | 1 - .../workspaces/useSetWorkspaceAutoName.ts | 25 --------------- .../TabsContent/Terminal/Terminal.tsx | 25 +++------------ 4 files changed, 4 insertions(+), 79 deletions(-) delete mode 100644 apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index e54102cda48..21705214431 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -701,38 +701,6 @@ export const createWorkspacesRouter = () => { return { success: true }; }), - // Set workspace name from terminal title (auto-update) - setAutoName: publicProcedure - .input( - z.object({ - id: z.string(), - name: z.string(), - }), - ) - .mutation(({ input }) => { - const workspace = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, input.id)) - .get(); - if (!workspace) { - return { success: false, reason: "not_found" }; - } - - // Skip if name hasn't changed - if (workspace.name === input.name) { - return { success: false, reason: "unchanged" }; - } - - localDb - .update(workspaces) - .set({ name: input.name }) - .where(eq(workspaces.id, input.id)) - .run(); - - return { success: true }; - }), - canDelete: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 432bd08d2d0..60a9c29b75d 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -5,5 +5,4 @@ export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; -export { useSetWorkspaceAutoName } from "./useSetWorkspaceAutoName"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts deleted file mode 100644 index cc700ef430d..00000000000 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetWorkspaceAutoName.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { trpc } from "renderer/lib/trpc"; - -/** - * Mutation hook for setting a workspace's auto-generated name. - * Only updates if the workspace name hasn't been customized (still equals branch name). - * Automatically invalidates all workspace queries on success. - */ -export function useSetWorkspaceAutoName( - options?: Parameters[0], -) { - const utils = trpc.useUtils(); - - return trpc.workspaces.setAutoName.useMutation({ - ...options, - onSuccess: async (...args) => { - // Only invalidate if the update was actually applied - if (args[0].success) { - await utils.workspaces.invalidate(); - } - - // Call user's onSuccess if provided - await options?.onSuccess?.(...args); - }, - }); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 7e2809e687b..494b4bf6346 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -6,7 +6,6 @@ import debounce from "lodash/debounce"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; -import { useSetWorkspaceAutoName } from "renderer/react-query/workspaces"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; @@ -104,20 +103,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const resizeMutation = trpc.terminal.resize.useMutation(); const detachMutation = trpc.terminal.detach.useMutation(); const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); - const setWorkspaceAutoNameMutation = useSetWorkspaceAutoName(); const createOrAttachRef = useRef(createOrAttachMutation.mutate); const writeRef = useRef(writeMutation.mutate); const resizeRef = useRef(resizeMutation.mutate); const detachRef = useRef(detachMutation.mutate); const clearScrollbackRef = useRef(clearScrollbackMutation.mutate); - const setWorkspaceAutoNameRef = useRef(setWorkspaceAutoNameMutation.mutate); createOrAttachRef.current = createOrAttachMutation.mutate; writeRef.current = writeMutation.mutate; resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; clearScrollbackRef.current = clearScrollbackMutation.mutate; - setWorkspaceAutoNameRef.current = setWorkspaceAutoNameMutation.mutate; const registerClearCallbackRef = useRef( useTerminalCallbacksStore.getState().registerClearCallback, @@ -138,15 +134,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, 100), ); - const workspaceIdRef = useRef(workspaceId); - workspaceIdRef.current = workspaceId; - - const debouncedSetWorkspaceAutoNameRef = useRef( - debounce((id: string, name: string) => { - setWorkspaceAutoNameRef.current({ id, name }); - }, 100), - ); - const handleStreamData = (event: TerminalStreamEvent) => { // Queue events until terminal is ready to prevent data loss if (!xtermRef.current || !subscriptionEnabled) { @@ -350,14 +337,11 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const inputDisposable = xterm.onData(handleTerminalInput); const keyDisposable = xterm.onKey(handleKeyPress); + // Listen for terminal title changes (OSC 0, 1, 2 sequences) + // Many shells and programs (vim, htop, etc.) set the terminal title via escape sequences const titleDisposable = xterm.onTitleChange((title) => { - if (title) { - // Update tab title - if (parentTabIdRef.current) { - debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); - } - // Update workspace name if it hasn't been customized - debouncedSetWorkspaceAutoNameRef.current(workspaceIdRef.current, title); + if (title && parentTabIdRef.current) { + debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title); } }); @@ -415,7 +399,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); - debouncedSetWorkspaceAutoNameRef.current?.cancel?.(); // Detach instead of kill to keep PTY running for reattachment detachRef.current({ paneId }); setSubscriptionEnabled(false); From 08f1d68fd1d796060cd798f0959bd870afa221ef Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 23 Dec 2025 22:40:35 -0800 Subject: [PATCH 5/5] deslop --- .../WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 494b4bf6346..acca806c0c1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -337,8 +337,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const inputDisposable = xterm.onData(handleTerminalInput); const keyDisposable = xterm.onKey(handleKeyPress); - // Listen for terminal title changes (OSC 0, 1, 2 sequences) - // Many shells and programs (vim, htop, etc.) set the terminal title via escape sequences const titleDisposable = xterm.onTitleChange((title) => { if (title && parentTabIdRef.current) { debouncedSetTabAutoTitleRef.current(parentTabIdRef.current, title);