From 4cc354c101e421eb76fa8776c1396e5765310d03 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:58:26 -0400 Subject: [PATCH 1/6] Add manual naming support for chat sessions --- .../main/services/chat/agentChatService.ts | 13 + .../services/lanes/laneLaunchContext.test.ts | 322 ++++++++++++++++++ .../src/main/services/pty/ptyService.test.ts | 2 +- .../src/main/services/pty/ptyService.ts | 72 +++- .../components/run/processUtils.test.ts | 121 +++++++ .../renderer/components/run/processUtils.ts | 13 +- .../components/settings/AiFeaturesSection.tsx | 2 +- .../terminals/SessionContextMenu.tsx | 79 ++++- .../components/terminals/TerminalsPage.tsx | 6 + .../components/terminals/WorkViewArea.tsx | 4 + .../components/terminals/cliLaunch.test.ts | 116 +++++++ apps/desktop/src/shared/types/chat.ts | 1 + docs/features/CHAT.md | 33 +- docs/features/TERMINALS_AND_SESSIONS.md | 51 ++- 14 files changed, 806 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts create mode 100644 apps/desktop/src/renderer/components/run/processUtils.test.ts create mode 100644 apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1ec8cd968..5ae7de55b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -566,6 +566,7 @@ type ManagedChatSession = { autoTitleSeed: string | null; autoTitleStage: "none" | "initial" | "final"; autoTitleInFlight: boolean; + manuallyNamed: boolean; summaryInFlight: boolean; activeAssistantMessageId: string | null; lastActivitySignature: string | null; @@ -2844,6 +2845,7 @@ export function createAgentChatService(args: { ): Promise => { const config = resolveChatConfig(); if (!config.autoTitleEnabled) return; + if (managed.manuallyNamed) return; if (managed.autoTitleInFlight) return; if (args.stage === "initial" && managed.autoTitleStage !== "none") return; if (args.stage === "final") { @@ -4121,6 +4123,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: hasCustomChatSessionTitle(row.title, provider) ? "initial" : "none", autoTitleInFlight: false, + manuallyNamed: false, summaryInFlight: false, continuitySummary: persisted?.continuitySummary ?? null, continuitySummaryUpdatedAt: persisted?.continuitySummaryUpdatedAt ?? null, @@ -7456,6 +7459,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + manuallyNamed: false, summaryInFlight: false, continuitySummary: null, continuitySummaryUpdatedAt: null, @@ -7763,6 +7767,7 @@ export function createAgentChatService(args: { autoTitleSeed: null, autoTitleStage: "none", autoTitleInFlight: false, + manuallyNamed: false, summaryInFlight: false, continuitySummary: null, continuitySummaryUpdatedAt: null, @@ -8948,6 +8953,7 @@ export function createAgentChatService(args: { const updateSession = async ({ sessionId, title, + manuallyNamed, modelId, reasoningEffort, interactionMode, @@ -9161,6 +9167,13 @@ export function createAgentChatService(args: { sessionId, title: normalizedTitle.length ? normalizedTitle : defaultChatSessionTitle(managed.session.provider), }); + if (manuallyNamed === true) { + managed.manuallyNamed = true; + } + } + // Allow resetting manuallyNamed independently when no title change is provided + if (manuallyNamed !== undefined && title === undefined) { + managed.manuallyNamed = manuallyNamed; } persistChatState(managed); diff --git a/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts new file mode 100644 index 000000000..b4737beb9 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneLaunchContext.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + statSync: vi.fn(), + realpathSync: vi.fn(), + resolvePathWithinRoot: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + default: { + statSync: mocks.statSync, + realpathSync: mocks.realpathSync, + }, + statSync: mocks.statSync, + realpathSync: mocks.realpathSync, +})); + +vi.mock("../shared/utils", () => ({ + resolvePathWithinRoot: mocks.resolvePathWithinRoot, +})); + +// --------------------------------------------------------------------------- +// Import the module under test AFTER mocks are set up +// --------------------------------------------------------------------------- + +import { resolveLaneLaunchContext } from "./laneLaunchContext"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeLaneService(worktreePath: string) { + return { + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/test", + worktreePath, + laneType: "standard" as const, + })), + } as unknown as Parameters[0]["laneService"]; +} + +function setupDirectoryExists(realPath: string) { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync.mockReturnValue(realPath); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("resolveLaneLaunchContext", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("happy path: no custom cwd", () => { + it("returns lane root as both laneWorktreePath and cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + + it("treats null requestedCwd the same as no cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: null, + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + + it("treats empty-string requestedCwd the same as no cwd", () => { + setupDirectoryExists("/real/lane/root"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: " ", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root", + }); + }); + }); + + describe("happy path: valid relative cwd inside worktree", () => { + it("resolves relative cwd within lane root", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync + .mockReturnValueOnce("/real/lane/root") // first ensureDirectoryExists (lane root) + .mockReturnValueOnce("/real/lane/root/src"); // second ensureDirectoryExists (cwd) + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/src"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "src", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root/src", + }); + expect(mocks.resolvePathWithinRoot).toHaveBeenCalledOnce(); + }); + }); + + describe("happy path: valid absolute cwd inside worktree", () => { + it("resolves absolute cwd within lane root", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync + .mockReturnValueOnce("/real/lane/root") // first ensureDirectoryExists (lane root) + .mockReturnValueOnce("/real/lane/root/packages/core"); // second ensureDirectoryExists (cwd) + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/packages/core"); + + const result = resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "/real/lane/root/packages/core", + purpose: "start agent", + }); + + expect(result).toEqual({ + laneWorktreePath: "/real/lane/root", + cwd: "/real/lane/root/packages/core", + }); + expect(mocks.resolvePathWithinRoot).toHaveBeenCalledWith( + "/real/lane/root", + "/real/lane/root/packages/core", + ); + }); + }); + + describe("error: lane has no worktree configured", () => { + it("throws when worktreePath is empty string", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-orphan", + purpose: "launch terminal", + }), + ).toThrow("Lane 'lane-orphan' has no worktree configured"); + }); + + it("throws when worktreePath is whitespace-only", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(" "), + laneId: "lane-ws", + purpose: "launch terminal", + }), + ).toThrow("Lane 'lane-ws' has no worktree configured"); + }); + + it("includes the purpose in the error message", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-1", + purpose: "run tests", + }), + ).toThrow("ADE cannot run tests outside the selected lane"); + }); + }); + + describe("error: lane worktree directory doesn't exist", () => { + it("throws when statSync fails (directory missing)", () => { + mocks.statSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/gone/lane"), + laneId: "lane-gone", + purpose: "deploy", + }), + ).toThrow("worktree is unavailable"); + }); + + it("throws when path is not a directory", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => false }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/some/file.txt"), + laneId: "lane-file", + purpose: "build", + }), + ).toThrow("worktree is unavailable"); + }); + + it("throws when realpathSync fails after stat succeeds", () => { + mocks.statSync.mockReturnValue({ isDirectory: () => true }); + mocks.realpathSync.mockImplementation(() => { + throw new Error("EACCES"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/broken/symlink"), + laneId: "lane-broken", + purpose: "launch agent", + }), + ).toThrow("worktree is unavailable"); + }); + }); + + describe("error: requested cwd escapes lane root (path traversal)", () => { + it("throws with descriptive message when resolvePathWithinRoot detects traversal", () => { + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockImplementation(() => { + throw new Error("Path escapes root"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "../../etc/passwd", + purpose: "start agent", + }), + ).toThrow("escapes lane 'lane-1'"); + }); + + it("re-throws non-traversal errors from resolvePathWithinRoot", () => { + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockImplementation(() => { + throw new Error("Permission denied"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "src", + purpose: "start agent", + }), + ).toThrow("Permission denied"); + }); + }); + + describe("error: requested cwd doesn't exist inside worktree", () => { + it("throws when cwd directory does not exist after path validation", () => { + // First ensureDirectoryExists (for lane root) succeeds + setupDirectoryExists("/real/lane/root"); + mocks.resolvePathWithinRoot.mockReturnValue("/real/lane/root/nonexistent"); + + // Second ensureDirectoryExists (for resolved cwd) fails + let callCount = 0; + mocks.statSync.mockImplementation(() => { + callCount++; + if (callCount <= 1) { + // First call: lane root check succeeds + return { isDirectory: () => true }; + } + // Second call: cwd check fails + throw new Error("ENOENT"); + }); + + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: "lane-1", + requestedCwd: "nonexistent", + purpose: "start agent", + }), + ).toThrow("is not an existing directory inside lane"); + }); + }); + + describe("edge cases", () => { + it("trims laneId whitespace", () => { + setupDirectoryExists("/real/lane/root"); + + resolveLaneLaunchContext({ + laneService: makeLaneService("/projects/my-lane"), + laneId: " lane-1 ", + purpose: "test", + }); + + const laneService = makeLaneService("/projects/my-lane"); + // Verify getLaneBaseAndBranch was called — the function trims before passing + expect(laneService.getLaneBaseAndBranch).not.toHaveBeenCalled(); + }); + + it("uses 'launch work' as default purpose when purpose is empty", () => { + expect(() => + resolveLaneLaunchContext({ + laneService: makeLaneService(""), + laneId: "lane-1", + purpose: "", + }), + ).toThrow("ADE cannot launch work outside the selected lane"); + }); + }); +}); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 63d327b8b..19c7fb8a1 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -465,7 +465,7 @@ describe("ptyService", () => { }); mockPty._emitter.emit("data", "generated enough output for a better title"); - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(6000); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/tmp/test-worktree/subdir" }), diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index bddb3ca92..bf9dc931d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -239,6 +239,18 @@ export function createPtyService({ return ai?.sessionIntelligence; }; + const isTitleGenerationEnabled = (): boolean => { + const si = getSessionIntelligence(); + const ai = projectConfigService?.get().effective.ai; + return si?.titles?.enabled ?? (ai?.chat as any)?.autoTitleEnabled ?? true; + }; + + const resolveTitleModelId = (): string | undefined => { + const si = getSessionIntelligence(); + const raw = si?.titles?.modelId; + return typeof raw === "string" && raw.trim().length ? raw.trim() : undefined; + }; + /** Only orchestrated worker sessions auto-close after the wrapped CLI exits back to shell. */ const TOOL_TYPES_WITH_AUTO_CLOSE = new Set([ "claude-orchestrated", @@ -365,6 +377,46 @@ export function createPtyService({ if (text.length) { sessionService.setSummary(sessionId, text); } + + // Refresh title on complete if enabled + // Note: aiIntegrationService availability and non-guest mode were already + // checked above (early return at the top of this block). + const refreshOnComplete = getSessionIntelligence()?.titles?.refreshOnComplete + ?? (projectConfigService?.get().effective.ai?.chat as any)?.autoTitleRefreshOnComplete + ?? true; + if (refreshOnComplete && isTitleGenerationEnabled()) { + try { + const titlePrompt = [ + "Generate a concise final title for this completed terminal session.", + "Return only plain text, max 80 characters, no punctuation at the end.", + "", + `Session type: ${session.toolType ?? "terminal"}`, + `Initial title: ${session.title}`, + session.goal ? `Current goal: ${session.goal}` : null, + `Exit code: ${session.exitCode ?? "unknown"}`, + "", + "Terminal transcript tail:", + transcript.slice(-2000), + ].filter(Boolean).join("\n"); + + const titleModelId = resolveTitleModelId(); + const titleResult = await aiIntegrationService.summarizeTerminal({ + cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, + prompt: titlePrompt, + timeoutMs: 8_000, + ...(titleModelId ? { model: titleModelId } : {}), + }); + const finalTitle = titleResult.text.trim().replace(/\s+/g, " ").slice(0, 80); + if (finalTitle) { + sessionService.updateMeta({ sessionId, title: finalTitle }); + } + } catch (err) { + logger.warn("pty.session_title_refresh_failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } }) .catch(() => { // ignore summary generation failures @@ -741,7 +793,7 @@ export function createPtyService({ // Accumulate initial output for session title generation if (!titleBufferFull) { titleOutputBuffer += data; - if (titleOutputBuffer.length >= 500) { + if (titleOutputBuffer.length >= 800) { titleBufferFull = true; } } @@ -770,14 +822,13 @@ export function createPtyService({ } } - // Fire-and-forget: after 4s, attempt AI title generation for non-shell sessions - if (aiIntegrationService && aiIntegrationService.getMode() === "subscription") { + // Fire-and-forget: after 6s, attempt AI title generation for non-shell sessions + if (aiIntegrationService && aiIntegrationService.getMode() !== "guest") { const capturedAi = aiIntegrationService; setTimeout(() => { if (entry.disposed) return; - const si = getSessionIntelligence(); - if (si?.titles?.enabled === false) return; + if (!isTitleGenerationEnabled()) return; const strippedOutput = stripAnsi(titleOutputBuffer).trim(); if (strippedOutput.length < 10) return; @@ -793,13 +844,10 @@ export function createPtyService({ "Return only plain text, max 80 characters, no punctuation at the end.", "", "Initial output:", - strippedOutput.slice(0, 500) + strippedOutput.slice(0, 800) ].join("\n"); - const titleModelId = typeof si?.titles?.modelId === "string" && si.titles.modelId.trim().length - ? si.titles.modelId.trim() - : undefined; - + const titleModelId = resolveTitleModelId(); capturedAi .summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, @@ -810,7 +858,7 @@ export function createPtyService({ .then((result) => { const title = result.text.trim().replace(/\s+/g, " ").slice(0, 80); if (title) { - sessionService.updateMeta({ sessionId, goal: title }); + sessionService.updateMeta({ sessionId, title }); } }) .catch((err) => { @@ -819,7 +867,7 @@ export function createPtyService({ error: err instanceof Error ? err.message : String(err) }); }); - }, 4000); + }, 6000); } logger.info("pty.create", { ptyId, sessionId, laneId, cwd, shell: selectedShell?.file ?? "unknown" }); diff --git a/apps/desktop/src/renderer/components/run/processUtils.test.ts b/apps/desktop/src/renderer/components/run/processUtils.test.ts new file mode 100644 index 000000000..ddf621304 --- /dev/null +++ b/apps/desktop/src/renderer/components/run/processUtils.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import type { ProcessRuntime, ProcessRuntimeStatus } from "../../../shared/types"; +import { formatProcessStatus, hasInspectableProcessOutput, isActiveProcessStatus } from "./processUtils"; + +const ALL_STATUSES: ProcessRuntimeStatus[] = [ + "stopped", + "starting", + "running", + "degraded", + "stopping", + "exited", + "crashed", +]; + +describe("isActiveProcessStatus", () => { + it.each(["running", "starting", "degraded", "stopping"] as const)( + "returns true for '%s'", + (status) => { + expect(isActiveProcessStatus(status)).toBe(true); + }, + ); + + it.each(["stopped", "exited", "crashed"] as const)( + "returns false for '%s'", + (status) => { + expect(isActiveProcessStatus(status)).toBe(false); + }, + ); + + it("covers every status in ProcessRuntimeStatus", () => { + for (const status of ALL_STATUSES) { + expect(typeof isActiveProcessStatus(status)).toBe("boolean"); + } + }); +}); + +function makeRuntime(overrides: Partial = {}): ProcessRuntime { + return { + laneId: "lane-1", + processId: "proc-1", + status: "stopped", + readiness: "unknown", + pid: null, + startedAt: null, + endedAt: null, + exitCode: null, + lastExitCode: null, + lastEndedAt: null, + uptimeMs: null, + ports: [], + logPath: null, + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +describe("hasInspectableProcessOutput", () => { + it("returns false for a pristine stopped process with no timing info", () => { + expect(hasInspectableProcessOutput(makeRuntime({ status: "stopped" }))).toBe(false); + }); + + it("returns true for any non-stopped status", () => { + for (const status of ALL_STATUSES.filter((s) => s !== "stopped")) { + expect(hasInspectableProcessOutput(makeRuntime({ status }))).toBe(true); + } + }); + + it("returns true for stopped process with startedAt", () => { + expect( + hasInspectableProcessOutput(makeRuntime({ status: "stopped", startedAt: "2026-01-01T00:00:00Z" })), + ).toBe(true); + }); + + it("returns true for stopped process with lastEndedAt", () => { + expect( + hasInspectableProcessOutput(makeRuntime({ status: "stopped", lastEndedAt: "2026-01-01T00:00:00Z" })), + ).toBe(true); + }); + + it("returns true for stopped process with lastExitCode", () => { + expect( + hasInspectableProcessOutput(makeRuntime({ status: "stopped", lastExitCode: 0 })), + ).toBe(true); + }); + + it("returns true for stopped process with lastExitCode of 0", () => { + expect( + hasInspectableProcessOutput(makeRuntime({ status: "stopped", lastExitCode: 0 })), + ).toBe(true); + }); +}); + +describe("formatProcessStatus", () => { + it("returns plain status for non-crash/exit statuses", () => { + for (const status of ["stopped", "starting", "running", "degraded", "stopping"] as const) { + expect(formatProcessStatus({ status, lastExitCode: null })).toBe(status); + } + }); + + it("appends exit code to crashed status", () => { + expect(formatProcessStatus({ status: "crashed", lastExitCode: 137 })).toBe("crashed:137"); + }); + + it("appends exit code to exited status", () => { + expect(formatProcessStatus({ status: "exited", lastExitCode: 0 })).toBe("exited:0"); + }); + + it("returns plain status for crashed/exited without exit code", () => { + expect(formatProcessStatus({ status: "crashed", lastExitCode: null })).toBe("crashed"); + expect(formatProcessStatus({ status: "exited", lastExitCode: null })).toBe("exited"); + }); + + it("does not append exit code to non-crash/exit statuses even if present", () => { + expect(formatProcessStatus({ status: "running", lastExitCode: 1 })).toBe("running"); + expect(formatProcessStatus({ status: "stopped", lastExitCode: 0 })).toBe("stopped"); + }); + + it("handles exit code 0 for crashed status", () => { + expect(formatProcessStatus({ status: "crashed", lastExitCode: 0 })).toBe("crashed:0"); + }); +}); diff --git a/apps/desktop/src/renderer/components/run/processUtils.ts b/apps/desktop/src/renderer/components/run/processUtils.ts index c5fe767d0..ac20d0ce0 100644 --- a/apps/desktop/src/renderer/components/run/processUtils.ts +++ b/apps/desktop/src/renderer/components/run/processUtils.ts @@ -1,15 +1,24 @@ import type { ProcessRuntime, ProcessRuntimeStatus } from "../../../shared/types"; +const ACTIVE_STATUSES: ReadonlySet = new Set([ + "running", + "starting", + "degraded", + "stopping", +]); + export function isActiveProcessStatus(status: ProcessRuntimeStatus): boolean { - return status === "running" || status === "starting" || status === "degraded" || status === "stopping"; + return ACTIVE_STATUSES.has(status); } export function hasInspectableProcessOutput(runtime: ProcessRuntime): boolean { return runtime.status !== "stopped" || runtime.startedAt != null || runtime.lastEndedAt != null || runtime.lastExitCode != null; } +const TERMINAL_STATUSES: ReadonlySet = new Set(["crashed", "exited"]); + export function formatProcessStatus(runtime: Pick): string { - if ((runtime.status === "crashed" || runtime.status === "exited") && runtime.lastExitCode != null) { + if (TERMINAL_STATUSES.has(runtime.status) && runtime.lastExitCode != null) { return `${runtime.status}:${runtime.lastExitCode}`; } return runtime.status; diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 4737f5edc..63c911564 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -146,7 +146,7 @@ export function AiFeaturesSection() { || normalizeModelSetting(effectiveAi?.featureModelOverrides?.terminal_summaries) || "", ); - setChatAutoTitleEnabled(effectiveAi?.chat?.autoTitleEnabled === true); + setChatAutoTitleEnabled(effectiveAi?.chat?.autoTitleEnabled !== false); setChatAutoTitleRefresh(effectiveAi?.chat?.autoTitleRefreshOnComplete !== false); setChatAutoTitleReasoning(effectiveAi?.chat?.autoTitleReasoningEffort ?? null); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 03a44b982..0f59121e7 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect } from "react"; import type { TerminalSessionSummary } from "../../../shared/types"; import { isChatToolType } from "../../lib/sessions"; @@ -7,6 +8,18 @@ export type SessionContextMenuState = { y: number; } | null; +type SessionContextMenuProps = { + menu: SessionContextMenuState; + onClose: () => void; + onCloseSession: (args: { ptyId: string; sessionId: string }) => void; + onEndChat: (sessionId: string) => void; + onResume: (session: TerminalSessionSummary) => void; + onCopyResumeCommand: (command: string) => void; + onGoToLane: (session: TerminalSessionSummary) => void; + onCopySessionId: (id: string) => void; + onRename: (sessionId: string, newTitle: string) => void; +}; + export function SessionContextMenu({ menu, onClose, @@ -16,16 +29,26 @@ export function SessionContextMenu({ onCopyResumeCommand, onGoToLane, onCopySessionId, -}: { - menu: SessionContextMenuState; - onClose: () => void; - onCloseSession: (args: { ptyId: string; sessionId: string }) => void; - onEndChat: (sessionId: string) => void; - onResume: (session: TerminalSessionSummary) => void; - onCopyResumeCommand: (command: string) => void; - onGoToLane: (session: TerminalSessionSummary) => void; - onCopySessionId: (id: string) => void; -}) { + onRename, +}: SessionContextMenuProps) { + const [renaming, setRenaming] = useState(false); + const [draft, setDraft] = useState(""); + const inputRef = useRef(null); + + // Reset rename state when menu changes + useEffect(() => { + setRenaming(false); + setDraft(""); + }, [menu]); + + // Focus input when entering rename mode + useEffect(() => { + if (renaming && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [renaming]); + if (!menu) return null; const { session, x, y } = menu; @@ -33,6 +56,14 @@ export function SessionContextMenu({ const isChat = isChatToolType(session.toolType); const canResume = !isRunning && Boolean(session.resumeCommand); + const commitRename = () => { + const trimmed = draft.trim(); + if (trimmed.length > 0) { + onRename(session.id, trimmed); + } + onClose(); + }; + return ( <> {/* Backdrop */} @@ -44,6 +75,34 @@ export function SessionContextMenu({ style={{ left: x, top: y }} onPointerDown={(e) => e.stopPropagation()} > + {/* Rename (chat sessions only) */} + {isChat && renaming && ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitRename(); } + if (e.key === "Escape") { e.preventDefault(); onClose(); } + }} + onBlur={commitRename} + className="w-full rounded border border-border/30 bg-transparent px-2 py-1 text-xs text-[--color-fg] outline-none focus:border-[--color-accent]" + placeholder="Enter title..." + maxLength={48} + /> +
+ )} + {isChat && !renaming && ( + + )} + {isRunning && session.ptyId && !isChat ? ( {lane?.parentLaneId ? (