diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd8a28..b9756ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 🎨 Design + +- Redesign the Mori sidebar as an attention inbox with Needs You and Running agent sections, filter pills, unified status dots, idle project clustering, and a single-tile collapsed rail. + ## [0.4.7] - 2026-06-07 ### ✨ Features diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 284c2d4..f4cf147 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### 🎨 界面优化 + +- 将 Mori 侧边栏重设计为注意力收件箱:新增 Needs You 与 Running agent 区块、筛选胶囊、统一状态圆点、空闲项目收纳,以及单 tile 折叠 rail。 + ## [0.4.7] - 2026-06-07 ### ✨ 新功能 diff --git a/Packages/MoriUI/Sources/MoriUI/ProjectLetterTile.swift b/Packages/MoriUI/Sources/MoriUI/ProjectLetterTile.swift index 1402257..3cf5f0d 100644 --- a/Packages/MoriUI/Sources/MoriUI/ProjectLetterTile.swift +++ b/Packages/MoriUI/Sources/MoriUI/ProjectLetterTile.swift @@ -6,17 +6,20 @@ import MoriCore /// colour-match rather than a text-read. struct ProjectLetterTile: View { let project: Project + var size: CGFloat = MoriTokens.Size.projectTile + var cornerRadius: CGFloat = MoriTokens.Radius.projectTile + var fontSize: CGFloat = 10 var body: some View { let pair = MoriTokens.ProjectPalette.pair(for: project.id) ZStack { - RoundedRectangle(cornerRadius: MoriTokens.Radius.projectTile) + RoundedRectangle(cornerRadius: cornerRadius) .fill(pair.background) Text(letter) - .font(MoriTokens.Font.projectTile) + .font(.system(size: fontSize, weight: .semibold, design: .monospaced)) .foregroundStyle(pair.foreground) } - .frame(width: MoriTokens.Size.projectTile, height: MoriTokens.Size.projectTile) + .frame(width: size, height: size) .accessibilityHidden(true) } diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index b9c9dfc..e056123 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -277,3 +277,12 @@ "No agents running" = "No agents running"; "Pin Project" = "Pin Project"; "Unpin Project" = "Unpin Project"; + +/* Sidebar attention inbox */ +"Needs You" = "Needs You"; +"All" = "All"; +"%lld idle projects" = "%lld idle projects"; +"reply sent — resuming…" = "reply sent — resuming…"; +"sent" = "sent"; +"Waiting for input" = "Waiting for input"; +"now" = "now"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index 6b28401..e2bf728 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -277,3 +277,12 @@ "No agents running" = "没有运行中的代理"; "Pin Project" = "置顶项目"; "Unpin Project" = "取消置顶"; + +/* Sidebar attention inbox */ +"Needs You" = "需要你处理"; +"All" = "全部"; +"%lld idle projects" = "%lld 个空闲项目"; +"reply sent — resuming…" = "回复已发送 — 正在继续…"; +"sent" = "已发送"; +"Waiting for input" = "等待输入"; +"now" = "现在"; diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index 925e670..5d3d4ab 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -39,18 +39,19 @@ public struct WorktreeRowView: View { .fill(statusDotColor) .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) - Text(worktree.name) - .font(MoriTokens.Font.rowTitle) - .foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.9)) + Text(branchDisplayText) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular, design: .monospaced)) + .foregroundStyle(isSelected ? Color.primary : MoriTokens.Color.muted) .lineLimit(1) - if isSelected { - selectedAgentLabel - } - Spacer(minLength: 0) - primaryBadge + if let timeText = relativeTimeText { + Text(timeText) + .font(MoriTokens.Font.monoSmall) + .foregroundStyle(MoriTokens.Color.inactive) + .lineLimit(1) + } if isHovered { overflowMenu @@ -67,6 +68,14 @@ public struct WorktreeRowView: View { RoundedRectangle(cornerRadius: MoriTokens.Radius.small) .strokeBorder(rowOutlineColor, lineWidth: rowOutlineColor == .clear ? 0 : 1) } + .overlay(alignment: .leading) { + if worktree.agentState == .none && worktree.status != .active { + Circle() + .strokeBorder(MoriTokens.Color.inactive.opacity(0.55), lineWidth: 1.5) + .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) + .padding(.leading, MoriTokens.Spacing.lg) + } + } .overlay(alignment: .leading) { if isSelected { // 2pt accent bar, inset 6pt top/bottom, rounded on the trailing edge. @@ -87,10 +96,13 @@ public struct WorktreeRowView: View { } private var statusDotColor: Color { + if worktree.agentState == .error { return MoriTokens.Color.error } if worktree.agentState == .waitingForInput { return MoriTokens.Color.attention } if worktree.agentState == .running { return MoriTokens.Color.success } - if worktree.status == .active { return MoriTokens.Color.success } - return MoriTokens.Color.inactive + // Live session with no agent: solid muted dot ("warm"), so semantic + // colors stay reserved for agent activity. + if worktree.status == .active { return MoriTokens.Color.inactive } + return .clear } private var iconView: some View { @@ -126,13 +138,6 @@ public struct WorktreeRowView: View { .lineLimit(1) } - if worktree.status == .active { - Circle() - .fill(MoriTokens.Color.success) - .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) - .accessibilityLabel(String.localized("Active")) - } - if let timeText = relativeTimeText { Text(timeText) .font(MoriTokens.Font.caption) @@ -251,13 +256,17 @@ public struct WorktreeRowView: View { private var relativeTimeText: String? { guard let date = worktree.lastActiveAt else { return nil } let seconds = Int(-date.timeIntervalSinceNow) - if seconds < 60 { return String.localized("just now") } - if seconds < 3600 { return String.localized("\(seconds / 60)m ago") } - if seconds < 86400 { return String.localized("\(seconds / 3600)h ago") } - if seconds < 604_800 { return String.localized("\(seconds / 86400)d ago") } + if seconds < 60 { return String.localized("now") } + if seconds < 3600 { return "\(seconds / 60)m" } + if seconds < 86400 { return "\(seconds / 3600)h" } + if seconds < 604_800 { return "\(seconds / 86400)d" } return nil } + private var branchDisplayText: String { + worktree.branch ?? worktree.name + } + private var gitSummaryText: String? { var parts: [String] = [] diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index e0b078a..249a726 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -1,8 +1,7 @@ import SwiftUI import MoriCore -/// Unified sidebar: grouped project sections, worktrees as two-line rows, -/// and windows indented below. +/// Sidebar redesigned as an attention inbox: agents needing input first, projects second. public struct WorktreeSidebarView: View { private let projects: [Project] private let selectedProjectId: UUID? @@ -29,864 +28,225 @@ public struct WorktreeSidebarView: View { private let shortcutHintsVisible: Bool private let isSidebarCollapsed: Bool + @State private var hoveredProjectId: UUID? + @State private var renamingProjectId: UUID? + @State private var renameText = "" + @State private var draggingProjectId: UUID? + @State private var dropTargetProjectId: UUID? + @State private var filter: SidebarFilter = .all + @State private var idleExpanded = false + @State private var awakenedProjectIds: Set = [] + public init( - projects: [Project] = [], - selectedProjectId: UUID? = nil, - worktrees: [Worktree], - windows: [RuntimeWindow], - panes: [RuntimePane] = [], - selectedWorktreeId: UUID?, - selectedWindowId: String?, - shortcutHintsVisible: Bool = false, - onSelectProject: ((UUID) -> Void)? = nil, - onSelectWorktree: @escaping (UUID) -> Void, - onSelectWindow: @escaping (String) -> Void, - onSelectPane: ((String) -> Void)? = nil, - onShowCreatePanel: (() -> Void)? = nil, - onRemoveWorktree: ((UUID) -> Void)? = nil, - onRemoveProject: ((UUID) -> Void)? = nil, - onEditRemoteProject: ((UUID) -> Void)? = nil, - onCloseWindow: ((String) -> Void)? = nil, - onToggleCollapse: ((UUID) -> Void)? = nil, - onAddProject: (() -> Void)? = nil, - onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, - onSendKeys: ((String, String) -> Void)? = nil, - onUpdateProject: ((Project) -> Void)? = nil, - onReorderProjects: (([UUID]) -> Void)? = nil, - isSidebarCollapsed: Bool = false + projects: [Project] = [], selectedProjectId: UUID? = nil, worktrees: [Worktree], windows: [RuntimeWindow], panes: [RuntimePane] = [], selectedWorktreeId: UUID?, selectedWindowId: String?, shortcutHintsVisible: Bool = false, onSelectProject: ((UUID) -> Void)? = nil, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, onSelectPane: ((String) -> Void)? = nil, onShowCreatePanel: (() -> Void)? = nil, onRemoveWorktree: ((UUID) -> Void)? = nil, onRemoveProject: ((UUID) -> Void)? = nil, onEditRemoteProject: ((UUID) -> Void)? = nil, onCloseWindow: ((String) -> Void)? = nil, onToggleCollapse: ((UUID) -> Void)? = nil, onAddProject: (() -> Void)? = nil, onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, onReorderProjects: (([UUID]) -> Void)? = nil, isSidebarCollapsed: Bool = false ) { - self.projects = projects - self.selectedProjectId = selectedProjectId - self.worktrees = worktrees - self.windows = windows - self.panes = panes - self.selectedWorktreeId = selectedWorktreeId - self.selectedWindowId = selectedWindowId - self.onSelectProject = onSelectProject - self.onSelectWorktree = onSelectWorktree - self.onSelectWindow = onSelectWindow - self.onSelectPane = onSelectPane - self.onShowCreatePanel = onShowCreatePanel - self.onRemoveWorktree = onRemoveWorktree - self.onRemoveProject = onRemoveProject - self.onEditRemoteProject = onEditRemoteProject - self.onCloseWindow = onCloseWindow - self.onToggleCollapse = onToggleCollapse - self.onAddProject = onAddProject - self.onRequestPaneOutput = onRequestPaneOutput - self.onSendKeys = onSendKeys - self.onUpdateProject = onUpdateProject - self.onReorderProjects = onReorderProjects - self.shortcutHintsVisible = shortcutHintsVisible - self.isSidebarCollapsed = isSidebarCollapsed - } - - private struct ActiveAgentPaneItem: Identifiable { - let pane: RuntimePane - let window: RuntimeWindow - let worktree: Worktree - let projectName: String - - var id: String { pane.tmuxPaneId } - } - - /// Count of agent panes needing attention across all worktrees. - private var attentionCount: Int { - activeAgentPaneItems.filter { - $0.pane.agentState == .waitingForInput || $0.pane.agentState == .error - }.count - } - - private var runningCount: Int { - activeAgentPaneItems.filter { $0.pane.agentState == .running }.count - } - - private var projectNamesById: [UUID: String] { - Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0.name) }) - } - - private var activeAgentPaneItems: [ActiveAgentPaneItem] { - panes - .filter { $0.detectedAgent != nil || $0.agentState != .none } - .compactMap { pane in - guard let window = windows.first(where: { $0.tmuxWindowId == pane.tmuxWindowId }), - let worktree = worktrees.first(where: { $0.id == window.worktreeId }), - worktree.status != .unavailable, - let projectName = projectNamesById[worktree.projectId] else { - return nil - } - return ActiveAgentPaneItem( - pane: pane, - window: window, - worktree: worktree, - projectName: projectName - ) - } - } - - /// Projects sorted for display: pinned first, then unpinned, preserving array order within each group. - private var sortedProjects: [Project] { - projects.filter { $0.isFavorite } + projects.filter { !$0.isFavorite } - } - - private var projectsCollapseToggleTitle: String { - isProjectsSectionCollapsed ? String.localized("Expand Projects") : String.localized("Collapse Projects") - } - - private var projectsCollapseButtonLabel: String { - isProjectsSectionCollapsed ? String.localized("Show List") : String.localized("Hide List") - } - - /// Global 1-based index for each window across all projects and worktrees. - /// Iterates projects in display order so indices match ⌘1-9 quick jump. - private var globalWindowIndices: [String: Int] { - var result: [String: Int] = [:] - var globalIndex = 1 - for project in sortedProjects where !project.isCollapsed { - let projectWorktrees = worktrees - .filter { $0.projectId == project.id && $0.status != .unavailable } - for worktree in projectWorktrees { - for window in allWindows(for: worktree) { - if globalIndex <= 9 { - result[window.tmuxWindowId] = globalIndex - } - globalIndex += 1 - } - } - } - return result + self.projects = projects; self.selectedProjectId = selectedProjectId; self.worktrees = worktrees; self.windows = windows; self.panes = panes; self.selectedWorktreeId = selectedWorktreeId; self.selectedWindowId = selectedWindowId; self.onSelectProject = onSelectProject; self.onSelectWorktree = onSelectWorktree; self.onSelectWindow = onSelectWindow; self.onSelectPane = onSelectPane; self.onShowCreatePanel = onShowCreatePanel; self.onRemoveWorktree = onRemoveWorktree; self.onRemoveProject = onRemoveProject; self.onEditRemoteProject = onEditRemoteProject; self.onCloseWindow = onCloseWindow; self.onToggleCollapse = onToggleCollapse; self.onAddProject = onAddProject; self.onRequestPaneOutput = onRequestPaneOutput; self.onSendKeys = onSendKeys; self.onUpdateProject = onUpdateProject; self.onReorderProjects = onReorderProjects; self.shortcutHintsVisible = shortcutHintsVisible; self.isSidebarCollapsed = isSidebarCollapsed } public var body: some View { - if isSidebarCollapsed { - collapsedBody - } else { - expandedBody - } + Group { isSidebarCollapsed ? AnyView(collapsedBody) : AnyView(expandedBody) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .alert("Rename Project", isPresented: Binding(get: { renamingProjectId != nil }, set: { if !$0 { renamingProjectId = nil } })) { + TextField("Project name", text: $renameText) + Button("Rename") { renameProject() } + Button("Cancel", role: .cancel) { renamingProjectId = nil } + } } private var expandedBody: some View { - VStack(spacing: 0) { - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: MoriTokens.Spacing.md) { - summaryStrip - activeWorktreeSection - projectsSectionHeader - - if !isProjectsSectionCollapsed { - let sorted = sortedProjects - let firstUnpinnedIndex = sorted.firstIndex(where: { !$0.isFavorite }) - - ForEach(Array(sorted.enumerated()), id: \.element.id) { index, project in - if let firstUnpinnedIndex, index == firstUnpinnedIndex, index > 0 { - Divider() - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.vertical, MoriTokens.Spacing.sm) - } - projectSection(project) - } - } - } - .padding(.top, MoriTokens.Spacing.lg) - .padding(.horizontal, MoriTokens.Spacing.sm) - .padding(.bottom, MoriTokens.Spacing.sm) - } - - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .alert("Rename Project", isPresented: Binding( - get: { renamingProjectId != nil }, - set: { if !$0 { renamingProjectId = nil } } - )) { - TextField("Project name", text: $renameText) - Button("Rename") { - if let id = renamingProjectId, - var project = projects.first(where: { $0.id == id }), - !renameText.trimmingCharacters(in: .whitespaces).isEmpty { - project.name = renameText.trimmingCharacters(in: .whitespaces) - onUpdateProject?(project) - } - renamingProjectId = nil - } - Button("Cancel", role: .cancel) { - renamingProjectId = nil + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: MoriTokens.Spacing.sm) { + filterBar + if filter != .running { needsYouSection } + if filter != .waiting { runningSection } + projectsHeader + ForEach(mainProjects) { projectSection($0) } + if filter == .all { idleCluster } } + .padding(.top, MoriTokens.Spacing.lg) + .padding(.horizontal, MoriTokens.Spacing.sm) + .padding(.bottom, MoriTokens.Spacing.sm) } } private var collapsedBody: some View { ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(Array(sortedProjects.enumerated()), id: \.element.id) { index, project in - if index > 0 { - Rectangle() - .fill(Color.primary.opacity(MoriTokens.Opacity.subtle)) - .frame(height: 1) - .padding(.horizontal, MoriTokens.Spacing.lg) - .padding(.vertical, MoriTokens.Spacing.md) - } - collapsedProjectGroup(project) - } + LazyVStack(spacing: MoriTokens.Spacing.lg) { + ForEach(sortedProjects) { railProject($0) } } - .padding(.top, MoriTokens.Spacing.lg) - .padding(.bottom, MoriTokens.Spacing.sm) + .padding(.vertical, MoriTokens.Spacing.lg) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func collapsedProjectGroup(_ project: Project) -> some View { - let projectWorktrees = worktrees.filter { $0.projectId == project.id && $0.status != .unavailable } - - return VStack(spacing: MoriTokens.Spacing.sm) { - ProjectLetterTile(project: project) - .help(project.name) - .accessibilityLabel(project.name) - - ForEach(projectWorktrees) { worktree in - collapsedWorktreeButton(worktree, projectId: project.id) - } + private var filterBar: some View { + HStack(spacing: MoriTokens.Spacing.sm) { + filterPill(.all, label: String.localized("All"), count: availableWorktreeCount) + filterPill(.waiting, label: "●", count: waitingItems.count, tint: MoriTokens.Color.attention) + filterPill(.running, label: "●", count: runningItems.count, tint: MoriTokens.Color.success) + Spacer() } + .padding(.horizontal, MoriTokens.Spacing.md) + .padding(.bottom, MoriTokens.Spacing.sm) } - @State private var hoveredCollapsedWorktreeId: UUID? - - private func collapsedWorktreeButton(_ worktree: Worktree, projectId: UUID) -> some View { - let isSelected = worktree.id == selectedWorktreeId - let isHovered = hoveredCollapsedWorktreeId == worktree.id - let ringColor = collapsedWorktreeRingColor(worktree) - let letter = String(worktree.name.prefix(1)).uppercased() + private func filterPill(_ value: SidebarFilter, label: String, count: Int, tint: Color = .primary) -> some View { + Button { filter = value } label: { + HStack(spacing: MoriTokens.Spacing.sm) { + Text(label).foregroundStyle(tint) + Text("\(count)").font(MoriTokens.Font.monoSmall) + } + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(filter == value ? Color.primary : MoriTokens.Color.muted) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(Capsule().fill(filter == value ? MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle) : Color.clear)) + .overlay(Capsule().strokeBorder(filter == value ? Color.primary.opacity(MoriTokens.Opacity.subtle) : Color.clear)) + }.buttonStyle(.plain) + } - return Button { - onSelectProject?(projectId) - onSelectWorktree(worktree.id) - } label: { - ZStack { - Circle() - .fill(isSelected - ? MoriTokens.Color.active.opacity(MoriTokens.Opacity.light) - : isHovered ? Color.primary.opacity(MoriTokens.Opacity.subtle) : Color.clear) - Circle() - .strokeBorder(ringColor, lineWidth: isSelected ? 2 : 1.5) - Text(letter) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundStyle(isSelected ? Color.primary : Color.primary.opacity(0.7)) + @ViewBuilder private var needsYouSection: some View { + if !waitingItems.isEmpty { + sectionTitle(String.localized("Needs You"), color: MoriTokens.Color.attention, count: waitingItems.count) + ForEach(Array(waitingItems.enumerated()), id: \.element.id) { offset, item in + NeedsYouCard(item: item, shortcutIndex: shortcutHintsVisible ? offset + 1 : nil, onSelect: { select(item) }, onRequestPaneOutput: onRequestPaneOutput, onSendKeys: onSendKeys) } - .frame(width: 26, height: 26) } - .buttonStyle(.plain) - .onHover { over in - withAnimation(.easeInOut(duration: 0.12)) { - hoveredCollapsedWorktreeId = over ? worktree.id : nil + } + + @ViewBuilder private var runningSection: some View { + if !runningItems.isEmpty { + sectionTitle(String.localized("Running"), color: MoriTokens.Color.success, count: runningItems.count) + ForEach(Array(runningItems.enumerated()), id: \.element.id) { offset, item in + AgentCompactRow(item: item, shortcutIndex: shortcutHintsVisible ? waitingItems.count + offset + 1 : nil) { select(item) } } } - .help(worktree.name) - .accessibilityLabel(worktree.name) } - private func collapsedWorktreeRingColor(_ worktree: Worktree) -> Color { - if worktree.agentState == .waitingForInput { return MoriTokens.Color.attention } - if worktree.agentState == .running { return MoriTokens.Color.success } - if worktree.status == .active { return MoriTokens.Color.success.opacity(0.5) } - return MoriTokens.Color.inactive.opacity(0.35) + private var projectsHeader: some View { + sectionHeader { Text(String.localized("Projects")).font(MoriTokens.Font.sectionTitle).tracking(MoriTokens.Sidebar.sectionTracking).foregroundStyle(MoriTokens.Color.muted) } accessory: { + if let onAddProject { Button(action: onAddProject) { Image(systemName: "plus").font(.system(size: 12, weight: .medium)).foregroundStyle(MoriTokens.Color.muted) }.buttonStyle(.plain).help(String.localized("Add Project")) } + }.padding(.top, MoriTokens.Spacing.lg) } - // MARK: - Project Section - - @State private var hoveredProjectId: UUID? - @State private var renamingProjectId: UUID? - @State private var renameText: String = "" - @State private var draggingProjectId: UUID? - @State private var dropTargetProjectId: UUID? - @State private var isProjectsSectionCollapsed = false - - @ViewBuilder private func projectSection(_ project: Project) -> some View { - let projectWorktrees = worktrees.filter { $0.projectId == project.id && $0.status != .unavailable } + let projectWorktrees = visibleWorktrees(for: project) let isSelectedProject = project.id == selectedProjectId - let palette = MoriTokens.ProjectPalette.pair(for: project.id) - - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: MoriTokens.Spacing.md) { - Image(systemName: project.isCollapsed ? "chevron.right" : "chevron.down") - .font(MoriTokens.Font.sidebarChevron) - .foregroundStyle(MoriTokens.Color.inactive) - .frame(width: MoriTokens.Size.sidebarChevron) - - ProjectLetterTile(project: project) - - Text(project.name) - .font(MoriTokens.Font.projectTitle) - .foregroundStyle(Color.primary) - .lineLimit(1) - .truncationMode(.tail) - - if project.isFavorite { - Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(MoriTokens.Color.inactive) - } - - Spacer(minLength: 0) - - Text("\(projectWorktrees.count)") - .font(MoriTokens.Font.badgeCount) - .foregroundStyle(isSelectedProject ? palette.foreground : MoriTokens.Color.inactive) - .padding(.horizontal, MoriTokens.Spacing.sm) - .padding(.vertical, MoriTokens.Spacing.xxs) - .background( - RoundedRectangle(cornerRadius: MoriTokens.Radius.badge) - .fill( - isSelectedProject - ? palette.background.opacity(0.32) - : Color.primary.opacity(MoriTokens.Opacity.quiet) - ) - ) - - if hoveredProjectId == project.id { - HStack(spacing: MoriTokens.Spacing.sm) { - Menu { - projectActions(project) - } label: { - Image(systemName: "ellipsis") - .font(MoriTokens.Font.sidebarAccessory) - .foregroundStyle(MoriTokens.Color.muted) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .frame(width: MoriTokens.Size.sidebarAccessory) - .help("More Actions") - } - .transition(.opacity) - } - } - .padding(.horizontal, MoriTokens.Spacing.md) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: MoriTokens.Radius.small) - .fill( - isSelectedProject - ? palette.background.opacity(0.24) - : palette.background.opacity(0.14) - ) - ) - .contentShape(Rectangle()) - .onTapGesture { - onToggleCollapse?(project.id) - onSelectProject?(project.id) - } - + return VStack(alignment: .leading, spacing: 0) { + projectHeader(project, count: projectWorktrees.count, selected: isSelectedProject) if !project.isCollapsed { - Rectangle() - .fill( - isSelectedProject - ? palette.foreground.opacity(0.24) - : Color.primary.opacity(MoriTokens.Opacity.subtle) - ) - .frame(height: 1) - .padding(.horizontal, MoriTokens.Spacing.md) - .padding(.top, MoriTokens.Spacing.xs) - - VStack(alignment: .leading, spacing: 3) { - if projectWorktrees.isEmpty, project.id == selectedProjectId { - Text("No worktrees") - .font(MoriTokens.Font.caption) - .foregroundStyle(MoriTokens.Color.muted) - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.vertical, MoriTokens.Spacing.sm) - } - + VStack(alignment: .leading, spacing: MoriTokens.Spacing.xs) { + if projectWorktrees.isEmpty, project.id == selectedProjectId { Text("No worktrees").font(MoriTokens.Font.caption).foregroundStyle(MoriTokens.Color.muted).padding(.horizontal, MoriTokens.Spacing.xl).padding(.vertical, MoriTokens.Spacing.sm) } ForEach(projectWorktrees) { worktree in worktreeRow(worktree) + ForEach(visibleDetailWindows(for: worktree)) { window in + WindowRowView(window: window, isActive: window.tmuxWindowId == selectedWindowId, shortcutIndex: globalWindowIndices[window.tmuxWindowId], shortcutHintsVisible: shortcutHintsVisible, onSelect: { onSelectWindow(window.tmuxWindowId) }, onRequestPaneOutput: onRequestPaneOutput, onSendKeys: onSendKeys) + .padding(.leading, MoriTokens.Spacing.xxl) + .padding(.horizontal, MoriTokens.Spacing.sm) + } } - } - .padding(.top, MoriTokens.Spacing.xs) - .padding(.bottom, MoriTokens.Spacing.xs) + }.padding(.top, MoriTokens.Spacing.xs).padding(.bottom, MoriTokens.Spacing.xs) } } + .opacity(projectMatchesFilter(project) ? 1 : 0.32) .padding(.horizontal, MoriTokens.Spacing.xs) - .padding(.vertical, MoriTokens.Spacing.xs) - .background( - RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) - .fill( - isSelectedProject - ? Color.primary.opacity(0.04) - : Color.clear - ) - ) - .overlay { - RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) - .strokeBorder( - isSelectedProject - ? MoriTokens.Color.active.opacity(0.22) - : Color.primary.opacity(0.04), - lineWidth: 1 - ) - } - .overlay(alignment: .top) { - if dropTargetProjectId == project.id && draggingProjectId != project.id { - Rectangle() - .fill(MoriTokens.Color.active) - .frame(height: 2) - .padding(.horizontal, MoriTokens.Spacing.lg) - } - } - .animation(.easeInOut(duration: 0.14), value: shortcutHintsVisible) - .draggable(project.id.uuidString) { - Text(project.name) - .font(MoriTokens.Font.projectTitle) - .padding(.horizontal, MoriTokens.Spacing.lg) - .padding(.vertical, MoriTokens.Spacing.sm) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small)) - } - .dropDestination(for: String.self) { items, _ in - guard let draggedIdStr = items.first, - let draggedId = UUID(uuidString: draggedIdStr), - draggedId != project.id else { - dropTargetProjectId = nil - return false - } - if var draggedProject = projects.first(where: { $0.id == draggedId }), - draggedProject.isFavorite != project.isFavorite { - draggedProject.isFavorite = project.isFavorite - onUpdateProject?(draggedProject) - } - var ids = projects.map { $0.id } - guard let fromIdx = ids.firstIndex(of: draggedId), - let toIdx = ids.firstIndex(of: project.id) else { - dropTargetProjectId = nil - return false - } - ids.remove(at: fromIdx) - ids.insert(draggedId, at: toIdx) - onReorderProjects?(ids) - dropTargetProjectId = nil - draggingProjectId = nil - return true - } isTargeted: { targeted in - dropTargetProjectId = targeted ? project.id : nil - } - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.15)) { - hoveredProjectId = hovering ? project.id : nil - } - } - .contextMenu { - projectActions(project) - } + .overlay(alignment: .top) { if dropTargetProjectId == project.id && draggingProjectId != project.id { Rectangle().fill(MoriTokens.Color.active).frame(height: 2).padding(.horizontal, MoriTokens.Spacing.lg) } } + .draggable(project.id.uuidString) { Text(project.name).padding().background(.regularMaterial) } + .dropDestination(for: String.self) { items, _ in reorder(dragged: items.first, before: project) } isTargeted: { dropTargetProjectId = $0 ? project.id : nil } + .onHover { hoveredProjectId = $0 ? project.id : nil } + .contextMenu { projectActions(project) } } - private func collapsedProjectButton(_ project: Project) -> some View { - let isSelectedProject = project.id == selectedProjectId - - return Button { - onSelectProject?(project.id) - } label: { + private func projectHeader(_ project: Project, count: Int, selected: Bool) -> some View { + HStack(spacing: MoriTokens.Spacing.md) { + Image(systemName: project.isCollapsed ? "chevron.right" : "chevron.down").font(MoriTokens.Font.sidebarChevron).foregroundStyle(MoriTokens.Color.inactive).frame(width: MoriTokens.Size.sidebarChevron) ProjectLetterTile(project: project) - .padding(MoriTokens.Spacing.xs) - .background( - RoundedRectangle(cornerRadius: MoriTokens.Radius.small) - .fill(isSelectedProject ? MoriTokens.Color.active.opacity(0.16) : Color.clear) - ) - .overlay { - RoundedRectangle(cornerRadius: MoriTokens.Radius.small) - .strokeBorder( - isSelectedProject ? MoriTokens.Color.active.opacity(0.28) : Color.clear, - lineWidth: 1 - ) - } - } - .buttonStyle(.plain) - .help(project.name) - .accessibilityLabel(project.name) - } - - // MARK: - Worktree Row - - @ViewBuilder - private func worktreeRow(_ worktree: Worktree) -> some View { - let isSelected = worktree.id == selectedWorktreeId - let worktreeWindows = allWindows(for: worktree) - let agentName = worktreeWindows.first(where: { - $0.detectedAgent != nil || $0.agentState != .none - })?.detectedAgent - - WorktreeRowView( - worktree: worktree, - agentName: agentName, - isSelected: isSelected, - onSelect: { onSelectWorktree(worktree.id) }, - onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } } - ) - .contextMenu { - let editors = EditorLauncher.installed - if !editors.isEmpty { - ForEach(editors) { editor in - Button { - editor.open(path: worktree.path) - } label: { - Label("Open in \(editor.name)", systemImage: editor.icon) - } - } - Divider() - } - - Button { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) - } label: { - Label("Reveal in Finder", systemImage: "folder") - } - - if !worktree.isMainWorktree, let onRemove = onRemoveWorktree { - Divider() - Button(role: .destructive) { - onRemove(worktree.id) - } label: { - Label("Remove Worktree…", systemImage: "trash") - } - } - } - .padding(.horizontal, MoriTokens.Spacing.sm) - } - - // MARK: - Helpers - - private func allWindows(for worktree: Worktree) -> [RuntimeWindow] { - windows - .filter { $0.worktreeId == worktree.id } - .sorted { $0.tmuxWindowIndex < $1.tmuxWindowIndex } - } - - private func visibleDetailWindows(for worktree: Worktree) -> [RuntimeWindow] { - let all = allWindows(for: worktree) - let relevant = all.filter(isRelevantDetailWindow) - - if !relevant.isEmpty { - return relevant - } - - return Array(all.prefix(1)) - } - - private func activeWorktreePriority(_ state: AgentState) -> Int { - switch state { - case .waitingForInput, .error: return 0 - case .running: return 1 - case .completed: return 2 - case .none: return 3 - } - } - - @ViewBuilder - private var projectsSectionHeader: some View { - sectionHeader { - HStack(spacing: MoriTokens.Spacing.sm) { - Text(String.localized("Projects")) - .font(MoriTokens.Font.sectionTitle) - .tracking(MoriTokens.Sidebar.sectionTracking) - .foregroundStyle(MoriTokens.Color.muted) - - if !projects.isEmpty { - Text("\(projects.count)") - .font(MoriTokens.Font.badgeCount) - .foregroundStyle(MoriTokens.Color.muted) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - Capsule() - .fill(Color.primary.opacity(MoriTokens.Opacity.quiet)) - ) - } - } - } accessory: { - HStack(spacing: MoriTokens.Spacing.md) { - if !projects.isEmpty { - Button { - withAnimation(.easeInOut(duration: 0.16)) { - isProjectsSectionCollapsed.toggle() - } - } label: { - HStack(spacing: MoriTokens.Spacing.sm) { - Text(projectsCollapseButtonLabel) - .font(.system(size: 11, weight: .semibold)) - Image(systemName: isProjectsSectionCollapsed ? "chevron.right" : "chevron.down") - .font(.system(size: 10, weight: .semibold)) - } - .foregroundStyle(MoriTokens.Color.muted) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill(Color.primary.opacity(0.06)) - ) - } - .buttonStyle(.plain) - .help(projectsCollapseToggleTitle) - .accessibilityLabel(projectsCollapseToggleTitle) - } - - if let onAddProject { - Button(action: onAddProject) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(MoriTokens.Color.muted) - } - .buttonStyle(.plain) - .help(String.localized("Add Project")) - } - } - } - .padding(.top, MoriTokens.Spacing.lg) - } - - @ViewBuilder - private func projectActions(_ project: Project) -> some View { - if !project.isCollapsed, onShowCreatePanel != nil { - Button { - onSelectProject?(project.id) - onShowCreatePanel?() - } label: { - Label("New Workspace…", systemImage: "plus") - } - } - - let editors = EditorLauncher.installed - if !editors.isEmpty { - Divider() - ForEach(editors) { editor in - Button { - editor.open(path: project.repoRootPath) - } label: { - Label("Open in \(editor.name)", systemImage: editor.icon) - } - } - } - - Divider() - - Button { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.repoRootPath) - } label: { - Label("Reveal in Finder", systemImage: "folder") - } - - Divider() - - Button { - renameText = project.name - renamingProjectId = project.id - } label: { - Label("Rename Project…", systemImage: "pencil") - } - - Button { - var updated = project - updated.isFavorite.toggle() - onUpdateProject?(updated) - } label: { - if project.isFavorite { - Label(String.localized("Unpin Project"), systemImage: "pin.slash") - } else { - Label(String.localized("Pin Project"), systemImage: "pin.fill") - } - } - - if case .ssh = (project.location ?? .local), let onEditRemoteProject { - Button { - onEditRemoteProject(project.id) - } label: { - Label("Update Remote Credentials…", systemImage: "key") - } - } - - if let onRemoveProject { - Divider() - Button(role: .destructive) { - onRemoveProject(project.id) - } label: { - Label("Remove Project…", systemImage: "trash") - } - } - } - - private func isRelevantDetailWindow(_ window: RuntimeWindow) -> Bool { - window.tmuxWindowId == selectedWindowId - || window.detectedAgent != nil - || window.agentState != .none - || window.badge != nil - || window.hasUnreadOutput - } - - /// Total worktree count across projects, excluding unavailable rows. - private var availableWorktreeCount: Int { - worktrees.filter { $0.status != .unavailable }.count - } - - /// Quiet indicator strip: status dots + counts on the left, tree total on the right. - /// Replaces the earlier Attention/Running tab-like chips so it stops competing with the list. - @ViewBuilder - private var summaryStrip: some View { - HStack(spacing: MoriTokens.Spacing.xl) { - summaryIndicator( - text: "\(attentionCount) \(String.localized("waiting"))", - tint: MoriTokens.Color.attention, - isActive: attentionCount > 0 - ) - - summaryIndicator( - text: "\(runningCount) \(String.localized("running"))", - tint: MoriTokens.Color.success, - isActive: runningCount > 0 - ) - + Text(project.name).font(MoriTokens.Font.projectTitle).foregroundStyle(Color.primary).lineLimit(1) + if project.isFavorite { Image(systemName: "pin.fill").font(.system(size: 9, weight: .semibold)).foregroundStyle(MoriTokens.Color.inactive) } Spacer(minLength: 0) - - Text("\(availableWorktreeCount) \(String.localized("trees"))") - .font(MoriTokens.Font.monoSmall) - .foregroundStyle(MoriTokens.Color.inactive) + StatusDot(state: aggregateState(for: project), pulsing: aggregateState(for: project) == .waiting) + if hoveredProjectId == project.id { Menu { projectActions(project) } label: { Image(systemName: "ellipsis").font(MoriTokens.Font.sidebarAccessory).foregroundStyle(MoriTokens.Color.muted) }.menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: MoriTokens.Size.sidebarAccessory) } } - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.bottom, MoriTokens.Spacing.sm) + .padding(.horizontal, MoriTokens.Spacing.md) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: MoriTokens.Radius.small).fill(selected ? MoriTokens.Color.active.opacity(MoriTokens.Opacity.quiet) : Color.clear)) + .contentShape(Rectangle()) + .onTapGesture { onToggleCollapse?(project.id); onSelectProject?(project.id) } } - private func summaryIndicator(text: String, tint: Color, isActive: Bool) -> some View { - HStack(spacing: MoriTokens.Spacing.sm) { - Circle() - .fill(isActive ? tint : MoriTokens.Color.inactive.opacity(MoriTokens.Opacity.medium)) - .frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot) - - Text(text) - .font(MoriTokens.Font.monoSmall) - .foregroundStyle(isActive ? Color.primary.opacity(0.85) : MoriTokens.Color.inactive) - .lineLimit(1) - } + private func worktreeRow(_ worktree: Worktree) -> some View { + WorktreeRowView(worktree: worktree, agentName: nil, isSelected: worktree.id == selectedWorktreeId, onSelect: { onSelectWorktree(worktree.id) }, onRemove: onRemoveWorktree.map { remove in { remove(worktree.id) } }) + .padding(.leading, 14) + .padding(.horizontal, MoriTokens.Spacing.sm) + .overlay(alignment: .leading) { Rectangle().fill(Color.primary.opacity(MoriTokens.Opacity.subtle)).frame(width: 1).padding(.leading, 18) } + .contextMenu { worktreeActions(worktree) } } - @ViewBuilder - private var activeWorktreeSection: some View { - if !activeAgentPaneItems.isEmpty { - VStack(alignment: .leading, spacing: MoriTokens.Spacing.sm) { - sectionHeader { - Text(String.localized("Agents")) - .font(MoriTokens.Font.sectionTitle) - .tracking(MoriTokens.Sidebar.sectionTracking) - .foregroundStyle(MoriTokens.Color.muted) - } accessory: { - Text("\(activeAgentPaneItems.count)") - .font(MoriTokens.Font.caption) - .foregroundStyle(MoriTokens.Color.inactive) - } - - VStack(spacing: MoriTokens.Spacing.sm) { - ForEach(activeAgentPaneItems) { item in - activeAgentRow(item) - } - } - .padding(.horizontal, MoriTokens.Spacing.sm) - .padding(.bottom, MoriTokens.Spacing.sm) - } - .padding(.top, MoriTokens.Spacing.xs) - .background( - RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) - .fill(Color.primary.opacity(MoriTokens.Opacity.quiet)) - ) + @ViewBuilder private var idleCluster: some View { + if !idleProjects.isEmpty { + Button { idleExpanded.toggle() } label: { HStack(spacing: MoriTokens.Spacing.md) { Image(systemName: idleExpanded ? "chevron.down" : "chevron.right").font(MoriTokens.Font.sidebarChevron); Text(String(format: String.localized("%lld idle projects"), idleProjects.count)).font(.system(size: 12, weight: .medium)); Spacer() }.foregroundStyle(MoriTokens.Color.inactive).padding(.horizontal, MoriTokens.Spacing.md).padding(.vertical, MoriTokens.Spacing.md).overlay(alignment: .top) { Rectangle().fill(Color.primary.opacity(MoriTokens.Opacity.subtle)).frame(height: 1) } }.buttonStyle(.plain) + if idleExpanded { FlowLayout(items: idleProjects) { project in Button { awakenedProjectIds.insert(project.id); onSelectProject?(project.id); if let worktree = visibleWorktrees(for: project).first { onSelectWorktree(worktree.id) } } label: { HStack(spacing: MoriTokens.Spacing.sm) { ProjectLetterTile(project: project).scaleEffect(0.84); Text(project.name).font(.system(size: 12, weight: .medium)).lineLimit(1) }.padding(.horizontal, MoriTokens.Spacing.sm).padding(.vertical, MoriTokens.Spacing.xs).overlay(RoundedRectangle(cornerRadius: MoriTokens.Radius.small).strokeBorder(Color.primary.opacity(MoriTokens.Opacity.subtle))) }.buttonStyle(.plain) }.padding(.horizontal, MoriTokens.Spacing.md) } } } - private func sectionHeader( - @ViewBuilder title: () -> Title, - @ViewBuilder accessory: () -> Accessory - ) -> some View { - HStack { - title() - - Spacer() - - accessory() + private func railProject(_ project: Project) -> some View { + let state = aggregateState(for: project) + let selected = project.id == selectedProjectId || worktrees.contains { $0.projectId == project.id && $0.id == selectedWorktreeId } + let waiting = visibleWorktrees(for: project).filter { $0.agentState == .waitingForInput }.count + return Button { onSelectProject?(project.id) } label: { + ProjectLetterTile(project: project, size: 34, cornerRadius: 9, fontSize: 13) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(selected ? MoriTokens.Color.active : state.color, lineWidth: (selected || state != .idle) ? 2 : 0)) + .overlay(alignment: .topTrailing) { if waiting > 0 { Text("\(waiting)").font(.system(size: 9, weight: .bold, design: .monospaced)).foregroundStyle(.black).padding(.horizontal, 4).frame(minWidth: 15, minHeight: 15).background(Capsule().fill(MoriTokens.Color.attention)).offset(x: 6, y: -5) } } } - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.bottom, MoriTokens.Spacing.sm) - } - - private func activeAgentRow(_ item: ActiveAgentPaneItem) -> some View { - AgentWindowRowView( - window: item.window, - projectName: item.projectName, - worktreeName: item.worktree.name, - isSelected: selectedWindowId == item.window.tmuxWindowId && item.pane.isActive, - paneId: item.pane.tmuxPaneId, - agentName: item.pane.detectedAgent, - agentState: item.pane.agentState, - subtitle: activeAgentSubtitle(for: item), - onSelect: { - onSelectProject?(item.worktree.projectId) - onSelectWorktree(item.worktree.id) - if let onSelectPane { - onSelectPane(item.pane.tmuxPaneId) - } else { - onSelectWindow(item.window.tmuxWindowId) - } - }, - onRequestPaneOutput: onRequestPaneOutput, - onSendKeys: onSendKeys - ) - } - - private func activeAgentSubtitle(for item: ActiveAgentPaneItem) -> String { - let paneLabel = item.pane.title?.isEmpty == false ? item.pane.title! : item.pane.tmuxPaneId - return "\(item.projectName)/\(item.worktree.name)/\(item.window.title)/\(paneLabel)" - } - + .buttonStyle(.plain) + .help(project.name + "\n" + visibleWorktrees(for: project).map { "• \($0.name)" }.joined(separator: "\n")) + } + + private func sectionTitle(_ title: String, color: Color, count: Int) -> some View { sectionHeader { HStack(spacing: MoriTokens.Spacing.sm) { StatusDot(state: color == MoriTokens.Color.attention ? .waiting : .running, pulsing: color == MoriTokens.Color.attention); Text(title).font(MoriTokens.Font.sectionTitle).tracking(MoriTokens.Sidebar.sectionTracking).foregroundStyle(color) } } accessory: { Text("\(count)").font(MoriTokens.Font.caption).foregroundStyle(MoriTokens.Color.inactive) } } + private func sectionHeader(@ViewBuilder title: () -> Title, @ViewBuilder accessory: () -> Accessory) -> some View { HStack { title(); Spacer(); accessory() }.padding(.horizontal, MoriTokens.Spacing.xl).padding(.bottom, MoriTokens.Spacing.sm) } + + private var sortedProjects: [Project] { projects.filter(\.isFavorite) + projects.filter { !$0.isFavorite } } + private var projectNamesById: [UUID: String] { Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0.name) }) } + private var availableWorktreeCount: Int { worktrees.filter { $0.status != .unavailable }.count } + private var agentItems: [AgentPaneItem] { panes.filter { $0.detectedAgent != nil || $0.agentState != .none }.compactMap { pane in guard let window = windows.first(where: { $0.tmuxWindowId == pane.tmuxWindowId }), let worktree = worktrees.first(where: { $0.id == window.worktreeId }), worktree.status != .unavailable, let projectName = projectNamesById[worktree.projectId] else { return nil }; return AgentPaneItem(pane: pane, window: window, worktree: worktree, projectName: projectName) } } + private var waitingItems: [AgentPaneItem] { agentItems.filter { $0.pane.agentState == .waitingForInput } } + private var runningItems: [AgentPaneItem] { agentItems.filter { $0.pane.agentState == .running } } + private var idleProjects: [Project] { sortedProjects.filter { !($0.isFavorite || awakenedProjectIds.contains($0.id) || $0.id == selectedProjectId) && visibleWorktrees(for: $0).allSatisfy { worktree in worktree.agentState == .none && worktree.status != .active && !allWindows(for: worktree).contains(where: \.hasUnreadOutput) } } } + private var mainProjects: [Project] { sortedProjects.filter { !idleProjects.map(\.id).contains($0.id) } } + private func visibleWorktrees(for project: Project) -> [Worktree] { worktrees.filter { $0.projectId == project.id && $0.status != .unavailable } } + private func projectMatchesFilter(_ project: Project) -> Bool { filter == .all || visibleWorktrees(for: project).contains { filter == .waiting ? $0.agentState == .waitingForInput : ($0.agentState == .running || $0.status == .active) } } + private func aggregateState(for project: Project) -> SidebarStatus { let ws = visibleWorktrees(for: project); if ws.contains(where: { $0.agentState == .error }) { return .error }; if ws.contains(where: { $0.agentState == .waitingForInput }) { return .waiting }; if ws.contains(where: { $0.agentState == .running }) { return .running }; return .idle } + private func allWindows(for worktree: Worktree) -> [RuntimeWindow] { windows.filter { $0.worktreeId == worktree.id }.sorted { $0.tmuxWindowIndex < $1.tmuxWindowIndex } } + private func visibleDetailWindows(for worktree: Worktree) -> [RuntimeWindow] { let all = allWindows(for: worktree); let relevant = all.filter { $0.tmuxWindowId == selectedWindowId || $0.detectedAgent != nil || $0.agentState != .none || $0.badge != nil || $0.hasUnreadOutput }; return relevant.isEmpty ? Array(all.prefix(1)) : relevant } + private var globalWindowIndices: [String: Int] { var result: [String: Int] = [:]; var i = waitingItems.count + runningItems.count + 1; for project in sortedProjects where !project.isCollapsed { for worktree in visibleWorktrees(for: project) { for window in allWindows(for: worktree) { if i <= 9 { result[window.tmuxWindowId] = i }; i += 1 } } }; return result } + private func select(_ item: AgentPaneItem) { onSelectProject?(item.worktree.projectId); onSelectWorktree(item.worktree.id); if let onSelectPane { onSelectPane(item.pane.tmuxPaneId) } else { onSelectWindow(item.window.tmuxWindowId) } } + private func renameProject() { if let id = renamingProjectId, var project = projects.first(where: { $0.id == id }), !renameText.trimmingCharacters(in: .whitespaces).isEmpty { project.name = renameText.trimmingCharacters(in: .whitespaces); onUpdateProject?(project) }; renamingProjectId = nil } + private func reorder(dragged: String?, before project: Project) -> Bool { guard let s = dragged, let draggedId = UUID(uuidString: s), draggedId != project.id else { dropTargetProjectId = nil; return false }; if var draggedProject = projects.first(where: { $0.id == draggedId }), draggedProject.isFavorite != project.isFavorite { draggedProject.isFavorite = project.isFavorite; onUpdateProject?(draggedProject) }; var ids = projects.map(\.id); guard let from = ids.firstIndex(of: draggedId), let to = ids.firstIndex(of: project.id) else { return false }; ids.remove(at: from); ids.insert(draggedId, at: to); onReorderProjects?(ids); dropTargetProjectId = nil; draggingProjectId = nil; return true } + + @ViewBuilder private func projectActions(_ project: Project) -> some View { + if !project.isCollapsed, onShowCreatePanel != nil { Button { onSelectProject?(project.id); onShowCreatePanel?() } label: { Label("New Workspace…", systemImage: "plus") } } + let editors = EditorLauncher.installed; if !editors.isEmpty { Divider(); ForEach(editors) { editor in Button { editor.open(path: project.repoRootPath) } label: { Label("Open in \(editor.name)", systemImage: editor.icon) } } } + Divider(); Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: project.repoRootPath) } label: { Label("Reveal in Finder", systemImage: "folder") } + Divider(); Button { renameText = project.name; renamingProjectId = project.id } label: { Label("Rename Project…", systemImage: "pencil") } + Button { var updated = project; updated.isFavorite.toggle(); onUpdateProject?(updated) } label: { Label(project.isFavorite ? String.localized("Unpin Project") : String.localized("Pin Project"), systemImage: project.isFavorite ? "pin.slash" : "pin.fill") } + if case .ssh = (project.location ?? .local), let onEditRemoteProject { Button { onEditRemoteProject(project.id) } label: { Label("Update Remote Credentials…", systemImage: "key") } } + if let onRemoveProject { Divider(); Button(role: .destructive) { onRemoveProject(project.id) } label: { Label("Remove Project…", systemImage: "trash") } } + } + @ViewBuilder private func worktreeActions(_ worktree: Worktree) -> some View { let editors = EditorLauncher.installed; if !editors.isEmpty { ForEach(editors) { editor in Button { editor.open(path: worktree.path) } label: { Label("Open in \(editor.name)", systemImage: editor.icon) } }; Divider() }; Button { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: worktree.path) } label: { Label("Reveal in Finder", systemImage: "folder") }; if !worktree.isMainWorktree, let onRemoveWorktree { Divider(); Button(role: .destructive) { onRemoveWorktree(worktree.id) } label: { Label("Remove Worktree…", systemImage: "trash") } } } } -// MARK: - Tree Connector +private enum SidebarFilter { case all, waiting, running } +private enum SidebarStatus { case waiting, running, idle, error; var color: Color { switch self { case .waiting: MoriTokens.Color.attention; case .running: MoriTokens.Color.success; case .idle: MoriTokens.Color.inactive.opacity(0.5); case .error: MoriTokens.Color.error } } } +private struct AgentPaneItem: Identifiable { let pane: RuntimePane; let window: RuntimeWindow; let worktree: Worktree; let projectName: String; var id: String { pane.tmuxPaneId }; var agentName: String { pane.detectedAgent ?? window.detectedAgent ?? window.title }; var path: String { "\(projectName)/\(worktree.branch ?? worktree.name)" }; var elapsed: String { RelativeTime.short(since: worktree.lastActiveAt) } } -/// Draws L-shaped tree connector branches (├── for middle rows, └── for last row). -/// Each child row gets a horizontal branch from the vertical line. -struct TreeConnectorGroup: View where Data.Element: Identifiable { - let data: Data - let row: (Data.Element) -> Row +private struct StatusDot: View { let state: SidebarStatus; var pulsing = false; var body: some View { Circle().fill(state == .idle ? Color.clear : state.color).frame(width: MoriTokens.Icon.dot, height: MoriTokens.Icon.dot).overlay(Circle().strokeBorder(state == .idle ? state.color : Color.clear, lineWidth: 1.5)).symbolEffect(.pulse, options: .repeating, value: pulsing) } } +private struct AgentCompactRow: View { let item: AgentPaneItem; let shortcutIndex: Int?; let onSelect: () -> Void; var body: some View { Button(action: onSelect) { HStack(spacing: MoriTokens.Spacing.md) { ProgressView().controlSize(.small).tint(MoriTokens.Color.success); Text(item.agentName).font(.system(size: 12, weight: .semibold, design: .monospaced)); Text(item.path).font(MoriTokens.Font.monoSmall).foregroundStyle(MoriTokens.Color.muted).lineLimit(1); Spacer(); if let shortcutIndex { ShortcutHintPill("⌘\(shortcutIndex)") } else { Text(item.elapsed).font(MoriTokens.Font.monoSmall).foregroundStyle(MoriTokens.Color.inactive) } }.padding(.horizontal, MoriTokens.Spacing.lg).padding(.vertical, 7).contentShape(Rectangle()) }.buttonStyle(.plain) } } - init(data: Data, @ViewBuilder row: @escaping (Data.Element) -> Row) { - self.data = data - self.row = row - } - - /// Horizontal offset to align with center of 28pt icon box (row padding + half box). - private let lineX = MoriTokens.Size.treeConnectorX - - /// Length of horizontal branch from vertical line to content. - private let branchLength = MoriTokens.Size.treeConnectorBranch - - private let lineColor = Color.primary.opacity(MoriTokens.Sidebar.connectorOpacity) - - var body: some View { - let items = Array(data) - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - let isLast = index == items.count - 1 - HStack(alignment: .top, spacing: 0) { - // Branch connector: vertical segment + horizontal arm - Canvas { ctx, size in - let midX: CGFloat = 0.5 - let midY = size.height / 2 - - var path = Path() - // Vertical segment: from top to midY (last) or full height (middle) - path.move(to: CGPoint(x: midX, y: 0)) - path.addLine(to: CGPoint(x: midX, y: isLast ? midY : size.height)) - // Horizontal arm from midY - path.move(to: CGPoint(x: midX, y: midY)) - path.addLine(to: CGPoint(x: branchLength, y: midY)) +private struct NeedsYouCard: View { let item: AgentPaneItem; let shortcutIndex: Int?; let onSelect: () -> Void; let onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)?; let onSendKeys: ((String, String) -> Void)?; @State private var output: String?; @State private var replying = false; @State private var sent = false + var body: some View { VStack(alignment: .leading, spacing: MoriTokens.Spacing.sm) { Button(action: { replying = true; onSelect(); load() }) { HStack(spacing: MoriTokens.Spacing.md) { sent ? AnyView(ProgressView().controlSize(.small).tint(MoriTokens.Color.success)) : AnyView(StatusDot(state: .waiting, pulsing: true)); Text(item.agentName).font(.system(size: 12, weight: .semibold, design: .monospaced)); Text(item.path).font(MoriTokens.Font.monoSmall).foregroundStyle(MoriTokens.Color.muted).lineLimit(1); Spacer(); if let shortcutIndex { ShortcutHintPill("⌘\(shortcutIndex)") } else { Text(sent ? String.localized("sent") : item.elapsed).font(MoriTokens.Font.monoSmall).foregroundStyle(sent ? MoriTokens.Color.success : MoriTokens.Color.attention) } }.contentShape(Rectangle()) }.buttonStyle(.plain); Text(sent ? String.localized("reply sent — resuming…") : question).font(.system(size: 12)).foregroundStyle(sent ? MoriTokens.Color.success : MoriTokens.Color.muted).lineLimit(2).padding(.leading, MoriTokens.Spacing.md).overlay(alignment: .leading) { Rectangle().fill(MoriTokens.Color.attention.opacity(0.35)).frame(width: 2) }; if replying && !sent { QuickReplyField(onSend: { text in onSendKeys?(item.pane.tmuxPaneId, text + "\n"); sent = true; replying = false }, onDismiss: { replying = false }).padding(.leading, MoriTokens.Spacing.md) } }.padding(10).background(RoundedRectangle(cornerRadius: 9).fill(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle))).overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(MoriTokens.Color.attention.opacity(0.22))).onAppear(perform: load) } + private var question: String { output?.split(separator: "\n").last.map(String.init) ?? String.localized("Waiting for input") } + private func load() { guard output == nil else { return }; onRequestPaneOutput?(item.pane.tmuxPaneId) { output = $0 } } +} - ctx.stroke(path, with: .color(lineColor), lineWidth: 1) - } - .frame(width: branchLength) - .padding(.leading, lineX) +private enum RelativeTime { static func short(since date: Date?) -> String { guard let date else { return "—" }; let seconds = max(0, Int(-date.timeIntervalSinceNow)); if seconds < 60 { return String.localized("now") }; if seconds < 3600 { return "\(seconds / 60)m" }; if seconds < 86400 { return "\(seconds / 3600)h" }; return "\(seconds / 86400)d" } } - row(item) - } - } - } - } -} +private struct FlowLayout: View where Data.Element: Identifiable { let items: Data; let content: (Data.Element) -> Content; var body: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 78), spacing: MoriTokens.Spacing.sm)], alignment: .leading, spacing: MoriTokens.Spacing.sm) { ForEach(items) { content($0) } } } }