From 4df948fc06b289b10089ab5f6286677535576087 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 02:12:08 -0700 Subject: [PATCH 01/39] Add Sessions panel to right sidebar Adds a Sessions mode to the right sidebar (cmd-option-b) alongside the existing file tree. A two-button toggle at the top of the panel switches between Files and Sessions. Sessions lists recent agent runs from: - Claude Code (~/.claude/projects/*/*.jsonl) - Codex (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) - OpenCode (~/.local/share/opencode/opencode.db, snapshotted before read) Each row shows the first user prompt as a title, the working directory, and a relative timestamp. Right-click for Open / Reveal in Finder / Copy File Path / Open Working Directory. A "This folder only" filter scopes the list to the current FileExplorer root. Mode selection persists in UserDefaults under "rightSidebar.mode". --- GhosttyTabs.xcodeproj/project.pbxproj | 12 + Resources/Localizable.xcstrings | 272 +++++++++++++++++++ Sources/ContentView.swift | 12 +- Sources/FileExplorerStore.swift | 7 + Sources/RightSidebarPanelView.swift | 120 +++++++++ Sources/SessionIndexStore.swift | 359 ++++++++++++++++++++++++++ Sources/SessionIndexView.swift | 289 +++++++++++++++++++++ 7 files changed, 1070 insertions(+), 1 deletion(-) create mode 100644 Sources/RightSidebarPanelView.swift create mode 100644 Sources/SessionIndexStore.swift create mode 100644 Sources/SessionIndexView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7e410de4afeb..5873bf3c15d5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ FE001101 /* FileExplorerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001001 /* FileExplorerStore.swift */; }; FE001102 /* FileExplorerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001002 /* FileExplorerView.swift */; }; + FE003101 /* SessionIndexStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003001 /* SessionIndexStore.swift */; }; + FE003102 /* SessionIndexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003002 /* SessionIndexView.swift */; }; + FE003103 /* RightSidebarPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003003 /* RightSidebarPanelView.swift */; }; FE002101 /* FileExplorerRootResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002001 /* FileExplorerRootResolverTests.swift */; }; FE002102 /* FileExplorerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE002002 /* FileExplorerStoreTests.swift */; }; A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; @@ -213,6 +216,9 @@ /* Begin PBXFileReference section */ FE001001 /* FileExplorerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStore.swift; sourceTree = ""; }; FE001002 /* FileExplorerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerView.swift; sourceTree = ""; }; + FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; + FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; + FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = ""; }; FE002001 /* FileExplorerRootResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerRootResolverTests.swift; sourceTree = ""; }; FE002002 /* FileExplorerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExplorerStoreTests.swift; sourceTree = ""; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -533,6 +539,9 @@ A5001655 /* CmuxDirectoryTrust.swift */, FE001001 /* FileExplorerStore.swift */, FE001002 /* FileExplorerView.swift */, + FE003001 /* SessionIndexStore.swift */, + FE003002 /* SessionIndexView.swift */, + FE003003 /* RightSidebarPanelView.swift */, ); path = Sources; sourceTree = ""; @@ -864,6 +873,9 @@ A5001654 /* CmuxDirectoryTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, FE001102 /* FileExplorerView.swift in Sources */, + FE003101 /* SessionIndexStore.swift in Sources */, + FE003102 /* SessionIndexView.swift in Sources */, + FE003103 /* RightSidebarPanelView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e2d1e2701913..8bf2dc4ec27a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -89686,6 +89686,278 @@ } } } + }, + "rightSidebar.mode.files": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル" + } + } + } + }, + "rightSidebar.mode.sessions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッション" + } + } + } + }, + "sessionIndex.agent.claude": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code" + } + } + } + }, + "sessionIndex.agent.codex": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex" + } + } + } + }, + "sessionIndex.agent.opencode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OpenCode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OpenCode" + } + } + } + }, + "sessionIndex.untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題のセッション" + } + } + } + }, + "sessionIndex.scope.thisFolder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This folder only" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このフォルダのみ" + } + } + } + }, + "sessionIndex.reload.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを再読み込み" + } + } + } + }, + "sessionIndex.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning sessions…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションをスキャン中…" + } + } + } + }, + "sessionIndex.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションが見つかりません" + } + } + } + }, + "sessionIndex.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions from Claude Code, Codex, and OpenCode will appear here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code、Codex、OpenCode のセッションがここに表示されます。" + } + } + } + }, + "sessionIndex.section.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションなし" + } + } + } + }, + "sessionIndex.row.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + } + } + }, + "sessionIndex.row.reveal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reveal in Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finder で表示" + } + } + } + }, + "sessionIndex.row.copyPath": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy File Path" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルパスをコピー" + } + } + } + }, + "sessionIndex.row.openCwd": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Working Directory" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業ディレクトリを開く" + } + } + } } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d90e52592971..814eb0934240 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1844,6 +1844,7 @@ struct ContentView: View { @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() @StateObject private var fileExplorerStore = FileExplorerStore() + @StateObject private var sessionIndexStore = SessionIndexStore() @State private var fileExplorerWidth: CGFloat = 220 @State private var fileExplorerDragStartWidth: CGFloat? @State private var previousSelectedWorkspaceId: UUID? @@ -2775,7 +2776,11 @@ struct ContentView: View { if explorerVisible { Divider() } - FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + RightSidebarPanelView( + fileExplorerStore: fileExplorerStore, + fileExplorerState: fileExplorerState, + sessionIndexStore: sessionIndexStore + ) .frame(width: explorerVisible ? fileExplorerWidth : 0) .clipped() .allowsHitTesting(explorerVisible) @@ -2974,6 +2979,11 @@ struct ContentView: View { guard !dir.isEmpty else { return } fileExplorerStore.showHiddenFiles = true + if !tab.isRemoteWorkspace { + sessionIndexStore.currentDirectory = dir + } else { + sessionIndexStore.currentDirectory = nil + } if tab.isRemoteWorkspace { let config = tab.remoteConfiguration diff --git a/Sources/FileExplorerStore.swift b/Sources/FileExplorerStore.swift index a6459f6c219a..411856a88663 100644 --- a/Sources/FileExplorerStore.swift +++ b/Sources/FileExplorerStore.swift @@ -440,6 +440,11 @@ final class FileExplorerState: ObservableObject { didSet { UserDefaults.standard.set(showHiddenFiles, forKey: "fileExplorer.showHidden") } } + /// Active mode for the right sidebar (file tree or session index). + @Published var mode: RightSidebarMode { + didSet { UserDefaults.standard.set(mode.rawValue, forKey: "rightSidebar.mode") } + } + init() { let defaults = UserDefaults.standard self.isVisible = defaults.bool(forKey: "fileExplorer.isVisible") @@ -449,6 +454,8 @@ final class FileExplorerState: ObservableObject { self.dividerPosition = storedPosition > 0 ? CGFloat(storedPosition) : 0.6 let storedShowHidden = defaults.object(forKey: "fileExplorer.showHidden") self.showHiddenFiles = storedShowHidden == nil ? true : defaults.bool(forKey: "fileExplorer.showHidden") + let storedMode = defaults.string(forKey: "rightSidebar.mode") ?? RightSidebarMode.files.rawValue + self.mode = RightSidebarMode(rawValue: storedMode) ?? .files } func toggle() { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift new file mode 100644 index 000000000000..3ea1d961008e --- /dev/null +++ b/Sources/RightSidebarPanelView.swift @@ -0,0 +1,120 @@ +import AppKit +import SwiftUI + +/// Mode shown in the right sidebar (the panel toggled by ⌘⌥B). +enum RightSidebarMode: String, CaseIterable { + case files + case sessions + + var label: String { + switch self { + case .files: return String(localized: "rightSidebar.mode.files", defaultValue: "Files") + case .sessions: return String(localized: "rightSidebar.mode.sessions", defaultValue: "Sessions") + } + } + + var symbolName: String { + switch self { + case .files: return "folder" + case .sessions: return "bubble.left.and.text.bubble.right" + } + } +} + +/// Right sidebar root view. Hosts a segmented mode picker plus the active panel. +struct RightSidebarPanelView: View { + @ObservedObject var fileExplorerStore: FileExplorerStore + @ObservedObject var fileExplorerState: FileExplorerState + @ObservedObject var sessionIndexStore: SessionIndexStore + + var body: some View { + VStack(spacing: 0) { + modeBar + Divider() + contentForMode + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var modeBar: some View { + HStack(spacing: 4) { + ForEach(RightSidebarMode.allCases, id: \.rawValue) { mode in + ModeBarButton( + mode: mode, + isSelected: fileExplorerState.mode == mode + ) { + if fileExplorerState.mode != mode { + fileExplorerState.mode = mode + if mode == .sessions { + sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty + ? nil + : fileExplorerStore.rootPath + if sessionIndexStore.entries.isEmpty { + sessionIndexStore.reload() + } + } + } + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .frame(height: 30) + } + + @ViewBuilder + private var contentForMode: some View { + switch fileExplorerState.mode { + case .files: + FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) + case .sessions: + SessionIndexView(store: sessionIndexStore) + .onAppear { + sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty + ? nil + : fileExplorerStore.rootPath + } + } + } +} + +private struct ModeBarButton: View { + let mode: RightSidebarMode + let isSelected: Bool + let action: () -> Void + + @State private var isHovered: Bool = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: mode.symbolName) + .font(.system(size: 11, weight: .medium)) + Text(mode.label) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(backgroundColor) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(mode.label) + } + + private var backgroundColor: Color { + if isSelected { + return Color.primary.opacity(0.10) + } + if isHovered { + return Color.primary.opacity(0.05) + } + return Color.clear + } +} diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift new file mode 100644 index 000000000000..0bc45782a447 --- /dev/null +++ b/Sources/SessionIndexStore.swift @@ -0,0 +1,359 @@ +import AppKit +import Combine +import Foundation +import SQLite3 + +// MARK: - Agents + +enum SessionAgent: String, CaseIterable, Identifiable, Hashable { + case claude + case codex + case opencode + + var id: String { rawValue } + + var displayName: String { + switch self { + case .claude: return String(localized: "sessionIndex.agent.claude", defaultValue: "Claude Code") + case .codex: return String(localized: "sessionIndex.agent.codex", defaultValue: "Codex") + case .opencode: return String(localized: "sessionIndex.agent.opencode", defaultValue: "OpenCode") + } + } + + var symbolName: String { + switch self { + case .claude: return "sparkles" + case .codex: return "terminal" + case .opencode: return "chevron.left.forwardslash.chevron.right" + } + } + + var accentColor: NSColor { + switch self { + case .claude: return NSColor.systemOrange + case .codex: return NSColor.secondaryLabelColor + case .opencode: return NSColor.systemTeal + } + } +} + +// MARK: - Session entry + +struct SessionEntry: Identifiable, Hashable { + let id: String + let agent: SessionAgent + let title: String + let cwd: String? + let modified: Date + let fileURL: URL? + + var displayTitle: String { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return String(localized: "sessionIndex.untitled", defaultValue: "Untitled session") + } + return trimmed + } + + var cwdLabel: String? { + guard let cwd, !cwd.isEmpty else { return nil } + let home = NSHomeDirectory() + if cwd.hasPrefix(home) { + return "~" + cwd.dropFirst(home.count) + } + return cwd + } +} + +// MARK: - Store + +@MainActor +final class SessionIndexStore: ObservableObject { + @Published private(set) var entries: [SessionEntry] = [] + @Published private(set) var isLoading: Bool = false + @Published var scopeToCurrentDirectory: Bool = false + @Published var currentDirectory: String? = nil + + private var loadTask: Task? + + func reload() { + loadTask?.cancel() + isLoading = true + loadTask = Task.detached(priority: .userInitiated) { [weak self] in + let scanned = await Self.scanAll() + await MainActor.run { + guard let self else { return } + if Task.isCancelled { return } + self.entries = scanned + self.isLoading = false + } + } + } + + func filteredEntries(for agent: SessionAgent) -> [SessionEntry] { + let base = entries.filter { $0.agent == agent } + guard scopeToCurrentDirectory, let dir = normalizedDirectory(currentDirectory) else { + return base + } + return base.filter { entry in + guard let cwd = normalizedDirectory(entry.cwd) else { return false } + return cwd == dir || cwd.hasPrefix(dir + "/") + } + } + + private func normalizedDirectory(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + var path = (value as NSString).standardizingPath + if path.count > 1 && path.hasSuffix("/") { + path.removeLast() + } + return path + } + + // MARK: - Scanning + + private static let perAgentLimit = 60 + private static let titlePreviewByteCap = 32 * 1024 + + private static func scanAll() async -> [SessionEntry] { + async let claude = scanClaude() + async let codex = scanCodex() + async let opencode = scanOpenCode() + let combined = await claude + codex + opencode + return combined.sorted { $0.modified > $1.modified } + } + + // MARK: Claude + + private static func scanClaude() async -> [SessionEntry] { + let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath + let fm = FileManager.default + guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { + return [] + } + + var candidates: [(URL, Date, String)] = [] + for dirName in projectDirs { + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } + + candidates.sort { $0.1 > $1.1 } + let limited = candidates.prefix(perAgentLimit) + + var results: [SessionEntry] = [] + results.reserveCapacity(limited.count) + for (url, mtime, dirName) in limited { + let preview = readFileHead(url: url, byteCap: titlePreviewByteCap) + let (title, cwd) = extractClaudeMetadata(jsonl: preview, projectDir: dirName) + let id = "claude:" + url.path + results.append(SessionEntry( + id: id, + agent: .claude, + title: title, + cwd: cwd, + modified: mtime, + fileURL: url + )) + } + return results + } + + private static func extractClaudeMetadata(jsonl: String, projectDir: String) -> (String, String?) { + var firstUserText: String? + var cwd: String? = decodeClaudeProjectDir(projectDir) + for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8) else { continue } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + if let cwdField = obj["cwd"] as? String, !cwdField.isEmpty { + cwd = cwdField + } + if firstUserText == nil, + (obj["type"] as? String) == "user", + let message = obj["message"] as? [String: Any], + (message["role"] as? String) == "user" { + if let content = message["content"] as? String, !content.isEmpty { + firstUserText = content + } else if let parts = message["content"] as? [[String: Any]] { + for part in parts { + if (part["type"] as? String) == "text", + let text = part["text"] as? String, !text.isEmpty { + firstUserText = text + break + } + } + } + } + if firstUserText != nil { break } + } + return (firstUserText ?? "", cwd) + } + + private static func decodeClaudeProjectDir(_ raw: String) -> String? { + // Claude encodes cwd by replacing "/" with "-" and prefixing "-" + // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq" + // This is lossy (cannot distinguish original "-" from "/"), so try as a hint only. + guard !raw.isEmpty else { return nil } + let stripped = raw.hasPrefix("-") ? String(raw.dropFirst()) : raw + let candidate = "/" + stripped.replacingOccurrences(of: "-", with: "/") + return candidate + } + + // MARK: Codex + + private static func scanCodex() async -> [SessionEntry] { + let root = ("~/.codex/sessions" as NSString).expandingTildeInPath + let fm = FileManager.default + let rootURL = URL(fileURLWithPath: root) + guard let enumerator = fm.enumerator( + at: rootURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var candidates: [(URL, Date)] = [] + for case let url as URL in enumerator { + guard url.pathExtension == "jsonl" else { continue } + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, + let mtime = values?.contentModificationDate else { continue } + candidates.append((url, mtime)) + } + candidates.sort { $0.1 > $1.1 } + let limited = candidates.prefix(perAgentLimit) + + var results: [SessionEntry] = [] + results.reserveCapacity(limited.count) + for (url, mtime) in limited { + let preview = readFileHead(url: url, byteCap: titlePreviewByteCap) + let (title, cwd) = extractCodexMetadata(jsonl: preview) + let id = "codex:" + url.path + results.append(SessionEntry( + id: id, + agent: .codex, + title: title, + cwd: cwd, + modified: mtime, + fileURL: url + )) + } + return results + } + + private static func extractCodexMetadata(jsonl: String) -> (String, String?) { + var cwd: String? + var firstUserMessage: String? + for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8) else { continue } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + let type = obj["type"] as? String + let payload = obj["payload"] as? [String: Any] + if type == "session_meta", let p = payload, let c = p["cwd"] as? String, !c.isEmpty { + cwd = c + } + if firstUserMessage == nil, type == "event_msg", let p = payload, + (p["type"] as? String) == "user_message", + let msg = p["message"] as? String, !msg.isEmpty { + firstUserMessage = msg + } + if firstUserMessage != nil && cwd != nil { break } + } + return (firstUserMessage ?? "", cwd) + } + + // MARK: OpenCode + + private static func scanOpenCode() async -> [SessionEntry] { + let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath + let fm = FileManager.default + guard fm.fileExists(atPath: dbPath) else { return [] } + + // Snapshot DB + WAL/SHM into a temp directory to avoid contention with a running OpenCode. + let snapshotDir = fm.temporaryDirectory.appendingPathComponent("cmux-opencode-snap-\(UUID().uuidString)", isDirectory: true) + do { + try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) + } catch { + return [] + } + defer { try? fm.removeItem(at: snapshotDir) } + + let snapshotDB = snapshotDir.appendingPathComponent("opencode.db") + do { + try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) + } catch { + return [] + } + for sidecar in ["-wal", "-shm"] { + let src = dbPath + sidecar + let dst = snapshotDB.path + sidecar + if fm.fileExists(atPath: src) { + try? fm.copyItem(atPath: src, toPath: dst) + } + } + + var db: OpaquePointer? + let openCode = sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) + guard openCode == SQLITE_OK, let db else { + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + + let sql = "SELECT id, title, directory, time_updated FROM session ORDER BY time_updated DESC LIMIT \(perAgentLimit)" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + sqlite3_finalize(stmt) + return [] + } + defer { sqlite3_finalize(stmt) } + + var results: [SessionEntry] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let sid = sqliteText(stmt, 0) ?? "" + let title = sqliteText(stmt, 1) ?? "" + let directory = sqliteText(stmt, 2) + let updatedMs = sqlite3_column_int64(stmt, 3) + let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) + results.append(SessionEntry( + id: "opencode:" + sid, + agent: .opencode, + title: title, + cwd: directory, + modified: modified, + fileURL: nil + )) + } + return results + } + + private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? { + guard let cString = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: cString) + } + + // MARK: Helpers + + /// Read up to `byteCap` bytes from the start of the file as UTF-8. + private static func readFileHead(url: URL, byteCap: Int) -> String { + guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } + defer { try? handle.close() } + let data: Data + if #available(macOS 10.15.4, *) { + data = (try? handle.read(upToCount: byteCap)) ?? Data() + } else { + data = handle.readData(ofLength: byteCap) + } + return String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + } +} diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift new file mode 100644 index 000000000000..36e572516848 --- /dev/null +++ b/Sources/SessionIndexView.swift @@ -0,0 +1,289 @@ +import AppKit +import SwiftUI + +struct SessionIndexView: View { + @ObservedObject var store: SessionIndexStore + @State private var expandedAgents: Set = Set(SessionAgent.allCases) + + static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + var body: some View { + VStack(spacing: 0) { + controlBar + Divider() + if store.isLoading && store.entries.isEmpty { + loadingView + } else if store.entries.isEmpty { + emptyView + } else { + sessionsList + } + } + .onAppear { + if store.entries.isEmpty { + store.reload() + } + } + } + + private var controlBar: some View { + HStack(spacing: 8) { + Toggle(isOn: $store.scopeToCurrentDirectory) { + Text(String(localized: "sessionIndex.scope.thisFolder", defaultValue: "This folder only")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .toggleStyle(.checkbox) + .controlSize(.small) + .disabled(store.currentDirectory == nil) + + Spacer(minLength: 4) + + Button { + store.reload() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .medium)) + } + .buttonStyle(.borderless) + .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload sessions")) + .disabled(store.isLoading) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(height: 28) + } + + private var loadingView: some View { + VStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sessionIndex.loading", defaultValue: "Scanning sessions…")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyView: some View { + VStack(spacing: 4) { + Text(String(localized: "sessionIndex.empty.title", defaultValue: "No sessions found")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + Text(String(localized: "sessionIndex.empty.subtitle", + defaultValue: "Sessions from Claude Code, Codex, and OpenCode will appear here.")) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var sessionsList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(SessionAgent.allCases) { agent in + let entries = store.filteredEntries(for: agent) + AgentSection( + agent: agent, + entries: entries, + isExpanded: Binding( + get: { expandedAgents.contains(agent) }, + set: { newValue in + if newValue { + expandedAgents.insert(agent) + } else { + expandedAgents.remove(agent) + } + } + ) + ) + } + } + .padding(.bottom, 8) + } + } +} + +private struct AgentSection: View { + let agent: SessionAgent + let entries: [SessionEntry] + @Binding var isExpanded: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(nil) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + Image(systemName: agent.symbolName) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color(agent.accentColor)) + .frame(width: 14) + Text(agent.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + Spacer() + Text("\(entries.count)") + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.secondary.opacity(0.15)) + ) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + if entries.isEmpty { + Text(String(localized: "sessionIndex.section.empty", defaultValue: "No sessions")) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.horizontal, 30) + .padding(.vertical, 4) + } else { + ForEach(entries) { entry in + SessionRow(entry: entry) + } + } + } + } + } +} + +private struct SessionRow: View { + let entry: SessionEntry + @State private var isHovered: Bool = false + + var body: some View { + Button { + open() + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entry.displayTitle) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + HStack(spacing: 6) { + if let cwdLabel = entry.cwdLabel { + Image(systemName: "folder") + .font(.system(size: 9)) + .foregroundColor(.secondary) + Text(folderDisplayName(cwdLabel)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 4) + Text(relativeTime(entry.modified)) + .font(.system(size: 10).monospacedDigit()) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.leading, 18) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .padding(.horizontal, 6) + ) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(helpText) + .contextMenu { + if entry.fileURL != nil { + Button { + open() + } label: { + Text(String(localized: "sessionIndex.row.open", defaultValue: "Open")) + } + Button { + revealInFinder() + } label: { + Text(String(localized: "sessionIndex.row.reveal", defaultValue: "Reveal in Finder")) + } + Divider() + Button { + copyPath() + } label: { + Text(String(localized: "sessionIndex.row.copyPath", defaultValue: "Copy File Path")) + } + } + if let cwd = entry.cwd, !cwd.isEmpty { + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) + } label: { + Text(String(localized: "sessionIndex.row.openCwd", defaultValue: "Open Working Directory")) + } + } + } + } + + private var helpText: String { + var lines: [String] = [entry.displayTitle] + if let cwd = entry.cwdLabel { + lines.append(cwd) + } + lines.append(absoluteTime(entry.modified)) + return lines.joined(separator: "\n") + } + + private func folderDisplayName(_ path: String) -> String { + (path as NSString).lastPathComponent + } + + private func open() { + guard let url = entry.fileURL else { + // OpenCode entries have no file URL — fall back to opening cwd + if let cwd = entry.cwd, !cwd.isEmpty { + NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) + } + return + } + NSWorkspace.shared.open(url) + } + + private func revealInFinder() { + guard let url = entry.fileURL else { return } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + private func copyPath() { + guard let url = entry.fileURL else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(url.path, forType: .string) + } + + private func relativeTime(_ date: Date) -> String { + SessionIndexView.relativeFormatter.localizedString(for: date, relativeTo: Date()) + } + + private func absoluteTime(_ date: Date) -> String { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f.string(from: date) + } +} From 2ac2036bdcb82848a0056eb463f98588f707ab33 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 02:30:11 -0700 Subject: [PATCH 02/39] Use brand icons, draggable section reorder, header tweak - Replace SF Symbols in agent section headers with the official Claude, OpenAI (Codex), and OpenCode brand marks. Icons rendered from upstream SVGs (simpleicons.org and opencode.ai/favicon.svg) at @1x/@2x/@3x and bundled under Assets.xcassets/AgentIcons. - Make agent sections drag-reorderable. Drag a section header onto another to put it first; the order persists in UserDefaults under "sessionIndex.agentOrder". - Right-sidebar mode bar: shift the toggle 2px left and bump header height from 30 to 31. --- .../AgentIcons/Claude.imageset/Claude.png | Bin 0 -> 573 bytes .../AgentIcons/Claude.imageset/Claude@2x.png | Bin 0 -> 1414 bytes .../AgentIcons/Claude.imageset/Claude@3x.png | Bin 0 -> 2250 bytes .../AgentIcons/Claude.imageset/Contents.json | 8 ++ .../AgentIcons/Codex.imageset/Codex.png | Bin 0 -> 402 bytes .../AgentIcons/Codex.imageset/Codex@2x.png | Bin 0 -> 934 bytes .../AgentIcons/Codex.imageset/Codex@3x.png | Bin 0 -> 1473 bytes .../AgentIcons/Codex.imageset/Contents.json | 8 ++ Assets.xcassets/AgentIcons/Contents.json | 1 + .../OpenCode.imageset/Contents.json | 8 ++ .../AgentIcons/OpenCode.imageset/OpenCode.png | Bin 0 -> 236 bytes .../OpenCode.imageset/OpenCode@2x.png | Bin 0 -> 229 bytes .../OpenCode.imageset/OpenCode@3x.png | Bin 0 -> 268 bytes Sources/RightSidebarPanelView.swift | 5 +- Sources/SessionIndexStore.swift | 58 ++++++-- Sources/SessionIndexView.swift | 136 +++++++++++++----- 16 files changed, 176 insertions(+), 48 deletions(-) create mode 100644 Assets.xcassets/AgentIcons/Claude.imageset/Claude.png create mode 100644 Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png create mode 100644 Assets.xcassets/AgentIcons/Claude.imageset/Claude@3x.png create mode 100644 Assets.xcassets/AgentIcons/Claude.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex.png create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex@3x.png create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/Contents.json create mode 100644 Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json create mode 100644 Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png create mode 100644 Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png create mode 100644 Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Claude.png b/Assets.xcassets/AgentIcons/Claude.imageset/Claude.png new file mode 100644 index 0000000000000000000000000000000000000000..8f68dd19f36c50972c972bedebc1fbb605844d70 GIT binary patch literal 573 zcmV-D0>b@?P)c2qO0?D5!Ipv$6vR3Tf^Qp< zivNH^p-r-=FNi-X4o)2eagZ#@QU}Er92EpHOGA@%7qQfqK!4b#-*d5=hBR|z=o^YFoo%m zP^oRho&$r1qGXh>|Bl*l7l9AL-b(5M7QjUkhfOvt>Oc=!}J8` z0NR0dpc8DlEwGb2Y8>$1^<#8eEt{S|Y16_Y5L zqua@K$@~$6eKHz34b>27;*)1-$o!(s1)27psQuNj|@C3?p!ioC)wZ~$$a!3~p!BtK-hHG@6g^u}OT zvZp{DQLk+*KjamR14}LuJoD>~d5meKii~*ARH4u^IdBsm ze<}H(xP<(m;lCuKeBC>{j&$z$qryzjRH4BCP|c3aRWj+pf9XE}4v^4%A_7vl00000 LNkvXXu0mjfV%iJ{ literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png b/Assets.xcassets/AgentIcons/Claude.imageset/Claude@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a771853bb039e6689d6536d8bc9c6d1b775db0ff GIT binary patch literal 1414 zcmV;11$p|3P)9h zMG+NZyb(jz4hZoR39vc>QIQCWBp9}?fS@D<&^RLz?}R_b)(%kP1!v?khAVUJd;FvA z)?GRAO)Sjs7u$N9 z^KnZyuFNpm3W-?Tv%nZ&h={IN$LWS`i`yNjR5Z7^ORq<)S3%UA&Df2M#OmgBDm#ur zn3aT@tN&Z@%B2AZfZFt4gyos_XmjKyAQwQajbzp$0pCNBfUiE{_iev!!O7dUwHa%l zZ%-)qMf~0=?TxwdThnXnl2h$s{*sx%hd?31GtpA-#2y8se&3@JfAOKH-?uoO^SmDZ zTp-+mHmlp$ac)Vinv435KAmzl0nvRx-&F02J5DF}?Sd+lBJ!XLJL&_z9lyNnbpyoX zu2LYDaydJ?qU0U`Ys}DOr<@eWv2`mRxjE%*K*y=bbHg`^Vu?`f0@U|=OkM&}aRqZQCUfR59 zucr_2m!LHkIp(r_G4dySQo`XxcbC@@X9C`VaU;{PvLEP^1(pCBfj8+2(UlpWif~wAx+v2WZtMzBIFfaE*E-%CqGiQv z5)zM~x(KvU(A6ZzTx$<4Gf@ggZ3sQ=agQ#r*zq7Q09C+mK%1ZqJvxl|ix(lR2N?>e z!cHTuwW^7uz?~|r6J-_1dC;3vZLL6Gh4;_78<);-YT{iDjCI0SItkX6dWX1-*(Q+w z*#0DHmU@DfpF{$_0|IkUHmcFnqOw(mVw9Z%d4ldyWDqb5;b4Bh7GF=-j*j8&I3~xK z6DcvPqHkPHSyZ(-r70&|J0!<>WlCe(sA5D!kcYbc8E7lYQ>HtyI ztuA+tUj#qs)HtT*?8_B_L}PUq0b5uex)GpZ zg?Fm8<``g5-YpDx}~VRx*8zTQtKi4 zk^Li$^{fvc2R5fHJl#;{yT2${{aGKEnF#F3yzzCLcj;)2wY(9%?me z<_q#Ya55WjR)>Y!y- zd%38irjB*6RiqeJ4-_begkG(vak2A`@)S?|98+7 z%mZc*sT8?m$8EP&jO4R>Rp1QZCIFQA1ART$96M&Yphr%NAhv3UhXcd z>W5>;8&p}Kb03Nw6xdp%>FyDoteiNcBSU;7(|FK-VIow2*}l83K5kr~0BOTAV9170 z^z1#Mh9yNEL{4y(4%V)TeNt$me!r7j$Evb5Ibp)%;|86xCYlh@wZjAGkBiwWI>SZ1 zdw4t2RpV?Rw~jMv*b}V3ywJ8N=-w4K&Ui2ApH|Gcx~jJYW$0Q{uE=hjArf$b>fa07 zyPE6QD0<5wm*ysG^ehnz^T^ZNt8v$;NGX`&jk4fu#m!YZV?GQE}hNw7mLUe zflsY+P#isE&_n4o2OWolr|spS#A4|}XAOreI(&qzEN z=mut%bV3h<90A>rNH%5o(3k5 z5@-s@l*~^#J(rqQwEW#TREJUi47wfBZKmh*ztukca{p)!0`PixiD$+a)4wWIOc7^b zsKiM2*xS_YD8#8j*5Jb|-D>%^s_AZ@C=f@42Y)%-!+qn^H` zO&#bxeEfk08#W9S1wB?JngeqbHX>Xwo;XEx1UR57`;D;L8)^I9vF-Ym{^@GXeG1

o!^>} zuAZ_$tSmxVl>JwV-Yh`>TNbE-Bti}KsI~y7xSIM@B#h-#5#5+!l>?w(85I`R*G`;s zxBHNsO?UGIE>h(JghjwikhhHJPfLP!H3!a1BkM%;{DR)LS>aY=?F=xl0+qn$h}?_n zy3(VU&8bQ0#<}5GawL=ZJ^op~)v@mE;ELm%L?p{qJ+GY<~*@48J|9o zwjB)S;hRyNjYt^uBNbwzvdrcCDo_W^%F=FC?fXt$>x)NAjjQN&IUWj}(^F|W6qXl2 zzU;xC;cJaP#;#p5khYr!A>~FTMU$)#`p?WXyvCIElH5w3PXV&JMMkc+!cg2ma z8*7=MUBUXRQ2s(@dcpfs37-=W@#(P2k*T)2ERTeWOE%A2k(F0L}kn|+a}KYI!T@TJsjkFUJWH94g~jXWag za|n-!p1CKfx%Ct z5Q(qwpW=-~g91x|euR^;_KiIu|JlVr$CO9kS#jg!Dku3SnP*6V7rc?^wYiH~BGk~R zs!wJEFY`vC59fRn!TO&l^1xs;ea#zbzf3?on;RC0XcVXcju^|uxxwdD_S+Lcl^NKW zhZO$RFV2wLqoVoQu-O^S>BynuBEZ{&HF8Nj7+4BW*V?wDvcLaqU<)u+jb2gMciEtw z!N77vb9bCCu(p2KRYF;i$4JW%&;=Vd42ZSg!?gqiTK9w+P63$xix)ogMxx(Up@`d&jio@LK^^YB>E8O;G?jSQit|T zymE|;Ws4Q51AWvNX@9<$jdkQa$sMSnr#8IlNHIGB?Nrlf#I9GQ!fJYRISk`EkGd5P z2A%{Ov&DVBNVMk5iVb7j^`lDUANk+Pf4;Q1S&ik;JF5Cp6&tFYo&glc@sHQzT1zPs4E}=-NEcyjVkriL#3UpJY!ZpO zEsVnCvNREG!XOfp#3kuggWKw|w1eTD#3hyv&ynylINS5&Ip6bpzUT0=@-c=hWO0Bd z@2Ca*LM8Grj$51uq%*hS1BP*dBGh3O$7sh#G~y6_Pfee2hCOVcDxwY`K{rNFf@=Ij zK?dLP6EnCE`by9@QHC0vpap+XpWrK6@%dTwo@h8j117PE`FPGIct9y0Q5;g%Fcpvq z{J<{Su#EmpSe(G`h&TU+?ZJ@KjA?WwLAXXOhJrqc*=Xtr_&o}6hoS`K_=6>EVLK*& zi?}yA(|fT4g`v1Ql#fVPkVsc@C0)^mbY-Wgc%t;jlrQnJ9Y@k5s0(SA@vo)10y?oT wJ+IpkmKRCZG53bFSJf1dtn>^=k$L{Xe}Vu>6|zl<0{{R307*qoM6N<$f`&n@WB>pF literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..42ab2f7429f7a3da96f105852b690a97aac222a1 GIT binary patch literal 934 zcmV;X16lluP)F0yEp3Zlr$ zT*dUDO}cMWraiPaxo8o!N*jqF7&NTtp+nEvC<;N?^s=ZKoQE{i;+)IxH$VULkov;q z@;l%AJLjIqJ@+ye$Z=uzcw5Ra+C+{B7p`;Q3!N!U=^b(!x^ZZW<~ z*(hE}Zwk)AH7#`BlmF~#5^FGlzww9&$s|@M-<{vR=&JqKjwe)J?{4t$eR4~SU;%zj zuWnA+=7zyFBFuHH!T^Twd`)pP0^3u52NC~uI3I7|+~!|D9eG?93gks3~pb;aKi`6kp^hvPHFjJFrbv2V*%eFAEN&pk4$`gKP8qXhx(h z$GViYs@N+;JXH?&7QsN0H`Nx7<$Q5XHlfN# z9mB~zgCEj?cSL`kz|+`|TXVQQckrbuB%OFWf7qGInpIlIh{!WMp8B4v@%N?d^&;4t zg4ayV!wsq&&lb{R@XlPnxwd9$%JvoI8*=UuiC&i9pW%8@5P#sHlZ)n{TFFm?5}oLg4H6%ujcnc(XPrBGawH5kZ5768Vc)0)6C&5q6z#WE_HB0F8a2i z=u*+fCPjnpNpB0fYJ7LY;8sx^Lm~oqH{ITo3)eOYTrIAkf<>w}Hz}^J#mPNr>J>MR zM_cH$Qe2kDOzo?6%o8~tG`-iaBsYS0MT?sj8&zlSWn71IMNvJZdQ_bdEvxxZ>J-Q4 zxT&pjMywYPpy?h*eWE&>I79WMd~vSE3Oewl2uZ)_M2Az^r=stcBXyd%T)#o1ryTmUAPx3@P4NE4V;SwxH4-W#zq{A zeb_7(HjJ$!;9iNbcvJHW{T0Xc(CS^uq?PFsobB7oP~Q9kiu zcoYXR`9Wcullvf;V?{RB(mJK1@Oq}WA5V(V(-7e(#|@=LypCBlV;_$vL;_eM%&H0# zHSK#d4yjUlQmbv9liJs(wrwT-MXCMcM7k=VACBAbX=zda#`kN;K9%X6Ru<05gqyU+ zv~ENf)=YR6-b*_B@Fx*9a>6fSW~pDkQ$H>m2ZTrF_HaG^Q!{xlI1R6eXmNtD`BO#C zpP2|3xB!0>(PSvKZOr6H=L4USt>D#)iR-@TVy$0vA|BCnu7l$1RN-O~Tl25G9Y4kF ztZh-=X-Oui$_(9z{tCaqC+YW2&BEjpk@Ks{y}?9mdx`M>viEs;hbJ<@v=I<3%Dn?v zF6z`t>Am>6@2D^LhVVOK$K`O?k_Cq|!H{-zBig{9ab`u|Gn1^)TaD>8glCFkr#8X- zArn+pMBRu!pmT~GlXUWGX_=CvBn%2WFLXb`F@)D|$P{OEn6M5Xq(SS@NIFF2wI4qd z!GFG}UkmI@ZL8AnNx}yVU~Q&2yJDgp>F+qLnWvwkyTD4}2P=h{J(S4jvGgEr)XaqQ z)7*~G^t4^tw||D__8-JEB9|A@qAuX)Sg1YR%);)}{!&Gs2Q^J#&Hz5c!$~+ktujxC zZwRNDFRGfW@lQRLVjb|B$m=9KEMZml{=I_GJ;HE_bIv2eunQazi#b_1!XQphvdOwT z(@3hRH?#MaW6F72L&I88kyl}w2sQnNr$r@s5$xAj^qr9P`(rMcS|VDMdJm~DVlTtn zx(EEC80m-T^o+;yTySkB*i|CDU4)d}910AoPoX=KtZ=-3h+dz~|I1vE=lm;ilL+!T z;kQIVQH2{sR48`hVSF>m7L0&sZZfCV5xpq24dJ)i^YE~6EcmY096o*&n z4+xVeChkUbmMEM%@)*RHr0|)heU&j*6eMqp>&0)^=*`l4SZoLf$x&p)5Ad|s6YW}& zfGV*xQ~Z6MQWs1XX|`~zB6K~f_4GNuw21s!EiNW%F+ubCrXfO7uNa5h#ct%2t`V0M zWn5T6a3g-8$AV{Wsq-?OS2qUNXqJ1$)9pIvu}t?_Tw(+_)Oo0Ch$Hmf!tTB$ir&-3 z7cU>uJ{K2dovw4HYetJL;?`xv_@VY&xo*!&aYXKf@%Ts9XUS+t7Wj_1K`CDxR6Y0g zFy3Slz>7YA*W-5gTBQ$(0^|(M!g@=Z_=-$#7{3+{v_ET~B6jE&%}e0TI9uyc=qtkj zexv>C?i4p5YqZ~)HpF@9q_2Zu8JCH(dpGmgE&f5#F-CsXI8+c`E}UhnAhr?DiF`kC bq=SC}??hxk$*}~&00000NkvXXu0mjf`a9kT literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json new file mode 100644 index 000000000000..d5eb2b241790 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "Codex.png", "scale" : "1x" }, + { "idiom" : "universal", "filename" : "Codex@2x.png", "scale" : "2x" }, + { "idiom" : "universal", "filename" : "Codex@3x.png", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AgentIcons/Contents.json b/Assets.xcassets/AgentIcons/Contents.json new file mode 100644 index 000000000000..e377ccd6c1f2 --- /dev/null +++ b/Assets.xcassets/AgentIcons/Contents.json @@ -0,0 +1 @@ +{ "info": { "author": "xcode", "version": 1 }, "properties": { "provides-namespace": true } } diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json b/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json new file mode 100644 index 000000000000..6a8dd1eb11af --- /dev/null +++ b/Assets.xcassets/AgentIcons/OpenCode.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "OpenCode.png", "scale" : "1x" }, + { "idiom" : "universal", "filename" : "OpenCode@2x.png", "scale" : "2x" }, + { "idiom" : "universal", "filename" : "OpenCode@3x.png", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode.png new file mode 100644 index 0000000000000000000000000000000000000000..9c1d94ad2ca3963d38cace196b1f51c53f30c736 GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh1SBVD?P>#3Y)RhkE)4%caKYZ?lYt@&JzX3_ zG$tmChzTSp@EHiM>}zanWIcE3;#DAUoxHE+=clQsr!SOAWMt%c_2$Koenu;6s|Q>W z7kXxzBp!Nud%L8BL`Z1pXD+S{)(lMF-rcSKazc=ejV)VRn#pVG)TuLP&IGCu5fLe< z;%2%L5ou{@smNoZxrvFJ$z3 cG4W((uy8WIP#At`3DB_&p00i_>zopr01g>Y5dZ)H literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..61b2e23aef95773e1776782786e4dbe0b5c99f7b GIT binary patch literal 229 zcmeAS@N?(olHy`uVBq!ia0vp^G9b*s1SJ3FdmIK*Y)RhkE)4%caKYZ?lYt_$JY5_^ zJUWv_!~_x+m?bRQ)w#HBQ-bK-EveiSfBmhgF<_P4cg~K->%+D_@Y+8-;bp7aSS1&fIMT7+ghQ;-{Uk~sPDmZZbbaq-=o2JLag*t22 zG&woVX!=pgJJ&$=VVh7#Ex*6Nt*xzzqh@C2pIvQLnx$`E1bXoteOJqM@PcQM^YH+N YoB7gfQ=iBb0$s`A>FVdQ&MBb@0ELZMw*UYD literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png b/Assets.xcassets/AgentIcons/OpenCode.imageset/OpenCode@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f95f02a5be04f7e087be9a032d7a7c578aecfc02 GIT binary patch literal 268 zcmeAS@N?(olHy`uVBq!ia0vp^S|H591SGu{#_<3twj^(N7l!{JxM1({$v}~Vo-U3d z5v|E0Vgd;g76(ik%KD@1v;H1n6#H}O4;ZA({`c|m@qT&xN%^%^X=#y_mN&1JmzI^4 zmOKbF*z~wACE8!%<-*3z>F1WHsXq=hsD2ldEG!%q`Tz6t^X~m}55pZMH7Z-bYY(}U zoRoI$;?<+;8>U?|HuCcJejM&Ft5Id;GAoI#byt1`zGi2b5cX!n=FOi!eR>q`;Qab4 zFHhQ;S+0}B{v@Ov|9fKFJi-4j*w$J{vaDHdDsi(A=*$yaR KKbLh*2~7Z7pLSsY literal 0 HcmV?d00001 diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 3ea1d961008e..b18c4fb1d5a4 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -58,9 +58,10 @@ struct RightSidebarPanelView: View { } Spacer(minLength: 0) } - .padding(.horizontal, 6) + .padding(.leading, 4) + .padding(.trailing, 6) .padding(.vertical, 4) - .frame(height: 30) + .frame(height: 31) } @ViewBuilder diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 0bc45782a447..e6d6828df9d6 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -5,7 +5,7 @@ import SQLite3 // MARK: - Agents -enum SessionAgent: String, CaseIterable, Identifiable, Hashable { +enum SessionAgent: String, CaseIterable, Identifiable, Hashable, Codable { case claude case codex case opencode @@ -20,19 +20,12 @@ enum SessionAgent: String, CaseIterable, Identifiable, Hashable { } } - var symbolName: String { + /// Asset catalog image name for the agent's brand mark. + var assetName: String { switch self { - case .claude: return "sparkles" - case .codex: return "terminal" - case .opencode: return "chevron.left.forwardslash.chevron.right" - } - } - - var accentColor: NSColor { - switch self { - case .claude: return NSColor.systemOrange - case .codex: return NSColor.secondaryLabelColor - case .opencode: return NSColor.systemTeal + case .claude: return "AgentIcons/Claude" + case .codex: return "AgentIcons/Codex" + case .opencode: return "AgentIcons/OpenCode" } } } @@ -74,6 +67,45 @@ final class SessionIndexStore: ObservableObject { @Published var scopeToCurrentDirectory: Bool = false @Published var currentDirectory: String? = nil + /// User-visible order of agent sections. Persisted across launches. + @Published var agentOrder: [SessionAgent] { + didSet { Self.persistOrder(agentOrder) } + } + + private static let agentOrderDefaultsKey = "sessionIndex.agentOrder" + + init() { + self.agentOrder = Self.loadOrder() + } + + func moveAgent(_ agent: SessionAgent, to newIndex: Int) { + guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } + var clamped = max(0, min(newIndex, agentOrder.count - 1)) + if oldIndex == clamped { return } + var next = agentOrder + next.remove(at: oldIndex) + if clamped > oldIndex { clamped -= 1 } + next.insert(agent, at: min(clamped, next.count)) + agentOrder = next + } + + private static func loadOrder() -> [SessionAgent] { + let defaults = UserDefaults.standard + let stored = defaults.array(forKey: agentOrderDefaultsKey) as? [String] ?? [] + var ordered: [SessionAgent] = stored.compactMap { SessionAgent(rawValue: $0) } + for agent in SessionAgent.allCases where !ordered.contains(agent) { + ordered.append(agent) + } + // Drop any duplicates while preserving first occurrence + var seen = Set() + ordered = ordered.filter { seen.insert($0).inserted } + return ordered + } + + private static func persistOrder(_ order: [SessionAgent]) { + UserDefaults.standard.set(order.map { $0.rawValue }, forKey: agentOrderDefaultsKey) + } + private var loadTask: Task? func reload() { diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 36e572516848..ac70df9c7e5d 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -86,7 +86,7 @@ struct SessionIndexView: View { private var sessionsList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(SessionAgent.allCases) { agent in + ForEach(store.agentOrder) { agent in let entries = store.filteredEntries(for: agent) AgentSection( agent: agent, @@ -100,7 +100,8 @@ struct SessionIndexView: View { expandedAgents.remove(agent) } } - ) + ), + store: store ) } } @@ -113,40 +114,12 @@ private struct AgentSection: View { let agent: SessionAgent let entries: [SessionEntry] @Binding var isExpanded: Bool + @ObservedObject var store: SessionIndexStore + @State private var isDropTarget: Bool = false var body: some View { VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(nil) { isExpanded.toggle() } - } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.secondary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - Image(systemName: agent.symbolName) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(Color(agent.accentColor)) - .frame(width: 14) - Text(agent.displayName) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - Spacer() - Text("\(entries.count)") - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.secondary.opacity(0.15)) - ) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + sectionHeader if isExpanded { if entries.isEmpty { @@ -162,6 +135,103 @@ private struct AgentSection: View { } } } + .overlay(alignment: .top) { + if isDropTarget { + Rectangle() + .fill(Color.accentColor) + .frame(height: 2) + } + } + } + + private var sectionHeader: some View { + Button { + withAnimation(nil) { isExpanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + Text(agent.displayName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + Spacer() + Text("\(entries.count)") + .font(.system(size: 10, weight: .medium).monospacedDigit()) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.secondary.opacity(0.15)) + ) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onDrag { + NSItemProvider(object: agent.rawValue as NSString) + } preview: { + HStack(spacing: 6) { + Image(agent.assetName) + .resizable() + .frame(width: 14, height: 14) + Text(agent.displayName) + .font(.system(size: 12, weight: .semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .onDrop( + of: [.text], + delegate: AgentSectionDropDelegate( + target: agent, + store: store, + isDropTarget: $isDropTarget + ) + ) + } +} + +private struct AgentSectionDropDelegate: DropDelegate { + let target: SessionAgent + let store: SessionIndexStore + @Binding var isDropTarget: Bool + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [.text]) + } + + func dropEntered(info: DropInfo) { + isDropTarget = true + } + + func dropExited(info: DropInfo) { + isDropTarget = false + } + + func performDrop(info: DropInfo) -> Bool { + isDropTarget = false + guard let provider = info.itemProviders(for: [.text]).first else { return false } + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let raw = object as? String, + let dragged = SessionAgent(rawValue: raw), + dragged != target else { return } + DispatchQueue.main.async { + guard let targetIndex = store.agentOrder.firstIndex(of: target) else { return } + store.moveAgent(dragged, to: targetIndex) + } + } + return true } } From f6085836dc7c8e7b42fe4a731d1e136a1c5df82b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 02:41:23 -0700 Subject: [PATCH 03/39] Reorder gaps + cwd/branch/PR chips on session rows - Replace per-section drop zones with insertion-gap slots between sections (and one after the last). Gaps adjacent to the dragged section are hidden, so dropping right next to itself is no longer a valid target. Gives a real "drop after the last" affordance too. - Show cwd basename, git branch, and PR number as small chips under each session title. Branch comes from per-entry gitBranch (Claude) or session_meta.payload.git.branch (Codex). PR badge taps open the URL. Adds "Open Pull Request" to the row context menu when present. - Reads file head + tail (64KB / 32KB) so late-arriving pr-link events near the end of long Claude transcripts are picked up cheaply. --- Resources/Localizable.xcstrings | 17 +++ Sources/SessionIndexStore.swift | 165 ++++++++++++++++++++------- Sources/SessionIndexView.swift | 192 ++++++++++++++++++++++++-------- 3 files changed, 287 insertions(+), 87 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8bf2dc4ec27a..685e1d8b1cca 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -89958,6 +89958,23 @@ } } } + }, + "sessionIndex.row.openPR": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Pull Request" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プルリクエストを開く" + } + } + } } } } diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index e6d6828df9d6..06e651d899f2 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -32,11 +32,19 @@ enum SessionAgent: String, CaseIterable, Identifiable, Hashable, Codable { // MARK: - Session entry +struct PullRequestLink: Hashable { + let number: Int + let url: String + let repository: String? +} + struct SessionEntry: Identifiable, Hashable { let id: String let agent: SessionAgent let title: String let cwd: String? + let gitBranch: String? + let pullRequest: PullRequestLink? let modified: Date let fileURL: URL? @@ -56,6 +64,11 @@ struct SessionEntry: Identifiable, Hashable { } return cwd } + + var cwdBasename: String? { + guard let cwd, !cwd.isEmpty else { return nil } + return (cwd as NSString).lastPathComponent + } } // MARK: - Store @@ -72,20 +85,24 @@ final class SessionIndexStore: ObservableObject { didSet { Self.persistOrder(agentOrder) } } + /// The agent currently being dragged, if any. Drives "hide adjacent drop slots". + @Published var draggedAgent: SessionAgent? = nil + private static let agentOrderDefaultsKey = "sessionIndex.agentOrder" init() { self.agentOrder = Self.loadOrder() } - func moveAgent(_ agent: SessionAgent, to newIndex: Int) { + /// Insert `agent` so that, after removal of its old position, it lands at `insertIndex`. + /// `insertIndex` is in the *post-removal* index space (0...count-1). + func moveAgent(_ agent: SessionAgent, toInsertIndex insertIndex: Int) { guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } - var clamped = max(0, min(newIndex, agentOrder.count - 1)) - if oldIndex == clamped { return } var next = agentOrder next.remove(at: oldIndex) - if clamped > oldIndex { clamped -= 1 } - next.insert(agent, at: min(clamped, next.count)) + let target = max(0, min(insertIndex, next.count)) + if target == oldIndex { return } + next.insert(agent, at: target) agentOrder = next } @@ -145,7 +162,8 @@ final class SessionIndexStore: ObservableObject { // MARK: - Scanning private static let perAgentLimit = 60 - private static let titlePreviewByteCap = 32 * 1024 + private static let headByteCap = 64 * 1024 + private static let tailByteCap = 32 * 1024 private static func scanAll() async -> [SessionEntry] { async let claude = scanClaude() @@ -185,14 +203,16 @@ final class SessionIndexStore: ObservableObject { var results: [SessionEntry] = [] results.reserveCapacity(limited.count) for (url, mtime, dirName) in limited { - let preview = readFileHead(url: url, byteCap: titlePreviewByteCap) - let (title, cwd) = extractClaudeMetadata(jsonl: preview, projectDir: dirName) - let id = "claude:" + url.path + let head = readFileHead(url: url, byteCap: headByteCap) + let tail = readFileTail(url: url, byteCap: tailByteCap) + let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) results.append(SessionEntry( - id: id, + id: "claude:" + url.path, agent: .claude, - title: title, - cwd: cwd, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: parsed.pr, modified: mtime, fileURL: url )) @@ -200,34 +220,63 @@ final class SessionIndexStore: ObservableObject { return results } - private static func extractClaudeMetadata(jsonl: String, projectDir: String) -> (String, String?) { - var firstUserText: String? - var cwd: String? = decodeClaudeProjectDir(projectDir) - for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { - guard let data = line.data(using: .utf8) else { continue } - guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + private struct ClaudeParsed { + var title: String = "" + var cwd: String? + var branch: String? + var pr: PullRequestLink? + } + + private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { + var out = ClaudeParsed() + out.cwd = decodeClaudeProjectDir(projectDir) + + for line in head.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } if let cwdField = obj["cwd"] as? String, !cwdField.isEmpty { - cwd = cwdField + out.cwd = cwdField + } + if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { + out.branch = branchField } - if firstUserText == nil, + if out.title.isEmpty, (obj["type"] as? String) == "user", let message = obj["message"] as? [String: Any], (message["role"] as? String) == "user" { if let content = message["content"] as? String, !content.isEmpty { - firstUserText = content + out.title = content } else if let parts = message["content"] as? [[String: Any]] { for part in parts { if (part["type"] as? String) == "text", let text = part["text"] as? String, !text.isEmpty { - firstUserText = text + out.title = text break } } } } - if firstUserText != nil { break } + if out.title.isEmpty == false && out.branch != nil { break } } - return (firstUserText ?? "", cwd) + + // Tail scan: pr-link tends to live at end; also catches branch updates. + for line in tail.split(separator: "\n", omittingEmptySubsequences: true) { + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + let type = obj["type"] as? String + if type == "pr-link", let number = obj["prNumber"] as? Int, + let url = obj["prUrl"] as? String { + out.pr = PullRequestLink( + number: number, + url: url, + repository: obj["prRepository"] as? String + ) + } + if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { + out.branch = branchField + } + } + return out } private static func decodeClaudeProjectDir(_ raw: String) -> String? { @@ -268,14 +317,15 @@ final class SessionIndexStore: ObservableObject { var results: [SessionEntry] = [] results.reserveCapacity(limited.count) for (url, mtime) in limited { - let preview = readFileHead(url: url, byteCap: titlePreviewByteCap) - let (title, cwd) = extractCodexMetadata(jsonl: preview) - let id = "codex:" + url.path + let head = readFileHead(url: url, byteCap: headByteCap) + let parsed = extractCodexMetadata(jsonl: head) results.append(SessionEntry( - id: id, + id: "codex:" + url.path, agent: .codex, - title: title, - cwd: cwd, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: nil, modified: mtime, fileURL: url )) @@ -283,25 +333,34 @@ final class SessionIndexStore: ObservableObject { return results } - private static func extractCodexMetadata(jsonl: String) -> (String, String?) { + private struct CodexParsed { + var title: String = "" var cwd: String? - var firstUserMessage: String? + var branch: String? + } + + private static func extractCodexMetadata(jsonl: String) -> CodexParsed { + var out = CodexParsed() for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { - guard let data = line.data(using: .utf8) else { continue } - guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + guard let data = line.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } let type = obj["type"] as? String let payload = obj["payload"] as? [String: Any] - if type == "session_meta", let p = payload, let c = p["cwd"] as? String, !c.isEmpty { - cwd = c + if type == "session_meta", let p = payload { + if let c = p["cwd"] as? String, !c.isEmpty { out.cwd = c } + if let git = p["git"] as? [String: Any], + let branch = git["branch"] as? String, !branch.isEmpty { + out.branch = branch + } } - if firstUserMessage == nil, type == "event_msg", let p = payload, + if out.title.isEmpty, type == "event_msg", let p = payload, (p["type"] as? String) == "user_message", let msg = p["message"] as? String, !msg.isEmpty { - firstUserMessage = msg + out.title = msg } - if firstUserMessage != nil && cwd != nil { break } + if !out.title.isEmpty && out.cwd != nil && out.branch != nil { break } } - return (firstUserMessage ?? "", cwd) + return out } // MARK: OpenCode @@ -362,6 +421,8 @@ final class SessionIndexStore: ObservableObject { agent: .opencode, title: title, cwd: directory, + gitBranch: nil, + pullRequest: nil, modified: modified, fileURL: nil )) @@ -388,4 +449,28 @@ final class SessionIndexStore: ObservableObject { } return String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) } + + /// Read up to `byteCap` bytes from the end of the file as UTF-8. + /// Used to find late-arriving events like pr-link without scanning the whole file. + private static func readFileTail(url: URL, byteCap: Int) -> String { + guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } + defer { try? handle.close() } + let size: UInt64 + do { size = try handle.seekToEnd() } catch { return "" } + if size == 0 { return "" } + let cap = UInt64(byteCap) + let offset: UInt64 = size > cap ? size - cap : 0 + do { try handle.seek(toOffset: offset) } catch { return "" } + let data: Data + if #available(macOS 10.15.4, *) { + data = (try? handle.read(upToCount: byteCap)) ?? Data() + } else { + data = handle.readData(ofLength: byteCap) + } + // Trim leading partial line (we likely cut mid-record). + if offset > 0, let nl = data.firstIndex(of: 0x0a) { + return String(data: data[(nl + 1)...], encoding: .utf8) ?? "" + } + return String(data: data, encoding: .utf8) ?? "" + } } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index ac70df9c7e5d..1cb76b61cd42 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -86,11 +86,11 @@ struct SessionIndexView: View { private var sessionsList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(store.agentOrder) { agent in - let entries = store.filteredEntries(for: agent) + ForEach(Array(store.agentOrder.enumerated()), id: \.element) { index, agent in + AgentReorderGap(insertIndex: index, store: store) AgentSection( agent: agent, - entries: entries, + entries: store.filteredEntries(for: agent), isExpanded: Binding( get: { expandedAgents.contains(agent) }, set: { newValue in @@ -104,6 +104,7 @@ struct SessionIndexView: View { store: store ) } + AgentReorderGap(insertIndex: store.agentOrder.count, store: store) } .padding(.bottom, 8) } @@ -115,7 +116,6 @@ private struct AgentSection: View { let entries: [SessionEntry] @Binding var isExpanded: Bool @ObservedObject var store: SessionIndexStore - @State private var isDropTarget: Bool = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -135,13 +135,7 @@ private struct AgentSection: View { } } } - .overlay(alignment: .top) { - if isDropTarget { - Rectangle() - .fill(Color.accentColor) - .frame(height: 2) - } - } + .opacity(store.draggedAgent == agent ? 0.45 : 1.0) } private var sectionHeader: some View { @@ -178,7 +172,8 @@ private struct AgentSection: View { } .buttonStyle(.plain) .onDrag { - NSItemProvider(object: agent.rawValue as NSString) + DispatchQueue.main.async { store.draggedAgent = agent } + return NSItemProvider(object: agent.rawValue as NSString) } preview: { HStack(spacing: 6) { Image(agent.assetName) @@ -191,50 +186,120 @@ private struct AgentSection: View { .padding(.vertical, 5) .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } - .onDrop( - of: [.text], - delegate: AgentSectionDropDelegate( - target: agent, - store: store, - isDropTarget: $isDropTarget + } +} + +private struct AgentReorderGap: View { + let insertIndex: Int + @ObservedObject var store: SessionIndexStore + @State private var isDropTarget: Bool = false + + var body: some View { + let isValid = isValidDrop + Rectangle() + .fill(Color.clear) + .frame(height: 8) + .overlay(alignment: .center) { + if isDropTarget && isValid { + Capsule() + .fill(Color.accentColor) + .frame(height: 3) + .padding(.horizontal, 10) + } + } + .onDrop( + of: [.text], + delegate: AgentGapDropDelegate( + insertIndex: insertIndex, + store: store, + isDropTarget: $isDropTarget + ) ) - ) + } + + /// A gap is invalid if the drag would be a no-op. For agent at oldIndex, + /// inserting at oldIndex or oldIndex+1 leaves the order unchanged. + private var isValidDrop: Bool { + guard let dragged = store.draggedAgent, + let oldIndex = store.agentOrder.firstIndex(of: dragged) else { + return true + } + return insertIndex != oldIndex && insertIndex != oldIndex + 1 } } -private struct AgentSectionDropDelegate: DropDelegate { - let target: SessionAgent +private struct AgentGapDropDelegate: DropDelegate { + let insertIndex: Int let store: SessionIndexStore @Binding var isDropTarget: Bool func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [.text]) - } - - func dropEntered(info: DropInfo) { - isDropTarget = true + guard info.hasItemsConforming(to: [.text]) else { return false } + guard let dragged = store.draggedAgent, + let oldIndex = store.agentOrder.firstIndex(of: dragged) else { + return true + } + return insertIndex != oldIndex && insertIndex != oldIndex + 1 } - func dropExited(info: DropInfo) { - isDropTarget = false - } + func dropEntered(info: DropInfo) { isDropTarget = true } + func dropExited(info: DropInfo) { isDropTarget = false } func performDrop(info: DropInfo) -> Bool { isDropTarget = false - guard let provider = info.itemProviders(for: [.text]).first else { return false } + guard let provider = info.itemProviders(for: [.text]).first else { + store.draggedAgent = nil + return false + } + let storedInsert = insertIndex provider.loadObject(ofClass: NSString.self) { object, _ in - guard let raw = object as? String, - let dragged = SessionAgent(rawValue: raw), - dragged != target else { return } DispatchQueue.main.async { - guard let targetIndex = store.agentOrder.firstIndex(of: target) else { return } - store.moveAgent(dragged, to: targetIndex) + defer { store.draggedAgent = nil } + guard let raw = object as? String, + let dragged = SessionAgent(rawValue: raw), + let oldIndex = store.agentOrder.firstIndex(of: dragged) else { return } + let target = storedInsert > oldIndex ? storedInsert - 1 : storedInsert + store.moveAgent(dragged, toInsertIndex: target) } } return true } } +private struct MetadataChip: View { + let symbol: String + let text: String + var accent: Color? = nil + var onTap: (() -> Void)? = nil + + var body: some View { + let chip = HStack(spacing: 3) { + Image(systemName: symbol) + .font(.system(size: 9, weight: .medium)) + Text(text) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundColor(accent ?? .secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill((accent ?? Color.secondary).opacity(0.12)) + ) + + if let onTap { + chip + .onTapGesture(perform: onTap) + .contentShape(Rectangle()) + } else { + chip + } + } +} + + private struct SessionRow: View { let entry: SessionEntry @State private var isHovered: Bool = false @@ -243,25 +308,16 @@ private struct SessionRow: View { Button { open() } label: { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { Text(entry.displayTitle) .font(.system(size: 12)) .foregroundColor(.primary) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) + metadataLine HStack(spacing: 6) { - if let cwdLabel = entry.cwdLabel { - Image(systemName: "folder") - .font(.system(size: 9)) - .foregroundColor(.secondary) - Text(folderDisplayName(cwdLabel)) - .font(.system(size: 10)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer(minLength: 4) + Spacer(minLength: 0) Text(relativeTime(entry.modified)) .font(.system(size: 10).monospacedDigit()) .foregroundColor(.secondary) @@ -307,7 +363,49 @@ private struct SessionRow: View { Text(String(localized: "sessionIndex.row.openCwd", defaultValue: "Open Working Directory")) } } + if let pr = entry.pullRequest, let url = URL(string: pr.url) { + Divider() + Button { + NSWorkspace.shared.open(url) + } label: { + Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open PR #\(pr.number)")) + } + } + } + } + + @ViewBuilder + private var metadataLine: some View { + let chips = metadataChips + if !chips.isEmpty { + HStack(spacing: 4) { + ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in + chip + } + Spacer(minLength: 0) + } + } + } + + private var metadataChips: [AnyView] { + var chips: [AnyView] = [] + if let basename = entry.cwdBasename { + chips.append(AnyView(MetadataChip(symbol: "folder", text: basename))) + } + if let branch = entry.gitBranch, !branch.isEmpty { + chips.append(AnyView(MetadataChip(symbol: "arrow.triangle.branch", text: branch))) + } + if let pr = entry.pullRequest { + chips.append(AnyView( + MetadataChip( + symbol: "arrow.triangle.pull", + text: "#\(pr.number)", + accent: Color.purple, + onTap: { NSWorkspace.shared.open(URL(string: pr.url)!) } + ) + )) } + return chips } private var helpText: String { From 80c9be2db1c74a12aac4770d68d218bfd77a0025 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 02:50:07 -0700 Subject: [PATCH 04/39] Group sessions by folder or agent + double-click resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds a grouping toggle at the top of the Sessions panel: "By folder" (default) or "By agent". Both modes use the same collapsible sections and gap-slot drag-to-reorder. Order persists per-mode in UserDefaults ("sessionIndex.directoryOrder" / "sessionIndex.agentOrder"). Newly discovered folders append by most-recent activity. - "By folder" mode shows the agent as a small chip on each row so you can still tell Claude/Codex/OpenCode runs apart at a glance. - Double-click a row to open a new cmux tab in the session's working directory and run the bare resume command. Per Lawrence: no override flags, no MCP handling — let each CLI restore its own settings. - Claude: claude --resume - Codex: codex resume - OpenCode: opencode --session - Adds "Resume in New Tab" and "Copy Resume Command" to the row context menu. --- Resources/Localizable.xcstrings | 85 ++++++++ Sources/ContentView.swift | 8 +- Sources/RightSidebarPanelView.swift | 3 +- Sources/SessionIndexStore.swift | 228 ++++++++++++++++++--- Sources/SessionIndexView.swift | 295 +++++++++++++++++++--------- 5 files changed, 490 insertions(+), 129 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 685e1d8b1cca..20bbc1365b5e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -89975,6 +89975,91 @@ } } } + }, + "sessionIndex.group.directory": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ別" + } + } + } + }, + "sessionIndex.group.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント別" + } + } + } + }, + "sessionIndex.directory.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(no folder)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(フォルダなし)" + } + } + } + }, + "sessionIndex.row.resume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Resume in New Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいタブで再開" + } + } + } + }, + "sessionIndex.row.copyResume": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Resume Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再開コマンドをコピー" + } + } + } } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 814eb0934240..8f62b0dffbab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2779,7 +2779,13 @@ struct ContentView: View { RightSidebarPanelView( fileExplorerStore: fileExplorerStore, fileExplorerState: fileExplorerState, - sessionIndexStore: sessionIndexStore + sessionIndexStore: sessionIndexStore, + onResumeSession: { entry in + tabManager.addWorkspace( + workingDirectory: entry.cwd, + initialTerminalCommand: entry.resumeCommand + ) + } ) .frame(width: explorerVisible ? fileExplorerWidth : 0) .clipped() diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index b18c4fb1d5a4..c5feec908e83 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -26,6 +26,7 @@ struct RightSidebarPanelView: View { @ObservedObject var fileExplorerStore: FileExplorerStore @ObservedObject var fileExplorerState: FileExplorerState @ObservedObject var sessionIndexStore: SessionIndexStore + let onResumeSession: ((SessionEntry) -> Void)? var body: some View { VStack(spacing: 0) { @@ -70,7 +71,7 @@ struct RightSidebarPanelView: View { case .files: FileExplorerPanelView(store: fileExplorerStore, state: fileExplorerState) case .sessions: - SessionIndexView(store: sessionIndexStore) + SessionIndexView(store: sessionIndexStore, onResume: onResumeSession) .onAppear { sessionIndexStore.currentDirectory = fileExplorerStore.rootPath.isEmpty ? nil diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 06e651d899f2..e0ceb80cf482 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -41,6 +41,8 @@ struct PullRequestLink: Hashable { struct SessionEntry: Identifiable, Hashable { let id: String let agent: SessionAgent + /// Native session identifier for the agent's CLI (used to build the resume command). + let sessionId: String let title: String let cwd: String? let gitBranch: String? @@ -48,6 +50,19 @@ struct SessionEntry: Identifiable, Hashable { let modified: Date let fileURL: URL? + /// Shell command that resumes this session in a new terminal. + /// Bare resume — let each CLI restore its own per-session settings. + var resumeCommand: String { + switch agent { + case .claude: + return "claude --resume \(sessionId)" + case .codex: + return "codex resume \(sessionId)" + case .opencode: + return "opencode --session \(sessionId)" + } + } + var displayTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -73,6 +88,50 @@ struct SessionEntry: Identifiable, Hashable { // MARK: - Store +enum SessionGrouping: String, CaseIterable, Identifiable, Codable { + case directory + case agent + + var id: String { rawValue } + + var label: String { + switch self { + case .directory: return String(localized: "sessionIndex.group.directory", defaultValue: "By folder") + case .agent: return String(localized: "sessionIndex.group.agent", defaultValue: "By agent") + } + } + + var symbolName: String { + switch self { + case .directory: return "folder" + case .agent: return "person.2" + } + } +} + +/// Identifier for a section in the index. For agent grouping, raw value is `agent:`; +/// for directory grouping, `dir:` (or `dir:` for unknown). +struct SectionKey: Hashable { + let raw: String + + static func agent(_ a: SessionAgent) -> SectionKey { SectionKey(raw: "agent:" + a.rawValue) } + static func directory(_ path: String?) -> SectionKey { SectionKey(raw: "dir:" + (path ?? "")) } +} + +struct IndexSection: Identifiable { + let key: SectionKey + let title: String + let icon: SectionIcon + let entries: [SessionEntry] + + var id: SectionKey { key } +} + +enum SectionIcon { + case agent(SessionAgent) + case folder +} + @MainActor final class SessionIndexStore: ObservableObject { @Published private(set) var entries: [SessionEntry] = [] @@ -80,49 +139,156 @@ final class SessionIndexStore: ObservableObject { @Published var scopeToCurrentDirectory: Bool = false @Published var currentDirectory: String? = nil - /// User-visible order of agent sections. Persisted across launches. + @Published var grouping: SessionGrouping { + didSet { UserDefaults.standard.set(grouping.rawValue, forKey: Self.groupingKey) } + } + + /// Persisted order for agent sections. @Published var agentOrder: [SessionAgent] { - didSet { Self.persistOrder(agentOrder) } + didSet { Self.persistAgentOrder(agentOrder) } + } + + /// Persisted order for directory sections (absolute paths; "" means "no folder"). + @Published var directoryOrder: [String] { + didSet { Self.persistDirectoryOrder(directoryOrder) } } - /// The agent currently being dragged, if any. Drives "hide adjacent drop slots". - @Published var draggedAgent: SessionAgent? = nil + /// The section currently being dragged, if any. Drives "hide adjacent drop slots". + @Published var draggedKey: SectionKey? = nil + private static let groupingKey = "sessionIndex.grouping" private static let agentOrderDefaultsKey = "sessionIndex.agentOrder" + private static let directoryOrderDefaultsKey = "sessionIndex.directoryOrder" init() { - self.agentOrder = Self.loadOrder() + self.agentOrder = Self.loadAgentOrder() + self.directoryOrder = Self.loadDirectoryOrder() + let storedGrouping = UserDefaults.standard.string(forKey: Self.groupingKey) + self.grouping = SessionGrouping(rawValue: storedGrouping ?? "") ?? .directory + } + + /// Returns the sections for the current grouping mode, in the user-saved order. + func sectionsForCurrentGrouping() -> [IndexSection] { + let visible = filteredEntriesForCurrentScope() + switch grouping { + case .agent: + return agentOrder.map { agent in + IndexSection( + key: .agent(agent), + title: agent.displayName, + icon: .agent(agent), + entries: visible.filter { $0.agent == agent } + ) + } + case .directory: + let buckets = Dictionary(grouping: visible) { $0.cwd ?? "" } + // Discover any directories not yet in saved order; append by most-recent activity. + let knownPaths = Set(directoryOrder) + let unknownSorted = buckets.keys + .filter { !knownPaths.contains($0) } + .sorted { lhs, rhs in + let lMax = buckets[lhs]?.map(\.modified).max() ?? .distantPast + let rMax = buckets[rhs]?.map(\.modified).max() ?? .distantPast + return lMax > rMax + } + if !unknownSorted.isEmpty { + let nextOrder = directoryOrder + unknownSorted + Task { @MainActor in self.directoryOrder = nextOrder } + } + return (directoryOrder + unknownSorted) + .filter { buckets[$0] != nil } + .map { path in + IndexSection( + key: .directory(path.isEmpty ? nil : path), + title: directoryDisplayName(path), + icon: .folder, + entries: buckets[path] ?? [] + ) + } + } + } + + private func filteredEntriesForCurrentScope() -> [SessionEntry] { + guard scopeToCurrentDirectory, let dir = normalizedDirectory(currentDirectory) else { + return entries + } + return entries.filter { entry in + guard let cwd = normalizedDirectory(entry.cwd) else { return false } + return cwd == dir || cwd.hasPrefix(dir + "/") + } } - /// Insert `agent` so that, after removal of its old position, it lands at `insertIndex`. - /// `insertIndex` is in the *post-removal* index space (0...count-1). - func moveAgent(_ agent: SessionAgent, toInsertIndex insertIndex: Int) { - guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } - var next = agentOrder - next.remove(at: oldIndex) - let target = max(0, min(insertIndex, next.count)) - if target == oldIndex { return } - next.insert(agent, at: target) - agentOrder = next + private func directoryDisplayName(_ path: String) -> String { + if path.isEmpty { + return String(localized: "sessionIndex.directory.unknown", defaultValue: "(no folder)") + } + return (path as NSString).lastPathComponent + } + + /// Insert `key` so that, after removing its old position, it lands at `insertIndex`. + /// `insertIndex` is in the *post-removal* index space. + func moveSection(_ key: SectionKey, toInsertIndex insertIndex: Int) { + switch grouping { + case .agent: + guard key.raw.hasPrefix("agent:"), + let agent = SessionAgent(rawValue: String(key.raw.dropFirst("agent:".count))) else { return } + guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } + var next = agentOrder + next.remove(at: oldIndex) + let target = max(0, min(insertIndex, next.count)) + if target == oldIndex { return } + next.insert(agent, at: target) + agentOrder = next + case .directory: + guard key.raw.hasPrefix("dir:") else { return } + let path = String(key.raw.dropFirst("dir:".count)) + guard let oldIndex = directoryOrder.firstIndex(of: path) else { return } + var next = directoryOrder + next.remove(at: oldIndex) + let target = max(0, min(insertIndex, next.count)) + if target == oldIndex { return } + next.insert(path, at: target) + directoryOrder = next + } } - private static func loadOrder() -> [SessionAgent] { - let defaults = UserDefaults.standard - let stored = defaults.array(forKey: agentOrderDefaultsKey) as? [String] ?? [] + /// Pre-removal index of the dragged key in the active grouping order, or nil if unknown. + func currentIndex(of key: SectionKey) -> Int? { + switch grouping { + case .agent: + guard key.raw.hasPrefix("agent:"), + let agent = SessionAgent(rawValue: String(key.raw.dropFirst("agent:".count))) else { return nil } + return agentOrder.firstIndex(of: agent) + case .directory: + guard key.raw.hasPrefix("dir:") else { return nil } + let path = String(key.raw.dropFirst("dir:".count)) + return directoryOrder.firstIndex(of: path) + } + } + + private static func loadAgentOrder() -> [SessionAgent] { + let stored = UserDefaults.standard.array(forKey: agentOrderDefaultsKey) as? [String] ?? [] var ordered: [SessionAgent] = stored.compactMap { SessionAgent(rawValue: $0) } for agent in SessionAgent.allCases where !ordered.contains(agent) { ordered.append(agent) } - // Drop any duplicates while preserving first occurrence var seen = Set() ordered = ordered.filter { seen.insert($0).inserted } return ordered } - private static func persistOrder(_ order: [SessionAgent]) { + private static func loadDirectoryOrder() -> [String] { + UserDefaults.standard.array(forKey: directoryOrderDefaultsKey) as? [String] ?? [] + } + + private static func persistAgentOrder(_ order: [SessionAgent]) { UserDefaults.standard.set(order.map { $0.rawValue }, forKey: agentOrderDefaultsKey) } + private static func persistDirectoryOrder(_ order: [String]) { + UserDefaults.standard.set(order, forKey: directoryOrderDefaultsKey) + } + private var loadTask: Task? func reload() { @@ -139,17 +305,6 @@ final class SessionIndexStore: ObservableObject { } } - func filteredEntries(for agent: SessionAgent) -> [SessionEntry] { - let base = entries.filter { $0.agent == agent } - guard scopeToCurrentDirectory, let dir = normalizedDirectory(currentDirectory) else { - return base - } - return base.filter { entry in - guard let cwd = normalizedDirectory(entry.cwd) else { return false } - return cwd == dir || cwd.hasPrefix(dir + "/") - } - } - private func normalizedDirectory(_ value: String?) -> String? { guard let value, !value.isEmpty else { return nil } var path = (value as NSString).standardizingPath @@ -206,9 +361,12 @@ final class SessionIndexStore: ObservableObject { let head = readFileHead(url: url, byteCap: headByteCap) let tail = readFileTail(url: url, byteCap: tailByteCap) let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) + // Claude session id is the .jsonl basename + let sid = url.deletingPathExtension().lastPathComponent results.append(SessionEntry( id: "claude:" + url.path, agent: .claude, + sessionId: sid, title: parsed.title, cwd: parsed.cwd, gitBranch: parsed.branch, @@ -322,6 +480,7 @@ final class SessionIndexStore: ObservableObject { results.append(SessionEntry( id: "codex:" + url.path, agent: .codex, + sessionId: parsed.sessionId, title: parsed.title, cwd: parsed.cwd, gitBranch: parsed.branch, @@ -334,6 +493,7 @@ final class SessionIndexStore: ObservableObject { } private struct CodexParsed { + var sessionId: String = "" var title: String = "" var cwd: String? var branch: String? @@ -348,6 +508,7 @@ final class SessionIndexStore: ObservableObject { let payload = obj["payload"] as? [String: Any] if type == "session_meta", let p = payload { if let c = p["cwd"] as? String, !c.isEmpty { out.cwd = c } + if let id = p["id"] as? String, !id.isEmpty { out.sessionId = id } if let git = p["git"] as? [String: Any], let branch = git["branch"] as? String, !branch.isEmpty { out.branch = branch @@ -358,7 +519,9 @@ final class SessionIndexStore: ObservableObject { let msg = p["message"] as? String, !msg.isEmpty { out.title = msg } - if !out.title.isEmpty && out.cwd != nil && out.branch != nil { break } + if !out.title.isEmpty && out.cwd != nil && out.branch != nil && !out.sessionId.isEmpty { + break + } } return out } @@ -419,6 +582,7 @@ final class SessionIndexStore: ObservableObject { results.append(SessionEntry( id: "opencode:" + sid, agent: .opencode, + sessionId: sid, title: title, cwd: directory, gitBranch: nil, diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 1cb76b61cd42..ff72f5e648f2 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -3,7 +3,8 @@ import SwiftUI struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore - @State private var expandedAgents: Set = Set(SessionAgent.allCases) + @State private var collapsedSections: Set = [] + let onResume: ((SessionEntry) -> Void)? static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() @@ -31,7 +32,20 @@ struct SessionIndexView: View { } private var controlBar: some View { - HStack(spacing: 8) { + HStack(spacing: 6) { + ForEach(SessionGrouping.allCases) { mode in + GroupingButton( + mode: mode, + isSelected: store.grouping == mode + ) { + if store.grouping != mode { + store.grouping = mode + } + } + } + + Spacer(minLength: 4) + Toggle(isOn: $store.scopeToCurrentDirectory) { Text(String(localized: "sessionIndex.scope.thisFolder", defaultValue: "This folder only")) .font(.system(size: 11)) @@ -41,8 +55,6 @@ struct SessionIndexView: View { .controlSize(.small) .disabled(store.currentDirectory == nil) - Spacer(minLength: 4) - Button { store.reload() } label: { @@ -53,9 +65,9 @@ struct SessionIndexView: View { .help(String(localized: "sessionIndex.reload.tooltip", defaultValue: "Reload sessions")) .disabled(store.isLoading) } - .padding(.horizontal, 10) + .padding(.horizontal, 8) .padding(.vertical, 4) - .frame(height: 28) + .frame(height: 32) } private var loadingView: some View { @@ -84,58 +96,90 @@ struct SessionIndexView: View { } private var sessionsList: some View { - ScrollView { + let sections = store.sectionsForCurrentGrouping() + return ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(Array(store.agentOrder.enumerated()), id: \.element) { index, agent in - AgentReorderGap(insertIndex: index, store: store) - AgentSection( - agent: agent, - entries: store.filteredEntries(for: agent), + ForEach(Array(sections.enumerated()), id: \.element.key) { index, section in + SectionReorderGap(insertIndex: index, store: store) + IndexSectionView( + section: section, + showsAgentChips: store.grouping == .directory, isExpanded: Binding( - get: { expandedAgents.contains(agent) }, + get: { !collapsedSections.contains(section.key) }, set: { newValue in if newValue { - expandedAgents.insert(agent) + collapsedSections.remove(section.key) } else { - expandedAgents.remove(agent) + collapsedSections.insert(section.key) } } ), - store: store + store: store, + onResume: onResume ) } - AgentReorderGap(insertIndex: store.agentOrder.count, store: store) + SectionReorderGap(insertIndex: sections.count, store: store) } .padding(.bottom, 8) } } } -private struct AgentSection: View { - let agent: SessionAgent - let entries: [SessionEntry] +private struct GroupingButton: View { + let mode: SessionGrouping + let isSelected: Bool + let action: () -> Void + @State private var isHovered: Bool = false + + var body: some View { + Button(action: action) { + HStack(spacing: 3) { + Image(systemName: mode.symbolName) + .font(.system(size: 10, weight: .medium)) + Text(mode.label) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isSelected ? Color.primary.opacity(0.10) + : (isHovered ? Color.primary.opacity(0.05) : Color.clear)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(mode.label) + } +} + +private struct IndexSectionView: View { + let section: IndexSection + let showsAgentChips: Bool @Binding var isExpanded: Bool @ObservedObject var store: SessionIndexStore + let onResume: ((SessionEntry) -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { sectionHeader - if isExpanded { - if entries.isEmpty { + if section.entries.isEmpty { Text(String(localized: "sessionIndex.section.empty", defaultValue: "No sessions")) .font(.system(size: 11)) .foregroundColor(.secondary.opacity(0.7)) .padding(.horizontal, 30) .padding(.vertical, 4) } else { - ForEach(entries) { entry in - SessionRow(entry: entry) + ForEach(section.entries) { entry in + SessionRow(entry: entry, showsAgentChip: showsAgentChips, onResume: onResume) } } } } - .opacity(store.draggedAgent == agent ? 0.45 : 1.0) + .opacity(store.draggedKey == section.key ? 0.45 : 1.0) } private var sectionHeader: some View { @@ -147,16 +191,14 @@ private struct AgentSection: View { .font(.system(size: 9, weight: .semibold)) .foregroundColor(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) - Image(agent.assetName) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .frame(width: 14, height: 14) - Text(agent.displayName) + sectionIconView + Text(section.title) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.primary) - Spacer() - Text("\(entries.count)") + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text("\(section.entries.count)") .font(.system(size: 10, weight: .medium).monospacedDigit()) .foregroundColor(.secondary) .padding(.horizontal, 6) @@ -172,14 +214,12 @@ private struct AgentSection: View { } .buttonStyle(.plain) .onDrag { - DispatchQueue.main.async { store.draggedAgent = agent } - return NSItemProvider(object: agent.rawValue as NSString) + DispatchQueue.main.async { store.draggedKey = section.key } + return NSItemProvider(object: section.key.raw as NSString) } preview: { HStack(spacing: 6) { - Image(agent.assetName) - .resizable() - .frame(width: 14, height: 14) - Text(agent.displayName) + sectionIconView + Text(section.title) .font(.system(size: 12, weight: .semibold)) } .padding(.horizontal, 10) @@ -187,9 +227,26 @@ private struct AgentSection: View { .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } } + + @ViewBuilder + private var sectionIconView: some View { + switch section.icon { + case .agent(let agent): + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + case .folder: + Image(systemName: "folder.fill") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.secondary) + .frame(width: 14, height: 14) + } + } } -private struct AgentReorderGap: View { +private struct SectionReorderGap: View { let insertIndex: Int @ObservedObject var store: SessionIndexStore @State private var isDropTarget: Bool = false @@ -209,7 +266,7 @@ private struct AgentReorderGap: View { } .onDrop( of: [.text], - delegate: AgentGapDropDelegate( + delegate: SectionGapDropDelegate( insertIndex: insertIndex, store: store, isDropTarget: $isDropTarget @@ -217,26 +274,24 @@ private struct AgentReorderGap: View { ) } - /// A gap is invalid if the drag would be a no-op. For agent at oldIndex, - /// inserting at oldIndex or oldIndex+1 leaves the order unchanged. private var isValidDrop: Bool { - guard let dragged = store.draggedAgent, - let oldIndex = store.agentOrder.firstIndex(of: dragged) else { + guard let dragged = store.draggedKey, + let oldIndex = store.currentIndex(of: dragged) else { return true } return insertIndex != oldIndex && insertIndex != oldIndex + 1 } } -private struct AgentGapDropDelegate: DropDelegate { +private struct SectionGapDropDelegate: DropDelegate { let insertIndex: Int let store: SessionIndexStore @Binding var isDropTarget: Bool func validateDrop(info: DropInfo) -> Bool { guard info.hasItemsConforming(to: [.text]) else { return false } - guard let dragged = store.draggedAgent, - let oldIndex = store.agentOrder.firstIndex(of: dragged) else { + guard let dragged = store.draggedKey, + let oldIndex = store.currentIndex(of: dragged) else { return true } return insertIndex != oldIndex && insertIndex != oldIndex + 1 @@ -248,60 +303,28 @@ private struct AgentGapDropDelegate: DropDelegate { func performDrop(info: DropInfo) -> Bool { isDropTarget = false guard let provider = info.itemProviders(for: [.text]).first else { - store.draggedAgent = nil + store.draggedKey = nil return false } let storedInsert = insertIndex provider.loadObject(ofClass: NSString.self) { object, _ in DispatchQueue.main.async { - defer { store.draggedAgent = nil } - guard let raw = object as? String, - let dragged = SessionAgent(rawValue: raw), - let oldIndex = store.agentOrder.firstIndex(of: dragged) else { return } + defer { store.draggedKey = nil } + guard let raw = object as? String else { return } + let key = SectionKey(raw: raw) + guard let oldIndex = store.currentIndex(of: key) else { return } let target = storedInsert > oldIndex ? storedInsert - 1 : storedInsert - store.moveAgent(dragged, toInsertIndex: target) + store.moveSection(key, toInsertIndex: target) } } return true } } -private struct MetadataChip: View { - let symbol: String - let text: String - var accent: Color? = nil - var onTap: (() -> Void)? = nil - - var body: some View { - let chip = HStack(spacing: 3) { - Image(systemName: symbol) - .font(.system(size: 9, weight: .medium)) - Text(text) - .font(.system(size: 10, weight: .medium)) - .lineLimit(1) - .truncationMode(.middle) - } - .foregroundColor(accent ?? .secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill((accent ?? Color.secondary).opacity(0.12)) - ) - - if let onTap { - chip - .onTapGesture(perform: onTap) - .contentShape(Rectangle()) - } else { - chip - } - } -} - - private struct SessionRow: View { let entry: SessionEntry + let showsAgentChip: Bool + let onResume: ((SessionEntry) -> Void)? @State private var isHovered: Bool = false var body: some View { @@ -337,7 +360,20 @@ private struct SessionRow: View { .buttonStyle(.plain) .onHover { isHovered = $0 } .help(helpText) + .simultaneousGesture( + TapGesture(count: 2).onEnded { + if let onResume { onResume(entry) } + } + ) .contextMenu { + if let onResume { + Button { + onResume(entry) + } label: { + Text(String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab")) + } + Divider() + } if entry.fileURL != nil { Button { open() @@ -356,6 +392,11 @@ private struct SessionRow: View { Text(String(localized: "sessionIndex.row.copyPath", defaultValue: "Copy File Path")) } } + Button { + copyResumeCommand() + } label: { + Text(String(localized: "sessionIndex.row.copyResume", defaultValue: "Copy Resume Command")) + } if let cwd = entry.cwd, !cwd.isEmpty { Button { NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) @@ -368,7 +409,7 @@ private struct SessionRow: View { Button { NSWorkspace.shared.open(url) } label: { - Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open PR #\(pr.number)")) + Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open Pull Request")) } } } @@ -389,6 +430,9 @@ private struct SessionRow: View { private var metadataChips: [AnyView] { var chips: [AnyView] = [] + if showsAgentChip { + chips.append(AnyView(AgentChip(agent: entry.agent))) + } if let basename = entry.cwdBasename { chips.append(AnyView(MetadataChip(symbol: "folder", text: basename))) } @@ -401,7 +445,11 @@ private struct SessionRow: View { symbol: "arrow.triangle.pull", text: "#\(pr.number)", accent: Color.purple, - onTap: { NSWorkspace.shared.open(URL(string: pr.url)!) } + onTap: { + if let url = URL(string: pr.url) { + NSWorkspace.shared.open(url) + } + } ) )) } @@ -417,13 +465,8 @@ private struct SessionRow: View { return lines.joined(separator: "\n") } - private func folderDisplayName(_ path: String) -> String { - (path as NSString).lastPathComponent - } - private func open() { guard let url = entry.fileURL else { - // OpenCode entries have no file URL — fall back to opening cwd if let cwd = entry.cwd, !cwd.isEmpty { NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) } @@ -444,6 +487,12 @@ private struct SessionRow: View { pb.setString(url.path, forType: .string) } + private func copyResumeCommand() { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(entry.resumeCommand, forType: .string) + } + private func relativeTime(_ date: Date) -> String { SessionIndexView.relativeFormatter.localizedString(for: date, relativeTo: Date()) } @@ -455,3 +504,59 @@ private struct SessionRow: View { return f.string(from: date) } } + +private struct AgentChip: View { + let agent: SessionAgent + + var body: some View { + HStack(spacing: 3) { + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 10, height: 10) + Text(agent.displayName) + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color.secondary.opacity(0.12)) + ) + } +} + +private struct MetadataChip: View { + let symbol: String + let text: String + var accent: Color? = nil + var onTap: (() -> Void)? = nil + + var body: some View { + let chip = HStack(spacing: 3) { + Image(systemName: symbol) + .font(.system(size: 9, weight: .medium)) + Text(text) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundColor(accent ?? .secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill((accent ?? Color.secondary).opacity(0.12)) + ) + + if let onTap { + chip + .onTapGesture(perform: onTap) + .contentShape(Rectangle()) + } else { + chip + } + } +} From f843cb1182f4c78b879b57327714a63f22fb57b5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 14:02:37 -0700 Subject: [PATCH 05/39] Drop single-click open() on session rows; double-click resumes only --- Sources/SessionIndexView.swift | 59 +++++++++++++++------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index ff72f5e648f2..f151b4204d7f 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -328,43 +328,36 @@ private struct SessionRow: View { @State private var isHovered: Bool = false var body: some View { - Button { - open() - } label: { - VStack(alignment: .leading, spacing: 3) { - Text(entry.displayTitle) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineLimit(2) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - metadataLine - HStack(spacing: 6) { - Spacer(minLength: 0) - Text(relativeTime(entry.modified)) - .font(.system(size: 10).monospacedDigit()) - .foregroundColor(.secondary) - } + VStack(alignment: .leading, spacing: 3) { + Text(entry.displayTitle) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + metadataLine + HStack(spacing: 6) { + Spacer(minLength: 0) + Text(relativeTime(entry.modified)) + .font(.system(size: 10).monospacedDigit()) + .foregroundColor(.secondary) } - .padding(.horizontal, 10) - .padding(.leading, 18) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isHovered ? Color.primary.opacity(0.06) : Color.clear) - .padding(.horizontal, 6) - ) } - .buttonStyle(.plain) + .padding(.horizontal, 10) + .padding(.leading, 18) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .padding(.horizontal, 6) + ) .onHover { isHovered = $0 } .help(helpText) - .simultaneousGesture( - TapGesture(count: 2).onEnded { - if let onResume { onResume(entry) } - } - ) + .onTapGesture(count: 2) { + if let onResume { onResume(entry) } + } .contextMenu { if let onResume { Button { From 450edec5c2b033530237020d69374850b688960e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 14:13:14 -0700 Subject: [PATCH 06/39] Minimal session rows + smart placement + Codex title fix - Single-line row: title + relative time only. Drop folder/branch/PR chips and the agent chip. Remove section count badge and chevron; section header is just folder/agent icon + name. - Cap each section at 5 rows by default with a "Show more" toggle. Empty sections show "No chats". - Resume command now goes through the user's normal shell via Ghostty's initial_input (typed into the shell after it starts) instead of initialCommand (which exec'd the command directly without sourcing zshrc, so PATH was missing). Plumbs initialTerminalInput through TerminalSurface, TerminalPanel, Workspace, and TabManager.addWorkspace, and adds initialInput to Workspace.newTerminalSurface. - Smart placement on resume: if the focused workspace's terminal is already in the same cwd, open a new tab inside that workspace via newTerminalSurface; otherwise spin up a new workspace. - Fix Codex "Untitled session": Codex rollouts have a huge base_instructions block in session_meta plus an AGENTS.md user message before the first real user_message, so 64 KB head reads ran out of buffer. Switch Codex extraction to a streaming line reader that stops early once title/cwd/branch/sessionId are found, with a 4 MB cap and a response_item user-message fallback. --- Resources/Localizable.xcstrings | 51 +++++++ Sources/ContentView.swift | 39 ++++- Sources/GhosttyTerminalView.swift | 11 +- Sources/Panels/TerminalPanel.swift | 2 + Sources/SessionIndexStore.swift | 70 +++++++-- Sources/SessionIndexView.swift | 224 ++++++++--------------------- Sources/TabManager.swift | 4 + Sources/Workspace.swift | 4 + 8 files changed, 231 insertions(+), 174 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 20bbc1365b5e..adedc94c52ec 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -90060,6 +90060,57 @@ } } } + }, + "sessionIndex.section.noChats": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No chats" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットなし" + } + } + } + }, + "sessionIndex.section.showMore": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もっと表示" + } + } + } + }, + "sessionIndex.section.showLess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "折りたたむ" + } + } + } } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8f62b0dffbab..2108ff3ec31a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2781,10 +2781,7 @@ struct ContentView: View { fileExplorerState: fileExplorerState, sessionIndexStore: sessionIndexStore, onResumeSession: { entry in - tabManager.addWorkspace( - workingDirectory: entry.cwd, - initialTerminalCommand: entry.resumeCommand - ) + resumeSession(entry: entry) } ) .frame(width: explorerVisible ? fileExplorerWidth : 0) @@ -2975,6 +2972,40 @@ struct ContentView: View { ) } + private func resumeSession(entry: SessionEntry) { + let inputWithReturn = entry.resumeCommand + "\n" + let targetCwd = entry.cwd + + // Smart placement: if the focused workspace's terminal is already in the + // same cwd, open a new tab in that workspace. Otherwise, create a new workspace. + let selected = tabManager.selectedWorkspace + let focusedDir = selected?.focusedTerminalPanel?.directory + let pwdMatches: Bool = { + guard let targetCwd, !targetCwd.isEmpty, + let focusedDir, !focusedDir.isEmpty else { return false } + let lhs = (targetCwd as NSString).standardizingPath + let rhs = (focusedDir as NSString).standardizingPath + return lhs == rhs + }() + + if pwdMatches, + let workspace = selected, + let paneId = workspace.bonsplitController.focusedPaneId { + workspace.newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: targetCwd, + initialInput: inputWithReturn + ) + return + } + + tabManager.addWorkspace( + workingDirectory: targetCwd, + initialTerminalInput: inputWithReturn + ) + } + private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 32a50628e2db..f4325695bc2c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3611,6 +3611,7 @@ final class TerminalSurface: Identifiable, ObservableObject { private let configTemplate: CmuxSurfaceConfigTemplate? private let workingDirectory: String? private let initialCommand: String? + private let initialInput: String? private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } private var additionalEnvironment: [String: String] @@ -3703,6 +3704,7 @@ final class TerminalSurface: Identifiable, ObservableObject { configTemplate: CmuxSurfaceConfigTemplate?, workingDirectory: String? = nil, initialCommand: String? = nil, + initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -3713,6 +3715,8 @@ final class TerminalSurface: Identifiable, ObservableObject { self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + let trimmedInput = initialInput?.isEmpty == false ? initialInput : nil + self.initialInput = trimmedInput self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides) self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer @@ -4462,7 +4466,12 @@ final class TerminalSurface: Identifiable, ObservableObject { } return baseConfig.command }() - let resolvedInitialInput = baseConfig.initialInput + let resolvedInitialInput: String? = { + if let initialInput, !initialInput.isEmpty { + return initialInput + } + return baseConfig.initialInput + }() func withOptionalCString(_ value: String?, _ body: (UnsafePointer?) -> T) -> T { guard let value else { return body(nil) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index c5d3a79f2596..417b45e81da7 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -94,6 +94,7 @@ final class TerminalPanel: Panel, ObservableObject { workingDirectory: String? = nil, portOrdinal: Int = 0, initialCommand: String? = nil, + initialInput: String? = nil, initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { @@ -103,6 +104,7 @@ final class TerminalPanel: Panel, ObservableObject { configTemplate: configTemplate, workingDirectory: workingDirectory, initialCommand: initialCommand, + initialInput: initialInput, initialEnvironmentOverrides: initialEnvironmentOverrides, additionalEnvironment: additionalEnvironment ) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index e0ceb80cf482..668da715096d 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -475,8 +475,7 @@ final class SessionIndexStore: ObservableObject { var results: [SessionEntry] = [] results.reserveCapacity(limited.count) for (url, mtime) in limited { - let head = readFileHead(url: url, byteCap: headByteCap) - let parsed = extractCodexMetadata(jsonl: head) + let parsed = extractCodexMetadata(url: url) results.append(SessionEntry( id: "codex:" + url.path, agent: .codex, @@ -499,11 +498,13 @@ final class SessionIndexStore: ObservableObject { var branch: String? } - private static func extractCodexMetadata(jsonl: String) -> CodexParsed { + /// Stream lines from `url` until we have everything we need. The first user_message + /// can sit ~100 KB into a Codex rollout (after huge base_instructions + AGENTS.md), + /// so a fixed head buffer is unreliable. + private static func extractCodexMetadata(url: URL) -> CodexParsed { var out = CodexParsed() - for line in jsonl.split(separator: "\n", omittingEmptySubsequences: true) { - guard let data = line.data(using: .utf8), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + let maxBytes = 4 * 1024 * 1024 + forEachJSONLine(url: url, maxBytes: maxBytes) { obj in let type = obj["type"] as? String let payload = obj["payload"] as? [String: Any] if type == "session_meta", let p = payload { @@ -519,13 +520,66 @@ final class SessionIndexStore: ObservableObject { let msg = p["message"] as? String, !msg.isEmpty { out.title = msg } - if !out.title.isEmpty && out.cwd != nil && out.branch != nil && !out.sessionId.isEmpty { - break + // Fallback: response_item with role=user and input_text content + if out.title.isEmpty, type == "response_item", let p = payload, + (p["type"] as? String) == "message", + (p["role"] as? String) == "user", + let content = p["content"] as? [[String: Any]] { + for part in content { + if (part["type"] as? String) == "input_text", + let text = part["text"] as? String, + !text.isEmpty, + !text.hasPrefix("# AGENTS.md"), + !text.hasPrefix(""), + !text.hasPrefix(" Bool + ) { + guard let handle = try? FileHandle(forReadingFrom: url) else { return } + defer { try? handle.close() } + var leftover = Data() + var totalRead = 0 + let chunkSize = 64 * 1024 + while totalRead < maxBytes { + let chunk: Data + if #available(macOS 10.15.4, *) { + chunk = (try? handle.read(upToCount: chunkSize)) ?? Data() + } else { + chunk = handle.readData(ofLength: chunkSize) + } + if chunk.isEmpty { break } + totalRead += chunk.count + leftover.append(chunk) + while let nl = leftover.firstIndex(of: 0x0a) { + let lineData = leftover.subdata(in: 0.. [SessionEntry] { diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index f151b4204d7f..6f0cb03d5940 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -3,9 +3,12 @@ import SwiftUI struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore - @State private var collapsedSections: Set = [] + @State private var expandedSections: Set = [] let onResume: ((SessionEntry) -> Void)? + /// Rows shown per section before "Show more" is tapped. + private static let collapsedRowLimit = 5 + static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated @@ -103,14 +106,14 @@ struct SessionIndexView: View { SectionReorderGap(insertIndex: index, store: store) IndexSectionView( section: section, - showsAgentChips: store.grouping == .directory, + rowLimit: Self.collapsedRowLimit, isExpanded: Binding( - get: { !collapsedSections.contains(section.key) }, + get: { expandedSections.contains(section.key) }, set: { newValue in if newValue { - collapsedSections.remove(section.key) + expandedSections.insert(section.key) } else { - collapsedSections.insert(section.key) + expandedSections.remove(section.key) } } ), @@ -157,7 +160,7 @@ private struct GroupingButton: View { private struct IndexSectionView: View { let section: IndexSection - let showsAgentChips: Bool + let rowLimit: Int @Binding var isExpanded: Bool @ObservedObject var store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? @@ -165,62 +168,61 @@ private struct IndexSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { sectionHeader - if isExpanded { - if section.entries.isEmpty { - Text(String(localized: "sessionIndex.section.empty", defaultValue: "No sessions")) - .font(.system(size: 11)) - .foregroundColor(.secondary.opacity(0.7)) - .padding(.horizontal, 30) - .padding(.vertical, 4) - } else { - ForEach(section.entries) { entry in - SessionRow(entry: entry, showsAgentChip: showsAgentChips, onResume: onResume) + if section.entries.isEmpty { + Text(String(localized: "sessionIndex.section.noChats", defaultValue: "No chats")) + .font(.system(size: 12)) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.leading, 32) + .padding(.vertical, 4) + } else { + let visible = isExpanded + ? section.entries + : Array(section.entries.prefix(rowLimit)) + ForEach(visible) { entry in + SessionRow(entry: entry, onResume: onResume) + } + if section.entries.count > rowLimit { + Button { + isExpanded.toggle() + } label: { + Text(isExpanded + ? String(localized: "sessionIndex.section.showLess", defaultValue: "Show less") + : String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.leading, 32) + .padding(.vertical, 4) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } } + Spacer(minLength: 6) } .opacity(store.draggedKey == section.key ? 0.45 : 1.0) } private var sectionHeader: some View { - Button { - withAnimation(nil) { isExpanded.toggle() } - } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.right") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.secondary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - sectionIconView - Text(section.title) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 4) - Text("\(section.entries.count)") - .font(.system(size: 10, weight: .medium).monospacedDigit()) - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(Color.secondary.opacity(0.15)) - ) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) } - .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) .onDrag { DispatchQueue.main.async { store.draggedKey = section.key } return NSItemProvider(object: section.key.raw as NSString) } preview: { - HStack(spacing: 6) { + HStack(spacing: 8) { sectionIconView Text(section.title) - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 13)) } .padding(.horizontal, 10) .padding(.vertical, 5) @@ -238,7 +240,7 @@ private struct IndexSectionView: View { .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) case .folder: - Image(systemName: "folder.fill") + Image(systemName: "folder") .font(.system(size: 12, weight: .regular)) .foregroundColor(.secondary) .frame(width: 14, height: 14) @@ -323,34 +325,30 @@ private struct SectionGapDropDelegate: DropDelegate { private struct SessionRow: View { let entry: SessionEntry - let showsAgentChip: Bool let onResume: ((SessionEntry) -> Void)? @State private var isHovered: Bool = false var body: some View { - VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { Text(entry.displayTitle) - .font(.system(size: 12)) - .foregroundColor(.primary) - .lineLimit(2) + .font(.system(size: 13)) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) .truncationMode(.tail) - .multilineTextAlignment(.leading) - metadataLine - HStack(spacing: 6) { - Spacer(minLength: 0) - Text(relativeTime(entry.modified)) - .font(.system(size: 10).monospacedDigit()) - .foregroundColor(.secondary) - } + Spacer(minLength: 8) + Text(relativeTime(entry.modified)) + .font(.system(size: 12).monospacedDigit()) + .foregroundColor(.secondary.opacity(0.65)) + .fixedSize() } - .padding(.horizontal, 10) - .padding(.leading, 18) - .padding(.vertical, 5) + .padding(.leading, 32) + .padding(.trailing, 12) + .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .fill(isHovered ? Color.primary.opacity(0.05) : Color.clear) .padding(.horizontal, 6) ) .onHover { isHovered = $0 } @@ -408,47 +406,6 @@ private struct SessionRow: View { } } - @ViewBuilder - private var metadataLine: some View { - let chips = metadataChips - if !chips.isEmpty { - HStack(spacing: 4) { - ForEach(Array(chips.enumerated()), id: \.offset) { _, chip in - chip - } - Spacer(minLength: 0) - } - } - } - - private var metadataChips: [AnyView] { - var chips: [AnyView] = [] - if showsAgentChip { - chips.append(AnyView(AgentChip(agent: entry.agent))) - } - if let basename = entry.cwdBasename { - chips.append(AnyView(MetadataChip(symbol: "folder", text: basename))) - } - if let branch = entry.gitBranch, !branch.isEmpty { - chips.append(AnyView(MetadataChip(symbol: "arrow.triangle.branch", text: branch))) - } - if let pr = entry.pullRequest { - chips.append(AnyView( - MetadataChip( - symbol: "arrow.triangle.pull", - text: "#\(pr.number)", - accent: Color.purple, - onTap: { - if let url = URL(string: pr.url) { - NSWorkspace.shared.open(url) - } - } - ) - )) - } - return chips - } - private var helpText: String { var lines: [String] = [entry.displayTitle] if let cwd = entry.cwdLabel { @@ -498,58 +455,3 @@ private struct SessionRow: View { } } -private struct AgentChip: View { - let agent: SessionAgent - - var body: some View { - HStack(spacing: 3) { - Image(agent.assetName) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .frame(width: 10, height: 10) - Text(agent.displayName) - .font(.system(size: 10, weight: .medium)) - } - .foregroundColor(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(Color.secondary.opacity(0.12)) - ) - } -} - -private struct MetadataChip: View { - let symbol: String - let text: String - var accent: Color? = nil - var onTap: (() -> Void)? = nil - - var body: some View { - let chip = HStack(spacing: 3) { - Image(systemName: symbol) - .font(.system(size: 9, weight: .medium)) - Text(text) - .font(.system(size: 10, weight: .medium)) - .lineLimit(1) - .truncationMode(.middle) - } - .foregroundColor(accent ?? .secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill((accent ?? Color.secondary).opacity(0.12)) - ) - - if let onTap { - chip - .onTapGesture(perform: onTap) - .contentShape(Rectangle()) - } else { - chip - } - } -} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a5f066b37893..103ec922dc18 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1891,6 +1891,7 @@ class TabManager: ObservableObject { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] ) -> Workspace { Workspace( @@ -1899,6 +1900,7 @@ class TabManager: ObservableObject { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -1950,6 +1952,7 @@ class TabManager: ObservableObject { title: String? = nil, workingDirectory overrideWorkingDirectory: String? = nil, initialTerminalCommand: String? = nil, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, @@ -1997,6 +2000,7 @@ class TabManager: ObservableObject { portOrdinal: ordinal, configTemplate: inheritedConfig, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) applyCreationChromeInheritance( diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d5250f6782d3..941e94c799d7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6862,6 +6862,7 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: Int = 0, configTemplate: CmuxSurfaceConfigTemplate? = nil, initialTerminalCommand: String? = nil, + initialTerminalInput: String? = nil, initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -6912,6 +6913,7 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, + initialInput: initialTerminalInput, initialEnvironmentOverrides: initialTerminalEnvironment ) configureTerminalPanel(terminalPanel) @@ -8938,6 +8940,7 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, focus: Bool? = nil, workingDirectory: String? = nil, + initialInput: String? = nil, startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) @@ -8955,6 +8958,7 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: workingDirectory, portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand, + initialInput: initialInput, additionalEnvironment: startupEnvironment ) configureTerminalPanel(newPanel) From a414ff042b1d55578d35da004dc50ae0f68540e5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 14:20:17 -0700 Subject: [PATCH 07/39] Inject per-session flags into resume command Resume now passes the saved per-session settings as CLI flags so the restored session uses the same model / permission / sandbox / effort the original was running with. - Claude: claude --resume --model --permission-mode (model from assistant.message.model with the [1m] suffix stripped; permission mode from per-entry permissionMode) - Codex: codex resume -m -a -s -c model_reasoning_effort= (all from turn_context.payload; effort uses -c since codex has no first-class --effort flag) - OpenCode: opencode --session -m --agent (pulled from the most recent assistant message via a correlated subquery on the messages table) Effort is not stored in Claude's local jsonl, so --effort is omitted for Claude. Values are shell-quoted when they contain unsafe characters (the OpenCode agent name, e.g. "Sisyphus (Ultraworker)", needs this). --- Sources/SessionIndexStore.swift | 154 ++++++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 18 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 668da715096d..c7f38ce9db8a 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -38,6 +38,13 @@ struct PullRequestLink: Hashable { let repository: String? } +/// Agent-specific fields used to build the resume command with appropriate flags. +enum AgentSpecifics: Hashable { + case claude(model: String?, permissionMode: String?) + case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?) + case opencode(providerModel: String?, agentName: String?) +} + struct SessionEntry: Identifiable, Hashable { let id: String let agent: SessionAgent @@ -49,20 +56,57 @@ struct SessionEntry: Identifiable, Hashable { let pullRequest: PullRequestLink? let modified: Date let fileURL: URL? + let specifics: AgentSpecifics - /// Shell command that resumes this session in a new terminal. - /// Bare resume — let each CLI restore its own per-session settings. + /// Shell command that resumes this session in a new terminal, with the agent's + /// known per-session settings injected as CLI flags. var resumeCommand: String { - switch agent { - case .claude: - return "claude --resume \(sessionId)" - case .codex: - return "codex resume \(sessionId)" - case .opencode: - return "opencode --session \(sessionId)" + switch specifics { + case let .claude(model, permissionMode): + var parts = ["claude --resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("--model \(Self.shellQuote(model))") + } + if let permissionMode, !permissionMode.isEmpty { + parts.append("--permission-mode \(Self.shellQuote(permissionMode))") + } + return parts.joined(separator: " ") + case let .codex(model, approval, sandbox, effort): + var parts = ["codex resume \(sessionId)"] + if let model, !model.isEmpty { + parts.append("-m \(Self.shellQuote(model))") + } + if let approval, !approval.isEmpty { + parts.append("-a \(Self.shellQuote(approval))") + } + if let sandbox, !sandbox.isEmpty { + parts.append("-s \(Self.shellQuote(sandbox))") + } + if let effort, !effort.isEmpty { + parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") + } + return parts.joined(separator: " ") + case let .opencode(providerModel, agentName): + var parts = ["opencode --session \(sessionId)"] + if let providerModel, !providerModel.isEmpty { + parts.append("-m \(Self.shellQuote(providerModel))") + } + if let agentName, !agentName.isEmpty { + parts.append("--agent \(Self.shellQuote(agentName))") + } + return parts.joined(separator: " ") } } + /// Single-quote a value for safe shell injection. Escapes embedded single quotes. + private static func shellQuote(_ value: String) -> String { + if value.range(of: "[^A-Za-z0-9_./:=+-]", options: .regularExpression) == nil { + return value + } + let escaped = value.replacingOccurrences(of: "'", with: #"'\''"#) + return "'\(escaped)'" + } + var displayTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -361,7 +405,6 @@ final class SessionIndexStore: ObservableObject { let head = readFileHead(url: url, byteCap: headByteCap) let tail = readFileTail(url: url, byteCap: tailByteCap) let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) - // Claude session id is the .jsonl basename let sid = url.deletingPathExtension().lastPathComponent results.append(SessionEntry( id: "claude:" + url.path, @@ -372,7 +415,8 @@ final class SessionIndexStore: ObservableObject { gitBranch: parsed.branch, pullRequest: parsed.pr, modified: mtime, - fileURL: url + fileURL: url, + specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) )) } return results @@ -383,6 +427,8 @@ final class SessionIndexStore: ObservableObject { var cwd: String? var branch: String? var pr: PullRequestLink? + var model: String? + var permissionMode: String? } private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { @@ -398,6 +444,14 @@ final class SessionIndexStore: ObservableObject { if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { out.branch = branchField } + if let mode = obj["permissionMode"] as? String, !mode.isEmpty { + out.permissionMode = mode + } + if (obj["type"] as? String) == "assistant", + let message = obj["message"] as? [String: Any], + let model = message["model"] as? String, !model.isEmpty { + out.model = model + } if out.title.isEmpty, (obj["type"] as? String) == "user", let message = obj["message"] as? [String: Any], @@ -414,10 +468,8 @@ final class SessionIndexStore: ObservableObject { } } } - if out.title.isEmpty == false && out.branch != nil { break } } - // Tail scan: pr-link tends to live at end; also catches branch updates. for line in tail.split(separator: "\n", omittingEmptySubsequences: true) { guard let data = line.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } @@ -433,6 +485,18 @@ final class SessionIndexStore: ObservableObject { if let branchField = obj["gitBranch"] as? String, !branchField.isEmpty { out.branch = branchField } + if let mode = obj["permissionMode"] as? String, !mode.isEmpty { + out.permissionMode = mode + } + if (obj["type"] as? String) == "assistant", + let message = obj["message"] as? [String: Any], + let model = message["model"] as? String, !model.isEmpty { + out.model = model + } + } + // Strip the [1m] suffix some Claude internal model IDs carry (claude-opus-4-7[1m]). + if let m = out.model, let bracket = m.firstIndex(of: "[") { + out.model = String(m[.. (String?, String?) { + guard let raw, let data = raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return (nil, nil) + } + let modelID = obj["modelID"] as? String + let providerID = obj["providerID"] as? String + let agentName = obj["agent"] as? String + let providerModel: String? = { + switch (providerID, modelID) { + case let (p?, m?) where !p.isEmpty && !m.isEmpty: return "\(p)/\(m)" + case let (_, m?) where !m.isEmpty: return m + default: return nil + } + }() + return (providerModel, agentName?.isEmpty == false ? agentName : nil) + } + private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? { guard let cString = sqlite3_column_text(stmt, index) else { return nil } return String(cString: cString) From a7eaaa993b7dc5ea47f2658f1dd0932bb3eef817 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 14:25:34 -0700 Subject: [PATCH 08/39] Per-section collapse + use live workspace cwd for resume placement - Bring back collapsible sections. Click anywhere on a section header to toggle; a small chevron at the end indicates state. Default is expanded. Collapse and "Show more" are independent. - Smart resume placement was checking the focused panel's `directory` field, which isn't reliably updated. Switch to the workspace's live `currentDirectory` (kept in sync via panel directory updates), so double-click correctly opens a new tab inside the current workspace when the cwd matches. --- Sources/ContentView.swift | 10 +-- Sources/SessionIndexView.swift | 113 ++++++++++++++++++++------------- 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2108ff3ec31a..c8264dcf6820 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2976,15 +2976,15 @@ struct ContentView: View { let inputWithReturn = entry.resumeCommand + "\n" let targetCwd = entry.cwd - // Smart placement: if the focused workspace's terminal is already in the - // same cwd, open a new tab in that workspace. Otherwise, create a new workspace. + // Smart placement: if the focused workspace's tracked cwd matches, open a + // new tab inside that workspace. Otherwise create a new workspace. let selected = tabManager.selectedWorkspace - let focusedDir = selected?.focusedTerminalPanel?.directory + let workspaceCwd = selected?.currentDirectory let pwdMatches: Bool = { guard let targetCwd, !targetCwd.isEmpty, - let focusedDir, !focusedDir.isEmpty else { return false } + let workspaceCwd, !workspaceCwd.isEmpty else { return false } let lhs = (targetCwd as NSString).standardizingPath - let rhs = (focusedDir as NSString).standardizingPath + let rhs = (workspaceCwd as NSString).standardizingPath return lhs == rhs }() diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 6f0cb03d5940..1564bd8ad04e 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -3,7 +3,10 @@ import SwiftUI struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore - @State private var expandedSections: Set = [] + /// Sections the user has explicitly collapsed (default is expanded). + @State private var collapsedSections: Set = [] + /// Sections with "Show more" tapped (default shows up to `rowLimit` rows). + @State private var showAllSections: Set = [] let onResume: ((SessionEntry) -> Void)? /// Rows shown per section before "Show more" is tapped. @@ -107,13 +110,23 @@ struct SessionIndexView: View { IndexSectionView( section: section, rowLimit: Self.collapsedRowLimit, - isExpanded: Binding( - get: { expandedSections.contains(section.key) }, + isCollapsed: Binding( + get: { collapsedSections.contains(section.key) }, set: { newValue in if newValue { - expandedSections.insert(section.key) + collapsedSections.insert(section.key) } else { - expandedSections.remove(section.key) + collapsedSections.remove(section.key) + } + } + ), + showAll: Binding( + get: { showAllSections.contains(section.key) }, + set: { newValue in + if newValue { + showAllSections.insert(section.key) + } else { + showAllSections.remove(section.key) } } ), @@ -161,60 +174,72 @@ private struct GroupingButton: View { private struct IndexSectionView: View { let section: IndexSection let rowLimit: Int - @Binding var isExpanded: Bool + @Binding var isCollapsed: Bool + @Binding var showAll: Bool @ObservedObject var store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { sectionHeader - if section.entries.isEmpty { - Text(String(localized: "sessionIndex.section.noChats", defaultValue: "No chats")) - .font(.system(size: 12)) - .foregroundColor(.secondary.opacity(0.6)) - .padding(.leading, 32) - .padding(.vertical, 4) - } else { - let visible = isExpanded - ? section.entries - : Array(section.entries.prefix(rowLimit)) - ForEach(visible) { entry in - SessionRow(entry: entry, onResume: onResume) - } - if section.entries.count > rowLimit { - Button { - isExpanded.toggle() - } label: { - Text(isExpanded - ? String(localized: "sessionIndex.section.showLess", defaultValue: "Show less") - : String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary.opacity(0.7)) - .padding(.leading, 32) - .padding(.vertical, 4) - .contentShape(Rectangle()) + if !isCollapsed { + if section.entries.isEmpty { + Text(String(localized: "sessionIndex.section.noChats", defaultValue: "No chats")) + .font(.system(size: 12)) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.leading, 32) + .padding(.vertical, 4) + } else { + let visible = showAll + ? section.entries + : Array(section.entries.prefix(rowLimit)) + ForEach(visible) { entry in + SessionRow(entry: entry, onResume: onResume) + } + if section.entries.count > rowLimit { + Button { + showAll.toggle() + } label: { + Text(showAll + ? String(localized: "sessionIndex.section.showLess", defaultValue: "Show less") + : String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.leading, 32) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + Spacer(minLength: 6) } - Spacer(minLength: 6) } .opacity(store.draggedKey == section.key ? 0.45 : 1.0) } private var sectionHeader: some View { - HStack(spacing: 8) { - sectionIconView - Text(section.title) - .font(.system(size: 13, weight: .regular)) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) + Button { + isCollapsed.toggle() + } label: { + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary.opacity(0.6)) + .rotationEffect(.degrees(isCollapsed ? -90 : 0)) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .contentShape(Rectangle()) + .buttonStyle(.plain) .onDrag { DispatchQueue.main.async { store.draggedKey = section.key } return NSItemProvider(object: section.key.raw as NSString) From bcc1a63af30bc35e5e0f6aaf5174456ecb879daf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 15:00:48 -0700 Subject: [PATCH 09/39] Show-more popover with search + draggable session rows - "Show N more" now opens a popover anchored to the leading edge of the button. Popover has a search field that filters by title, cwd, or branch, and a 420pt-tall scrollable list of all session entries for that section. Double-click in the popover resumes and dismisses; rows are draggable just like in the main list. - Section rows in the main list are no longer rendered past the 5-row limit (they live in the popover instead), so the list stays short and the scrollbar size shouldn't shift around as you scroll. - SessionRow + PopoverRow expose the resume command as a draggable text payload. Dropping a session onto an existing terminal surface inserts the command via the terminal's existing text-drop handler. Real drop-on-bonsplit-to-create-a-new-pane needs a hook in the bonsplit submodule and isn't wired yet. --- Resources/Localizable.xcstrings | 51 +++++++ Sources/SessionIndexView.swift | 236 ++++++++++++++++++++++++++++---- 2 files changed, 260 insertions(+), 27 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index adedc94c52ec..4908eb185c5c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -90111,6 +90111,57 @@ } } } + }, + "sessionIndex.section.showMoreCount": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show %lld more" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あと %lld 件を表示" + } + } + } + }, + "sessionIndex.popover.searchPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search sessions" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを検索" + } + } + } + }, + "sessionIndex.popover.noMatches": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matches" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致なし" + } + } + } } } } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 1564bd8ad04e..72295ece6ca5 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -5,8 +5,8 @@ struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore /// Sections the user has explicitly collapsed (default is expanded). @State private var collapsedSections: Set = [] - /// Sections with "Show more" tapped (default shows up to `rowLimit` rows). - @State private var showAllSections: Set = [] + /// Section whose "Show more" popover is currently open. + @State private var openPopoverSection: SectionKey? = nil let onResume: ((SessionEntry) -> Void)? /// Rows shown per section before "Show more" is tapped. @@ -120,14 +120,10 @@ struct SessionIndexView: View { } } ), - showAll: Binding( - get: { showAllSections.contains(section.key) }, + isPopoverOpen: Binding( + get: { openPopoverSection == section.key }, set: { newValue in - if newValue { - showAllSections.insert(section.key) - } else { - showAllSections.remove(section.key) - } + openPopoverSection = newValue ? section.key : nil } ), store: store, @@ -175,7 +171,7 @@ private struct IndexSectionView: View { let section: IndexSection let rowLimit: Int @Binding var isCollapsed: Bool - @Binding var showAll: Bool + @Binding var isPopoverOpen: Bool @ObservedObject var store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? @@ -190,26 +186,11 @@ private struct IndexSectionView: View { .padding(.leading, 32) .padding(.vertical, 4) } else { - let visible = showAll - ? section.entries - : Array(section.entries.prefix(rowLimit)) - ForEach(visible) { entry in + ForEach(Array(section.entries.prefix(rowLimit))) { entry in SessionRow(entry: entry, onResume: onResume) } if section.entries.count > rowLimit { - Button { - showAll.toggle() - } label: { - Text(showAll - ? String(localized: "sessionIndex.section.showLess", defaultValue: "Show less") - : String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary.opacity(0.7)) - .padding(.leading, 32) - .padding(.vertical, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + showMoreButton } } Spacer(minLength: 6) @@ -218,6 +199,29 @@ private struct IndexSectionView: View { .opacity(store.draggedKey == section.key ? 0.45 : 1.0) } + private var showMoreButton: some View { + let extra = section.entries.count - rowLimit + let template = String(localized: "sessionIndex.section.showMoreCount", + defaultValue: "Show %lld more") + let label = String(format: template, extra) + return Button { + isPopoverOpen = true + } label: { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary.opacity(0.7)) + .padding(.leading, 32) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover(isPresented: $isPopoverOpen, arrowEdge: .leading) { + SectionPopoverView(section: section, onResume: onResume) { + isPopoverOpen = false + } + } + } + private var sectionHeader: some View { Button { isCollapsed.toggle() @@ -381,6 +385,27 @@ private struct SessionRow: View { .onTapGesture(count: 2) { if let onResume { onResume(entry) } } + .onDrag { + // Provide the resume command as plain text. Dropping on a terminal + // surface inserts it; right-click → Resume in New Tab still works + // for the create-tab path. + NSItemProvider(object: entry.resumeCommand as NSString) + } preview: { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) + Text(entry.displayTitle) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + } .contextMenu { if let onResume { Button { @@ -480,3 +505,160 @@ private struct SessionRow: View { } } + +// MARK: - "Show more" popover with search + +private struct SectionPopoverView: View { + let section: IndexSection + let onResume: ((SessionEntry) -> Void)? + let onDismiss: () -> Void + + @State private var query: String = "" + @FocusState private var searchFocused: Bool + + private var filtered: [SessionEntry] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return section.entries } + let needle = trimmed.lowercased() + return section.entries.filter { entry in + entry.displayTitle.lowercased().contains(needle) + || (entry.cwd ?? "").lowercased().contains(needle) + || (entry.gitBranch ?? "").lowercased().contains(needle) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + sectionIconView + Text(section.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + Text("\(section.entries.count)") + .font(.system(size: 11).monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + TextField( + String(localized: "sessionIndex.popover.searchPlaceholder", + defaultValue: "Search sessions"), + text: $query + ) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($searchFocused) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .padding(.horizontal, 10) + .padding(.bottom, 8) + + Divider() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if filtered.isEmpty { + Text(String(localized: "sessionIndex.popover.noMatches", + defaultValue: "No matches")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(filtered) { entry in + PopoverRow(entry: entry) { + onResume?(entry) + onDismiss() + } + } + } + } + .padding(.vertical, 4) + } + .frame(maxHeight: 420) + } + .frame(width: 360) + .onAppear { searchFocused = true } + } + + @ViewBuilder + private var sectionIconView: some View { + switch section.icon { + case .agent(let agent): + Image(agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + case .folder: + Image(systemName: "folder") + .font(.system(size: 12, weight: .regular)) + .foregroundColor(.secondary) + .frame(width: 14, height: 14) + } + } +} + +private struct PopoverRow: View { + let entry: SessionEntry + let onActivate: () -> Void + + @State private var isHovered: Bool = false + + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f + }() + + var body: some View { + HStack(spacing: 8) { + Text(entry.displayTitle) + .font(.system(size: 12)) + .foregroundColor(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(Self.relativeFormatter.localizedString(for: entry.modified, relativeTo: Date())) + .font(.system(size: 11).monospacedDigit()) + .foregroundColor(.secondary.opacity(0.7)) + .fixedSize() + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background(isHovered ? Color.primary.opacity(0.06) : Color.clear) + .onHover { isHovered = $0 } + .onTapGesture(count: 2) { onActivate() } + .onDrag { + NSItemProvider(object: entry.resumeCommand as NSString) + } + .help(entry.cwdLabel ?? entry.displayTitle) + } +} From c36c7f468c9f6a925c1c871f95bd39b7b53c981b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 15:11:06 -0700 Subject: [PATCH 10/39] Wire session drag into bonsplit drop overlays Sessions now show the same blue insert / split overlays as dragging an existing tab, and dropping creates a brand new terminal at the chosen destination (insert into pane, or split horizontally / vertically) with the resume command running in it. Approach: - The session row's NSItemProvider exposes a com.splittabbar.tabtransfer payload alongside the public.utf8-plain-text fallback. The tabtransfer payload is shaped exactly like Bonsplit.TabTransferData (mirrors the TabItem CodingKeys), with a synthetic UUID as the tab id and the current process id so isFromCurrentProcess passes. - That synthetic UUID is registered in a new SessionDragRegistry singleton paired with the originating SessionEntry. Auto-expires after 60s if the drag is cancelled. - Workspace.handleExternalTabDrop checks the registry first: if the request's tab id maps to a pending session, it spawns a new terminal at the destination instead of trying to move a non-existent tab. - Adds Workspace.splitPaneWithNewTerminal(targetPane:orientation: insertFirst:workingDirectory:initialInput:) so the .split destination case can create a brand-new terminal in a freshly split pane. The existing terminal text-drop handler still sees public.utf8-plain-text, so dropping on a terminal that's not on a pane edge inserts the resume command as text (unchanged behavior). --- Sources/SessionIndexStore.swift | 33 +++++++++++++ Sources/SessionIndexView.swift | 63 ++++++++++++++++++++++-- Sources/Workspace.swift | 87 +++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index c7f38ce9db8a..08e7f5d6afd5 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -130,6 +130,39 @@ struct SessionEntry: Identifiable, Hashable { } } +// MARK: - Drag registry + +/// Process-wide registry that pairs a synthetic drag UUID with a SessionEntry. +/// Used to forward sessions through bonsplit's external-tab-drop hook (which only +/// carries UUIDs in its payload). Workspace.handleExternalTabDrop consults this +/// to decide whether a drop should spawn a brand new terminal vs. move an existing tab. +final class SessionDragRegistry { + static let shared = SessionDragRegistry() + + private let lock = NSLock() + private var pending: [UUID: SessionEntry] = [:] + + func register(_ entry: SessionEntry) -> UUID { + let id = UUID() + lock.lock() + pending[id] = entry + lock.unlock() + // Auto-expire so a cancelled drag doesn't leak forever. + DispatchQueue.global().asyncAfter(deadline: .now() + 60) { [weak self] in + self?.lock.lock() + self?.pending.removeValue(forKey: id) + self?.lock.unlock() + } + return id + } + + func consume(id: UUID) -> SessionEntry? { + lock.lock() + defer { lock.unlock() } + return pending.removeValue(forKey: id) + } +} + // MARK: - Store enum SessionGrouping: String, CaseIterable, Identifiable, Codable { diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 72295ece6ca5..9ac280674f00 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -386,10 +386,7 @@ private struct SessionRow: View { if let onResume { onResume(entry) } } .onDrag { - // Provide the resume command as plain text. Dropping on a terminal - // surface inserts it; right-click → Resume in New Tab still works - // for the create-tab path. - NSItemProvider(object: entry.resumeCommand as NSString) + sessionDragItemProvider(for: entry) } preview: { HStack(spacing: 6) { Image(entry.agent.assetName) @@ -657,8 +654,64 @@ private struct PopoverRow: View { .onHover { isHovered = $0 } .onTapGesture(count: 2) { onActivate() } .onDrag { - NSItemProvider(object: entry.resumeCommand as NSString) + sessionDragItemProvider(for: entry) } .help(entry.cwdLabel ?? entry.displayTitle) } } + +// MARK: - Drag payload + +/// Build the NSItemProvider used when dragging a session row. +/// +/// Two type representations: +/// 1. `com.splittabbar.tabtransfer` — bonsplit's drop overlays light up so the +/// user can pick insert / split-left / split-right / etc. The synthetic UUID +/// embedded as the tab id is registered in `SessionDragRegistry` so +/// `Workspace.handleExternalTabDrop` can look up the original SessionEntry +/// and create a brand new terminal at the chosen destination. +/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. Inserts the +/// resume command into an existing terminal surface. +private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { + let dragId = SessionDragRegistry.shared.register(entry) + let provider = NSItemProvider() + + // tabTransfer JSON. Field names mirror Bonsplit.TabTransferData / TabItem + // CodingKeys. UUIDs are encoded as uppercased hyphenated strings (Swift's + // default UUID Codable representation). + let tabPayload: [String: Any] = [ + "id": dragId.uuidString, + "title": entry.displayTitle, + "hasCustomTitle": false, + "icon": "terminal.fill", + "kind": "terminal", + "isDirty": false, + "showsNotificationBadge": false, + "isLoading": false, + "isPinned": false, + ] + let transferPayload: [String: Any] = [ + "tab": tabPayload, + "sourcePaneId": UUID().uuidString, + "sourceProcessId": Int(ProcessInfo.processInfo.processIdentifier), + ] + if let data = try? JSONSerialization.data(withJSONObject: transferPayload) { + provider.registerDataRepresentation( + forTypeIdentifier: "com.splittabbar.tabtransfer", + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + } + + let textData = entry.resumeCommand.data(using: .utf8) ?? Data() + provider.registerDataRepresentation( + forTypeIdentifier: "public.utf8-plain-text", + visibility: .all + ) { completion in + completion(textData, nil) + return nil + } + return provider +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 941e94c799d7..48576da7e09e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -11135,7 +11135,94 @@ final class Workspace: Identifiable, ObservableObject { } } + private func handleSessionDrop( + entry: SessionEntry, + destination: BonsplitController.ExternalTabDropRequest.Destination + ) -> Bool { + let inputWithReturn = entry.resumeCommand + "\n" + switch destination { + case .insert(let paneId, _): + let panel = newTerminalSurface( + inPane: paneId, + focus: true, + workingDirectory: entry.cwd, + initialInput: inputWithReturn + ) + return panel != nil + case .split(let paneId, let orientation, let insertFirst): + let panel = splitPaneWithNewTerminal( + targetPane: paneId, + orientation: orientation, + insertFirst: insertFirst, + workingDirectory: entry.cwd, + initialInput: inputWithReturn + ) + return panel != nil + } + } + + /// Split `paneId` and place a brand-new terminal in the resulting pane. + /// Used by the session-index drop path; mirrors `newTerminalSplit(from:...)` but + /// targets a destination pane directly rather than inheriting from a source panel. + @discardableResult + func splitPaneWithNewTerminal( + targetPane paneId: PaneID, + orientation: SplitOrientation, + insertFirst: Bool, + workingDirectory: String?, + initialInput: String? + ) -> TerminalPanel? { + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + + let newPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialInput: initialInput + ) + configureTerminalPanel(newPanel) + panels[newPanel.id] = newPanel + panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) + + let newTab = Bonsplit.Tab( + title: newPanel.displayTitle, + icon: newPanel.displayIcon, + kind: SurfaceKind.terminal, + isDirty: newPanel.isDirty, + isPinned: false + ) + surfaceIdToPanelId[newTab.id] = newPanel.id + + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane( + paneId, + orientation: orientation, + withTab: newTab, + insertFirst: insertFirst + ) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } + + bonsplitController.selectTab(newTab.id) + newPanel.focus() + return newPanel + } + private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + // Session-index drag → spawn a brand new terminal at the destination instead + // of moving an existing tab. + if let entry = SessionDragRegistry.shared.consume(id: request.tabId.uuid) { + return handleSessionDrop(entry: entry, destination: request.destination) + } + guard let app = AppDelegate.shared else { return false } #if DEBUG let dropStart = ProcessInfo.processInfo.systemUptime From e209abae85d770627c759493c683b40f9db486ae Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 15:39:59 -0700 Subject: [PATCH 11/39] Use mirror Codable structs for session drag payload + log Switch the session-drag NSItemProvider from a hand-built JSONSerialization dict to a Codable struct that mirrors Bonsplit.TabTransferData / TabItem field-by-field. This guarantees the encoded JSON shape matches what Bonsplit's external-drop decoder expects, so validateDrop accepts the payload and the blue insert/split overlays appear when the cursor is over a pane. Also instrument the drag start with dlog and set suggestedName so the drag preview shows the session title. --- Sources/SessionIndexView.swift | 74 ++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 9ac280674f00..bbee3312126e 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import SwiftUI struct SessionIndexView: View { @@ -662,40 +663,58 @@ private struct PopoverRow: View { // MARK: - Drag payload +/// Mirrors `Bonsplit.TabItem`'s Codable shape so we can produce a JSON payload +/// that bonsplit's external-drop path will decode and accept. +private struct MirrorTabItem: Codable { + let id: UUID + let title: String + let hasCustomTitle: Bool + let icon: String? + let iconImageData: Data? + let kind: String? + let isDirty: Bool + let showsNotificationBadge: Bool + let isLoading: Bool + let isPinned: Bool +} + +/// Mirrors `Bonsplit.TabTransferData` exactly. +private struct MirrorTabTransferData: Codable { + let tab: MirrorTabItem + let sourcePaneId: UUID + let sourceProcessId: Int32 +} + /// Build the NSItemProvider used when dragging a session row. /// -/// Two type representations: +/// Two type representations on the same provider: /// 1. `com.splittabbar.tabtransfer` — bonsplit's drop overlays light up so the /// user can pick insert / split-left / split-right / etc. The synthetic UUID /// embedded as the tab id is registered in `SessionDragRegistry` so -/// `Workspace.handleExternalTabDrop` can look up the original SessionEntry -/// and create a brand new terminal at the chosen destination. -/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. Inserts the -/// resume command into an existing terminal surface. +/// `Workspace.handleExternalTabDrop` can look it back up. +/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { let dragId = SessionDragRegistry.shared.register(entry) let provider = NSItemProvider() - // tabTransfer JSON. Field names mirror Bonsplit.TabTransferData / TabItem - // CodingKeys. UUIDs are encoded as uppercased hyphenated strings (Swift's - // default UUID Codable representation). - let tabPayload: [String: Any] = [ - "id": dragId.uuidString, - "title": entry.displayTitle, - "hasCustomTitle": false, - "icon": "terminal.fill", - "kind": "terminal", - "isDirty": false, - "showsNotificationBadge": false, - "isLoading": false, - "isPinned": false, - ] - let transferPayload: [String: Any] = [ - "tab": tabPayload, - "sourcePaneId": UUID().uuidString, - "sourceProcessId": Int(ProcessInfo.processInfo.processIdentifier), - ] - if let data = try? JSONSerialization.data(withJSONObject: transferPayload) { + let mirror = MirrorTabTransferData( + tab: MirrorTabItem( + id: dragId, + title: entry.displayTitle, + hasCustomTitle: false, + icon: "terminal.fill", + iconImageData: nil, + kind: "terminal", + isDirty: false, + showsNotificationBadge: false, + isLoading: false, + isPinned: false + ), + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + if let data = try? JSONEncoder().encode(mirror) { provider.registerDataRepresentation( forTypeIdentifier: "com.splittabbar.tabtransfer", visibility: .ownProcess @@ -703,6 +722,9 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider completion(data, nil) return nil } + #if DEBUG + dlog("session.drag.start id=\(dragId.uuidString.prefix(5)) bytes=\(data.count) entry=\(entry.agent.rawValue):\(entry.sessionId.prefix(8))") + #endif } let textData = entry.resumeCommand.data(using: .utf8) ?? Data() @@ -713,5 +735,7 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider completion(textData, nil) return nil } + + provider.suggestedName = entry.displayTitle return provider } From 26b10f91ac899009a6a9b89ea5b6281a95742f17 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 16:25:38 -0700 Subject: [PATCH 12/39] Mirror session drag payload onto the system .drag pasteboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI's NSItemProvider → drag pasteboard bridge doesn't always surface custom UTTypes on NSPasteboard(name: .drag), so bonsplit's decodeTransfer(from: info), which reads from that pasteboard directly, sees no com.splittabbar.tabtransfer payload and validateDrop returns false — meaning no blue insert/split overlays. Mirror our encoded MirrorTabTransferData blob onto the .drag pasteboard manually right after onDrag fires, using addTypes (preserves existing SwiftUI-provided types) + setData. This matches what bonsplit's own tab drag relies on, just done by hand since we're entering bonsplit from outside. Also adds a session.drag.pasteboard debug log so we can confirm the type and byte count actually land on the pasteboard. --- Sources/SessionIndexView.swift | 46 ++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index bbee3312126e..87adda18d84c 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import SwiftUI +import UniformTypeIdentifiers struct SessionIndexView: View { @ObservedObject var store: SessionIndexStore @@ -685,18 +686,8 @@ private struct MirrorTabTransferData: Codable { let sourceProcessId: Int32 } -/// Build the NSItemProvider used when dragging a session row. -/// -/// Two type representations on the same provider: -/// 1. `com.splittabbar.tabtransfer` — bonsplit's drop overlays light up so the -/// user can pick insert / split-left / split-right / etc. The synthetic UUID -/// embedded as the tab id is registered in `SessionDragRegistry` so -/// `Workspace.handleExternalTabDrop` can look it back up. -/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. -private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { - let dragId = SessionDragRegistry.shared.register(entry) - let provider = NSItemProvider() - +/// Build the encoded payload bonsplit's external-drop decoder accepts. +private func sessionTabTransferData(for entry: SessionEntry, dragId: UUID) -> Data? { let mirror = MirrorTabTransferData( tab: MirrorTabItem( id: dragId, @@ -713,8 +704,22 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider sourcePaneId: UUID(), sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) ) + return try? JSONEncoder().encode(mirror) +} - if let data = try? JSONEncoder().encode(mirror) { +/// NSItemProvider used by `.onDrag {}`. Registers two type representations: +/// 1. `com.splittabbar.tabtransfer` — bonsplit's drop overlays light up. +/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. +/// +/// Plus also exposes the tabTransfer payload directly on the system drag +/// pasteboard so AppKit drag-aware views in the same process see it +/// synchronously (some SwiftUI drop delegates only consult the pasteboard +/// during validateDrop, not the NSItemProvider). +private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { + let dragId = SessionDragRegistry.shared.register(entry) + let provider = NSItemProvider() + + if let data = sessionTabTransferData(for: entry, dragId: dragId) { provider.registerDataRepresentation( forTypeIdentifier: "com.splittabbar.tabtransfer", visibility: .ownProcess @@ -722,6 +727,21 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider completion(data, nil) return nil } + // Mirror onto the system drag pasteboard so bonsplit's + // decodeTransfer(from: NSPasteboard(name: .drag)) sees it. SwiftUI's + // NSItemProvider bridge sometimes doesn't surface the type on the + // system .drag pasteboard reliably for custom UTTypes. + DispatchQueue.main.async { + let pb = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + pb.addTypes([type], owner: nil) + pb.setData(data, forType: type) + #if DEBUG + let types = pb.types?.map(\.rawValue).joined(separator: ",") ?? "-" + let pbData = pb.data(forType: type) + dlog("session.drag.pasteboard types=\(types) tabTransferBytes=\(pbData?.count ?? -1)") + #endif + } #if DEBUG dlog("session.drag.start id=\(dragId.uuidString.prefix(5)) bytes=\(data.count) entry=\(entry.agent.rawValue):\(entry.sessionId.prefix(8))") #endif From ab6ae04756f109077b2c72e797365e3fa8130447 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 16:29:41 -0700 Subject: [PATCH 13/39] Defer to bonsplit on terminal hover + use Codex thread_name as title Drag fixes: - Terminal surface now rejects drags whose pasteboard contains com.splittabbar.tabtransfer or com.cmux.sidebar-tab-reorder. Without this, dragging a session row over a terminal made the terminal claim the drop and insert the resume command as text, hiding bonsplit's blue insert/split overlays. The terminal's existing text/file drop handling for non-tab drags is unchanged. Codex titles: - Recent Codex versions emit `event_msg.thread_name_updated` with a short generated `thread_name` (e.g. "Explain PostHog metrics"). Capture it during streaming and prefer it over the first user_message fallback. Older sessions without thread_name keep using the first user message. --- Sources/GhosttyTerminalView.swift | 12 ++++++++++++ Sources/SessionIndexStore.swift | 28 ++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f4325695bc2c..9436c53f5b99 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8618,6 +8618,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } + // Defer to bonsplit when a tab/session drag is in flight: bonsplit's pane + // drop overlays should win over the terminal's text/file drop handling. + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return [] + } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -8630,6 +8635,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return [] + } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } @@ -8637,6 +8645,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let types = sender.draggingPasteboard.types ?? [] + if types.contains(Self.tabTransferPasteboardType) || types.contains(Self.sidebarTabReorderPasteboardType) { + return false + } #if DEBUG dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 08e7f5d6afd5..b8b424a10681 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -596,13 +596,20 @@ final class SessionIndexStore: ObservableObject { private struct CodexParsed { var sessionId: String = "" - var title: String = "" + /// First user message — used only if Codex never assigns a thread_name. + var firstUserMessage: String = "" + /// Codex-generated session title (`event_msg.thread_name_updated`). Wins over firstUserMessage. + var threadName: String = "" var cwd: String? var branch: String? var model: String? var approvalPolicy: String? var sandboxMode: String? var effort: String? + + var title: String { + threadName.isEmpty ? firstUserMessage : threadName + } } /// Stream lines from `url` until we have everything we need. The first user_message @@ -631,12 +638,17 @@ final class SessionIndexStore: ObservableObject { } if let e = p["effort"] as? String, !e.isEmpty { out.effort = e } } - if out.title.isEmpty, type == "event_msg", let p = payload, + if type == "event_msg", let p = payload, + (p["type"] as? String) == "thread_name_updated", + let name = p["thread_name"] as? String, !name.isEmpty { + out.threadName = name + } + if out.firstUserMessage.isEmpty, type == "event_msg", let p = payload, (p["type"] as? String) == "user_message", let msg = p["message"] as? String, !msg.isEmpty { - out.title = msg + out.firstUserMessage = msg } - if out.title.isEmpty, type == "response_item", let p = payload, + if out.firstUserMessage.isEmpty, type == "response_item", let p = payload, (p["type"] as? String) == "message", (p["role"] as? String) == "user", let content = p["content"] as? [[String: Any]] { @@ -647,12 +659,16 @@ final class SessionIndexStore: ObservableObject { !text.hasPrefix("# AGENTS.md"), !text.hasPrefix(""), !text.hasPrefix(" Date: Thu, 16 Apr 2026 16:38:14 -0700 Subject: [PATCH 14/39] Drop terminal-text fallback from session drag + tighten section padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag fix: - Stop registering public.utf8-plain-text on the session drag's NSItemProvider. The terminal NSDraggingDestination accepts .string, so as long as our drag carried text, the terminal was hit-tested for every drag-update event and bonsplit's SwiftUI .onDrop overlay never got a chance to fire — the AppKit drag system stayed "owned" by the terminal NSView, even when draggingEntered returned []. With only com.splittabbar.tabtransfer on the provider (still mirrored onto the drag pasteboard), the terminal isn't a candidate destination and bonsplit's blue insert/split overlays render across the whole pane including its center. - Right-click → Copy Resume Command / Resume in New Tab still cover the "type into existing terminal" case if needed. Padding: - Section header vertical padding 6 → 3. - SectionReorderGap height 8 → 4. - Trailing Spacer in expanded section minLength 6 → 2. Removed temporary session.drag.* debug logs from the previous round — the log already proved the pasteboard mirror works, so they're done their job. --- Sources/SessionIndexView.swift | 44 ++++++++++------------------------ 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 87adda18d84c..7434b0121c03 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -195,7 +195,7 @@ private struct IndexSectionView: View { showMoreButton } } - Spacer(minLength: 6) + Spacer(minLength: 2) } } .opacity(store.draggedKey == section.key ? 0.45 : 1.0) @@ -242,7 +242,7 @@ private struct IndexSectionView: View { Spacer(minLength: 0) } .padding(.horizontal, 12) - .padding(.vertical, 6) + .padding(.vertical, 3) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -288,7 +288,7 @@ private struct SectionReorderGap: View { let isValid = isValidDrop Rectangle() .fill(Color.clear) - .frame(height: 8) + .frame(height: 4) .overlay(alignment: .center) { if isDropTarget && isValid { Capsule() @@ -707,14 +707,17 @@ private func sessionTabTransferData(for entry: SessionEntry, dragId: UUID) -> Da return try? JSONEncoder().encode(mirror) } -/// NSItemProvider used by `.onDrag {}`. Registers two type representations: -/// 1. `com.splittabbar.tabtransfer` — bonsplit's drop overlays light up. -/// 2. `public.utf8-plain-text` — fallback for terminal text-drop. +/// NSItemProvider used by `.onDrag {}`. Registers ONLY +/// `com.splittabbar.tabtransfer` so the terminal's NSDraggingDestination +/// (which accepts `.string` / `public.utf8-plain-text`) is not hit-tested +/// for our drag. With the terminal out of the way, bonsplit's SwiftUI +/// `.onDrop(of: [.tabTransfer])` overlay can render the blue insert/split +/// zones across the entire pane (including its center). /// -/// Plus also exposes the tabTransfer payload directly on the system drag -/// pasteboard so AppKit drag-aware views in the same process see it -/// synchronously (some SwiftUI drop delegates only consult the pasteboard -/// during validateDrop, not the NSItemProvider). +/// Also mirrors the encoded blob onto NSPasteboard(name: .drag) since +/// bonsplit's external-drop decoder reads from that pasteboard directly +/// and SwiftUI's NSItemProvider bridge doesn't always surface custom +/// UTTypes there reliably. private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider { let dragId = SessionDragRegistry.shared.register(entry) let provider = NSItemProvider() @@ -727,33 +730,12 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider completion(data, nil) return nil } - // Mirror onto the system drag pasteboard so bonsplit's - // decodeTransfer(from: NSPasteboard(name: .drag)) sees it. SwiftUI's - // NSItemProvider bridge sometimes doesn't surface the type on the - // system .drag pasteboard reliably for custom UTTypes. DispatchQueue.main.async { let pb = NSPasteboard(name: .drag) let type = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") pb.addTypes([type], owner: nil) pb.setData(data, forType: type) - #if DEBUG - let types = pb.types?.map(\.rawValue).joined(separator: ",") ?? "-" - let pbData = pb.data(forType: type) - dlog("session.drag.pasteboard types=\(types) tabTransferBytes=\(pbData?.count ?? -1)") - #endif } - #if DEBUG - dlog("session.drag.start id=\(dragId.uuidString.prefix(5)) bytes=\(data.count) entry=\(entry.agent.rawValue):\(entry.sessionId.prefix(8))") - #endif - } - - let textData = entry.resumeCommand.data(using: .utf8) ?? Data() - provider.registerDataRepresentation( - forTypeIdentifier: "public.utf8-plain-text", - visibility: .all - ) { completion in - completion(textData, nil) - return nil } provider.suggestedName = entry.displayTitle From 4dd48437e18f13b5432c4f41793286d684751a21 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 16:50:21 -0700 Subject: [PATCH 15/39] Host Show-more popover in a real NSPopover so search field accepts focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI's `.popover()` doesn't reliably let an embedded TextField become first responder in cmux's focus-managed environment — the terminal's applyFirstResponderIfNeeded path keeps grabbing focus back, so clicking the search field did nothing. Switch to a custom NSPopover hosted via NSViewRepresentable, modeled on UpdatePill's existing pattern. Uses .transient behavior, anchors to the "Show N more" button via a hidden NSView, and hosts SectionPopoverView through NSHostingController. The popover gets its own key window which the search TextField can claim cleanly. --- Sources/SessionIndexView.swift | 130 +++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 7434b0121c03..d0f506501ab4 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -217,11 +217,13 @@ private struct IndexSectionView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .popover(isPresented: $isPopoverOpen, arrowEdge: .leading) { - SectionPopoverView(section: section, onResume: onResume) { - isPopoverOpen = false - } - } + .background( + SectionPopoverHost( + isPresented: $isPopoverOpen, + section: section, + onResume: onResume + ) + ) } private var sectionHeader: some View { @@ -741,3 +743,121 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider provider.suggestedName = entry.displayTitle return provider } + +// MARK: - NSPopover host + +/// Hosts SectionPopoverView in a real NSPopover. SwiftUI's native `.popover()` +/// doesn't reliably let the embedded TextField become first responder in cmux's +/// focus-managed environment — the terminal keeps grabbing focus back. +private struct SectionPopoverHost: NSViewRepresentable { + @Binding var isPresented: Bool + let section: IndexSection + let onResume: ((SessionEntry) -> Void)? + + func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + let coordinator = context.coordinator + coordinator.anchorView = nsView + coordinator.update( + section: section, + onResume: onResume + ) + if isPresented { + coordinator.present() + } else { + coordinator.dismiss() + } + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.dismiss() + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + weak var anchorView: NSView? + + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + private var currentSection: IndexSection? + private var currentOnResume: ((SessionEntry) -> Void)? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func update(section: IndexSection, onResume: ((SessionEntry) -> Void)?) { + currentSection = section + currentOnResume = onResume + refreshContent() + } + + private func refreshContent() { + guard let section = currentSection else { return } + let onResume = currentOnResume + hostingController.rootView = AnyView( + SectionPopoverView(section: section, onResume: onResume) { [weak self] in + self?.closeFromContent() + } + ) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + updateContentSize() + } + + func present() { + guard let anchorView, anchorView.window != nil else { + isPresented = false + return + } + anchorView.superview?.layoutSubtreeIfNeeded() + let popover = popover ?? makePopover() + updateContentSize() + guard !popover.isShown else { return } + popover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxX) + } + + func dismiss() { + popover?.performClose(nil) + } + + func closeFromContent() { + isPresented = false + dismiss() + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let p = NSPopover() + p.behavior = .transient + p.animates = true + p.contentViewController = hostingController + p.delegate = self + self.popover = p + return p + } + + private func updateContentSize() { + let fitting = hostingController.view.fittingSize + guard fitting.width > 0, fitting.height > 0 else { return } + popover?.contentSize = NSSize( + width: ceil(max(fitting.width, 360)), + height: ceil(min(fitting.height, 480)) + ) + } + } +} From 906eada6e2dfde8a2f975f7f6a7a0a4757e34b9e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 16:58:27 -0700 Subject: [PATCH 16/39] Don't steal first responder from a text editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause from the dogfood log: the popover's NSPopover hosting window became key and its TextField field editor was first responder, but ~35ms later a deferred applyFirstResponderIfNeeded ran on the terminal's GhosttySurfaceScrollView. The window.isKeyWindow guard didn't reject — NSPopover propagates first responder through the parent in a way that keeps that guard true — so window.makeFirstResponder(surfaceView) ran and clobbered the field editor. Add an explicit early-return when window.firstResponder is an NSText (NSTextField/NSTextView/field editor). The terminal surface uses its own GhosttyNSView for input and never NSText, so this can't suppress a legitimate terminal-focus restore. Removed the temporary session.popover.* probes — they did their job. The new SKIP-on-NSText log is permanent (low volume, high signal). --- Sources/GhosttyTerminalView.swift | 11 +++++++++++ Sources/SessionIndexView.swift | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9436c53f5b99..4d9855bfde47 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10878,6 +10878,17 @@ final class GhosttySurfaceScrollView: NSView { if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { #if DEBUG dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") +#endif + return + } + // Don't steal focus from an active text editor (NSTextField/NSTextView/field editor). + // The terminal surface uses its own GhosttyNSView for input, never NSText, so this is + // safe. Without this guard, a popover (e.g. session-index search) loses its text-field + // focus instantly because applyFirstResponderIfNeeded runs on a deferred async tick and + // sees the field editor as a "foreign" responder to clobber. + if window.firstResponder is NSText { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=textEditorFocused") #endif return } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index d0f506501ab4..05f15ccd009f 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -604,7 +604,10 @@ private struct SectionPopoverView: View { .frame(maxHeight: 420) } .frame(width: 360) - .onAppear { searchFocused = true } + .onAppear { + // Defer one runloop turn so the popover's window is fully key. + DispatchQueue.main.async { searchFocused = true } + } } @ViewBuilder From abbab88cc9eda884f1a7520da3701ff11e06fe1f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 17:27:26 -0700 Subject: [PATCH 17/39] Stop key-routing repair from stealing from popover text fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause from the dogfood log: the popover's TextField uses macOS's shared field editor, which gets registered as firstResponder of the parent (cmux main) window. The first key event hits cmux_sendEvent on the main window and runs repairFocusedTerminalKeyboardRoutingIfNeeded. responderHasViableKeyRoutingOwner returns false because the field editor's owning view (the popover NSTextField) has ownerView.window !== window, so the repair logic calls ensureFocus on the focused terminal panel — clobbering the field editor's responder and routing the key to the terminal. Add an early-return when window.firstResponder is NSText. Symmetric with the earlier guard in applyFirstResponderIfNeeded. Terminals use GhosttyNSView, not NSText, so this can't suppress legitimate terminal repair. Also added Escape-to-close to the popover via an NSEvent local monitor so users can dismiss without clicking outside. Removed temporary session.popover.* and session.probe.* debug lines. --- Sources/AppDelegate.swift | 8 ++++++ Sources/SessionIndexView.swift | 52 ++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 225106b87587..98dd015ecfe0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5761,6 +5761,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } + // If the active first responder is a text-field editor (e.g. a popover's + // search field whose field editor is borrowed from the parent window), + // never re-route the keystroke to the terminal. Symmetric with + // applyFirstResponderIfNeeded's NSText guard. Terminals use GhosttyNSView, + // never NSText, so this can't suppress legitimate terminal repair. + if window.firstResponder is NSText { + return + } guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), let workspace = context.tabManager.selectedWorkspace, let panelId = workspace.focusedPanelId, diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 05f15ccd009f..fa413cd73fb2 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -74,8 +74,8 @@ struct SessionIndexView: View { .disabled(store.isLoading) } .padding(.horizontal, 8) - .padding(.vertical, 4) - .frame(height: 32) + .padding(.vertical, 3) + .frame(height: 29) } private var loadingView: some View { @@ -604,6 +604,9 @@ private struct SectionPopoverView: View { .frame(maxHeight: 420) } .frame(width: 360) + .background( + EscapeKeyCatcher { onDismiss() } + ) .onAppear { // Defer one runloop turn so the popover's window is fully key. DispatchQueue.main.async { searchFocused = true } @@ -864,3 +867,48 @@ private struct SectionPopoverHost: NSViewRepresentable { } } } + +// MARK: - Escape key catcher + +/// Invisible AppKit view that fires `onEscape` when Escape is pressed while +/// the popover content is key. Lives in the popover's view tree so it inherits +/// the popover's responder chain. +private struct EscapeKeyCatcher: NSViewRepresentable { + let onEscape: () -> Void + + func makeNSView(context: Context) -> NSView { + let view = EscapeMonitorView() + view.onEscape = onEscape + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + (nsView as? EscapeMonitorView)?.onEscape = onEscape + } + + private final class EscapeMonitorView: NSView { + var onEscape: (() -> Void)? + private var monitor: Any? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + guard window != nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, let win = self.window, win.isKeyWindow else { return event } + if event.keyCode == 53 { // kVK_Escape + self.onEscape?() + return nil + } + return event + } + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } + } +} From 82b58b58defc32ea94b4640c4b2a75f6e30234d6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 17:29:49 -0700 Subject: [PATCH 18/39] Async + cached search filter so typing isn't laggy Previously every keystroke recomputed the filter on the main thread and re-lowercased every entry's title/cwd/branch on every comparison, which became visibly slow once the popover had a few hundred sessions. Three changes: - Pre-lowercase a single combined searchable string per entry once on popover appear, so each filter pass is a cheap substring scan. - Run the filter on a detached background task at userInitiated priority and drop the result into a @State the body reads, so typing never blocks on the scan. - 30ms debounce on each new query: a flurry of fast keystrokes collapses into one filter pass instead of N. New keystrokes cancel the in-flight task so we don't render stale results. Empty query skips the task entirely and snaps back to the full list. --- Sources/SessionIndexView.swift | 58 ++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index fa413cd73fb2..19c41246b8d4 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -517,16 +517,12 @@ private struct SectionPopoverView: View { @State private var query: String = "" @FocusState private var searchFocused: Bool - private var filtered: [SessionEntry] { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return section.entries } - let needle = trimmed.lowercased() - return section.entries.filter { entry in - entry.displayTitle.lowercased().contains(needle) - || (entry.cwd ?? "").lowercased().contains(needle) - || (entry.gitBranch ?? "").lowercased().contains(needle) - } - } + /// Pre-lowercased searchable text per entry (computed once on appear). + /// Avoids re-allocating lowercased strings on every keystroke. + @State private var searchablePairs: [(SessionEntry, String)] = [] + /// Latest filtered result. Updated asynchronously off-main so typing isn't blocked. + @State private var filtered: [SessionEntry] = [] + @State private var filterTask: Task? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -608,9 +604,51 @@ private struct SectionPopoverView: View { EscapeKeyCatcher { onDismiss() } ) .onAppear { + // Pre-lowercase the searchable text once. Typing then only does a + // cheap substring scan over the cached strings. + let entries = section.entries + searchablePairs = entries.map { entry in + let combined = (entry.displayTitle + " " + + (entry.cwd ?? "") + " " + + (entry.gitBranch ?? "")).lowercased() + return (entry, combined) + } + filtered = entries // Defer one runloop turn so the popover's window is fully key. DispatchQueue.main.async { searchFocused = true } } + .onChange(of: query) { newValue in + scheduleFilter(query: newValue) + } + .onDisappear { + filterTask?.cancel() + filterTask = nil + } + } + + /// Cancel the in-flight filter, debounce briefly, then run the substring scan + /// on a detached task. Empty queries skip the task entirely so the full list + /// shows instantly. + private func scheduleFilter(query newValue: String) { + filterTask?.cancel() + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty { + filtered = section.entries + return + } + let pairs = searchablePairs + filterTask = Task { @MainActor in + // Tiny debounce: collapse a flurry of keystrokes into one filter pass. + try? await Task.sleep(nanoseconds: 30_000_000) + if Task.isCancelled { return } + let result = await Task.detached(priority: .userInitiated) { + pairs.compactMap { (entry, text) -> SessionEntry? in + text.contains(trimmed) ? entry : nil + } + }.value + if Task.isCancelled { return } + filtered = result + } } @ViewBuilder From 071e17889fcb9d1aa76abf1c7582f39e625782ca Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 17:31:32 -0700 Subject: [PATCH 19/39] Add bottom padding to Show-more popover scroll content --- Sources/SessionIndexView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 19c41246b8d4..dc72dd56259d 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -595,7 +595,8 @@ private struct SectionPopoverView: View { } } } - .padding(.vertical, 4) + .padding(.top, 4) + .padding(.bottom, 10) } .frame(maxHeight: 420) } From 7e47391c5d91b81bea8717df37ddf0b0664c2192 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 17:38:45 -0700 Subject: [PATCH 20/39] Make session rows equatable to skip body re-eval during scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LazyVStack's biggest scroll-perf cost in this view was SwiftUI re-evaluating each row's body on every scroll tick — rebuilding the .contextMenu, .onDrag preview, .help() text, and the RelativeDateTimeFormatter call for every visible row, every frame. Two changes: - SessionRow and PopoverRow now conform to Equatable, comparing on entry only (closures and per-instance @State aren't compared, which is correct: closures come from stable parent state and @State stays per-instance regardless). - ForEach call sites apply .equatable() so SwiftUI uses the conformance to short-circuit body re-eval when the entry hasn't changed — exactly what scroll causes. --- Sources/SessionIndexView.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index dc72dd56259d..6e99ea7e4df0 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -190,6 +190,7 @@ private struct IndexSectionView: View { } else { ForEach(Array(section.entries.prefix(rowLimit))) { entry in SessionRow(entry: entry, onResume: onResume) + .equatable() } if section.entries.count > rowLimit { showMoreButton @@ -356,11 +357,17 @@ private struct SectionGapDropDelegate: DropDelegate { } } -private struct SessionRow: View { +private struct SessionRow: View, Equatable { let entry: SessionEntry let onResume: ((SessionEntry) -> Void)? @State private var isHovered: Bool = false + static func == (lhs: SessionRow, rhs: SessionRow) -> Bool { + // Skip body re-eval during scroll when the entry is unchanged. + // The closure isn't compared (it comes from stable parent state). + lhs.entry == rhs.entry + } + var body: some View { HStack(spacing: 8) { Text(entry.displayTitle) @@ -592,6 +599,7 @@ private struct SectionPopoverView: View { onResume?(entry) onDismiss() } + .equatable() } } } @@ -670,12 +678,16 @@ private struct SectionPopoverView: View { } } -private struct PopoverRow: View { +private struct PopoverRow: View, Equatable { let entry: SessionEntry let onActivate: () -> Void @State private var isHovered: Bool = false + static func == (lhs: PopoverRow, rhs: PopoverRow) -> Bool { + lhs.entry == rhs.entry + } + private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated From 3808749fae289be1201feb388229e296f19cdcbd Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 21:26:49 -0700 Subject: [PATCH 21/39] Show agent brand icon on each session row --- Sources/SessionIndexView.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 6e99ea7e4df0..293bc2374ab4 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -369,7 +369,12 @@ private struct SessionRow: View, Equatable { } var body: some View { - HStack(spacing: 8) { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) Text(entry.displayTitle) .font(.system(size: 13)) .foregroundColor(.primary.opacity(0.92)) @@ -695,7 +700,12 @@ private struct PopoverRow: View, Equatable { }() var body: some View { - HStack(spacing: 8) { + HStack(spacing: 6) { + Image(entry.agent.assetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 12, height: 12) Text(entry.displayTitle) .font(.system(size: 12)) .foregroundColor(.primary.opacity(0.92)) From ce3b3b76c5e3e2f1fdd5eec4fdc868f8d93b49d7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 21:32:00 -0700 Subject: [PATCH 22/39] Bump per-agent session cap from 60 to 200 --- Sources/SessionIndexStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index b8b424a10681..1042257f54c1 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -393,7 +393,7 @@ final class SessionIndexStore: ObservableObject { // MARK: - Scanning - private static let perAgentLimit = 60 + private static let perAgentLimit = 200 private static let headByteCap = 64 * 1024 private static let tailByteCap = 32 * 1024 From 064e1ee58dd74dce77408b4412bb6b8d625c6195 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 21:44:06 -0700 Subject: [PATCH 23/39] Drop in-memory cap to 30; deep-search disk + DB on popover query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 200-cap workaround with a proper "fast cache + on-demand deep search" architecture. - perAgentLimit drops from 200 → 30. Initial scan stays cheap and the main session list always shows just the most-recent rows. - Section "Show more" popover no longer filters the cached list. Empty query shows the cached entries; non-empty query kicks off SessionIndexStore.searchSessions on a detached background task with a 200ms debounce. - searchSessions implementation: - Claude: enumerates ~/.claude/projects/*/*.jsonl, sorts by mtime desc, scans up to 1500 files reading 128 KB head + 32 KB tail each, case-insensitive substring match, parses metadata for hits, caps at 200 results. - Codex: same idea over ~/.codex/sessions/**/rollout-*.jsonl. - OpenCode: SQL with WHERE LOWER(s.title) LIKE ? OR LOWER(s.directory) LIKE ?, optionally AND s.directory = ? when the section is a folder bucket. - Scope is derived from the section key: "agent:claude" → search just Claude; "dir:/foo/bar" → fan out across all three agents and filter by cwd. - All static helpers used from the detached task are marked nonisolated so they can be called outside the @MainActor store. - Popover shows a "Searching…" spinner row while the deep search runs. No third-party dep — Foundation file enumeration + Foundation string range(of:options:.caseInsensitive) for the substring scan, plus the existing SQLite3 module already linked for OpenCode. --- Resources/Localizable.xcstrings | 17 +++ Sources/SessionIndexStore.swift | 229 ++++++++++++++++++++++++++++++-- Sources/SessionIndexView.swift | 95 +++++++------ 3 files changed, 293 insertions(+), 48 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4908eb185c5c..579053a9782c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -90162,6 +90162,23 @@ } } } + }, + "sessionIndex.popover.searching": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Searching…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索中…" + } + } + } } } } diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 1042257f54c1..f7616412a0d0 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -393,9 +393,13 @@ final class SessionIndexStore: ObservableObject { // MARK: - Scanning - private static let perAgentLimit = 200 + private static let perAgentLimit = 30 private static let headByteCap = 64 * 1024 private static let tailByteCap = 32 * 1024 + /// Limits for the on-demand deep search (popover "Show more"). + private static let searchMaxResults = 200 + private static let searchMaxFiles = 1500 + private static let searchFileByteCap = 128 * 1024 private static func scanAll() async -> [SessionEntry] { async let claude = scanClaude() @@ -464,7 +468,7 @@ final class SessionIndexStore: ObservableObject { var permissionMode: String? } - private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { + nonisolated private static func extractClaudeMetadata(head: String, tail: String, projectDir: String) -> ClaudeParsed { var out = ClaudeParsed() out.cwd = decodeClaudeProjectDir(projectDir) @@ -534,7 +538,7 @@ final class SessionIndexStore: ObservableObject { return out } - private static func decodeClaudeProjectDir(_ raw: String) -> String? { + nonisolated private static func decodeClaudeProjectDir(_ raw: String) -> String? { // Claude encodes cwd by replacing "/" with "-" and prefixing "-" // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq" // This is lossy (cannot distinguish original "-" from "/"), so try as a hint only. @@ -615,7 +619,7 @@ final class SessionIndexStore: ObservableObject { /// Stream lines from `url` until we have everything we need. The first user_message /// can sit ~100 KB into a Codex rollout (after huge base_instructions + AGENTS.md), /// so a fixed head buffer is unreliable. - private static func extractCodexMetadata(url: URL) -> CodexParsed { + nonisolated private static func extractCodexMetadata(url: URL) -> CodexParsed { var out = CodexParsed() let maxBytes = 4 * 1024 * 1024 forEachJSONLine(url: url, maxBytes: maxBytes) { obj in @@ -679,7 +683,7 @@ final class SessionIndexStore: ObservableObject { /// Stream JSON-lines from the start of `url`. `body` returns true to stop early. /// Caps total bytes read at `maxBytes`. - private static func forEachJSONLine( + nonisolated private static func forEachJSONLine( url: URL, maxBytes: Int, body: ([String: Any]) -> Bool @@ -797,7 +801,7 @@ final class SessionIndexStore: ObservableObject { return results } - private static func parseOpenCodeAssistant(_ raw: String?) -> (String?, String?) { + nonisolated private static func parseOpenCodeAssistant(_ raw: String?) -> (String?, String?) { guard let raw, let data = raw.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) @@ -815,15 +819,222 @@ final class SessionIndexStore: ObservableObject { return (providerModel, agentName?.isEmpty == false ? agentName : nil) } - private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? { + nonisolated private static func sqliteText(_ stmt: OpaquePointer, _ index: Int32) -> String? { guard let cString = sqlite3_column_text(stmt, index) else { return nil } return String(cString: cString) } + // MARK: - Deep search (popover "Show more") + + enum SearchScope { + case agent(SessionAgent) + /// Filter by absolute cwd; nil/"" = unknown-folder bucket. + case directory(String?) + } + + /// Async on-demand search across the full filesystem (Claude/Codex) and SQLite (OpenCode). + /// Returns up to ~`searchMaxResults` matching entries sorted by mtime desc. + /// Empty query returns an empty array — callers should fall back to the cached entries. + func searchSessions(query: String, scope: SearchScope) async -> [SessionEntry] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + let needle = trimmed.lowercased() + return await Task.detached(priority: .userInitiated) { + switch scope { + case .agent(let a): + return Self.searchAgent(needle: needle, agent: a, cwdFilter: nil) + case .directory(let path): + let cwdFilter = (path?.isEmpty == false) ? path : nil + async let c = Self.searchAgent(needle: needle, agent: .claude, cwdFilter: cwdFilter) + async let x = Self.searchAgent(needle: needle, agent: .codex, cwdFilter: cwdFilter) + async let o = Self.searchAgent(needle: needle, agent: .opencode, cwdFilter: cwdFilter) + let merged = (await c) + (await x) + (await o) + return merged.sorted { $0.modified > $1.modified } + } + }.value + } + + nonisolated private static func searchAgent(needle: String, agent: SessionAgent, cwdFilter: String?) -> [SessionEntry] { + switch agent { + case .claude: return searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter) + case .codex: return searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter) + case .opencode: return searchOpenCodeInDB(needle: needle, cwdFilter: cwdFilter) + } + } + + nonisolated private static func searchClaudeOnDisk(needle: String, cwdFilter: String?) -> [SessionEntry] { + let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath + let fm = FileManager.default + guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } + var candidates: [(URL, Date, String)] = [] + for dirName in projectDirs { + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } + candidates.sort { $0.1 > $1.1 } + + var results: [SessionEntry] = [] + for (url, mtime, dirName) in candidates.prefix(searchMaxFiles) { + if results.count >= searchMaxResults { break } + let head = readFileHead(url: url, byteCap: searchFileByteCap) + let tail = readFileTail(url: url, byteCap: tailByteCap) + let combined = head + "\n" + tail + guard combined.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) + if let cwdFilter, parsed.cwd != cwdFilter { continue } + let sid = url.deletingPathExtension().lastPathComponent + results.append(SessionEntry( + id: "claude:" + url.path, + agent: .claude, + sessionId: sid, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: parsed.pr, + modified: mtime, + fileURL: url, + specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) + )) + } + return results + } + + nonisolated private static func searchCodexOnDisk(needle: String, cwdFilter: String?) -> [SessionEntry] { + let root = ("~/.codex/sessions" as NSString).expandingTildeInPath + let fm = FileManager.default + let rootURL = URL(fileURLWithPath: root) + guard let enumerator = fm.enumerator( + at: rootURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var candidates: [(URL, Date)] = [] + for case let url as URL in enumerator { + guard url.pathExtension == "jsonl" else { continue } + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, + let mtime = values?.contentModificationDate else { continue } + candidates.append((url, mtime)) + } + candidates.sort { $0.1 > $1.1 } + + var results: [SessionEntry] = [] + for (url, mtime) in candidates.prefix(searchMaxFiles) { + if results.count >= searchMaxResults { break } + let head = readFileHead(url: url, byteCap: searchFileByteCap) + guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + let parsed = extractCodexMetadata(url: url) + if let cwdFilter, parsed.cwd != cwdFilter { continue } + results.append(SessionEntry( + id: "codex:" + url.path, + agent: .codex, + sessionId: parsed.sessionId, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: nil, + modified: mtime, + fileURL: url, + specifics: .codex( + model: parsed.model, + approvalPolicy: parsed.approvalPolicy, + sandboxMode: parsed.sandboxMode, + effort: parsed.effort + ) + )) + } + return results + } + + nonisolated private static func searchOpenCodeInDB(needle: String, cwdFilter: String?) -> [SessionEntry] { + let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath + let fm = FileManager.default + guard fm.fileExists(atPath: dbPath) else { return [] } + let snapshotDir = fm.temporaryDirectory.appendingPathComponent("cmux-opencode-search-\(UUID().uuidString)", isDirectory: true) + do { try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) } catch { return [] } + defer { try? fm.removeItem(at: snapshotDir) } + let snapshotDB = snapshotDir.appendingPathComponent("opencode.db") + do { try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) } catch { return [] } + for sidecar in ["-wal", "-shm"] { + let src = dbPath + sidecar + let dst = snapshotDB.path + sidecar + if fm.fileExists(atPath: src) { try? fm.copyItem(atPath: src, toPath: dst) } + } + var db: OpaquePointer? + guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + + let likePattern = "%\(needle)%" + var sql = """ + SELECT s.id, s.title, s.directory, s.time_updated, ( + SELECT data FROM message + WHERE session_id = s.id AND data LIKE '%"role":"assistant"%' + ORDER BY time_created DESC LIMIT 1 + ) AS last_assistant + FROM session s + WHERE (LOWER(s.title) LIKE ? OR LOWER(s.directory) LIKE ?) + """ + if cwdFilter != nil { + sql += " AND s.directory = ?" + } + sql += " ORDER BY s.time_updated DESC LIMIT \(searchMaxResults)" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + sqlite3_finalize(stmt) + return [] + } + defer { sqlite3_finalize(stmt) } + + let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, likePattern, -1, SQLITE_TRANSIENT_FN) + sqlite3_bind_text(stmt, 2, likePattern, -1, SQLITE_TRANSIENT_FN) + if let cwdFilter { + sqlite3_bind_text(stmt, 3, cwdFilter, -1, SQLITE_TRANSIENT_FN) + } + + var results: [SessionEntry] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let sid = sqliteText(stmt, 0) ?? "" + let title = sqliteText(stmt, 1) ?? "" + let directory = sqliteText(stmt, 2) + let updatedMs = sqlite3_column_int64(stmt, 3) + let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) + let lastJSON = sqliteText(stmt, 4) + let (providerModel, agentName) = parseOpenCodeAssistant(lastJSON) + results.append(SessionEntry( + id: "opencode:" + sid, + agent: .opencode, + sessionId: sid, + title: title, + cwd: directory, + gitBranch: nil, + pullRequest: nil, + modified: modified, + fileURL: nil, + specifics: .opencode(providerModel: providerModel, agentName: agentName) + )) + } + return results + } + // MARK: Helpers /// Read up to `byteCap` bytes from the start of the file as UTF-8. - private static func readFileHead(url: URL, byteCap: Int) -> String { + nonisolated private static func readFileHead(url: URL, byteCap: Int) -> String { guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } defer { try? handle.close() } let data: Data @@ -837,7 +1048,7 @@ final class SessionIndexStore: ObservableObject { /// Read up to `byteCap` bytes from the end of the file as UTF-8. /// Used to find late-arriving events like pr-link without scanning the whole file. - private static func readFileTail(url: URL, byteCap: Int) -> String { + nonisolated private static func readFileTail(url: URL, byteCap: Int) -> String { guard let handle = try? FileHandle(forReadingFrom: url) else { return "" } defer { try? handle.close() } let size: UInt64 diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 293bc2374ab4..7d04fecb75bc 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -203,14 +203,10 @@ private struct IndexSectionView: View { } private var showMoreButton: some View { - let extra = section.entries.count - rowLimit - let template = String(localized: "sessionIndex.section.showMoreCount", - defaultValue: "Show %lld more") - let label = String(format: template, extra) - return Button { + Button { isPopoverOpen = true } label: { - Text(label) + Text(String(localized: "sessionIndex.section.showMore", defaultValue: "Show more")) .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary.opacity(0.7)) .padding(.leading, 32) @@ -222,6 +218,7 @@ private struct IndexSectionView: View { SectionPopoverHost( isPresented: $isPopoverOpen, section: section, + store: store, onResume: onResume ) ) @@ -523,18 +520,18 @@ private struct SessionRow: View, Equatable { private struct SectionPopoverView: View { let section: IndexSection + let store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? let onDismiss: () -> Void @State private var query: String = "" @FocusState private var searchFocused: Bool - /// Pre-lowercased searchable text per entry (computed once on appear). - /// Avoids re-allocating lowercased strings on every keystroke. - @State private var searchablePairs: [(SessionEntry, String)] = [] - /// Latest filtered result. Updated asynchronously off-main so typing isn't blocked. + /// Latest filtered/search result. Empty query → cached entries; non-empty query → + /// async on-disk/SQL deep search via SessionIndexStore.searchSessions. @State private var filtered: [SessionEntry] = [] @State private var filterTask: Task? + @State private var isSearching: Bool = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -590,7 +587,18 @@ private struct SectionPopoverView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - if filtered.isEmpty { + if isSearching && filtered.isEmpty { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sessionIndex.popover.searching", + defaultValue: "Searching…")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else if filtered.isEmpty { Text(String(localized: "sessionIndex.popover.noMatches", defaultValue: "No matches")) .font(.system(size: 12)) @@ -618,53 +626,58 @@ private struct SectionPopoverView: View { EscapeKeyCatcher { onDismiss() } ) .onAppear { - // Pre-lowercase the searchable text once. Typing then only does a - // cheap substring scan over the cached strings. - let entries = section.entries - searchablePairs = entries.map { entry in - let combined = (entry.displayTitle + " " - + (entry.cwd ?? "") + " " - + (entry.gitBranch ?? "")).lowercased() - return (entry, combined) - } - filtered = entries - // Defer one runloop turn so the popover's window is fully key. + filtered = section.entries DispatchQueue.main.async { searchFocused = true } } .onChange(of: query) { newValue in - scheduleFilter(query: newValue) + scheduleSearch(query: newValue) } .onDisappear { filterTask?.cancel() filterTask = nil + isSearching = false } } - /// Cancel the in-flight filter, debounce briefly, then run the substring scan - /// on a detached task. Empty queries skip the task entirely so the full list - /// shows instantly. - private func scheduleFilter(query newValue: String) { + /// Empty query → cached top-N entries (instant). Non-empty query → debounced + /// on-demand deep search via SessionIndexStore.searchSessions, which scans the + /// full filesystem (Claude/Codex) or the OpenCode SQLite database. + private func scheduleSearch(query newValue: String) { filterTask?.cancel() - let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { filtered = section.entries + isSearching = false return } - let pairs = searchablePairs + let scope = sectionSearchScope + let store = self.store + isSearching = true filterTask = Task { @MainActor in - // Tiny debounce: collapse a flurry of keystrokes into one filter pass. - try? await Task.sleep(nanoseconds: 30_000_000) + // 200ms debounce: deep search hits disk/SQL, more expensive than the + // in-memory pass — collapse rapid keystrokes into one query. + try? await Task.sleep(nanoseconds: 200_000_000) if Task.isCancelled { return } - let result = await Task.detached(priority: .userInitiated) { - pairs.compactMap { (entry, text) -> SessionEntry? in - text.contains(trimmed) ? entry : nil - } - }.value + let result = await store.searchSessions(query: trimmed, scope: scope) if Task.isCancelled { return } filtered = result + isSearching = false } } + private var sectionSearchScope: SessionIndexStore.SearchScope { + let raw = section.key.raw + if raw.hasPrefix("agent:"), + let agent = SessionAgent(rawValue: String(raw.dropFirst("agent:".count))) { + return .agent(agent) + } + if raw.hasPrefix("dir:") { + let path = String(raw.dropFirst("dir:".count)) + return .directory(path.isEmpty ? nil : path) + } + return .directory(nil) + } + @ViewBuilder private var sectionIconView: some View { switch section.icon { @@ -819,6 +832,7 @@ private func sessionDragItemProvider(for entry: SessionEntry) -> NSItemProvider private struct SectionPopoverHost: NSViewRepresentable { @Binding var isPresented: Bool let section: IndexSection + let store: SessionIndexStore let onResume: ((SessionEntry) -> Void)? func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) } @@ -835,6 +849,7 @@ private struct SectionPopoverHost: NSViewRepresentable { coordinator.anchorView = nsView coordinator.update( section: section, + store: store, onResume: onResume ) if isPresented { @@ -855,23 +870,25 @@ private struct SectionPopoverHost: NSViewRepresentable { private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) private var popover: NSPopover? private var currentSection: IndexSection? + private var currentStore: SessionIndexStore? private var currentOnResume: ((SessionEntry) -> Void)? init(isPresented: Binding) { _isPresented = isPresented } - func update(section: IndexSection, onResume: ((SessionEntry) -> Void)?) { + func update(section: IndexSection, store: SessionIndexStore, onResume: ((SessionEntry) -> Void)?) { currentSection = section + currentStore = store currentOnResume = onResume refreshContent() } private func refreshContent() { - guard let section = currentSection else { return } + guard let section = currentSection, let store = currentStore else { return } let onResume = currentOnResume hostingController.rootView = AnyView( - SectionPopoverView(section: section, onResume: onResume) { [weak self] in + SectionPopoverView(section: section, store: store, onResume: onResume) { [weak self] in self?.closeFromContent() } ) From fb8ea17738861d7f7b8d97c6df832d616edf4ce5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 16 Apr 2026 22:56:46 -0700 Subject: [PATCH 24/39] Paginate Show-more popover with load-more on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "fetch up to 200 in one shot" popover with infinite scroll. Page size is 30; the popover loads page 0 on appear and each subsequent page when a sentinel row at the tail of the LazyVStack becomes visible. Store changes: - searchSessions gains offset+limit. Empty query is now a valid input (used for "browse the most recent" without a needle), so the inner per-agent helpers skip the substring scan when needle.isEmpty and just return the next mtime-sorted page. - Claude/Codex helpers count matches and return the [offset..offset+limit] slice. Caps at searchMaxFiles (1500) per call so deep scrolling stays bounded. - OpenCode SQL builds WHERE clauses conditionally (LIKE only when needle non-empty, AND directory= only when filtered) and uses real LIMIT/OFFSET. - Directory scope (multi-agent) fetches offset+limit from each agent, merges, sorts by mtime desc, and slices to the page. UI changes: - Popover state replaced with `loaded`/`hasMore`/`isLoading`. - `loadGeneration` counter prevents stale tasks from clobbering newer results when SwiftUI cancellation hasn't propagated yet. - "Loading…" sentinel row appears at the bottom while a page is fetching AND triggers loadMore via .onAppear when scrolled into view. - Same row also covers the empty-query open and the search-in-progress states, so users always see feedback while the popover is fetching. --- Resources/Localizable.xcstrings | 17 +++++ Sources/SessionIndexStore.swift | 128 ++++++++++++++++++++++---------- Sources/SessionIndexView.swift | 126 ++++++++++++++++++++----------- 3 files changed, 191 insertions(+), 80 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 579053a9782c..4c1253748a7a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -90179,6 +90179,23 @@ } } } + }, + "sessionIndex.popover.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中…" + } + } + } } } } diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index f7616412a0d0..17416f473245 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -832,37 +832,64 @@ final class SessionIndexStore: ObservableObject { case directory(String?) } - /// Async on-demand search across the full filesystem (Claude/Codex) and SQLite (OpenCode). - /// Returns up to ~`searchMaxResults` matching entries sorted by mtime desc. - /// Empty query returns an empty array — callers should fall back to the cached entries. - func searchSessions(query: String, scope: SearchScope) async -> [SessionEntry] { + /// Paginated on-demand search across the full filesystem (Claude/Codex) and + /// SQLite (OpenCode). Empty query is allowed and returns the most-recent + /// entries (used when the user just opens the popover and scrolls). + /// Returns up to `limit` entries sorted by mtime desc, skipping the first + /// `offset` matches. + func searchSessions( + query: String, + scope: SearchScope, + offset: Int, + limit: Int + ) async -> [SessionEntry] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } let needle = trimmed.lowercased() return await Task.detached(priority: .userInitiated) { switch scope { case .agent(let a): - return Self.searchAgent(needle: needle, agent: a, cwdFilter: nil) + return Self.searchAgent( + needle: needle, agent: a, cwdFilter: nil, + offset: offset, limit: limit + ) case .directory(let path): let cwdFilter = (path?.isEmpty == false) ? path : nil - async let c = Self.searchAgent(needle: needle, agent: .claude, cwdFilter: cwdFilter) - async let x = Self.searchAgent(needle: needle, agent: .codex, cwdFilter: cwdFilter) - async let o = Self.searchAgent(needle: needle, agent: .opencode, cwdFilter: cwdFilter) + // Multi-agent merge: fetch the union of (offset+limit) per agent so the + // merge-sort can produce a stable global ordering, then slice. + let target = offset + limit + async let c = Self.searchAgent( + needle: needle, agent: .claude, cwdFilter: cwdFilter, + offset: 0, limit: target + ) + async let x = Self.searchAgent( + needle: needle, agent: .codex, cwdFilter: cwdFilter, + offset: 0, limit: target + ) + async let o = Self.searchAgent( + needle: needle, agent: .opencode, cwdFilter: cwdFilter, + offset: 0, limit: target + ) let merged = (await c) + (await x) + (await o) - return merged.sorted { $0.modified > $1.modified } + let sorted = merged.sorted { $0.modified > $1.modified } + return Array(sorted.dropFirst(offset).prefix(limit)) } }.value } - nonisolated private static func searchAgent(needle: String, agent: SessionAgent, cwdFilter: String?) -> [SessionEntry] { + nonisolated private static func searchAgent( + needle: String, agent: SessionAgent, cwdFilter: String?, + offset: Int, limit: Int + ) -> [SessionEntry] { switch agent { - case .claude: return searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter) - case .codex: return searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter) - case .opencode: return searchOpenCodeInDB(needle: needle, cwdFilter: cwdFilter) + case .claude: return searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .codex: return searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .opencode: return searchOpenCodeInDB(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) } } - nonisolated private static func searchClaudeOnDisk(needle: String, cwdFilter: String?) -> [SessionEntry] { + nonisolated private static func searchClaudeOnDisk( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) -> [SessionEntry] { let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath let fm = FileManager.default guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } @@ -882,17 +909,23 @@ final class SessionIndexStore: ObservableObject { } candidates.sort { $0.1 > $1.1 } - var results: [SessionEntry] = [] - for (url, mtime, dirName) in candidates.prefix(searchMaxFiles) { - if results.count >= searchMaxResults { break } + let target = offset + limit + var matches: [SessionEntry] = [] + var scanned = 0 + for (url, mtime, dirName) in candidates { + if matches.count >= target { break } + if scanned >= searchMaxFiles { break } + scanned += 1 let head = readFileHead(url: url, byteCap: searchFileByteCap) let tail = readFileTail(url: url, byteCap: tailByteCap) - let combined = head + "\n" + tail - guard combined.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + if !needle.isEmpty { + let combined = head + "\n" + tail + guard combined.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + } let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) if let cwdFilter, parsed.cwd != cwdFilter { continue } let sid = url.deletingPathExtension().lastPathComponent - results.append(SessionEntry( + matches.append(SessionEntry( id: "claude:" + url.path, agent: .claude, sessionId: sid, @@ -905,10 +938,12 @@ final class SessionIndexStore: ObservableObject { specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) )) } - return results + return Array(matches.dropFirst(offset).prefix(limit)) } - nonisolated private static func searchCodexOnDisk(needle: String, cwdFilter: String?) -> [SessionEntry] { + nonisolated private static func searchCodexOnDisk( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) -> [SessionEntry] { let root = ("~/.codex/sessions" as NSString).expandingTildeInPath let fm = FileManager.default let rootURL = URL(fileURLWithPath: root) @@ -928,14 +963,20 @@ final class SessionIndexStore: ObservableObject { } candidates.sort { $0.1 > $1.1 } - var results: [SessionEntry] = [] - for (url, mtime) in candidates.prefix(searchMaxFiles) { - if results.count >= searchMaxResults { break } - let head = readFileHead(url: url, byteCap: searchFileByteCap) - guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + let target = offset + limit + var matches: [SessionEntry] = [] + var scanned = 0 + for (url, mtime) in candidates { + if matches.count >= target { break } + if scanned >= searchMaxFiles { break } + scanned += 1 + if !needle.isEmpty { + let head = readFileHead(url: url, byteCap: searchFileByteCap) + guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + } let parsed = extractCodexMetadata(url: url) if let cwdFilter, parsed.cwd != cwdFilter { continue } - results.append(SessionEntry( + matches.append(SessionEntry( id: "codex:" + url.path, agent: .codex, sessionId: parsed.sessionId, @@ -953,10 +994,12 @@ final class SessionIndexStore: ObservableObject { ) )) } - return results + return Array(matches.dropFirst(offset).prefix(limit)) } - nonisolated private static func searchOpenCodeInDB(needle: String, cwdFilter: String?) -> [SessionEntry] { + nonisolated private static func searchOpenCodeInDB( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) -> [SessionEntry] { let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath let fm = FileManager.default guard fm.fileExists(atPath: dbPath) else { return [] } @@ -977,7 +1020,6 @@ final class SessionIndexStore: ObservableObject { } defer { sqlite3_close(db) } - let likePattern = "%\(needle)%" var sql = """ SELECT s.id, s.title, s.directory, s.time_updated, ( SELECT data FROM message @@ -985,12 +1027,18 @@ final class SessionIndexStore: ObservableObject { ORDER BY time_created DESC LIMIT 1 ) AS last_assistant FROM session s - WHERE (LOWER(s.title) LIKE ? OR LOWER(s.directory) LIKE ?) """ + var conditions: [String] = [] + if !needle.isEmpty { + conditions.append("(LOWER(s.title) LIKE ? OR LOWER(s.directory) LIKE ?)") + } if cwdFilter != nil { - sql += " AND s.directory = ?" + conditions.append("s.directory = ?") } - sql += " ORDER BY s.time_updated DESC LIMIT \(searchMaxResults)" + if !conditions.isEmpty { + sql += " WHERE " + conditions.joined(separator: " AND ") + } + sql += " ORDER BY s.time_updated DESC LIMIT \(limit) OFFSET \(offset)" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { @@ -1000,10 +1048,14 @@ final class SessionIndexStore: ObservableObject { defer { sqlite3_finalize(stmt) } let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) - sqlite3_bind_text(stmt, 1, likePattern, -1, SQLITE_TRANSIENT_FN) - sqlite3_bind_text(stmt, 2, likePattern, -1, SQLITE_TRANSIENT_FN) + var bindIndex: Int32 = 1 + if !needle.isEmpty { + let likePattern = "%\(needle)%" + sqlite3_bind_text(stmt, bindIndex, likePattern, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 + sqlite3_bind_text(stmt, bindIndex, likePattern, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 + } if let cwdFilter { - sqlite3_bind_text(stmt, 3, cwdFilter, -1, SQLITE_TRANSIENT_FN) + sqlite3_bind_text(stmt, bindIndex, cwdFilter, -1, SQLITE_TRANSIENT_FN); bindIndex += 1 } var results: [SessionEntry] = [] diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 7d04fecb75bc..de5f8a6bf5e3 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -527,11 +527,18 @@ private struct SectionPopoverView: View { @State private var query: String = "" @FocusState private var searchFocused: Bool - /// Latest filtered/search result. Empty query → cached entries; non-empty query → - /// async on-disk/SQL deep search via SessionIndexStore.searchSessions. - @State private var filtered: [SessionEntry] = [] - @State private var filterTask: Task? - @State private var isSearching: Bool = false + /// Pages of results loaded so far. Each page is `pageSize` rows from the store's + /// paginated search. + @State private var loaded: [SessionEntry] = [] + @State private var hasMore: Bool = true + @State private var isLoading: Bool = false + @State private var activeQuery: String = "" + @State private var loadTask: Task? + /// Bumped on each query reset so an in-flight task knows it's been superseded + /// even if cancellation hasn't propagated yet. + @State private var loadGeneration: Int = 0 + + private static let pageSize = 30 var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -587,18 +594,9 @@ private struct SectionPopoverView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - if isSearching && filtered.isEmpty { - HStack(spacing: 6) { - ProgressView().controlSize(.small) - Text(String(localized: "sessionIndex.popover.searching", - defaultValue: "Searching…")) - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - } else if filtered.isEmpty { + if isLoading && loaded.isEmpty { + loadingRow + } else if loaded.isEmpty { Text(String(localized: "sessionIndex.popover.noMatches", defaultValue: "No matches")) .font(.system(size: 12)) @@ -607,13 +605,19 @@ private struct SectionPopoverView: View { .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) } else { - ForEach(filtered) { entry in + ForEach(loaded) { entry in PopoverRow(entry: entry) { onResume?(entry) onDismiss() } .equatable() } + if hasMore { + // Sentinel row: appearance triggers loadMore. Renders the + // "Loading more…" indicator while fetching. + loadingRow + .onAppear { loadMore() } + } } } .padding(.top, 4) @@ -626,42 +630,80 @@ private struct SectionPopoverView: View { EscapeKeyCatcher { onDismiss() } ) .onAppear { - filtered = section.entries + resetAndLoad(query: "") DispatchQueue.main.async { searchFocused = true } } .onChange(of: query) { newValue in - scheduleSearch(query: newValue) + resetAndLoad(query: newValue) } .onDisappear { - filterTask?.cancel() - filterTask = nil - isSearching = false + loadTask?.cancel() + loadTask = nil + isLoading = false } } - /// Empty query → cached top-N entries (instant). Non-empty query → debounced - /// on-demand deep search via SessionIndexStore.searchSessions, which scans the - /// full filesystem (Claude/Codex) or the OpenCode SQLite database. - private func scheduleSearch(query newValue: String) { - filterTask?.cancel() + private var loadingRow: some View { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(String(localized: "sessionIndex.popover.loading", defaultValue: "Loading…")) + .font(.system(size: 11)) + .foregroundColor(.secondary) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Reset the page and load page 0. Empty query loads instantly; non-empty + /// query waits 200ms first to debounce keystrokes. + private func resetAndLoad(query newValue: String) { + loadTask?.cancel() + loadGeneration += 1 + let generation = loadGeneration let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - filtered = section.entries - isSearching = false - return + loaded = [] + hasMore = true + activeQuery = trimmed + isLoading = true + let scope = sectionSearchScope + let store = self.store + let needsDebounce = !trimmed.isEmpty + loadTask = Task { @MainActor in + if needsDebounce { + try? await Task.sleep(nanoseconds: 200_000_000) + if Task.isCancelled || generation != loadGeneration { return } + } + let page = await store.searchSessions( + query: trimmed, scope: scope, + offset: 0, limit: Self.pageSize + ) + if Task.isCancelled || generation != loadGeneration { return } + loaded = page + hasMore = page.count >= Self.pageSize + isLoading = false } + } + + /// Append the next page to `loaded`. Triggered by the sentinel row's onAppear. + private func loadMore() { + guard !isLoading, hasMore else { return } + isLoading = true + let generation = loadGeneration let scope = sectionSearchScope let store = self.store - isSearching = true - filterTask = Task { @MainActor in - // 200ms debounce: deep search hits disk/SQL, more expensive than the - // in-memory pass — collapse rapid keystrokes into one query. - try? await Task.sleep(nanoseconds: 200_000_000) - if Task.isCancelled { return } - let result = await store.searchSessions(query: trimmed, scope: scope) - if Task.isCancelled { return } - filtered = result - isSearching = false + let query = activeQuery + let offset = loaded.count + loadTask = Task { @MainActor in + let page = await store.searchSessions( + query: query, scope: scope, + offset: offset, limit: Self.pageSize + ) + if Task.isCancelled || generation != loadGeneration { return } + loaded.append(contentsOf: page) + hasMore = page.count >= Self.pageSize + isLoading = false } } From 8f6a73e9392a15b09ac1b4fc3042c35a80ec7796 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:10:06 -0700 Subject: [PATCH 25/39] Use rg to pre-filter session search when available + drop count badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-filter Claude/Codex search with `rg --files-with-matches` when rg is on PATH (cached at first lookup). Falls back to the existing Foundation file enumeration + 128 KB head substring scan when rg is missing or fails. - Skips the post-rg substring re-check when rg already confirmed the match. Net effect: when rg is present, we only do head/tail reads for files that actually contain the needle, and rg has already scanned the entire file (not just our 128 KB head), so deep-conversation matches are reachable too. - OpenCode stays on SQLite — no rg benefit there. - Drop the entry count badge from the popover header. --- Sources/SessionIndexStore.swift | 139 ++++++++++++++++++++++++++------ Sources/SessionIndexView.swift | 5 +- 2 files changed, 116 insertions(+), 28 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 17416f473245..585dc4366af7 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -887,25 +887,104 @@ final class SessionIndexStore: ObservableObject { } } + /// Path to `rg` (ripgrep), if installed. Resolved once. nil when not found — + /// the search code falls back to the Foundation substring scan. + nonisolated private static let cachedRipgrepPath: String? = { + let fm = FileManager.default + let common = [ + "/opt/homebrew/bin/rg", + "/usr/local/bin/rg", + "/usr/bin/rg", + "/opt/local/bin/rg", + ] + for path in common where fm.isExecutableFile(atPath: path) { + return path + } + if let pathEnv = ProcessInfo.processInfo.environment["PATH"] { + for dir in pathEnv.split(separator: ":") { + let full = String(dir) + "/rg" + if fm.isExecutableFile(atPath: full) { return full } + } + } + return nil + }() + + /// Run `rg --files-with-matches --ignore-case --fixed-strings` for `needle` + /// under `root`, restricted to `glob` (e.g. `*.jsonl`). Returns matched file + /// URLs, or nil if rg isn't available or the run failed (caller falls back). + nonisolated private static func ripgrepMatchingPaths( + needle: String, root: String, fileGlob: String + ) -> [URL]? { + guard let rg = cachedRipgrepPath else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: rg) + process.arguments = [ + "--files-with-matches", + "--ignore-case", + "--fixed-strings", + "--no-messages", + "--no-ignore", + "--hidden", + "--glob", fileGlob, + "--", + needle, + root, + ] + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + do { try process.run() } catch { return nil } + process.waitUntilExit() + // rg exit codes: 0 = matches, 1 = no matches, 2 = error. + switch process.terminationStatus { + case 0: + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + guard let str = String(data: data, encoding: .utf8) else { return nil } + return str.split(separator: "\n", omittingEmptySubsequences: true) + .map { URL(fileURLWithPath: String($0)) } + case 1: + return [] + default: + return nil + } + } + nonisolated private static func searchClaudeOnDisk( needle: String, cwdFilter: String?, offset: Int, limit: Int ) -> [SessionEntry] { let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath let fm = FileManager.default - guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } + + // Pre-filter via rg when we have a needle — rg is parallel, mmaps the + // file, and scans the WHOLE file (not just our 128 KB head), so it both + // speeds the scan up and finds matches deeper in long transcripts. + var rgFiltered = false var candidates: [(URL, Date, String)] = [] - for dirName in projectDirs { - let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } - guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), + if !needle.isEmpty, + let rgPaths = ripgrepMatchingPaths(needle: needle, root: projectsRoot, fileGlob: "*.jsonl") { + rgFiltered = true + for url in rgPaths { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), let mtime = attrs[.modificationDate] as? Date else { continue } + let dirName = url.deletingLastPathComponent().lastPathComponent candidates.append((url, mtime, dirName)) } + } else { + guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } + for dirName in projectDirs { + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } + guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } } candidates.sort { $0.1 > $1.1 } @@ -918,7 +997,8 @@ final class SessionIndexStore: ObservableObject { scanned += 1 let head = readFileHead(url: url, byteCap: searchFileByteCap) let tail = readFileTail(url: url, byteCap: tailByteCap) - if !needle.isEmpty { + // Skip the substring re-check when rg already confirmed the file matches. + if !needle.isEmpty && !rgFiltered { let combined = head + "\n" + tail guard combined.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } } @@ -946,20 +1026,31 @@ final class SessionIndexStore: ObservableObject { ) -> [SessionEntry] { let root = ("~/.codex/sessions" as NSString).expandingTildeInPath let fm = FileManager.default - let rootURL = URL(fileURLWithPath: root) - guard let enumerator = fm.enumerator( - at: rootURL, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { return [] } + var rgFiltered = false var candidates: [(URL, Date)] = [] - for case let url as URL in enumerator { - guard url.pathExtension == "jsonl" else { continue } - let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) - guard values?.isRegularFile == true, - let mtime = values?.contentModificationDate else { continue } - candidates.append((url, mtime)) + if !needle.isEmpty, + let rgPaths = ripgrepMatchingPaths(needle: needle, root: root, fileGlob: "*.jsonl") { + rgFiltered = true + for url in rgPaths { + guard let attrs = try? fm.attributesOfItem(atPath: url.path), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime)) + } + } else { + let rootURL = URL(fileURLWithPath: root) + guard let enumerator = fm.enumerator( + at: rootURL, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return [] } + for case let url as URL in enumerator { + guard url.pathExtension == "jsonl" else { continue } + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true, + let mtime = values?.contentModificationDate else { continue } + candidates.append((url, mtime)) + } } candidates.sort { $0.1 > $1.1 } @@ -970,7 +1061,7 @@ final class SessionIndexStore: ObservableObject { if matches.count >= target { break } if scanned >= searchMaxFiles { break } scanned += 1 - if !needle.isEmpty { + if !needle.isEmpty && !rgFiltered { let head = readFileHead(url: url, byteCap: searchFileByteCap) guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index de5f8a6bf5e3..44dba3764b65 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -549,10 +549,7 @@ private struct SectionPopoverView: View { .foregroundColor(.primary) .lineLimit(1) .truncationMode(.middle) - Spacer(minLength: 4) - Text("\(section.entries.count)") - .font(.system(size: 11).monospacedDigit()) - .foregroundColor(.secondary) + Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.top, 10) From 59638812915136d37b2fa862db1870d66bb28100 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:16:46 -0700 Subject: [PATCH 26/39] Fix rg pipe-buffer deadlock: drain stdout before waitUntilExit --- Sources/SessionIndexStore.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 585dc4366af7..cbc582e5517f 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -931,15 +931,23 @@ final class SessionIndexStore: ObservableObject { root, ] let outPipe = Pipe() - let errPipe = Pipe() process.standardOutput = outPipe - process.standardError = errPipe + // Discard stderr to /dev/null so its pipe can never deadlock either. + if let nullDev = FileHandle(forWritingAtPath: "/dev/null") { + process.standardError = nullDev + } do { try process.run() } catch { return nil } + // Drain stdout BEFORE waitUntilExit. With many matches rg writes more + // than the ~64 KB pipe buffer can hold, blocks on the next write, and + // process.waitUntilExit() then deadlocks (rg can't exit until stdout + // drains, we won't drain until rg exits). readDataToEndOfFile reads + // until EOF (= rg closes its stdout when it finishes), so the order + // matters: read, then wait. + let data = outPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() // rg exit codes: 0 = matches, 1 = no matches, 2 = error. switch process.terminationStatus { case 0: - let data = outPipe.fileHandleForReading.readDataToEndOfFile() guard let str = String(data: data, encoding: .utf8) else { return nil } return str.split(separator: "\n", omittingEmptySubsequences: true) .map { URL(fileURLWithPath: String($0)) } From 874d4949c2e9e75f94101c65430e1599f63d6493 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:22:14 -0700 Subject: [PATCH 27/39] Make rg search async + cancellable; drop Task.detached hop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per swift-guidance review: the previous shape had two related concurrency problems. 1. Subprocess survived Task cancellation. searchSessions wrapped its work in Task.detached(...).value. Detached tasks are deliberately isolated from parent cancellation, so when the popover's loadTask was cancelled (every keystroke does this), the previous rg kept running to completion. Fast typing piled up rg invocations contending for I/O. 2. process.waitUntilExit() blocked a cooperative-pool thread inside the detached task. The pool has a small fixed size, so a stuck rg stole capacity from rendering / next-debounce work. Fixes: - ripgrepMatchingPaths becomes async and is wrapped in withTaskCancellationHandler { ... } onCancel: { process.terminate() }. Cancellation now actually kills the in-flight rg. - The wait uses process.terminationHandler + withCheckedContinuation for a true async suspension — no blocked thread. - searchSessions drops the Task.detached(...).value wrapper. searchAgent / searchClaudeOnDisk / searchCodexOnDisk become async; the helpers are already nonisolated and Sendable-clean, so the async keyword is enough to push them off the @MainActor caller. - searchOpenCodeInDB stays sync (SQL is fast and synchronous). Net result: typing "ti" -> "tin" -> "tiny" terminates each previous rg on the first keystroke after, and the SwiftUI render thread stays unblocked while rg works. --- Sources/SessionIndexStore.swift | 123 +++++++++++++++++--------------- 1 file changed, 67 insertions(+), 56 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index cbc582e5517f..6e9815c98278 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -845,44 +845,42 @@ final class SessionIndexStore: ObservableObject { ) async -> [SessionEntry] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let needle = trimmed.lowercased() - return await Task.detached(priority: .userInitiated) { - switch scope { - case .agent(let a): - return Self.searchAgent( - needle: needle, agent: a, cwdFilter: nil, - offset: offset, limit: limit - ) - case .directory(let path): - let cwdFilter = (path?.isEmpty == false) ? path : nil - // Multi-agent merge: fetch the union of (offset+limit) per agent so the - // merge-sort can produce a stable global ordering, then slice. - let target = offset + limit - async let c = Self.searchAgent( - needle: needle, agent: .claude, cwdFilter: cwdFilter, - offset: 0, limit: target - ) - async let x = Self.searchAgent( - needle: needle, agent: .codex, cwdFilter: cwdFilter, - offset: 0, limit: target - ) - async let o = Self.searchAgent( - needle: needle, agent: .opencode, cwdFilter: cwdFilter, - offset: 0, limit: target - ) - let merged = (await c) + (await x) + (await o) - let sorted = merged.sorted { $0.modified > $1.modified } - return Array(sorted.dropFirst(offset).prefix(limit)) - } - }.value + switch scope { + case .agent(let a): + return await Self.searchAgent( + needle: needle, agent: a, cwdFilter: nil, + offset: offset, limit: limit + ) + case .directory(let path): + let cwdFilter = (path?.isEmpty == false) ? path : nil + // Multi-agent merge: fetch the union of (offset+limit) per agent so the + // merge-sort can produce a stable global ordering, then slice. + let target = offset + limit + async let c = Self.searchAgent( + needle: needle, agent: .claude, cwdFilter: cwdFilter, + offset: 0, limit: target + ) + async let x = Self.searchAgent( + needle: needle, agent: .codex, cwdFilter: cwdFilter, + offset: 0, limit: target + ) + async let o = Self.searchAgent( + needle: needle, agent: .opencode, cwdFilter: cwdFilter, + offset: 0, limit: target + ) + let merged = (await c) + (await x) + (await o) + let sorted = merged.sorted { $0.modified > $1.modified } + return Array(sorted.dropFirst(offset).prefix(limit)) + } } nonisolated private static func searchAgent( needle: String, agent: SessionAgent, cwdFilter: String?, offset: Int, limit: Int - ) -> [SessionEntry] { + ) async -> [SessionEntry] { switch agent { - case .claude: return searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) - case .codex: return searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .claude: return await searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .codex: return await searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) case .opencode: return searchOpenCodeInDB(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) } } @@ -912,9 +910,15 @@ final class SessionIndexStore: ObservableObject { /// Run `rg --files-with-matches --ignore-case --fixed-strings` for `needle` /// under `root`, restricted to `glob` (e.g. `*.jsonl`). Returns matched file /// URLs, or nil if rg isn't available or the run failed (caller falls back). + /// + /// Async by design so we can wire cancellation: when the awaiting Task is + /// cancelled (e.g. user types another key), `onCancel` calls + /// `process.terminate()`, killing the in-flight rg instead of letting it + /// grind to completion. Wait is also async (via `terminationHandler`) so we + /// don't tie up a cooperative-pool thread on `waitUntilExit`. nonisolated private static func ripgrepMatchingPaths( needle: String, root: String, fileGlob: String - ) -> [URL]? { + ) async -> [URL]? { guard let rg = cachedRipgrepPath else { return nil } let process = Process() process.executableURL = URL(fileURLWithPath: rg) @@ -936,31 +940,38 @@ final class SessionIndexStore: ObservableObject { if let nullDev = FileHandle(forWritingAtPath: "/dev/null") { process.standardError = nullDev } - do { try process.run() } catch { return nil } - // Drain stdout BEFORE waitUntilExit. With many matches rg writes more - // than the ~64 KB pipe buffer can hold, blocks on the next write, and - // process.waitUntilExit() then deadlocks (rg can't exit until stdout - // drains, we won't drain until rg exits). readDataToEndOfFile reads - // until EOF (= rg closes its stdout when it finishes), so the order - // matters: read, then wait. - let data = outPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - // rg exit codes: 0 = matches, 1 = no matches, 2 = error. - switch process.terminationStatus { - case 0: - guard let str = String(data: data, encoding: .utf8) else { return nil } - return str.split(separator: "\n", omittingEmptySubsequences: true) - .map { URL(fileURLWithPath: String($0)) } - case 1: - return [] - default: - return nil + + return await withTaskCancellationHandler { + do { try process.run() } catch { return nil as [URL]? } + // Drain stdout BEFORE awaiting exit. With many matches rg writes + // more than the ~64 KB pipe buffer; reading until EOF lets rg + // make progress, and EOF arrives when rg closes its stdout on exit. + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + // Async wait via terminationHandler — no blocked cooperative-pool thread. + await withCheckedContinuation { (cont: CheckedContinuation) in + process.terminationHandler = { _ in cont.resume() } + } + // rg exit codes: 0 = matches, 1 = no matches, 2 = error/terminated. + switch process.terminationStatus { + case 0: + guard let str = String(data: data, encoding: .utf8) else { return nil as [URL]? } + return str.split(separator: "\n", omittingEmptySubsequences: true) + .map { URL(fileURLWithPath: String($0)) } + case 1: + return [] + default: + return nil + } + } onCancel: { + // Fires synchronously when the awaiting Task is cancelled. Safe to + // call before/after run(); a not-yet-running process just no-ops. + process.terminate() } } nonisolated private static func searchClaudeOnDisk( needle: String, cwdFilter: String?, offset: Int, limit: Int - ) -> [SessionEntry] { + ) async -> [SessionEntry] { let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath let fm = FileManager.default @@ -970,7 +981,7 @@ final class SessionIndexStore: ObservableObject { var rgFiltered = false var candidates: [(URL, Date, String)] = [] if !needle.isEmpty, - let rgPaths = ripgrepMatchingPaths(needle: needle, root: projectsRoot, fileGlob: "*.jsonl") { + let rgPaths = await ripgrepMatchingPaths(needle: needle, root: projectsRoot, fileGlob: "*.jsonl") { rgFiltered = true for url in rgPaths { guard let attrs = try? fm.attributesOfItem(atPath: url.path), @@ -1031,14 +1042,14 @@ final class SessionIndexStore: ObservableObject { nonisolated private static func searchCodexOnDisk( needle: String, cwdFilter: String?, offset: Int, limit: Int - ) -> [SessionEntry] { + ) async -> [SessionEntry] { let root = ("~/.codex/sessions" as NSString).expandingTildeInPath let fm = FileManager.default var rgFiltered = false var candidates: [(URL, Date)] = [] if !needle.isEmpty, - let rgPaths = ripgrepMatchingPaths(needle: needle, root: root, fileGlob: "*.jsonl") { + let rgPaths = await ripgrepMatchingPaths(needle: needle, root: root, fileGlob: "*.jsonl") { rgFiltered = true for url in rgPaths { guard let attrs = try? fm.attributesOfItem(atPath: url.path), From f42a674e2df0074ac0f8d185c3921fade3071dce Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:29:01 -0700 Subject: [PATCH 28/39] DRY scan + search into one path; ensure empty-needle fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pairs of functions did almost the same thing — initial scan and deep search were 90% duplicate code per agent. Unified each pair into a single load*Entries(needle:cwdFilter:offset:limit:) function. - scanClaude + searchClaudeOnDisk → loadClaudeEntries - scanCodex + searchCodexOnDisk → loadCodexEntries - scanOpenCode + searchOpenCodeInDB → loadOpenCodeEntries scanAll calls each with `needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit`. searchSessions calls them with the trimmed needle and the requested page. Empty-needle fast path is now explicit and proven by code path: - Skip the rg subprocess (already gated by `if !needle.isEmpty`). - Skip the post-rg substring re-check. - Loop breaks at `matches.count >= target` so the candidate scan stops at exactly `offset+limit` files for empty queries. - OpenCode skips its `LOWER(...) LIKE ?` clause and is just `ORDER BY time_updated DESC LIMIT ? OFFSET ?`. Also dropped the searchFileByteCap (128 KB) constant and folded both read paths to headByteCap (64 KB). With rg as the primary needle filter, the post-filter metadata read only needs the title region — the larger 128 KB cap was redundant. --- Sources/SessionIndexStore.swift | 217 ++++---------------------------- 1 file changed, 25 insertions(+), 192 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 6e9815c98278..0448228451c5 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -396,69 +396,17 @@ final class SessionIndexStore: ObservableObject { private static let perAgentLimit = 30 private static let headByteCap = 64 * 1024 private static let tailByteCap = 32 * 1024 - /// Limits for the on-demand deep search (popover "Show more"). - private static let searchMaxResults = 200 + /// Hard cap on candidate files inspected per call to keep deep-page searches bounded. private static let searchMaxFiles = 1500 - private static let searchFileByteCap = 128 * 1024 private static func scanAll() async -> [SessionEntry] { - async let claude = scanClaude() - async let codex = scanCodex() - async let opencode = scanOpenCode() + async let claude = loadClaudeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) + async let codex = loadCodexEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) + async let opencode = loadOpenCodeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) let combined = await claude + codex + opencode return combined.sorted { $0.modified > $1.modified } } - // MARK: Claude - - private static func scanClaude() async -> [SessionEntry] { - let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath - let fm = FileManager.default - guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { - return [] - } - - var candidates: [(URL, Date, String)] = [] - for dirName in projectDirs { - let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) - var isDir: ObjCBool = false - guard fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue else { continue } - guard let contents = try? fm.contentsOfDirectory(atPath: dirPath) else { continue } - for name in contents where name.hasSuffix(".jsonl") { - let filePath = (dirPath as NSString).appendingPathComponent(name) - let url = URL(fileURLWithPath: filePath) - guard let attrs = try? fm.attributesOfItem(atPath: filePath), - let mtime = attrs[.modificationDate] as? Date else { continue } - candidates.append((url, mtime, dirName)) - } - } - - candidates.sort { $0.1 > $1.1 } - let limited = candidates.prefix(perAgentLimit) - - var results: [SessionEntry] = [] - results.reserveCapacity(limited.count) - for (url, mtime, dirName) in limited { - let head = readFileHead(url: url, byteCap: headByteCap) - let tail = readFileTail(url: url, byteCap: tailByteCap) - let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) - let sid = url.deletingPathExtension().lastPathComponent - results.append(SessionEntry( - id: "claude:" + url.path, - agent: .claude, - sessionId: sid, - title: parsed.title, - cwd: parsed.cwd, - gitBranch: parsed.branch, - pullRequest: parsed.pr, - modified: mtime, - fileURL: url, - specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) - )) - } - return results - } - private struct ClaudeParsed { var title: String = "" var cwd: String? @@ -550,54 +498,6 @@ final class SessionIndexStore: ObservableObject { // MARK: Codex - private static func scanCodex() async -> [SessionEntry] { - let root = ("~/.codex/sessions" as NSString).expandingTildeInPath - let fm = FileManager.default - let rootURL = URL(fileURLWithPath: root) - guard let enumerator = fm.enumerator( - at: rootURL, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return [] - } - - var candidates: [(URL, Date)] = [] - for case let url as URL in enumerator { - guard url.pathExtension == "jsonl" else { continue } - let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) - guard values?.isRegularFile == true, - let mtime = values?.contentModificationDate else { continue } - candidates.append((url, mtime)) - } - candidates.sort { $0.1 > $1.1 } - let limited = candidates.prefix(perAgentLimit) - - var results: [SessionEntry] = [] - results.reserveCapacity(limited.count) - for (url, mtime) in limited { - let parsed = extractCodexMetadata(url: url) - results.append(SessionEntry( - id: "codex:" + url.path, - agent: .codex, - sessionId: parsed.sessionId, - title: parsed.title, - cwd: parsed.cwd, - gitBranch: parsed.branch, - pullRequest: nil, - modified: mtime, - fileURL: url, - specifics: .codex( - model: parsed.model, - approvalPolicy: parsed.approvalPolicy, - sandboxMode: parsed.sandboxMode, - effort: parsed.effort - ) - )) - } - return results - } - private struct CodexParsed { var sessionId: String = "" /// First user message — used only if Codex never assigns a thread_name. @@ -721,86 +621,6 @@ final class SessionIndexStore: ObservableObject { // MARK: OpenCode - private static func scanOpenCode() async -> [SessionEntry] { - let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath - let fm = FileManager.default - guard fm.fileExists(atPath: dbPath) else { return [] } - - // Snapshot DB + WAL/SHM into a temp directory to avoid contention with a running OpenCode. - let snapshotDir = fm.temporaryDirectory.appendingPathComponent("cmux-opencode-snap-\(UUID().uuidString)", isDirectory: true) - do { - try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) - } catch { - return [] - } - defer { try? fm.removeItem(at: snapshotDir) } - - let snapshotDB = snapshotDir.appendingPathComponent("opencode.db") - do { - try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) - } catch { - return [] - } - for sidecar in ["-wal", "-shm"] { - let src = dbPath + sidecar - let dst = snapshotDB.path + sidecar - if fm.fileExists(atPath: src) { - try? fm.copyItem(atPath: src, toPath: dst) - } - } - - var db: OpaquePointer? - let openCode = sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) - guard openCode == SQLITE_OK, let db else { - sqlite3_close(db) - return [] - } - defer { sqlite3_close(db) } - - // Pull the latest assistant message per session in one query so we can carry - // model + agent forward into the resume command. - let sql = """ - SELECT s.id, s.title, s.directory, s.time_updated, ( - SELECT data FROM message - WHERE session_id = s.id AND data LIKE '%"role":"assistant"%' - ORDER BY time_created DESC LIMIT 1 - ) AS last_assistant - FROM session s - ORDER BY s.time_updated DESC - LIMIT \(perAgentLimit) - """ - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { - sqlite3_finalize(stmt) - return [] - } - defer { sqlite3_finalize(stmt) } - - var results: [SessionEntry] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - let sid = sqliteText(stmt, 0) ?? "" - let title = sqliteText(stmt, 1) ?? "" - let directory = sqliteText(stmt, 2) - let updatedMs = sqlite3_column_int64(stmt, 3) - let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) - let lastJSON = sqliteText(stmt, 4) - let (providerModel, agentName) = parseOpenCodeAssistant(lastJSON) - results.append(SessionEntry( - id: "opencode:" + sid, - agent: .opencode, - sessionId: sid, - title: title, - cwd: directory, - gitBranch: nil, - pullRequest: nil, - modified: modified, - fileURL: nil, - specifics: .opencode(providerModel: providerModel, agentName: agentName) - )) - } - return results - } - nonisolated private static func parseOpenCodeAssistant(_ raw: String?) -> (String?, String?) { guard let raw, let data = raw.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -879,9 +699,9 @@ final class SessionIndexStore: ObservableObject { offset: Int, limit: Int ) async -> [SessionEntry] { switch agent { - case .claude: return await searchClaudeOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) - case .codex: return await searchCodexOnDisk(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) - case .opencode: return searchOpenCodeInDB(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .claude: return await loadClaudeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .codex: return await loadCodexEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .opencode: return loadOpenCodeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) } } @@ -969,7 +789,14 @@ final class SessionIndexStore: ObservableObject { } } - nonisolated private static func searchClaudeOnDisk( + /// Returns Claude session entries paginated by mtime desc. + /// - When `needle` is empty: fast path. Skips rg, enumerates `~/.claude/projects`, + /// takes the top `offset+limit` by mtime, parses metadata, returns the slice. + /// - When `needle` is non-empty and rg is on PATH: rg pre-filters the candidate + /// set; we only parse files that actually contain the needle. + /// - When `needle` is non-empty and rg is missing/failed: falls back to the + /// Foundation enumeration + 64 KB head + 32 KB tail substring scan. + nonisolated private static func loadClaudeEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int ) async -> [SessionEntry] { let projectsRoot = ("~/.claude/projects" as NSString).expandingTildeInPath @@ -1014,7 +841,7 @@ final class SessionIndexStore: ObservableObject { if matches.count >= target { break } if scanned >= searchMaxFiles { break } scanned += 1 - let head = readFileHead(url: url, byteCap: searchFileByteCap) + let head = readFileHead(url: url, byteCap: headByteCap) let tail = readFileTail(url: url, byteCap: tailByteCap) // Skip the substring re-check when rg already confirmed the file matches. if !needle.isEmpty && !rgFiltered { @@ -1040,7 +867,9 @@ final class SessionIndexStore: ObservableObject { return Array(matches.dropFirst(offset).prefix(limit)) } - nonisolated private static func searchCodexOnDisk( + /// Returns Codex session entries paginated by mtime desc. + /// Same shape as `loadClaudeEntries` — see that doc comment for the empty/needle/rg paths. + nonisolated private static func loadCodexEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int ) async -> [SessionEntry] { let root = ("~/.codex/sessions" as NSString).expandingTildeInPath @@ -1081,7 +910,7 @@ final class SessionIndexStore: ObservableObject { if scanned >= searchMaxFiles { break } scanned += 1 if !needle.isEmpty && !rgFiltered { - let head = readFileHead(url: url, byteCap: searchFileByteCap) + let head = readFileHead(url: url, byteCap: headByteCap) guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } } let parsed = extractCodexMetadata(url: url) @@ -1107,7 +936,11 @@ final class SessionIndexStore: ObservableObject { return Array(matches.dropFirst(offset).prefix(limit)) } - nonisolated private static func searchOpenCodeInDB( + /// Returns OpenCode session entries paginated by `time_updated` desc. + /// Empty needle skips the `LIKE` clause entirely so it's just `ORDER BY … LIMIT/OFFSET`. + /// Sync because the SQL pass is fast and SQLite's API is sync; the caller + /// awaits the wrapping `searchSessions`/`scanAll` boundaries. + nonisolated private static func loadOpenCodeEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int ) -> [SessionEntry] { let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath From 3f976457d6b2262ce1073c16615a78e0977e9e3d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:32:03 -0700 Subject: [PATCH 29/39] Show-more popover: instant cached page on open, no spinner flash --- Sources/SessionIndexView.swift | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 44dba3764b65..008e6930db24 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -653,25 +653,36 @@ private struct SectionPopoverView: View { .frame(maxWidth: .infinity, alignment: .leading) } - /// Reset the page and load page 0. Empty query loads instantly; non-empty - /// query waits 200ms first to debounce keystrokes. + /// Reset the page and load page 0. + /// - Empty query: synchronous fast path. Show the cached top-N from + /// `section.entries` immediately so opening the popover never flashes a + /// loading spinner. The sentinel row's loadMore will then fetch any + /// additional pages from disk/SQL when the user scrolls past them. + /// - Non-empty query: 200ms debounce then deep search via the store. private func resetAndLoad(query newValue: String) { loadTask?.cancel() loadGeneration += 1 let generation = loadGeneration let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + activeQuery = trimmed + + if trimmed.isEmpty { + loaded = section.entries + // Optimistic: assume there might be more on disk; loadMore will + // discover the truth and flip hasMore off if a fetch returns nothing. + hasMore = !section.entries.isEmpty + isLoading = false + return + } + loaded = [] hasMore = true - activeQuery = trimmed isLoading = true let scope = sectionSearchScope let store = self.store - let needsDebounce = !trimmed.isEmpty loadTask = Task { @MainActor in - if needsDebounce { - try? await Task.sleep(nanoseconds: 200_000_000) - if Task.isCancelled || generation != loadGeneration { return } - } + try? await Task.sleep(nanoseconds: 200_000_000) + if Task.isCancelled || generation != loadGeneration { return } let page = await store.searchSessions( query: trimmed, scope: scope, offset: 0, limit: Self.pageSize From 0841e6a8963711b669db0d458d5b9b24ca3273d8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:35:17 -0700 Subject: [PATCH 30/39] Fix rg async deadlock: terminationHandler was registered after exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape set process.terminationHandler AFTER readDataToEndOfFile() returned. But readDataToEndOfFile returns when rg closes stdout, which is when rg exits — so the handler was being installed on an already-terminated process and never fired, leaving the awaiting continuation hung forever. Symptom: popover stuck on "Loading…" for any non-empty query that exercised the rg pre-filter. Fix: drop the terminationHandler/continuation pair and use waitUntilExit() right after readDataToEndOfFile(). The process is already exiting at that point so this is essentially instant — it just makes terminationStatus observable. The withTaskCancellationHandler is unchanged: cancel still fires process.terminate(), which closes rg's stdout, unblocks readDataToEndOfFile, and lets the body return cleanly. Trade-off vs the old shape: this briefly blocks the cooperative-pool thread during the wait. In practice the wait is microseconds (process already exited by the time we get there), so the swift-guidance "no blocking on cooperative pool" concern is satisfied for any realistic workload while restoring correctness. --- Sources/SessionIndexStore.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 0448228451c5..9ab1e3a88f7e 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -763,14 +763,16 @@ final class SessionIndexStore: ObservableObject { return await withTaskCancellationHandler { do { try process.run() } catch { return nil as [URL]? } - // Drain stdout BEFORE awaiting exit. With many matches rg writes + // Drain stdout BEFORE waitUntilExit. With many matches rg writes // more than the ~64 KB pipe buffer; reading until EOF lets rg - // make progress, and EOF arrives when rg closes its stdout on exit. + // make progress and EOF arrives when rg closes its stdout on exit. + // Once readDataToEndOfFile returns, the process is already exiting, + // so waitUntilExit is essentially instant — we just need it to make + // terminationStatus observable. (Setting terminationHandler here + // would race: if rg already exited, the handler is registered too + // late and never fires → deadlock.) let data = outPipe.fileHandleForReading.readDataToEndOfFile() - // Async wait via terminationHandler — no blocked cooperative-pool thread. - await withCheckedContinuation { (cont: CheckedContinuation) in - process.terminationHandler = { _ in cont.resume() } - } + process.waitUntilExit() // rg exit codes: 0 = matches, 1 = no matches, 2 = error/terminated. switch process.terminationStatus { case 0: @@ -783,8 +785,9 @@ final class SessionIndexStore: ObservableObject { return nil } } onCancel: { - // Fires synchronously when the awaiting Task is cancelled. Safe to - // call before/after run(); a not-yet-running process just no-ops. + // Fires synchronously when the awaiting Task is cancelled. Sends + // SIGTERM to rg, which closes stdout, lets readDataToEndOfFile + // return, and unblocks the body so this call can complete cleanly. process.terminate() } } From 792ec5813ddb56c0809dd05962ab2daa9f890c5b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:39:46 -0700 Subject: [PATCH 31/39] Skip Codex envelope messages + reset popover state on each open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex titles - Some Codex sessions surface as "..." because the first user_message in the rollout is an envelope wrapper, not the user's real prompt. The response_item path already filtered AGENTS.md / / envelopes; pulled that into realCodexUserMessage(_:) and applied it to BOTH the event_msg and response_item paths. Loop continues past envelopes until a real user prompt is found (or until thread_name_updated supersedes it entirely). Popover state - Reopening "Show more" was carrying the previous open's @State — typed query, scroll position, etc. — because NSHostingController reuses the same SwiftUI view tree across rootView replacements. Symptom: you'd see the prior search text highlighted in the field while the result list was the cached (un-filtered) entries from .onAppear, putting query and loaded out of sync. - Fix: bump a presentationCount on each present() and tag the SectionPopoverView with .id(presentationCount). SwiftUI now treats each open as a brand-new view, so all @State (query, loaded, hasMore, isLoading, searchFocused) starts clean. --- Sources/SessionIndexStore.swift | 40 ++++++++++++++++++++++++--------- Sources/SessionIndexView.swift | 11 +++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 9ab1e3a88f7e..b72fe12f7d9f 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -486,6 +486,27 @@ final class SessionIndexStore: ObservableObject { return out } + /// Returns a usable user-prompt string from a Codex `user_message` / + /// `response_item.input_text` payload, or nil when the message is just an + /// envelope/system wrapper (`...`, ``, + /// ``, AGENTS.md preamble) that we don't want to surface as a + /// session title. + nonisolated private static func realCodexUserMessage(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let envelopePrefixes = [ + " String? { // Claude encodes cwd by replacing "/" with "-" and prefixing "-" // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq" @@ -549,23 +570,20 @@ final class SessionIndexStore: ObservableObject { } if out.firstUserMessage.isEmpty, type == "event_msg", let p = payload, (p["type"] as? String) == "user_message", - let msg = p["message"] as? String, !msg.isEmpty { - out.firstUserMessage = msg + let msg = p["message"] as? String, + let real = realCodexUserMessage(msg) { + out.firstUserMessage = real } if out.firstUserMessage.isEmpty, type == "response_item", let p = payload, (p["type"] as? String) == "message", (p["role"] as? String) == "user", let content = p["content"] as? [[String: Any]] { for part in content { - if (part["type"] as? String) == "input_text", - let text = part["text"] as? String, - !text.isEmpty, - !text.hasPrefix("# AGENTS.md"), - !text.hasPrefix(""), - !text.hasPrefix(" Void)? + /// Bumped on every present(). Used as the SwiftUI view identity so each + /// open gets fresh @State (empty query, fresh focus, no stale results). + private var presentationCount = 0 init(isPresented: Binding) { _isPresented = isPresented @@ -937,10 +940,14 @@ private struct SectionPopoverHost: NSViewRepresentable { private func refreshContent() { guard let section = currentSection, let store = currentStore else { return } let onResume = currentOnResume + let identity = presentationCount hostingController.rootView = AnyView( SectionPopoverView(section: section, store: store, onResume: onResume) { [weak self] in self?.closeFromContent() } + // Tied to presentationCount so reopening the popover discards + // the prior open's @State (typed query, scrolled position, etc.). + .id(identity) ) hostingController.view.invalidateIntrinsicContentSize() hostingController.view.layoutSubtreeIfNeeded() @@ -954,6 +961,10 @@ private struct SectionPopoverHost: NSViewRepresentable { } anchorView.superview?.layoutSubtreeIfNeeded() let popover = popover ?? makePopover() + // Bump identity + refresh BEFORE showing so the SwiftUI tree mounts + // with the new id (= reset @State) on this open. + presentationCount += 1 + refreshContent() updateContentSize() guard !popover.isShown else { return } popover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxX) From a40fb2fa55f84694064481ceab9391dc7086e54d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:51:14 -0700 Subject: [PATCH 32/39] Stop infinite popover refresh + ship dark Codex icon variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infinite loop - present() was bumping presentationCount + calling refreshContent on every NSViewRepresentable update. updateNSView fires on every parent re-render (which the @ObservedObject store triggers constantly), so the SectionPopoverView .id kept incrementing, SwiftUI kept treating it as a brand-new view, @State kept resetting → loaded snapped back to section.entries forever and the popover looked stuck loading. - Fix: only bump presentationCount on a hidden→shown transition. Once the popover is up, updateNSView still flows through coordinator. update → refreshContent, but presentationCount is stable so the .id stays the same and SwiftUI preserves @State. Reopening (transition from hidden) still bumps and resets — the original goal. Codex icon - Codex (OpenAI mark) was a black PNG that became invisible in light mode and looked off in some themes. Add a white variant tagged appearance=luminosity/dark in the Codex.imageset Contents.json. The asset catalog auto-picks based on system appearance — no SwiftUI changes needed, no template-rendering coupling, Claude/OpenCode full-color icons stay untouched. --- .../AgentIcons/Codex.imageset/Codex-dark.png | Bin 0 -> 446 bytes .../AgentIcons/Codex.imageset/Codex-dark@2x.png | Bin 0 -> 997 bytes .../AgentIcons/Codex.imageset/Codex-dark@3x.png | Bin 0 -> 1576 bytes .../AgentIcons/Codex.imageset/Contents.json | 5 ++++- Sources/SessionIndexView.swift | 12 ++++++++---- 5 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@2x.png create mode 100644 Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..49b13dca3bf00d57766603f392feb8daad18d4e4 GIT binary patch literal 446 zcmV;v0YUzWP)0E(Iy}oh=l}Fg+v8AEsSpKrP0D-EAc{#$L0v`vZp)n&YXE?240L>qkd5@ z)snie=BP56ihUXA1#&#XF)Pj_F8RLr`2<{UhPpg)PL#+wN>p`kJO20P`g@G zchzmRBcx8MsXC_4sEul;dac%GfLUM*SOgw`R$v`C0(O8FpbIzw27xQ!RSFCNZ-Lh@ z)Q<9$&dte^8t+@UX_>Ec@ z7I%i_GZ8@=kz7VZ+Y#BRntjePAK?zD*$DkOO3})+yVZNbZ3BJ?}BVF zaMhe%0X_#R40FH?;GE%ijkp~+3akfmZ%eq_oE--SX5jTe8>QtDu&P|LTHq=0FYrNv z<$#}TFR?5v13m((fhoW)px5wCZz7rq907XFcxQxF0xi-0Jph_RnlpSgnC)|5MwY?q zXw_j{YS;+irs;ZtZ9sK2cPKC^!{=8v=Tr(b0R`X*u+mZjFyHV-`)&p5lBm~4Q6GzO zv-!iYkwH-f{9{qgGTrusunFi%9L)tffG%Kb&!6m5AvIk~T{EQ_?x$S74ZPt{`cHq|1`#0CNO< zVJo;qu~*gr2l2nkVPGRkl_pX408RG3#{6xG@Fk|(7Zj&Wu{YU=Y0)0{m(oDE!0fk2 zHX}`UHaK5xV~eCSz(h;e1xZsSHA{LZ=~h}al5Q!Kd}#^U85jt|dDbUsmZWPIZH;rT z;GFwK(l|*6B<+{CpO*-grjZTY0>;=(cZ?lu1e=zm!v(C&RWzDB-Ptb zU+9PM8ynjsNx*!|@sIXhjc-@si0Q!p;9d9@HaGT`6wNDp2+YPefxm&lVZtagx{){< zi*MNA8*~d$8R2yi-k1>X!k<80R_zOwI6q~Ei_&}mp8`Xp?;#Z19QdPagkj4~7g}2% z@a=0!#@|t%TO1x1gMIjG{}JHJa(ui1cmS-^ TT>CsX00000NkvXXu0mjfY8uWr literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png b/Assets.xcassets/AgentIcons/Codex.imageset/Codex-dark@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..06a7d337f55bf749a5ecb4c7a76117753381f157 GIT binary patch literal 1576 zcmV+@2G{wCP)H$RK~z|U#hH6(mt_>kzt3t;mlJf$?82lhQSz<`B_>p&q=^ceh>@v< zL}q1qfx?RFFEfo&v?Mc)ln7&zWC&RaWjM{!Oe%AlmocxYpADqsXzg`NP0xleUf@fDwlL#(lJRtNcvRL7tXo*LVdJKfDXV?pq}h< z02uv$kn92cmO$trupOud&L-%W07b3w?eZYmP11LgDnipaNsA<{an2nwxN9U0ku*or z7-OPJ(h*5FO1doRgrsUoRnED(R*az;$r50%nW682n{w#4IEU>~53B=j`!9&TU<_9S z*JZ(X1=i<4(#l2P(bkY`3)BT;m%ww9?;h59Ip*{610PXRI&C0}F2o?d;fMW^EGb{l=2!G<^%lI--n#aUE zZTi;%6`8_hx0T1Y`c!7+VX|KplXc2G{_s1rCMpb20pk z*dTTSLsM+u0<6XdfcZd43a&Fv`}+iNZ4zj$ae5fIJGAc(Z6{;+xuN}dAl>3Z2igHI z0_S6c`W+aTLUx_O^@}UV8|6{Vot6X9=TaO_0R9Y^%fKf5tzl&=&^y-8uGC{udlCLb z+Wj!yIrm4B^IQbz2W*qHK~h&qTO{>$&W)DTThb;2zgN;OU>Q&z+Kw1~M{9V18Nt^H zj?-pzo}E$r{#r@X;kp9MNEyrs{M~Bh6z~)<*0jyFo#q=wi^z}z(FMS>z?txUmZrqy z3@`_1lVG+fM6g>(c8l`)Uz$&0i__xZ;?ysbzdq6qx4t^iUrJDs- z8AW-5p=?9}z6Ay*^c@&|b z#rS<5$uXdlBo#?IXt2G~9A=Oj=NxIupsNX=)sh+{&6ackn2FC{BkW~KGbL>dOYTeG=?VG7T#{x2rA3lHmoLgB zX-dNK&yw_`q_&c_0UuLfEDuC#oO6>T4U=?9QXff6W0+%-)`idZl13Ie=PpQE5gN-R zEysjI=iJYd2FfoHXE#iWPO(?J)7 z>3oPzH~ycu3LEps08auZV}$nsBT{B}20jr1zY;5e>w|7i0YvSV>6=D$cxWpJ-lk<7 zt^&FT%$+nZU%n!i@a9VtUsOg?9UsBtMma5}>jiunBYO#-3`c`F2uw(s?VO3?&&qM* zXI$Kv%}dv$I2=pMx2N;?2a0wH_#*t4h#Y4m3Je8~WaNQA6tw|xfv~?4a3%PJWIyme zaB~W7D9wvSfax(zlzY|yAJM#`-2s#(*%q+S06$0sWf7nfA7-x@TaEbAy2RkhNSDn( z`%F$MXnMW@n4Jf~QsAYq8dkOggA(xfClEXWJWPg2XAMk@bzYKbbohL85`OutWPHv6 z-ZR(@z(^8GmTY9_$DAGoUc>LZ8}JWZ{qaE{UZh>bKa{0D=isAGbX(K{ZSx`+Gd7i$ zuX5RBJRxhl3 zb*+5*_Mm}QNqR%lcIRA!>EBJ#R7uZBiXMSaNV>~8XG@{0Blrvb&4k%K3+%$5`1qSC z_{Xw4t~$X4!lUrDdp7w8SVX2fGDQk0(9+1Zku+S=cu9lh|M#Q4eEG6R(pE_yI_GK& avHvgS_#7uaxvF{q0000`|5K5 literal 0 HcmV?d00001 diff --git a/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json index d5eb2b241790..a480c5db6c00 100644 --- a/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json +++ b/Assets.xcassets/AgentIcons/Codex.imageset/Contents.json @@ -2,7 +2,10 @@ "images" : [ { "idiom" : "universal", "filename" : "Codex.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Codex@2x.png", "scale" : "2x" }, - { "idiom" : "universal", "filename" : "Codex@3x.png", "scale" : "3x" } + { "idiom" : "universal", "filename" : "Codex@3x.png", "scale" : "3x" }, + { "idiom" : "universal", "filename" : "Codex-dark.png", "scale" : "1x", "appearances" : [{"appearance":"luminosity","value":"dark"}] }, + { "idiom" : "universal", "filename" : "Codex-dark@2x.png", "scale" : "2x", "appearances" : [{"appearance":"luminosity","value":"dark"}] }, + { "idiom" : "universal", "filename" : "Codex-dark@3x.png", "scale" : "3x", "appearances" : [{"appearance":"luminosity","value":"dark"}] } ], "info" : { "author" : "xcode", "version" : 1 } } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 8f552d4d371e..3880ba34ec41 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -961,10 +961,14 @@ private struct SectionPopoverHost: NSViewRepresentable { } anchorView.superview?.layoutSubtreeIfNeeded() let popover = popover ?? makePopover() - // Bump identity + refresh BEFORE showing so the SwiftUI tree mounts - // with the new id (= reset @State) on this open. - presentationCount += 1 - refreshContent() + // Only bump identity on a hidden→shown transition. Bumping on every + // updateNSView (which fires on parent re-renders, e.g. ObservedObject + // store changes) would reset SectionPopoverView's @State on every + // tick — typed query gone, loaded reset, looks like infinite loading. + if !popover.isShown { + presentationCount += 1 + refreshContent() + } updateContentSize() guard !popover.isShown else { return } popover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxX) From d36dd69218ba52b1d136e29568ec63624271c6c0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 00:57:34 -0700 Subject: [PATCH 33/39] Claude/Codex cwd-filter fast paths + per-agent timing logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory-scope "Show more" → load more was slow because for an empty needle with cwdFilter, every per-agent helper enumerated all files and parsed their metadata just to check the cwd. Two targeted fixes, self-benchmarked against the real data on this machine: Claude - Project dir name encodes the cwd ("/Users/x/y" -> "-Users-x-y"). - When cwdFilter is set, skip enumerating any other project dir. - Bench: 141ms -> 17ms warm (~8x). Files inspected: 765 -> 313. Codex - session_meta is always the FIRST line of the rollout. - Add peekCodexSessionMetaCwd: read up to 64 KB head, parse the first line, return cwd. If it doesn't match cwdFilter, skip the file before doing the full streaming extract (which can read MBs looking for thread_name/model/etc). - Bench: ~38ms -> ~33ms warm in this dataset; the bigger win is per-skipped-file read size dropping from 256 KB+ to 64 KB. Diagnostics - searchSessions logs total ms + needle/offset/limit. - timedAgent wrapper logs per-agent ms and result count for the multi-agent directory fanout. - All gated to #if DEBUG. --- Sources/SessionIndexStore.swift | 78 +++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index b72fe12f7d9f..48da19bc63e2 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Combine import Foundation import SQLite3 @@ -517,6 +518,13 @@ final class SessionIndexStore: ObservableObject { return candidate } + /// Inverse of `decodeClaudeProjectDir`. Used as a fast path: when filtering + /// by cwd we can skip enumerating other project dirs entirely. + nonisolated private static func encodeClaudeProjectDir(_ path: String) -> String { + // "/Users/x/y" -> "-Users-x-y" + return path.replacingOccurrences(of: "/", with: "-") + } + // MARK: Codex private struct CodexParsed { @@ -537,6 +545,24 @@ final class SessionIndexStore: ObservableObject { } } + /// Cheap cwd peek for Codex rollouts. `session_meta` is always the first line + /// of the file, but the line itself can be 30+ KB (it embeds the full system + /// prompt). Read up to 64 KB to cover that, parse the JSON, return cwd. + nonisolated private static func peekCodexSessionMetaCwd(url: URL) -> String? { + let head = readFileHead(url: url, byteCap: headByteCap) + guard let nl = head.firstIndex(of: "\n") else { return nil } + let firstLine = head[.. [SessionEntry] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let needle = trimmed.lowercased() + #if DEBUG + let totalStart = ProcessInfo.processInfo.systemUptime + defer { + let totalMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000 + dlog("session.search.total ms=\(String(format: "%.0f", totalMs)) needle=\"\(trimmed.prefix(20))\" offset=\(offset) limit=\(limit)") + } + #endif switch scope { case .agent(let a): return await Self.searchAgent( @@ -694,15 +727,15 @@ final class SessionIndexStore: ObservableObject { // Multi-agent merge: fetch the union of (offset+limit) per agent so the // merge-sort can produce a stable global ordering, then slice. let target = offset + limit - async let c = Self.searchAgent( + async let c = Self.timedAgent( needle: needle, agent: .claude, cwdFilter: cwdFilter, offset: 0, limit: target ) - async let x = Self.searchAgent( + async let x = Self.timedAgent( needle: needle, agent: .codex, cwdFilter: cwdFilter, offset: 0, limit: target ) - async let o = Self.searchAgent( + async let o = Self.timedAgent( needle: needle, agent: .opencode, cwdFilter: cwdFilter, offset: 0, limit: target ) @@ -712,6 +745,21 @@ final class SessionIndexStore: ObservableObject { } } + nonisolated private static func timedAgent( + needle: String, agent: SessionAgent, cwdFilter: String?, + offset: Int, limit: Int + ) async -> [SessionEntry] { + #if DEBUG + let start = ProcessInfo.processInfo.systemUptime + let result = await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit) + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + dlog("session.search.agent agent=\(agent.rawValue) ms=\(String(format: "%.0f", ms)) results=\(result.count) cwd=\(cwdFilter?.suffix(40) ?? "nil")") + return result + #else + return await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit) + #endif + } + nonisolated private static func searchAgent( needle: String, agent: SessionAgent, cwdFilter: String?, offset: Int, limit: Int @@ -837,6 +885,22 @@ final class SessionIndexStore: ObservableObject { let dirName = url.deletingLastPathComponent().lastPathComponent candidates.append((url, mtime, dirName)) } + } else if let cwdFilter { + // Fast path: the project directory name encodes the cwd. We can skip + // enumerating every other project entirely. + let dirName = encodeClaudeProjectDir(cwdFilter) + let dirPath = (projectsRoot as NSString).appendingPathComponent(dirName) + var isDir: ObjCBool = false + if fm.fileExists(atPath: dirPath, isDirectory: &isDir), isDir.boolValue, + let contents = try? fm.contentsOfDirectory(atPath: dirPath) { + for name in contents where name.hasSuffix(".jsonl") { + let filePath = (dirPath as NSString).appendingPathComponent(name) + let url = URL(fileURLWithPath: filePath) + guard let attrs = try? fm.attributesOfItem(atPath: filePath), + let mtime = attrs[.modificationDate] as? Date else { continue } + candidates.append((url, mtime, dirName)) + } + } } else { guard let projectDirs = try? fm.contentsOfDirectory(atPath: projectsRoot) else { return [] } for dirName in projectDirs { @@ -934,6 +998,14 @@ final class SessionIndexStore: ObservableObject { let head = readFileHead(url: url, byteCap: headByteCap) guard head.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } } + // Fast cwd reject: session_meta is the FIRST line of every Codex + // rollout. Pull just that line and bail before streaming the + // (potentially MB-sized) rest of the file looking for title/branch. + if let cwdFilter, + let firstLineCwd = peekCodexSessionMetaCwd(url: url), + firstLineCwd != cwdFilter { + continue + } let parsed = extractCodexMetadata(url: url) if let cwdFilter, parsed.cwd != cwdFilter { continue } matches.append(SessionEntry( From c10e231226b00188b0b4f9837057d3509e14f279 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:04:04 -0700 Subject: [PATCH 34/39] Use Codex's own state_5.sqlite for session metadata Codex maintains ~/.codex/state_5.sqlite with a `threads` table that already has every field we were extracting from jsonl rollouts the hard way: id, rollout_path, cwd, title, model, git_branch, approval_mode, sandbox_policy (JSON with type), reasoning_effort, first_user_message, updated_at_ms. Indexed on updated_at(_ms). Bench against this machine's data (293 cmuxterm-hq Codex sessions of 453 total): page 0 unfiltered (30): 3 ms page 0 cwd-filtered (30): 1 ms page 5 cwd-filtered (offset 150): 7 ms search 'posthog' (30): 8 ms search 'posthog' + cwd (30): 1 ms Versus the file-scan path which was reading 64 KB head from every jsonl just to check cwd (~30+ ms warm, far worse cold). loadCodexEntries now snapshots state_5.sqlite to /tmp (same WAL-safety pattern we already use for OpenCode), runs a single query with conditional WHERE clauses for cwd / needle, and constructs SessionEntry rows directly. Falls back to the renamed loadCodexEntriesFromDisk (=former loadCodexEntries) when state_5.sqlite is missing. Codex's curated `title` column is preferred over first_user_message, which is itself filtered through realCodexUserMessage so envelope wrappers (...) don't leak in as titles. --- Sources/SessionIndexStore.swift | 139 +++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 48da19bc63e2..eb390c5876f4 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -953,9 +953,146 @@ final class SessionIndexStore: ObservableObject { } /// Returns Codex session entries paginated by mtime desc. - /// Same shape as `loadClaudeEntries` — see that doc comment for the empty/needle/rg paths. + /// Primary path: query Codex's own `~/.codex/state_5.sqlite` (`threads` + /// table) — Codex pre-extracts cwd, title, model, branch, approval, sandbox, + /// effort, and rollout_path so we don't need to read jsonl files at all. + /// Fallback (DB missing): the file-scan path below. nonisolated private static func loadCodexEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int + ) async -> [SessionEntry] { + if let viaSQL = loadCodexEntriesViaSQL( + needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit + ) { + return viaSQL + } + return await loadCodexEntriesFromDisk( + needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit + ) + } + + /// SQL-backed Codex loader. Returns nil if `state_5.sqlite` doesn't exist or + /// can't be opened, signaling caller to fall back to the disk scan. + nonisolated private static func loadCodexEntriesViaSQL( + needle: String, cwdFilter: String?, offset: Int, limit: Int + ) -> [SessionEntry]? { + let dbPath = ("~/.codex/state_5.sqlite" as NSString).expandingTildeInPath + let fm = FileManager.default + guard fm.fileExists(atPath: dbPath) else { return nil } + + // Snapshot DB + WAL/SHM to avoid contention with a running Codex. + let snapshotDir = fm.temporaryDirectory.appendingPathComponent( + "cmux-codex-search-\(UUID().uuidString)", isDirectory: true + ) + do { try fm.createDirectory(at: snapshotDir, withIntermediateDirectories: true) } catch { return nil } + defer { try? fm.removeItem(at: snapshotDir) } + let snapshotDB = snapshotDir.appendingPathComponent("state.db") + do { try fm.copyItem(atPath: dbPath, toPath: snapshotDB.path) } catch { return nil } + for sidecar in ["-wal", "-shm"] { + let src = dbPath + sidecar + let dst = snapshotDB.path + sidecar + if fm.fileExists(atPath: src) { try? fm.copyItem(atPath: src, toPath: dst) } + } + + var db: OpaquePointer? + guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + + var sql = """ + SELECT id, rollout_path, cwd, title, model, git_branch, + approval_mode, sandbox_policy, reasoning_effort, + first_user_message, updated_at_ms + FROM threads + WHERE archived = 0 + """ + var conditions: [String] = [] + if !needle.isEmpty { + // Match against title, first_user_message, cwd, and git_branch. + conditions.append("(LOWER(title) LIKE ?1 OR LOWER(first_user_message) LIKE ?1 OR LOWER(cwd) LIKE ?1 OR LOWER(git_branch) LIKE ?1)") + } + if cwdFilter != nil { + conditions.append("cwd = ?\(needle.isEmpty ? 1 : 2)") + } + if !conditions.isEmpty { + sql += " AND " + conditions.joined(separator: " AND ") + } + sql += " ORDER BY updated_at_ms DESC LIMIT \(limit) OFFSET \(offset)" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + sqlite3_finalize(stmt) + return nil + } + defer { sqlite3_finalize(stmt) } + + let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + if !needle.isEmpty { + sqlite3_bind_text(stmt, 1, "%\(needle)%", -1, SQLITE_TRANSIENT_FN) + } + if let cwdFilter { + sqlite3_bind_text(stmt, needle.isEmpty ? 1 : 2, cwdFilter, -1, SQLITE_TRANSIENT_FN) + } + + var results: [SessionEntry] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let sid = sqliteText(stmt, 0) ?? "" + let rollout = sqliteText(stmt, 1) ?? "" + let cwd = sqliteText(stmt, 2) + let titleField = sqliteText(stmt, 3) ?? "" + let model = sqliteText(stmt, 4) + let branch = sqliteText(stmt, 5) + let approval = sqliteText(stmt, 6) + let sandboxJSON = sqliteText(stmt, 7) + let effort = sqliteText(stmt, 8) + let firstMsg = sqliteText(stmt, 9) ?? "" + let updatedMs = sqlite3_column_int64(stmt, 10) + let modified = Date(timeIntervalSince1970: TimeInterval(updatedMs) / 1000.0) + + // Codex stores sandbox_policy as JSON like {"type":"danger-full-access"}. + let sandboxMode = sandboxJSON + .flatMap { $0.data(using: .utf8) } + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + .flatMap { $0["type"] as? String } + + // Prefer Codex's curated `title`. Fall back to first_user_message, + // skipping envelope wrappers (, etc.). + let displayTitle: String + if !titleField.isEmpty { + displayTitle = titleField + } else if let real = realCodexUserMessage(firstMsg) { + displayTitle = real + } else { + displayTitle = "" + } + + let fileURL: URL? = rollout.isEmpty ? nil : URL(fileURLWithPath: rollout) + results.append(SessionEntry( + id: "codex:" + (fileURL?.path ?? sid), + agent: .codex, + sessionId: sid, + title: displayTitle, + cwd: cwd, + gitBranch: branch?.isEmpty == false ? branch : nil, + pullRequest: nil, + modified: modified, + fileURL: fileURL, + specifics: .codex( + model: model?.isEmpty == false ? model : nil, + approvalPolicy: approval?.isEmpty == false ? approval : nil, + sandboxMode: sandboxMode, + effort: effort?.isEmpty == false ? effort : nil + ) + )) + } + return results + } + + /// Disk-scan fallback for Codex when state_5.sqlite isn't present (very old + /// Codex installs, or non-default config). Same shape as the original loader. + nonisolated private static func loadCodexEntriesFromDisk( + needle: String, cwdFilter: String?, offset: Int, limit: Int ) async -> [SessionEntry] { let root = ("~/.codex/sessions" as NSString).expandingTildeInPath let fm = FileManager.default From e4187ccb54450fb49d0674039ae6c70a6d9c9d68 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:19:04 -0700 Subject: [PATCH 35/39] Parallelize Claude per-file reads + LRU cache + timing breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production timings showed reads dominating (read=2664ms parse=111ms loop=4288ms for 50 new files). The benchmark predicted 40ms because SwiftBenchmark scripts run in a tight loop on a single thread; the real app was already spending most of its time in serial FileHandle reads on big jsonls (some are 50+MB). Two changes: 1. Wrap the per-file work in withTaskGroup so reads happen across cooperative-pool threads instead of one at a time. Each task does the same work (cache lookup, head/tail read, optional needle re-check, JSON parse, build SessionEntry); we collect, sort by original mtime index, and slice. With ~8 cores this should drop the read-bound phase from 50ms*N serial to ~50ms*N/8 parallel. 2. ClaudeMetadataCache (added previous commit) is now exercised correctly by the parallel path. Repeat scrolling reuses parsed entries without re-touching disk. Investigated whether Claude has a metadata SQLite — it doesn't. The state_5.sqlite / logs_2.sqlite files exist but Claude truncates them to 0 bytes on startup. session-env/ holds env scripts, not metadata. Reading the jsonls is the only path. --- Sources/SessionIndexStore.swift | 129 +++++++++++++++++++++++++------- 1 file changed, 101 insertions(+), 28 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index eb390c5876f4..ac01259c04ce 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -131,6 +131,41 @@ struct SessionEntry: Identifiable, Hashable { } } +// MARK: - Parsed metadata cache + +/// Process-wide cache for parsed Claude session metadata, keyed by file URL with +/// mtime as the freshness check. Avoids re-reading and re-parsing the same +/// jsonls across pagination calls. Bounded by `maxEntries` to keep memory in +/// check (LRU on insert). +final class ClaudeMetadataCache: @unchecked Sendable { + static let shared = ClaudeMetadataCache() + private let maxEntries = 1000 + private let lock = NSLock() + private var entries: [URL: (mtime: Date, entry: SessionEntry)] = [:] + + func get(url: URL, mtime: Date) -> SessionEntry? { + lock.lock() + defer { lock.unlock() } + guard let cached = entries[url], cached.mtime == mtime else { return nil } + return cached.entry + } + + func put(url: URL, mtime: Date, entry: SessionEntry) { + lock.lock() + defer { lock.unlock() } + entries[url] = (mtime, entry) + if entries.count > maxEntries { + // Evict ~10% (oldest mtimes) to amortize cleanup cost. + let evictCount = entries.count / 10 + let oldestKeys = entries + .sorted { $0.value.mtime < $1.value.mtime } + .prefix(evictCount) + .map(\.key) + for k in oldestKeys { entries.removeValue(forKey: k) } + } + } +} + // MARK: - Drag registry /// Process-wide registry that pairs a synthetic drag UUID with a SessionEntry. @@ -919,37 +954,75 @@ final class SessionIndexStore: ObservableObject { } candidates.sort { $0.1 > $1.1 } + // Take a generous window of candidates to inspect in parallel. We need + // enough to cover both targets and skipped files; we'll trim to + // (offset+limit) matches afterwards. Cap at searchMaxFiles. let target = offset + limit - var matches: [SessionEntry] = [] - var scanned = 0 - for (url, mtime, dirName) in candidates { - if matches.count >= target { break } - if scanned >= searchMaxFiles { break } - scanned += 1 - let head = readFileHead(url: url, byteCap: headByteCap) - let tail = readFileTail(url: url, byteCap: tailByteCap) - // Skip the substring re-check when rg already confirmed the file matches. - if !needle.isEmpty && !rgFiltered { - let combined = head + "\n" + tail - guard combined.range(of: needle, options: [.caseInsensitive, .literal]) != nil else { continue } + let workSize = min(target * 2, candidates.count, searchMaxFiles) + let workCandidates = Array(candidates.prefix(workSize)) + + #if DEBUG + let loopStart = ProcessInfo.processInfo.systemUptime + #endif + + // Parallelize per-file work. Each file's read + parse is independent; + // running them in a TaskGroup lets the cooperative pool fan I/O out + // across cores instead of one-file-at-a-time blocking on disk. + let processed: [(Int, SessionEntry?, Bool)] = await withTaskGroup( + of: (Int, SessionEntry?, Bool).self + ) { group in + for (idx, candidate) in workCandidates.enumerated() { + let (url, mtime, dirName) = candidate + group.addTask { + // Cache hit + if let cached = ClaudeMetadataCache.shared.get(url: url, mtime: mtime) { + if let cwdFilter, cached.cwd != cwdFilter { return (idx, nil, true) } + return (idx, cached, true) + } + let head = readFileHead(url: url, byteCap: headByteCap) + let tail = readFileTail(url: url, byteCap: tailByteCap) + if !needle.isEmpty && !rgFiltered { + let combined = head + "\n" + tail + if combined.range(of: needle, options: [.caseInsensitive, .literal]) == nil { + return (idx, nil, false) + } + } + let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) + if let cwdFilter, parsed.cwd != cwdFilter { return (idx, nil, false) } + let sid = url.deletingPathExtension().lastPathComponent + let entry = SessionEntry( + id: "claude:" + url.path, + agent: .claude, + sessionId: sid, + title: parsed.title, + cwd: parsed.cwd, + gitBranch: parsed.branch, + pullRequest: parsed.pr, + modified: mtime, + fileURL: url, + specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) + ) + if needle.isEmpty { + ClaudeMetadataCache.shared.put(url: url, mtime: mtime, entry: entry) + } + return (idx, entry, false) + } } - let parsed = extractClaudeMetadata(head: head, tail: tail, projectDir: dirName) - if let cwdFilter, parsed.cwd != cwdFilter { continue } - let sid = url.deletingPathExtension().lastPathComponent - matches.append(SessionEntry( - id: "claude:" + url.path, - agent: .claude, - sessionId: sid, - title: parsed.title, - cwd: parsed.cwd, - gitBranch: parsed.branch, - pullRequest: parsed.pr, - modified: mtime, - fileURL: url, - specifics: .claude(model: parsed.model, permissionMode: parsed.permissionMode) - )) + var collected: [(Int, SessionEntry?, Bool)] = [] + collected.reserveCapacity(workCandidates.count) + for await item in group { collected.append(item) } + return collected } - return Array(matches.dropFirst(offset).prefix(limit)) + // Restore original mtime ordering (TaskGroup completes out-of-order). + let sorted = processed.sorted { $0.0 < $1.0 } + let matched = sorted.compactMap { $0.1 } + #if DEBUG + let cachedCount = sorted.filter { $0.2 }.count + let skippedCount = sorted.filter { $0.1 == nil && !$0.2 }.count + sorted.filter { $0.1 == nil && $0.2 }.count + let totalMs = (ProcessInfo.processInfo.systemUptime - loopStart) * 1000 + dlog("session.claude.detail target=\(target) workSize=\(workSize) matched=\(matched.count) cachedHits=\(cachedCount) skipped=\(skippedCount) parallelMs=\(Int(totalMs))") + #endif + return Array(matched.prefix(target).dropFirst(offset).prefix(limit)) } /// Returns Codex session entries paginated by mtime desc. From 7efcb4e02fb06a1d8949e04818d7b860b1614d6e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:24:39 -0700 Subject: [PATCH 36/39] SearchOutcome carries errors; popover shows them above the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing change: when the Codex SQLite or OpenCode SQLite returns a schema/open error (the usual symptom of an upstream version bump changing field names), the popover now shows an orange-tinted banner above the result list with the actual sqlite3 errmsg, instead of silently rendering an empty "No matches". Refactor: - searchSessions returns SearchOutcome { entries, errors }. - ErrorBag (lock-protected, @unchecked Sendable) is threaded through searchAgent → loadCodexEntriesViaSQL / loadOpenCodeEntries. - Each SQL helper appends a human-readable message on open or prepare failure; Codex still falls back to the file-scan path so the user also gets results when possible. - SectionPopoverView holds @State errorMessages, clears on each new query, populates from the outcome, renders a small warning row. --- Sources/SessionIndexStore.swift | 91 +++++++++++++++++++++++++-------- Sources/SessionIndexView.swift | 35 ++++++++++--- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index ac01259c04ce..503470568118 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -436,9 +436,13 @@ final class SessionIndexStore: ObservableObject { private static let searchMaxFiles = 1500 private static func scanAll() async -> [SessionEntry] { + // Initial scan errors are silently ignored — UI just shows the cached + // entries we did get. Errors get surfaced when the user actively + // searches via the popover. + let bag = ErrorBag() async let claude = loadClaudeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) - async let codex = loadCodexEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) - async let opencode = loadOpenCodeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit) + async let codex = loadCodexEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit, errorBag: bag) + async let opencode = loadOpenCodeEntries(needle: "", cwdFilter: nil, offset: 0, limit: perAgentLimit, errorBag: bag) let combined = await claude + codex + opencode return combined.sorted { $0.modified > $1.modified } } @@ -723,6 +727,11 @@ final class SessionIndexStore: ObservableObject { return String(cString: cString) } + nonisolated private static func sqliteMessage(_ db: OpaquePointer?) -> String? { + guard let db, let cString = sqlite3_errmsg(db) else { return nil } + return String(cString: cString) + } + // MARK: - Deep search (popover "Show more") enum SearchScope { @@ -731,6 +740,31 @@ final class SessionIndexStore: ObservableObject { case directory(String?) } + /// What the popover gets back. `errors` is non-empty when one or more + /// agents failed to read their data source (schema mismatch, file missing, + /// SQL error). UI should surface them so users see why the list looks + /// short or empty rather than thinking nothing matched. + struct SearchOutcome: Sendable { + var entries: [SessionEntry] + var errors: [String] + } + + /// Thread-safe accumulator passed down to per-agent helpers so they can + /// report failures (e.g. SQL prepare errors when an agent bumps its + /// schema) without requiring the helpers to throw across actor boundaries. + final class ErrorBag: @unchecked Sendable { + private let lock = NSLock() + private var messages: [String] = [] + func add(_ msg: String) { + lock.lock(); defer { lock.unlock() } + messages.append(msg) + } + func snapshot() -> [String] { + lock.lock(); defer { lock.unlock() } + return messages + } + } + /// Paginated on-demand search across the full filesystem (Claude/Codex) and /// SQLite (OpenCode). Empty query is allowed and returns the most-recent /// entries (used when the user just opens the popover and scrolls). @@ -741,21 +775,23 @@ final class SessionIndexStore: ObservableObject { scope: SearchScope, offset: Int, limit: Int - ) async -> [SessionEntry] { + ) async -> SearchOutcome { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let needle = trimmed.lowercased() + let bag = ErrorBag() #if DEBUG let totalStart = ProcessInfo.processInfo.systemUptime defer { let totalMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000 - dlog("session.search.total ms=\(String(format: "%.0f", totalMs)) needle=\"\(trimmed.prefix(20))\" offset=\(offset) limit=\(limit)") + dlog("session.search.total ms=\(String(format: "%.0f", totalMs)) needle=\"\(trimmed.prefix(20))\" offset=\(offset) limit=\(limit) errors=\(bag.snapshot().count)") } #endif + let entries: [SessionEntry] switch scope { case .agent(let a): - return await Self.searchAgent( + entries = await Self.searchAgent( needle: needle, agent: a, cwdFilter: nil, - offset: offset, limit: limit + offset: offset, limit: limit, errorBag: bag ) case .directory(let path): let cwdFilter = (path?.isEmpty == false) ? path : nil @@ -764,45 +800,46 @@ final class SessionIndexStore: ObservableObject { let target = offset + limit async let c = Self.timedAgent( needle: needle, agent: .claude, cwdFilter: cwdFilter, - offset: 0, limit: target + offset: 0, limit: target, errorBag: bag ) async let x = Self.timedAgent( needle: needle, agent: .codex, cwdFilter: cwdFilter, - offset: 0, limit: target + offset: 0, limit: target, errorBag: bag ) async let o = Self.timedAgent( needle: needle, agent: .opencode, cwdFilter: cwdFilter, - offset: 0, limit: target + offset: 0, limit: target, errorBag: bag ) let merged = (await c) + (await x) + (await o) let sorted = merged.sorted { $0.modified > $1.modified } - return Array(sorted.dropFirst(offset).prefix(limit)) + entries = Array(sorted.dropFirst(offset).prefix(limit)) } + return SearchOutcome(entries: entries, errors: bag.snapshot()) } nonisolated private static func timedAgent( needle: String, agent: SessionAgent, cwdFilter: String?, - offset: Int, limit: Int + offset: Int, limit: Int, errorBag: ErrorBag ) async -> [SessionEntry] { #if DEBUG let start = ProcessInfo.processInfo.systemUptime - let result = await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit) + let result = await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 dlog("session.search.agent agent=\(agent.rawValue) ms=\(String(format: "%.0f", ms)) results=\(result.count) cwd=\(cwdFilter?.suffix(40) ?? "nil")") return result #else - return await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit) + return await searchAgent(needle: needle, agent: agent, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) #endif } nonisolated private static func searchAgent( needle: String, agent: SessionAgent, cwdFilter: String?, - offset: Int, limit: Int + offset: Int, limit: Int, errorBag: ErrorBag ) async -> [SessionEntry] { switch agent { case .claude: return await loadClaudeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) - case .codex: return await loadCodexEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) - case .opencode: return loadOpenCodeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit) + case .codex: return await loadCodexEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) + case .opencode: return loadOpenCodeEntries(needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, errorBag: errorBag) } } @@ -1031,10 +1068,12 @@ final class SessionIndexStore: ObservableObject { /// effort, and rollout_path so we don't need to read jsonl files at all. /// Fallback (DB missing): the file-scan path below. nonisolated private static func loadCodexEntries( - needle: String, cwdFilter: String?, offset: Int, limit: Int + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag ) async -> [SessionEntry] { if let viaSQL = loadCodexEntriesViaSQL( - needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit + needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, + errorBag: errorBag ) { return viaSQL } @@ -1043,10 +1082,13 @@ final class SessionIndexStore: ObservableObject { ) } - /// SQL-backed Codex loader. Returns nil if `state_5.sqlite` doesn't exist or - /// can't be opened, signaling caller to fall back to the disk scan. + /// SQL-backed Codex loader. Returns nil if `state_5.sqlite` doesn't exist + /// (signaling caller to fall back to the disk scan). On schema mismatch or + /// other SQL errors, appends a user-facing message to `errorBag` and still + /// returns nil so the caller can fall back. nonisolated private static func loadCodexEntriesViaSQL( - needle: String, cwdFilter: String?, offset: Int, limit: Int + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag ) -> [SessionEntry]? { let dbPath = ("~/.codex/state_5.sqlite" as NSString).expandingTildeInPath let fm = FileManager.default @@ -1068,6 +1110,7 @@ final class SessionIndexStore: ObservableObject { var db: OpaquePointer? guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + errorBag.add("Codex: cannot open state_5.sqlite (\(sqliteMessage(db) ?? "unknown error"))") sqlite3_close(db) return nil } @@ -1095,6 +1138,7 @@ final class SessionIndexStore: ObservableObject { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + errorBag.add("Codex: schema unsupported — \(sqliteMessage(db) ?? "prepare failed"). Falling back to file scan.") sqlite3_finalize(stmt) return nil } @@ -1244,7 +1288,8 @@ final class SessionIndexStore: ObservableObject { /// Sync because the SQL pass is fast and SQLite's API is sync; the caller /// awaits the wrapping `searchSessions`/`scanAll` boundaries. nonisolated private static func loadOpenCodeEntries( - needle: String, cwdFilter: String?, offset: Int, limit: Int + needle: String, cwdFilter: String?, offset: Int, limit: Int, + errorBag: ErrorBag ) -> [SessionEntry] { let dbPath = ("~/.local/share/opencode/opencode.db" as NSString).expandingTildeInPath let fm = FileManager.default @@ -1261,6 +1306,7 @@ final class SessionIndexStore: ObservableObject { } var db: OpaquePointer? guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + errorBag.add("OpenCode: cannot open opencode.db (\(sqliteMessage(db) ?? "unknown error"))") sqlite3_close(db) return [] } @@ -1288,6 +1334,7 @@ final class SessionIndexStore: ObservableObject { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + errorBag.add("OpenCode: schema unsupported — \(sqliteMessage(db) ?? "prepare failed")") sqlite3_finalize(stmt) return [] } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 3880ba34ec41..79dd2eb3fd5d 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -534,6 +534,7 @@ private struct SectionPopoverView: View { @State private var isLoading: Bool = false @State private var activeQuery: String = "" @State private var loadTask: Task? + @State private var errorMessages: [String] = [] /// Bumped on each query reset so an in-flight task knows it's been superseded /// even if cancellation hasn't propagated yet. @State private var loadGeneration: Int = 0 @@ -589,6 +590,24 @@ private struct SectionPopoverView: View { Divider() + if !errorMessages.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(errorMessages, id: \.self) { msg in + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + .foregroundColor(.orange) + Text(msg) + .font(.system(size: 11)) + .foregroundColor(.primary.opacity(0.85)) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.10)) + } ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if isLoading && loaded.isEmpty { @@ -672,24 +691,27 @@ private struct SectionPopoverView: View { // discover the truth and flip hasMore off if a fetch returns nothing. hasMore = !section.entries.isEmpty isLoading = false + errorMessages = [] return } loaded = [] hasMore = true isLoading = true + errorMessages = [] let scope = sectionSearchScope let store = self.store loadTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 200_000_000) if Task.isCancelled || generation != loadGeneration { return } - let page = await store.searchSessions( + let outcome = await store.searchSessions( query: trimmed, scope: scope, offset: 0, limit: Self.pageSize ) if Task.isCancelled || generation != loadGeneration { return } - loaded = page - hasMore = page.count >= Self.pageSize + loaded = outcome.entries + hasMore = outcome.entries.count >= Self.pageSize + errorMessages = outcome.errors isLoading = false } } @@ -704,13 +726,14 @@ private struct SectionPopoverView: View { let query = activeQuery let offset = loaded.count loadTask = Task { @MainActor in - let page = await store.searchSessions( + let outcome = await store.searchSessions( query: query, scope: scope, offset: offset, limit: Self.pageSize ) if Task.isCancelled || generation != loadGeneration { return } - loaded.append(contentsOf: page) - hasMore = page.count >= Self.pageSize + loaded.append(contentsOf: outcome.entries) + hasMore = outcome.entries.count >= Self.pageSize + errorMessages = outcome.errors isLoading = false } } From 7c4c103db54e1b8048a420afa54e09b6772f2356 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:36:57 -0700 Subject: [PATCH 37/39] Virtualize popover list with NSTableView-backed List Replace the LazyVStack inside ScrollView with a SwiftUI List using .listStyle(.plain) so the popover gets true cell recycling on macOS for long search results. List requires a concrete height to render rows on macOS, so the frame is set explicitly to 420. Also DRY: extract the repeated post-fetch state update into applyOutcome(_:append:) so resetAndLoad and loadMore share the loaded / hasMore / errors / loading bookkeeping. Pull the three repeated .listRow* modifiers into a PopoverListRow ViewModifier. --- Sources/SessionIndexView.swift | 98 ++++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 35 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 79dd2eb3fd5d..865fc6e45ba0 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -608,38 +608,46 @@ private struct SectionPopoverView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.10)) } - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - if isLoading && loaded.isEmpty { - loadingRow - } else if loaded.isEmpty { - Text(String(localized: "sessionIndex.popover.noMatches", - defaultValue: "No matches")) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - ForEach(loaded) { entry in - PopoverRow(entry: entry) { - onResume?(entry) - onDismiss() - } - .equatable() - } - if hasMore { - // Sentinel row: appearance triggers loadMore. Renders the - // "Loading more…" indicator while fetching. - loadingRow - .onAppear { loadMore() } + if isLoading && loaded.isEmpty { + loadingRow + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } else if loaded.isEmpty { + Text(String(localized: "sessionIndex.popover.noMatches", + defaultValue: "No matches")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + // NSTableView-backed virtualization: List recycles row cells so + // a long result set only realizes the visible window. Default + // List chrome (separators, insets, background, min row height) + // is stripped so rows render identically to the previous + // LazyVStack. List requires a concrete height to render on + // macOS, so we set 420 directly here instead of relying on + // a parent maxHeight. + List { + ForEach(loaded) { entry in + PopoverRow(entry: entry) { + onResume?(entry) + onDismiss() } + .equatable() + .modifier(PopoverListRow()) + } + if hasMore { + loadingRow + .onAppear { loadMore() } + .modifier(PopoverListRow()) } } - .padding(.top, 4) - .padding(.bottom, 10) + .listStyle(.plain) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .frame(height: 420) } - .frame(maxHeight: 420) } .frame(width: 360) .background( @@ -709,10 +717,7 @@ private struct SectionPopoverView: View { offset: 0, limit: Self.pageSize ) if Task.isCancelled || generation != loadGeneration { return } - loaded = outcome.entries - hasMore = outcome.entries.count >= Self.pageSize - errorMessages = outcome.errors - isLoading = false + applyOutcome(outcome, append: false) } } @@ -731,11 +736,23 @@ private struct SectionPopoverView: View { offset: offset, limit: Self.pageSize ) if Task.isCancelled || generation != loadGeneration { return } + applyOutcome(outcome, append: true) + } + } + + /// Merge a fetch result into the popover's display state. Both the + /// initial-page and load-more paths converge here so the count/hasMore/ + /// error/loading bookkeeping lives in one place. + @MainActor + private func applyOutcome(_ outcome: SessionIndexStore.SearchOutcome, append: Bool) { + if append { loaded.append(contentsOf: outcome.entries) - hasMore = outcome.entries.count >= Self.pageSize - errorMessages = outcome.errors - isLoading = false + } else { + loaded = outcome.entries } + hasMore = outcome.entries.count >= Self.pageSize + errorMessages = outcome.errors + isLoading = false } private var sectionSearchScope: SessionIndexStore.SearchScope { @@ -769,6 +786,17 @@ private struct SectionPopoverView: View { } } +/// Strips default `List` row chrome so popover rows render flush with our +/// custom padding and hover background. +private struct PopoverListRow: ViewModifier { + func body(content: Content) -> some View { + content + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } +} + private struct PopoverRow: View, Equatable { let entry: SessionEntry let onActivate: () -> Void From 6ce48a40e540b5951b8a6cd9d2bfe5ad38387fa4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:45:09 -0700 Subject: [PATCH 38/39] Address review feedback batch - Resume routing: skip cwd-match when the selected workspace is remote so a local-indexed session is never sent into a remote shell just because the path string coincides. - syncFileExplorerDirectory: clear sessionIndexStore.currentDirectory on the early-return paths so a previous tab's cwd doesn't stick around as a stale filter. - decodeClaudeProjectDir: validate against the filesystem before returning so directories with a real "-" segment (e.g. "my-cool-project") don't decode to a wrong path; callers fall back to the JSONL `cwd` field. - cwdLabel: compare home prefix on a path boundary so "/Users/al" doesn't match a home of "/Users/alice". - absoluteTime: cache DateFormatter as a static so tooltip hover doesn't reallocate each access. - Section reorder: anchor moves to a visible neighbor key instead of a positional index. Hidden sections (filtered by scope) keep their relative position to visible neighbors instead of getting shuffled. - SessionIndexView appear: skip reload when the store is already loading, so the mode-toggle's reload isn't immediately cancelled and restarted. - Codex disk loader: check Task.isCancelled inside the per-file loop so a cancelled scan stops promptly instead of finishing the whole batch. - Popover rows: add the same right-click context menu as full SessionRow. Extracted the menu items into a shared `sessionRowMenuItems` builder and the row actions into inline closures so both rows stay in sync. --- Sources/ContentView.swift | 18 ++- Sources/SessionIndexStore.swift | 73 +++++++----- Sources/SessionIndexView.swift | 190 +++++++++++++++----------------- 3 files changed, 151 insertions(+), 130 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c8264dcf6820..530c434fe7df 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2978,10 +2978,18 @@ struct ContentView: View { // Smart placement: if the focused workspace's tracked cwd matches, open a // new tab inside that workspace. Otherwise create a new workspace. + // Remote workspaces are excluded from cwd-match: a session indexed from + // the local filesystem must not be resumed inside a remote shell just + // because the path string happens to coincide. let selected = tabManager.selectedWorkspace + let selectedTab = tabManager.selectedTabId.flatMap { id in + tabManager.tabs.first(where: { $0.id == id }) + } + let isRemoteSelection = selectedTab?.isRemoteWorkspace ?? false let workspaceCwd = selected?.currentDirectory let pwdMatches: Bool = { - guard let targetCwd, !targetCwd.isEmpty, + guard !isRemoteSelection, + let targetCwd, !targetCwd.isEmpty, let workspaceCwd, !workspaceCwd.isEmpty else { return false } let lhs = (targetCwd as NSString).standardizingPath let rhs = (workspaceCwd as NSString).standardizingPath @@ -3009,11 +3017,17 @@ struct ContentView: View { private func syncFileExplorerDirectory() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { + // No selection means we have no local cwd to scope by; clear so the + // sessions panel doesn't keep filtering by a stale previous tab. + sessionIndexStore.currentDirectory = nil return } let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !dir.isEmpty else { return } + guard !dir.isEmpty else { + sessionIndexStore.currentDirectory = nil + return + } fileExplorerStore.showHiddenFiles = true if !tab.isRemoteWorkspace { diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 503470568118..2e0ad13100d5 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -119,7 +119,12 @@ struct SessionEntry: Identifiable, Hashable { var cwdLabel: String? { guard let cwd, !cwd.isEmpty else { return nil } let home = NSHomeDirectory() - if cwd.hasPrefix(home) { + // Compare on a path boundary so /Users/al doesn't get matched by a + // home of /Users/alice (would render as "~ice/foo"). + if cwd == home { + return "~" + } + if cwd.hasPrefix(home + "/") { return "~" + cwd.dropFirst(home.count) } return cwd @@ -338,9 +343,12 @@ final class SessionIndexStore: ObservableObject { return (path as NSString).lastPathComponent } - /// Insert `key` so that, after removing its old position, it lands at `insertIndex`. - /// `insertIndex` is in the *post-removal* index space. - func moveSection(_ key: SectionKey, toInsertIndex insertIndex: Int) { + /// Move `key` so it lands immediately before `referenceKey` in the + /// persisted order (or at the end if `referenceKey` is nil). Anchoring + /// to a neighbor key (rather than a positional index) means scope filters + /// can hide some sections without corrupting reorders: hidden sections + /// keep their relative position to their visible neighbors. + func moveSection(_ key: SectionKey, before referenceKey: SectionKey?) { switch grouping { case .agent: guard key.raw.hasPrefix("agent:"), @@ -348,34 +356,33 @@ final class SessionIndexStore: ObservableObject { guard let oldIndex = agentOrder.firstIndex(of: agent) else { return } var next = agentOrder next.remove(at: oldIndex) - let target = max(0, min(insertIndex, next.count)) - if target == oldIndex { return } - next.insert(agent, at: target) - agentOrder = next + if let referenceKey, + referenceKey.raw.hasPrefix("agent:"), + let refAgent = SessionAgent(rawValue: String(referenceKey.raw.dropFirst("agent:".count))), + let refIndex = next.firstIndex(of: refAgent) { + next.insert(agent, at: refIndex) + } else { + next.append(agent) + } + if next != agentOrder { agentOrder = next } case .directory: guard key.raw.hasPrefix("dir:") else { return } let path = String(key.raw.dropFirst("dir:".count)) guard let oldIndex = directoryOrder.firstIndex(of: path) else { return } var next = directoryOrder next.remove(at: oldIndex) - let target = max(0, min(insertIndex, next.count)) - if target == oldIndex { return } - next.insert(path, at: target) - directoryOrder = next - } - } - - /// Pre-removal index of the dragged key in the active grouping order, or nil if unknown. - func currentIndex(of key: SectionKey) -> Int? { - switch grouping { - case .agent: - guard key.raw.hasPrefix("agent:"), - let agent = SessionAgent(rawValue: String(key.raw.dropFirst("agent:".count))) else { return nil } - return agentOrder.firstIndex(of: agent) - case .directory: - guard key.raw.hasPrefix("dir:") else { return nil } - let path = String(key.raw.dropFirst("dir:".count)) - return directoryOrder.firstIndex(of: path) + if let referenceKey, + referenceKey.raw.hasPrefix("dir:") { + let refPath = String(referenceKey.raw.dropFirst("dir:".count)) + if let refIndex = next.firstIndex(of: refPath) { + next.insert(path, at: refIndex) + } else { + next.append(path) + } + } else { + next.append(path) + } + if next != directoryOrder { directoryOrder = next } } } @@ -549,11 +556,20 @@ final class SessionIndexStore: ObservableObject { nonisolated private static func decodeClaudeProjectDir(_ raw: String) -> String? { // Claude encodes cwd by replacing "/" with "-" and prefixing "-" - // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq" - // This is lossy (cannot distinguish original "-" from "/"), so try as a hint only. + // e.g. "-Users-lawrence-fun-cmuxterm-hq" -> "/Users/lawrence/fun/cmuxterm-hq". + // The encoding is lossy: a real path segment containing "-" + // (e.g. "my-cool-project") collapses to multiple segments + // ("/my/cool/project") on decode, which is wrong. Only return the + // candidate if it actually exists on disk; otherwise let the caller + // fall back to the JSONL `cwd` field. guard !raw.isEmpty else { return nil } let stripped = raw.hasPrefix("-") ? String(raw.dropFirst()) : raw let candidate = "/" + stripped.replacingOccurrences(of: "-", with: "/") + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: candidate, isDirectory: &isDir), + isDir.boolValue else { + return nil + } return candidate } @@ -1245,6 +1261,7 @@ final class SessionIndexStore: ObservableObject { var matches: [SessionEntry] = [] var scanned = 0 for (url, mtime) in candidates { + if Task.isCancelled { break } if matches.count >= target { break } if scanned >= searchMaxFiles { break } scanned += 1 diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 865fc6e45ba0..cda47c248697 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -20,6 +20,13 @@ struct SessionIndexView: View { return f }() + static let absoluteFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + var body: some View { VStack(spacing: 0) { controlBar @@ -33,7 +40,10 @@ struct SessionIndexView: View { } } .onAppear { - if store.entries.isEmpty { + // RightSidebarPanelView's mode toggle also kicks reload() when + // entries are empty, so guard against the double-reload that + // would otherwise cancel and restart the in-flight scan. + if store.entries.isEmpty && !store.isLoading { store.reload() } } @@ -108,7 +118,8 @@ struct SessionIndexView: View { return ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(Array(sections.enumerated()), id: \.element.key) { index, section in - SectionReorderGap(insertIndex: index, store: store) + // Drop above this row → insert dragged section BEFORE this section's key. + SectionReorderGap(beforeKey: section.key, store: store) IndexSectionView( section: section, rowLimit: Self.collapsedRowLimit, @@ -131,8 +142,10 @@ struct SessionIndexView: View { store: store, onResume: onResume ) + let _ = index } - SectionReorderGap(insertIndex: sections.count, store: store) + // Trailing gap → append. + SectionReorderGap(beforeKey: nil, store: store) } .padding(.bottom, 8) } @@ -280,7 +293,9 @@ private struct IndexSectionView: View { } private struct SectionReorderGap: View { - let insertIndex: Int + /// Section the dragged item should land BEFORE if dropped here. `nil` for + /// the trailing gap (drop appends to the end of persisted order). + let beforeKey: SectionKey? @ObservedObject var store: SessionIndexStore @State private var isDropTarget: Bool = false @@ -300,7 +315,7 @@ private struct SectionReorderGap: View { .onDrop( of: [.text], delegate: SectionGapDropDelegate( - insertIndex: insertIndex, + beforeKey: beforeKey, store: store, isDropTarget: $isDropTarget ) @@ -308,26 +323,23 @@ private struct SectionReorderGap: View { } private var isValidDrop: Bool { - guard let dragged = store.draggedKey, - let oldIndex = store.currentIndex(of: dragged) else { - return true - } - return insertIndex != oldIndex && insertIndex != oldIndex + 1 + guard let dragged = store.draggedKey else { return true } + // Skip no-op drops: dropping on the gap immediately above yourself, or + // the trailing gap when you're already last. + if dragged == beforeKey { return false } + return true } } private struct SectionGapDropDelegate: DropDelegate { - let insertIndex: Int + let beforeKey: SectionKey? let store: SessionIndexStore @Binding var isDropTarget: Bool func validateDrop(info: DropInfo) -> Bool { guard info.hasItemsConforming(to: [.text]) else { return false } - guard let dragged = store.draggedKey, - let oldIndex = store.currentIndex(of: dragged) else { - return true - } - return insertIndex != oldIndex && insertIndex != oldIndex + 1 + guard let dragged = store.draggedKey else { return true } + return dragged != beforeKey } func dropEntered(info: DropInfo) { isDropTarget = true } @@ -339,15 +351,13 @@ private struct SectionGapDropDelegate: DropDelegate { store.draggedKey = nil return false } - let storedInsert = insertIndex + let beforeKey = self.beforeKey provider.loadObject(ofClass: NSString.self) { object, _ in DispatchQueue.main.async { defer { store.draggedKey = nil } guard let raw = object as? String else { return } let key = SectionKey(raw: raw) - guard let oldIndex = store.currentIndex(of: key) else { return } - let target = storedInsert > oldIndex ? storedInsert - 1 : storedInsert - store.moveSection(key, toInsertIndex: target) + store.moveSection(key, before: beforeKey) } } return true @@ -417,52 +427,7 @@ private struct SessionRow: View, Equatable { .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) } .contextMenu { - if let onResume { - Button { - onResume(entry) - } label: { - Text(String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab")) - } - Divider() - } - if entry.fileURL != nil { - Button { - open() - } label: { - Text(String(localized: "sessionIndex.row.open", defaultValue: "Open")) - } - Button { - revealInFinder() - } label: { - Text(String(localized: "sessionIndex.row.reveal", defaultValue: "Reveal in Finder")) - } - Divider() - Button { - copyPath() - } label: { - Text(String(localized: "sessionIndex.row.copyPath", defaultValue: "Copy File Path")) - } - } - Button { - copyResumeCommand() - } label: { - Text(String(localized: "sessionIndex.row.copyResume", defaultValue: "Copy Resume Command")) - } - if let cwd = entry.cwd, !cwd.isEmpty { - Button { - NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) - } label: { - Text(String(localized: "sessionIndex.row.openCwd", defaultValue: "Open Working Directory")) - } - } - if let pr = entry.pullRequest, let url = URL(string: pr.url) { - Divider() - Button { - NSWorkspace.shared.open(url) - } label: { - Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open Pull Request")) - } - } + sessionRowMenuItems(entry: entry, onResume: onResume) } } @@ -475,43 +440,71 @@ private struct SessionRow: View, Equatable { return lines.joined(separator: "\n") } - private func open() { - guard let url = entry.fileURL else { - if let cwd = entry.cwd, !cwd.isEmpty { - NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) - } - return - } - NSWorkspace.shared.open(url) + private func relativeTime(_ date: Date) -> String { + SessionIndexView.relativeFormatter.localizedString(for: date, relativeTo: Date()) } - private func revealInFinder() { - guard let url = entry.fileURL else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) + private func absoluteTime(_ date: Date) -> String { + SessionIndexView.absoluteFormatter.string(from: date) } +} - private func copyPath() { - guard let url = entry.fileURL else { return } - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(url.path, forType: .string) - } +// MARK: - Shared row actions - private func copyResumeCommand() { +/// Right-click menu items for any session row (full or popover). Built as a +/// free `@ViewBuilder` so SessionRow and PopoverRow both attach the same set +/// without duplicating the button list or the action helpers. +@ViewBuilder +private func sessionRowMenuItems(entry: SessionEntry, onResume: ((SessionEntry) -> Void)?) -> some View { + if let onResume { + Button { + onResume(entry) + } label: { + Text(String(localized: "sessionIndex.row.resume", defaultValue: "Resume in New Tab")) + } + Divider() + } + if let url = entry.fileURL { + Button { + NSWorkspace.shared.open(url) + } label: { + Text(String(localized: "sessionIndex.row.open", defaultValue: "Open")) + } + Button { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } label: { + Text(String(localized: "sessionIndex.row.reveal", defaultValue: "Reveal in Finder")) + } + Divider() + Button { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(url.path, forType: .string) + } label: { + Text(String(localized: "sessionIndex.row.copyPath", defaultValue: "Copy File Path")) + } + } + Button { let pb = NSPasteboard.general pb.clearContents() pb.setString(entry.resumeCommand, forType: .string) + } label: { + Text(String(localized: "sessionIndex.row.copyResume", defaultValue: "Copy Resume Command")) } - - private func relativeTime(_ date: Date) -> String { - SessionIndexView.relativeFormatter.localizedString(for: date, relativeTo: Date()) + if let cwd = entry.cwd, !cwd.isEmpty { + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: cwd)) + } label: { + Text(String(localized: "sessionIndex.row.openCwd", defaultValue: "Open Working Directory")) + } } - - private func absoluteTime(_ date: Date) -> String { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f.string(from: date) + if let pr = entry.pullRequest, let url = URL(string: pr.url) { + Divider() + Button { + NSWorkspace.shared.open(url) + } label: { + Text(String(localized: "sessionIndex.row.openPR", defaultValue: "Open Pull Request")) + } } } @@ -807,12 +800,6 @@ private struct PopoverRow: View, Equatable { lhs.entry == rhs.entry } - private static let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - return f - }() - var body: some View { HStack(spacing: 6) { Image(entry.agent.assetName) @@ -826,7 +813,7 @@ private struct PopoverRow: View, Equatable { .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 8) - Text(Self.relativeFormatter.localizedString(for: entry.modified, relativeTo: Date())) + Text(SessionIndexView.relativeFormatter.localizedString(for: entry.modified, relativeTo: Date())) .font(.system(size: 11).monospacedDigit()) .foregroundColor(.secondary.opacity(0.7)) .fixedSize() @@ -842,6 +829,9 @@ private struct PopoverRow: View, Equatable { sessionDragItemProvider(for: entry) } .help(entry.cwdLabel ?? entry.displayTitle) + .contextMenu { + sessionRowMenuItems(entry: entry, onResume: { _ in onActivate() }) + } } } From e055b59856efbf367a40bbde0b85ddf78b041e0b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 17 Apr 2026 01:53:25 -0700 Subject: [PATCH 39/39] Revert popover to LazyVStack; fix unit-test mock signatures - Switch popover back to ScrollView + LazyVStack. The List + frame(420) approach kept colliding with the surrounding popover sizing; LazyVStack is already lazy enough for our result sizes. - WorkspaceUnitTests: mock overrides of makeWorkspaceForCreation must include the new initialTerminalInput parameter or they fail to override. --- Sources/SessionIndexView.swift | 73 +++++++++++------------------- cmuxTests/WorkspaceUnitTests.swift | 4 ++ 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index cda47c248697..13dc188f4d5d 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -601,46 +601,36 @@ private struct SectionPopoverView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.10)) } - if isLoading && loaded.isEmpty { - loadingRow - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) - } else if loaded.isEmpty { - Text(String(localized: "sessionIndex.popover.noMatches", - defaultValue: "No matches")) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - // NSTableView-backed virtualization: List recycles row cells so - // a long result set only realizes the visible window. Default - // List chrome (separators, insets, background, min row height) - // is stripped so rows render identically to the previous - // LazyVStack. List requires a concrete height to render on - // macOS, so we set 420 directly here instead of relying on - // a parent maxHeight. - List { - ForEach(loaded) { entry in - PopoverRow(entry: entry) { - onResume?(entry) - onDismiss() - } - .equatable() - .modifier(PopoverListRow()) - } - if hasMore { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + if isLoading && loaded.isEmpty { loadingRow - .onAppear { loadMore() } - .modifier(PopoverListRow()) + } else if loaded.isEmpty { + Text(String(localized: "sessionIndex.popover.noMatches", + defaultValue: "No matches")) + .font(.system(size: 12)) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(loaded) { entry in + PopoverRow(entry: entry) { + onResume?(entry) + onDismiss() + } + .equatable() + } + if hasMore { + loadingRow + .onAppear { loadMore() } + } } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .frame(height: 420) + .padding(.top, 4) + .padding(.bottom, 10) } + .frame(maxHeight: 420) } .frame(width: 360) .background( @@ -779,17 +769,6 @@ private struct SectionPopoverView: View { } } -/// Strips default `List` row chrome so popover rows render flush with our -/// custom padding and hover background. -private struct PopoverListRow: ViewModifier { - func body(content: Content) -> some View { - content - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - } -} - private struct PopoverRow: View, Equatable { let entry: SessionEntry let onActivate: () -> Void diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index da365d54b845..ce8c4c9c84f9 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -1553,6 +1553,7 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { beforeCreateWorkspace?() @@ -1562,6 +1563,7 @@ final class WorkspaceCreationPlacementTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) } @@ -1812,6 +1814,7 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialTerminalCommand: String?, + initialTerminalInput: String?, initialTerminalEnvironment: [String: String] ) -> Workspace { capturedConfigTemplate = configTemplate @@ -1821,6 +1824,7 @@ final class WorkspaceCreationConfigSanitizationTests: XCTestCase { portOrdinal: portOrdinal, configTemplate: configTemplate, initialTerminalCommand: initialTerminalCommand, + initialTerminalInput: initialTerminalInput, initialTerminalEnvironment: initialTerminalEnvironment ) }