diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 33ece759b..092497522 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -736,6 +736,8 @@ export async function createAdeRuntime(args: { laneService, sessionService, processRegistry, + aiIntegrationService, + projectConfigService, logger, broadcastData: (event) => { pushEvent("pty", { type: "pty_data", event }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e92bc8922..31fb759d9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -12069,6 +12069,38 @@ describe("createAgentChatService", () => { }); }); + describe("readTranscript", () => { + it("refuses non-chat sessions even when a transcript file exists", async () => { + const { service, sessionService } = createService(); + const transcriptPath = path.join(tmpRoot, "transcripts", "terminal-session.chat.jsonl"); + fs.writeFileSync( + transcriptPath, + `${JSON.stringify({ + sessionId: "terminal-session", + timestamp: "2026-06-30T12:00:00.000Z", + event: { type: "user_message", text: "terminal secret" }, + sequence: 1, + })}\n`, + "utf8", + ); + sessionService.create({ + sessionId: "terminal-session", + laneId: "lane-1", + toolType: "terminal", + transcriptPath, + }); + vi.mocked(parseAgentChatTranscript).mockReturnValue([{ + sessionId: "terminal-session", + timestamp: "2026-06-30T12:00:00.000Z", + event: { type: "user_message", text: "terminal secret" }, + sequence: 1, + }]); + + await expect(service.readTranscript("terminal-session")).resolves.toEqual([]); + expect(parseAgentChatTranscript).not.toHaveBeenCalled(); + }); + }); + describe("getChatEventHistory", () => { it("returns an empty history for an unknown session", async () => { const { service } = createService(); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 4be2e56c8..ed563cc84 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -28003,10 +28003,14 @@ export function createAgentChatService(args: { limit?: number, since?: string, ): Promise => { - const managed = managedSessions.get(sessionId); + const trimmedId = sessionId.trim(); + if (!trimmedId.length) return []; + const row = sessionService.get(trimmedId); + if (!row || !isChatToolType(row.toolType)) return []; + const managed = managedSessions.get(trimmedId); const entries = managed ? readTranscriptEntries(managed) - : transcriptEntriesFromEnvelopes(sessionId, readFullTranscriptEnvelopesForSessionId(sessionId)); + : transcriptEntriesFromEnvelopes(trimmedId, readFullTranscriptEnvelopesForSessionId(trimmedId)); let filtered = entries; if (typeof since === "string" && since.trim().length) { filtered = filtered.filter((entry) => entry.timestamp >= since); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index b4f0bb80d..3a011abce 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -3311,7 +3311,7 @@ describe("ptyService", () => { }); const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; - expect(createdSessionId).toBeTruthy(); + expect(typeof createdSessionId).toBe("string"); service.write({ ptyId, data: "Fix the flaky login tests\r" }); @@ -3324,6 +3324,43 @@ describe("ptyService", () => { expect(sessionService.get(createdSessionId)?.goal).toBe("Fix the flaky login tests"); }); + it("uses the wrapped ADE user task instead of launch guidance for CLI fallback titles", async () => { + const { service, sessionService } = createHarness(); + const { ptyId } = await service.create({ + laneId: "lane-1", + title: "Codex", + cols: 80, + rows: 24, + toolType: "codex", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(typeof createdSessionId).toBe("string"); + + service.write({ + ptyId, + data: [ + "ADE session guidance. Treat this as operating guidance for the CLI session.", + "Start working on that user prompt immediately.", + "", + "User prompt:", + "You are working in ADE lane:", + "/repo/.ade/worktrees/context-iphone-17-simulator", + "", + "Edits and mutating commands must stay inside that worktree.", + "", + "The user is debugging the ADE iOS Work chat scroll/layout bugs.", + ].join("\n") + "\r", + }); + + expect(sessionService.get(createdSessionId)?.title).toBe( + "The user is debugging the ADE iOS Work chat scroll/layout bugs", + ); + expect(sessionService.get(createdSessionId)?.goal).toBe( + "The user is debugging the ADE iOS Work chat scroll/layout bugs.", + ); + }); + it("ignores provider slash commands when choosing the first CLI title seed", async () => { const { service, sessionService } = createHarness(); const { ptyId } = await service.create({ @@ -3335,7 +3372,7 @@ describe("ptyService", () => { }); const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; - expect(createdSessionId).toBeTruthy(); + expect(typeof createdSessionId).toBe("string"); service.write({ ptyId, data: "/model\r" }); @@ -3359,7 +3396,7 @@ describe("ptyService", () => { }); const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; - expect(createdSessionId).toBeTruthy(); + expect(typeof createdSessionId).toBe("string"); service.write({ ptyId, data: "/this is a test\r" }); @@ -3418,6 +3455,69 @@ describe("ptyService", () => { } }); + it("adopts Claude runtime window titles emitted by the live PTY", async () => { + const { service, mockPty, sessionService } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Start with context skill then i wanna redesign the ade", + cols: 80, + rows: 24, + toolType: "claude", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(typeof createdSessionId).toBe("string"); + + mockPty._emitter.emit( + "data", + "\x1b]0;\u2802 Redesign ADE mobile app with unified project hub\x07", + ); + + expect(sessionService.get(createdSessionId)?.title).toBe( + "Redesign ADE mobile app with unified project hub", + ); + expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: createdSessionId, + title: "Redesign ADE mobile app with unified project hub", + manuallyNamed: false, + }), + ); + }); + + it("does not let provider window titles replace ADE AI title generation", async () => { + const aiIntegrationService = { + getMode: vi.fn(() => "subscription"), + summarizeTerminal: vi.fn(async () => ({ text: "ADE generated title" })), + }; + const { service, mockPty, sessionService } = createHarness({ aiIntegrationService }); + await service.create({ + laneId: "lane-1", + title: "Start with context skill then i wanna redesign the ade", + cols: 80, + rows: 24, + toolType: "claude", + }); + + const createdSessionId = (sessionService.create as ReturnType).mock.calls[0]?.[0]?.sessionId; + expect(typeof createdSessionId).toBe("string"); + + mockPty._emitter.emit( + "data", + "\x1b]0;\u2802 Redesign ADE mobile app with unified project hub\x07", + ); + + expect(sessionService.get(createdSessionId)?.title).toBe( + "Start with context skill then i wanna redesign the ade", + ); + expect(sessionService.updateMeta).not.toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: createdSessionId, + title: "Redesign ADE mobile app with unified project hub", + }), + ); + }); + it("sets a deterministic title immediately, then upgrades it via a deferred AI pass for Claude CLI sessions", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 0983d2df2..6697ead5e 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -53,7 +53,11 @@ import type { PtyProcessResourceUsageSnapshot, } from "../../../shared/types"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; -import { withCodexNoAltScreen } from "../../../shared/cliLaunch"; +import { + sanitizeTrackedCliPromptSeed, + trackedCliTitleFromPromptSeed, + withCodexNoAltScreen, +} from "../../../shared/cliLaunch"; import { stripAnsi } from "../../utils/ansiStrip"; import { summarizeTerminalSession } from "../../utils/sessionSummary"; import { derivePreviewFromChunk } from "../../utils/terminalPreview"; @@ -109,8 +113,6 @@ function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolea } const CLI_USER_TITLE_SEED_MIN_LEN = 3; -const CLI_USER_TITLE_SEED_MAX_LEN = 180; -const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72; const CODEX_ADE_GUIDANCE_SCAN_BYTES = 160 * 1024; const CODEX_THREAD_NAME_SCAN_BYTES = 512 * 1024; const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024; @@ -492,52 +494,6 @@ function withAdeTerminalContextEnv(env: NodeJS.ProcessEnv, args: { return next; } -function sanitizeCliUserTitleSeed(raw: string): string { - const stripped = stripAnsi(raw) - .replace(/\r\n/g, "\n") - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - .replace(/\n/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (!stripped.length) return ""; - return stripped.slice(0, CLI_USER_TITLE_SEED_MAX_LEN); -} - -function trimPromptLeadIn(raw: string): string { - let text = raw.trim(); - for (let i = 0; i < 4; i += 1) { - const next = text - .replace(/^(?:ok(?:ay)?|so|hey|hi|hello|please|pls|vv)\b[\s,.:;-]*/iu, "") - .trim(); - if (next === text) break; - text = next; - } - return text; -} - -function sentenceCase(raw: string): string { - return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : raw; -} - -function deterministicCliTitleFromSeed(seed: string): string { - const naturalLanguageSlashTitle = seed.startsWith("/") && !isProviderSlashCommandInput(seed) - ? seed.slice(1).trim() - : seed; - const cleaned = trimPromptLeadIn(naturalLanguageSlashTitle) - .replace(/^["'`]+|["'`]+$/g, "") - .replace(/\s+/g, " ") - .trim(); - if (!cleaned) return ""; - - const clauseMatch = cleaned.match(/^(.{18,}?[,.!?;:])\s/u); - const clause = clauseMatch?.[1]?.replace(/[,.!?;:]+$/u, "").trim(); - const base = clause && clause.length >= 12 ? clause : cleaned; - const clipped = base.length > CLI_USER_TITLE_FALLBACK_MAX_LEN - ? base.slice(0, CLI_USER_TITLE_FALLBACK_MAX_LEN).replace(/\s+\S*$/u, "").trim() - : base; - return sentenceCase(clipped || base.slice(0, CLI_USER_TITLE_FALLBACK_MAX_LEN).trim()).replace(/[.?!,:;]+$/u, ""); -} - function isCliPlaceholderTitle(title: string | null | undefined, toolType: TerminalToolType | null | undefined): boolean { const normalized = String(title ?? "").trim().toLowerCase(); if (!normalized.length) return true; @@ -578,6 +534,30 @@ function sanitizeGeneratedCliTitle(raw: string): string { return rejected.has(collapsed) ? "" : title; } +function extractLatestOscWindowTitle(entry: PtyEntry, data: string): string { + const combined = `${entry.runtimeWindowTitleScanBuffer}${data}`.slice(-2048); + const titlePattern = /\x1b\](?:0|2);([^\x07\x1b]*)(?:\x07|\x1b\\)/g; + let match: RegExpExecArray | null; + let latestRawTitle = ""; + let latestEnd = 0; + while ((match = titlePattern.exec(combined))) { + latestRawTitle = match[1] ?? ""; + latestEnd = titlePattern.lastIndex; + } + + const partialStart = combined.lastIndexOf("\x1b]"); + entry.runtimeWindowTitleScanBuffer = partialStart >= latestEnd + ? combined.slice(partialStart) + : ""; + + if (!latestRawTitle.trim()) return ""; + return sanitizeGeneratedCliTitle( + latestRawTitle + .replace(/^[\s\u2800-\u28ff\u2022\u00b7.:-]+/u, "") + .trim(), + ); +} + function isSessionManuallyNamed( sessionService: ReturnType, sessionId: string, @@ -626,6 +606,7 @@ type PtyEntry = { lastUserInputAt: number; terminalSnapshot: TerminalSnapshotMirror | null; recentOutputTail: string; + runtimeWindowTitleScanBuffer: string; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ aiTitleTimer: ReturnType | null; startupTimer: ReturnType | null; @@ -1495,7 +1476,7 @@ export function createPtyService({ if (idx === -1) break; const segment = entry.cliUserTitleLineBuffer.slice(0, idx); entry.cliUserTitleLineBuffer = entry.cliUserTitleLineBuffer.slice(idx + 1); - const seed = sanitizeCliUserTitleSeed(segment); + const seed = sanitizeTrackedCliPromptSeed(segment); if (seed.length < CLI_USER_TITLE_SEED_MIN_LEN) continue; if (isProviderSlashCommandInput(seed)) continue; @@ -1515,7 +1496,7 @@ export function createPtyService({ return; } if (isCliPlaceholderTitle(session.title, session.toolType)) { - const fallbackTitle = deterministicCliTitleFromSeed(seed); + const fallbackTitle = trackedCliTitleFromPromptSeed(seed); if (fallbackTitle) { sessionService.updateMeta({ sessionId: entry.sessionId, title: fallbackTitle, manuallyNamed: false }); } @@ -1540,6 +1521,25 @@ export function createPtyService({ } }; + const adoptCliRuntimeWindowTitle = (entry: PtyEntry, data: string): void => { + if (!CLI_USER_TITLE_TOOL_TYPES.has(entry.toolTypeHint ?? "shell")) return; + if (aiIntegrationService && aiIntegrationService.getMode() !== "guest" && isTitleGenerationEnabled()) return; + const title = extractLatestOscWindowTitle(entry, data); + if (!title) return; + if (isSessionManuallyNamed(sessionService, entry.sessionId)) { + logger.info("pty.cli_runtime_window_title_skipped_user_renamed", { sessionId: entry.sessionId }); + return; + } + const session = sessionService.get(entry.sessionId); + if (!session || session.title?.trim() === title) return; + sessionService.updateMeta({ sessionId: entry.sessionId, title, manuallyNamed: false }); + logger.info("pty.cli_runtime_window_title_adopted", { + sessionId: entry.sessionId, + toolType: entry.toolTypeHint, + titleLength: title.length, + }); + }; + const clearIdleTimer = (sessionId: string) => { const state = runtimeStates.get(sessionId); if (!state?.idleTimer) return; @@ -3917,6 +3917,7 @@ export function createPtyService({ lastUserInputAt: 0, terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null, recentOutputTail: "", + runtimeWindowTitleScanBuffer: "", aiTitleTimer: null, startupTimer: null, initialInputTimer: null, @@ -3945,6 +3946,7 @@ export function createPtyService({ if (entry.disposed) return; resyncLiveSessionRowIfNeeded(entry, ptyId); appendRecentOutput(entry, data); + adoptCliRuntimeWindowTitle(entry, data); writeTranscript(entry, data); feedTerminalSnapshot(entry, data); updatePreviewThrottled(entry, data); diff --git a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx index 387e0adeb..2578480fe 100644 --- a/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx +++ b/apps/desktop/src/renderer/components/files/v2/EditorGroups.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from "react"; import { Funnel, Stack } from "@phosphor-icons/react"; import { Group, Panel } from "react-resizable-panels"; -import type { FilesWorkspace, LaneSummary } from "../../../../shared/types"; +import type { LaneSummary } from "../../../../shared/types"; import { ResizeGutter } from "../../ui/ResizeGutter"; import type { MonacoModelRegistry } from "../monacoModelRegistry"; import type { EditorTab, GroupsState } from "./editorGroupsStore"; @@ -22,7 +22,6 @@ export type TabWorkspaceContext = { export type EditorGroupsProps = { sessionKey: string; state: GroupsState; - workspaces: FilesWorkspace[]; explorerWorkspaceId: string; explorerLaneId: string | null; lanes: LaneSummary[]; @@ -51,9 +50,14 @@ export type EditorGroupsProps = { }; export function EditorGroups(props: EditorGroupsProps) { - const ids = props.state.groupOrder.filter((id) => props.state.groups[id]); - const evenSize = (100 / Math.max(1, ids.length)).toFixed(4); - const layoutKey = ids.join("|"); + const groupEntries = props.state.groupOrder + .map((id) => { + const group = props.state.groups[id]; + return group ? { id, group } : null; + }) + .filter((entry): entry is { id: string; group: GroupsState["groups"][string] } => entry != null); + const evenSize = (100 / Math.max(1, groupEntries.length)).toFixed(4); + const layoutKey = groupEntries.map((entry) => entry.id).join("|"); const sizeKey = `${props.sessionKey}::${layoutKey}`; const persisted = splitSizesByKey.get(sizeKey); @@ -94,11 +98,11 @@ export function EditorGroups(props: EditorGroupsProps) { if (next && Object.keys(next).length > 1) splitSizesByKey.set(sizeKey, next); }} > - {ids.map((id, i) => ( + {groupEntries.map(({ id, group }, i) => ( - {i < ids.length - 1 ? : null} + {i < groupEntries.length - 1 ? : null} ))} diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx index 65be7b886..9b44d4b75 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx @@ -4,8 +4,10 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FilesWorkspace } from "../../../../shared/types"; +import { filesSessionKey } from "../treeHelpers"; import { useEditorGroupsStore } from "./editorGroupsStore"; import { FilesWorkbench } from "./FilesWorkbench"; +import { recordRecentFile } from "./recentFiles"; const testState = vi.hoisted(() => ({ appState: { @@ -24,18 +26,26 @@ vi.mock("../../../state/appStore", () => ({ })); vi.mock("../FilesExplorer", () => ({ - FilesExplorer: ({ onOpenFile }: { onOpenFile: (path: string) => void }) => ( - + FilesExplorer: ({ canMutate, onOpenFile }: { canMutate?: boolean; onOpenFile: (path: string) => void }) => ( +
+
{String(canMutate)}
+ +
), })); vi.mock("./WorkspacePicker", () => ({ WorkspacePicker: ({ onChange }: { onChange: (workspaceId: string) => void }) => ( - +
+ + +
), })); @@ -82,6 +92,15 @@ const workspaces: FilesWorkspace[] = [ name: "Lane B", rootPath: "/repo/.ade/worktrees/b", laneId: "lane-b", + isReadOnlyByDefault: true, + mobileReadOnly: true, + }, + { + id: "workspace-c", + kind: "attached", + name: "Attached", + rootPath: "/repo-attached", + laneId: null, isReadOnlyByDefault: false, mobileReadOnly: true, }, @@ -89,6 +108,8 @@ const workspaces: FilesWorkspace[] = [ describe("FilesWorkbench", () => { beforeEach(() => { + testState.appState.project = { rootPath: "/repo" }; + testState.appState.selectedLaneId = "lane-a"; useEditorGroupsStore.setState({ sessions: {} }); vi.spyOn(window, "confirm").mockReturnValue(true); Object.defineProperty(window, "ade", { @@ -143,4 +164,54 @@ describe("FilesWorkbench", () => { expect(window.confirm).not.toHaveBeenCalled(); await waitFor(() => expect(screen.getByTestId("dirty-count").textContent).toBe("1")); }); + + it("lets read-only-by-default workspaces opt into editing for the session", async () => { + const { rerender } = render(); + + fireEvent.click(await screen.findByTestId("switch-workspace")); + + await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("false")); + fireEvent.click(screen.getByTestId("open-file")); + await waitFor(() => expect(screen.getByTestId("can-edit").textContent).toBe("false")); + + fireEvent.click(screen.getByRole("button", { name: /enable editing/i })); + + await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("true")); + expect(screen.getByTestId("can-edit").textContent).toBe("true"); + + testState.appState.project = { rootPath: "/other-repo" }; + rerender(); + + await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("false")); + }); + + it("keeps recent files scoped to the selected lane workspace", async () => { + recordRecentFile(filesSessionKey("/repo", "lane-a"), "src/lane-a.ts"); + recordRecentFile(filesSessionKey("/repo", "lane-b"), "src/lane-b.ts"); + + render(); + + await screen.findByRole("button", { name: /lane-a\.ts/i }); + expect(screen.queryByRole("button", { name: /lane-b\.ts/i })).toBeNull(); + + fireEvent.click(screen.getByTestId("switch-workspace")); + + await screen.findByRole("button", { name: /lane-b\.ts/i }); + expect(screen.queryByRole("button", { name: /lane-a\.ts/i })).toBeNull(); + }); + + it("keeps recent files scoped to attached workspace ids", async () => { + recordRecentFile(filesSessionKey("/repo", "lane-a"), "src/lane-a.ts"); + recordRecentFile(filesSessionKey("/repo", "workspace-c"), "src/attached.ts"); + + render(); + + await screen.findByRole("button", { name: /lane-a\.ts/i }); + expect(screen.queryByRole("button", { name: /attached\.ts/i })).toBeNull(); + + fireEvent.click(screen.getByTestId("switch-attached-workspace")); + + await screen.findByRole("button", { name: /attached\.ts/i }); + expect(screen.queryByRole("button", { name: /lane-a\.ts/i })).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index d25c6b970..bb8220130 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowSquareOut, Copy, FilePlus, FolderPlus, PencilSimple, Trash } from "@phosphor-icons/react"; +import { ArrowSquareOut, Copy, FilePlus, FolderPlus, LockOpen, PencilSimple, Trash } from "@phosphor-icons/react"; import type { FileTreeNode, FilesWorkspace } from "../../../../shared/types"; import { useAppStore } from "../../../state/appStore"; import { createMonacoModelRegistry } from "../monacoModelRegistry"; @@ -68,9 +68,21 @@ const workspacesCacheByProject = new Map(); const rootTreeCacheByKey = new Map(); const readCachedWorkspaces = (projectRoot: string): FilesWorkspace[] => workspacesCacheByProject.get(projectRoot) ?? []; const rootTreeCacheKey = (projectRoot: string, workspaceId: string): string => `${projectRoot}::${workspaceId}`; +const editOverrideKey = (projectRoot: string, workspaceId: string): string => `${projectRoot}::${workspaceId}`; -function canEditWorkspace(workspace: FilesWorkspace | null | undefined): boolean { - return workspace != null && !workspace.isReadOnlyByDefault; +function recentScopeIdForWorkspace(workspace: FilesWorkspace | null | undefined, fallbackLaneId: string | null): string | null { + if (!workspace) return fallbackLaneId; + if (workspace.kind === "worktree") return workspace.laneId ?? workspace.id; + if (workspace.kind === "primary") return workspace.laneId ?? fallbackLaneId; + return workspace.id; +} + +function canEditWorkspace( + workspace: FilesWorkspace | null | undefined, + editOverrides: ReadonlySet = new Set(), + projectRoot = "", +): boolean { + return workspace != null && (!workspace.isReadOnlyByDefault || editOverrides.has(editOverrideKey(projectRoot, workspace.id))); } function mergeExternalWorkspaces(next: FilesWorkspace[], previous: FilesWorkspace[]): FilesWorkspace[] { @@ -126,11 +138,26 @@ export function FilesWorkbench({ const [workspaceId, setWorkspaceId] = useState(initialWorkspaceId); const workspace = useMemo(() => workspaces.find((w) => w.id === workspaceId) ?? null, [workspaces, workspaceId]); const rootPath = workspace?.rootPath ?? projectRootPath; - const canEdit = canEditWorkspace(workspace); + const [editOverrides, setEditOverrides] = useState>(() => new Set()); + const workspaceEditOverrideKey = workspace ? editOverrideKey(projectRootPath, workspace.id) : ""; + const canEdit = canEditWorkspace(workspace, editOverrides, projectRootPath); const canRevealInFinder = workspace != null && (workspace.kind === "external" || !isRemoteProject); + const showEnableEditing = Boolean(workspace?.isReadOnlyByDefault) && !editOverrides.has(workspaceEditOverrideKey); + const enableEditingForWorkspace = useCallback(() => { + if (!workspace) return; + setEditOverrides((prev) => { + const next = new Set(prev); + next.add(editOverrideKey(projectRootPath, workspace.id)); + return next; + }); + }, [projectRootPath, workspace]); const branch = workspace?.branchRef?.replace("refs/heads/", "") ?? null; const theme: EditorThemeMode = "dark"; const sessionKey = filesProjectSessionKey(projectRootPath); + const recentSessionKey = filesSessionKey( + projectRootPath, + recentScopeIdForWorkspace(workspace, globalLaneId), + ); const [tabScope, setTabScope] = useState(() => getFilesTabScope(projectRootPath)); const [tree, setTree] = useState( @@ -210,11 +237,11 @@ export function FilesWorkbench({ workspaceId: tab.workspaceId, rootPath: wsRoot, laneId: tab.laneId, - canEdit: canEditWorkspace(ws), + canEdit: canEditWorkspace(ws, editOverrides, projectRootPath), canRevealInFinder: ws != null && (ws.kind === "external" || !isRemoteProject), }; }, - [isRemoteProject, projectRootPath, workspaces], + [editOverrides, isRemoteProject, projectRootPath, workspaces], ); const migratedSessionsRef = useRef(null); @@ -266,7 +293,7 @@ export function FilesWorkbench({ }, [projectRootPath]); const knownRootPaths = useMemo(() => new Set(tree.map((node) => node.path)), [tree]); - const recentFiles = getRecentFiles(sessionKey); + const recentFiles = getRecentFiles(recentSessionKey); const visibleRecentFiles = useMemo( () => ( tree.length > 0 @@ -278,8 +305,8 @@ export function FilesWorkbench({ useEffect(() => { if (tree.length === 0) return; - pruneMissingRootRecentFiles(sessionKey, knownRootPaths); - }, [knownRootPaths, sessionKey, tree.length]); + pruneMissingRootRecentFiles(recentSessionKey, knownRootPaths); + }, [knownRootPaths, recentSessionKey, tree.length]); const dirtyTabsUnder = useCallback( (wsId: string, target: string): string[] => @@ -732,12 +759,12 @@ export function FilesWorkbench({ pinned: false, }; applyGroups((s) => openInGroup(s, s.activeGroupId, tab, { preview: opts.preview ?? true })); - recordRecentFile(sessionKey, path); + recordRecentFile(recentSessionKey, path); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, - [workspace, workspaceId, applyGroups, sessionKey], + [workspace, workspaceId, applyGroups, recentSessionKey], ); const handleActivateTab = useCallback( @@ -941,11 +968,11 @@ export function FilesWorkbench({ setError(err instanceof Error ? err.message : String(err)); return; } - forgetRecentFilesUnder(sessionKey, sourcePath); + forgetRecentFilesUnder(recentSessionKey, sourcePath); closeOpenTabsUnder(workspaceId, sourcePath); await refreshRoot(); }, - [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, refreshRoot, sessionKey, workspaceId], + [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, recentSessionKey, refreshRoot, workspaceId], ); const deletePath = useCallback( @@ -960,14 +987,14 @@ export function FilesWorkbench({ if (!confirmDiscardDirtyTabIds(dirtyTabsUnder(workspaceId, path), "Delete it")) return; try { await window.ade.files.delete({ workspaceId, path }); - forgetRecentFilesUnder(sessionKey, path); + forgetRecentFilesUnder(recentSessionKey, path); closeOpenTabsUnder(workspaceId, path); await refreshRoot(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } }, - [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, refreshRoot, sessionKey, workspaceId], + [canEdit, closeOpenTabsUnder, confirmDiscardDirtyTabIds, dirtyTabsUnder, recentSessionKey, refreshRoot, workspaceId], ); const dirForNode = (menu: FilesExplorerContextMenuEvent): string => @@ -1096,6 +1123,18 @@ export function FilesWorkbench({ {!embedded ? ( ) : null} + {showEnableEditing ? ( + + ) : null}
& { + path: string; +}; + export function editorTabId(workspaceId: string, path: string): string { return `${workspaceId}::${path}`; } @@ -318,18 +322,18 @@ export function upgradeLegacySession( const group = session.groups[groupId]; if (!group) continue; const upgradedTabs = group.tabs.map((raw) => { - const partial = raw as Partial & { path: string }; - const id = partial.id ?? editorTabId(workspaceId, partial.path); + const legacy = raw as LegacyEditorTab; + const id = legacy.id ?? editorTabId(workspaceId, legacy.path); return { id, - workspaceId: partial.workspaceId ?? workspaceId, - laneId: partial.laneId !== undefined ? partial.laneId : laneId, - path: partial.path, - title: partial.title ?? partial.path.split("/").pop() ?? partial.path, - viewerKind: partial.viewerKind ?? "code", - languageId: partial.languageId ?? "plaintext", - preview: partial.preview ?? false, - pinned: partial.pinned ?? false, + workspaceId: legacy.workspaceId ?? workspaceId, + laneId: legacy.laneId !== undefined ? legacy.laneId : laneId, + path: legacy.path, + title: legacy.title ?? legacy.path.split("/").pop() ?? legacy.path, + viewerKind: legacy.viewerKind ?? "code", + languageId: legacy.languageId ?? "plaintext", + preview: legacy.preview ?? false, + pinned: legacy.pinned ?? false, } satisfies EditorTab; }); const pathToId = new Map(upgradedTabs.map((t) => [t.path, t.id])); diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 16ffc0cb6..62e8b5b13 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -5,6 +5,7 @@ import { buildTrackedCliStartupCommand, buildOpenCodeReplayResumeCommand, defaultTrackedCliStartupCommand, + deriveTrackedCliInitialInputSessionMeta, resolveCleanShellLaunchFields, resolveTrackedCliResumeCommand, withCodexNoAltScreen, @@ -372,6 +373,57 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.startupCommand).not.toContain("Fix the failing Work tests."); }); + it("derives initial metadata from the user task inside ADE guidance", () => { + const meta = deriveTrackedCliInitialInputSessionMeta({ + provider: "codex", + title: "Codex", + initialInput: [ + "ADE session guidance. Treat this as operating guidance for the CLI session.", + "Start working on that user prompt immediately.", + "", + "User prompt:", + "You are working in ADE lane:", + "/repo/.ade/worktrees/context-iphone-17-simulator", + "", + "Edits and mutating commands must stay inside that worktree.", + "", + "The user is debugging the ADE iOS Work chat scroll/layout bugs.", + ].join("\n"), + }); + + expect(meta.goal).toBe("The user is debugging the ADE iOS Work chat scroll/layout bugs."); + expect(meta.title).toBe("The user is debugging the ADE iOS Work chat scroll/layout bugs"); + expect(meta.promptTitle).toBe("The user is debugging the ADE iOS Work chat scroll/layout bugs"); + }); + + it("derives metadata from ADE lane guidance without a blank separator", () => { + const meta = deriveTrackedCliInitialInputSessionMeta({ + provider: "codex", + title: "Codex", + initialInput: [ + "You are working in ADE lane:", + "/repo/.ade/worktrees/context-iphone-17-simulator", + "Redesign the ADE mobile project hub.", + ].join("\n"), + }); + + expect(meta.goal).toBe("Redesign the ADE mobile project hub."); + expect(meta.title).toBe("Redesign the ADE mobile project hub"); + expect(meta.promptTitle).toBe("Redesign the ADE mobile project hub"); + }); + + it("does not unwrap ordinary prompts that mention ADE guidance text", () => { + const meta = deriveTrackedCliInitialInputSessionMeta({ + provider: "codex", + title: "Codex", + initialInput: "Explain why docs say Start working on that user prompt immediately.", + }); + + expect(meta.goal).toBe("Explain why docs say Start working on that user prompt immediately."); + expect(meta.title).toBe("Explain why docs say Start working on that user prompt immediately"); + expect(meta.promptTitle).toBe("Explain why docs say Start working on that user prompt immediately"); + }); + it("passes explicit Codex service tier overrides for fast mode", () => { const fastLaunch = buildTrackedCliLaunchCommand({ provider: "codex", diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 87b4cdf9d..9ab466755 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -79,9 +79,13 @@ function stripAnsiForCliTitle(raw: string): string { } export function sanitizeTrackedCliPromptSeed(raw: string): string { - const stripped = stripAnsiForCliTitle(raw) + const normalized = stripAnsiForCliTitle(raw) .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .trim(); + const unwrapped = unwrapAdeGuidancePromptForTitle(normalized); + const stripped = unwrapped .replace(/\n/g, " ") .replace(/\s+/g, " ") .trim(); @@ -89,6 +93,52 @@ export function sanitizeTrackedCliPromptSeed(raw: string): string { return stripped.slice(0, TRACKED_CLI_PROMPT_SEED_MAX_LEN); } +function unwrapAdeGuidancePromptForTitle(raw: string): string { + const text = raw.trim(); + if (!text.length) return ""; + const marker = /\bUser prompt:\s*/iu.exec(text); + const looksLikeAdeGuidance = + /^ADE session guidance\b/iu.test(text) + || (/^Start working on that user prompt immediately\./iu.test(text) && marker != null); + if (!looksLikeAdeGuidance) return stripAdeLaneDirectiveForTitle(text); + + const userPrompt = marker ? text.slice(marker.index + marker[0].length).trim() : text; + return stripAdeLaneDirectiveForTitle(userPrompt); +} + +function stripAdeLaneDirectiveForTitle(raw: string): string { + const lines = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + let start = lines.findIndex((line) => line.trim().length > 0); + if (start < 0) return ""; + + const firstLine = lines[start]?.trim() ?? ""; + const pathLine = lines[start + 1]?.trim() ?? ""; + const looksLikeLaneDirective = + /^You are working in ADE lane:?$/iu.test(firstLine) + && ( + pathLine.includes(".ade/worktrees/") + || pathLine.startsWith("/") + || /^[A-Za-z]:[\\/]/u.test(pathLine) + ); + if (!looksLikeLaneDirective) return raw.trim(); + + let i = start + 2; + while (i < lines.length && lines[i]!.trim().length === 0) i += 1; + + const maybeMutationRule = lines[i]?.trim() ?? ""; + if ( + maybeMutationRule.length > 0 + && /(?:edit|edits|mutating|commands)/iu.test(maybeMutationRule) + && /(?:worktree|lane|inside)/iu.test(maybeMutationRule) + ) { + i += 1; + while (i < lines.length && lines[i]!.trim().length === 0) i += 1; + } + + const remainder = lines.slice(i).join("\n").trim(); + return remainder; +} + function trimPromptLeadIn(raw: string): string { let text = raw.trim(); for (let i = 0; i < 4; i += 1) { diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index da6f9be96..ce493edcc 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -352,11 +352,12 @@ See the detail docs for the specifics: dispatch and event streaming continue asynchronously. `ade chat create --prompt` uses this same follow-up send after the session is created, and `ade chat read ` calls `chat.readTranscript` to inspect recent - transcript messages. Interactive chat sends are not wall-clock bounded by - the service; the turn runs until the provider completes or the user/app - interrupts it. The blocking `runSessionTurn` helper used by automation has - a 5 min default RPC timeout unless the caller passes `timeoutMs: null`; - background/headless chat launches opt out. + transcript messages for chat sessions only; shell/terminal transcript reads + stay on the terminal/session surfaces. Interactive chat sends are not + wall-clock bounded by the service; the turn runs until the provider + completes or the user/app interrupts it. The blocking `runSessionTurn` + helper used by automation has a 5 min default RPC timeout unless the caller + passes `timeoutMs: null`; background/headless chat launches opt out. 4. The runtime streams events through the main-process event emitter and into the renderer via `ade.agentChat.event` (a push channel owned by `registerIpc.ts`). diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 2e1aa9a20..ee8ac0e72 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -422,9 +422,12 @@ Renderer surfaces: without a startup command. `deriveTrackedCliInitialInputSessionMeta` seeds the session title and `goal` field from the first prompt (sanitised + clipped to ~72 chars) when the caller did not supply a - manual title, so tracked CLI rows render with a meaningful name - instead of "Codex" / "Claude" while still letting providers like - Shell fall back to the generic profile title. + manual title; ADE launch guidance is unwrapped first so lane/worktree + directives do not become the title. Tracked CLI rows render with a + meaningful name instead of "Codex" / "Claude" while still letting + providers like Shell fall back to the generic profile title. If ADE + AI title generation is unavailable, `ptyService` can also adopt a + provider-emitted terminal window title after sanitizing it. - `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` — thin re-export of `apps/desktop/src/shared/cliLaunch.ts` plus the renderer- local Work launch envelope types: `WorkPtyLaunchDisposition` @@ -582,7 +585,11 @@ See `apps/desktop/src/shared/types/sessions.ts` for the full shape. service may summarize the early output into a short title via the AI integration service. For Claude/Codex it prefers the first submitted user line (`tryCliUserTitleFromWrite`) because the TUI hides useful - text in the alternate screen. + text in the alternate screen; ADE guidance wrappers are stripped + before deriving the visible title/goal. Provider-emitted OSC window + titles are accepted only when ADE title generation is not available, + so the ADE summarizer remains authoritative for normal desktop and + CLI runtime launches. 5. **Runtime exit** — on PTY exit, `sessionService.end()` finalizes `endedAt`, `exitCode`, and `status`. The transcript stream is flushed, then: