ROOT CAUSE of "no responses ever appear": Claude's streaming-input mode (stdin kept open) hangs when combined with --permission-prompt-tool — it emits init then produces nothing. Verified by reproduction: single-shot + approval server works; open-stdin + approval server hangs; open-stdin without the approval server works. Fix — conversational sessions now run one single-shot process PER TURN, with follow-ups via native `claude --resume <id>` carrying the message. Verified against real Claude: turn 1 "my word is ZORP" → turn 2 (resume) "what was it?" → "ZORP" (context retained). This is the only reliable path with approvals. - ResumeSpec carries a `prompt`; ClaudeCodeBackend.resume sends it. - SessionController `conversational` mode: a turn's terminal runFinished means awaitingInput (session stays alive); sendInput resumes (or starts the first turn). shutdown ends it. Single-shot/one-shot behavior unchanged for non-chat. - AppStore: sessions are conversational; createSession's prompt is optional (create-without-start), plus newSession(in:) for random-named chats. UX per request: - New chat goes straight to the chat interface with a random three-word name (RandomName, e.g. amber-quiet-otter); the worktree is created up front and the agent runs on the first message. Per-project "+" in the sidebar; sessions nest under their project/directory. - Agent Stop control (interrupt) in the session toolbar while a turn runs. Tests: conversational per-turn-resume SessionController test; AppStore tests updated for the awaiting-input-between-turns model. Full suite 80 green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
119 lines
4.1 KiB
Swift
119 lines
4.1 KiB
Swift
import SwiftUI
|
||
import NucleicCore
|
||
|
||
/// Sidebar (projects → sessions) + detail, the macOS information architecture
|
||
/// from UX_MACOS.
|
||
struct RootView: View {
|
||
@Environment(AppStore.self) private var store
|
||
@State private var showingAddProject = false
|
||
|
||
var body: some View {
|
||
@Bindable var store = store
|
||
NavigationSplitView {
|
||
List(selection: $store.openSessionID) {
|
||
if store.projects.isEmpty {
|
||
Text("Add a project to begin").foregroundStyle(.secondary)
|
||
}
|
||
ForEach(store.projects) { project in
|
||
Section {
|
||
let sessions = store.summaries(for: project.id)
|
||
if sessions.isEmpty {
|
||
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
ForEach(sessions) { summary in
|
||
SessionRow(summary: summary).tag(Optional(summary.id))
|
||
}
|
||
} header: {
|
||
HStack {
|
||
Text(project.name)
|
||
Spacer()
|
||
Button {
|
||
Task { await store.newSession(in: project) }
|
||
} label: {
|
||
Image(systemName: "plus.circle")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("New chat in \(project.name)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Nucleic")
|
||
.frame(minWidth: 240)
|
||
.toolbar {
|
||
ToolbarItem {
|
||
Button { showingAddProject = true } label: {
|
||
Label("Add Project", systemImage: "folder.badge.plus")
|
||
}
|
||
}
|
||
}
|
||
} detail: {
|
||
if store.openSessionID != nil {
|
||
SessionDetailView()
|
||
} else {
|
||
ContentUnavailableView(
|
||
"No chat selected", systemImage: "bubble.left.and.bubble.right",
|
||
description: Text("Pick a project in the sidebar and press + to start a chat."))
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
|
||
.overlay(alignment: .bottom) {
|
||
if let error = store.lastError {
|
||
Text(error)
|
||
.font(.caption).padding(8)
|
||
.background(.red.opacity(0.85), in: .rect(cornerRadius: 8))
|
||
.foregroundStyle(.white).padding()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SessionRow: View {
|
||
let summary: SessionSummary
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
Circle().fill(summary.status.color).frame(width: 8, height: 8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(summary.title).lineLimit(1)
|
||
Text(summary.status.label).font(.caption2).foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
if summary.pendingApprovalCount > 0 {
|
||
Image(systemName: "pause.circle.fill").foregroundStyle(.orange)
|
||
}
|
||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||
Text("+\(diff.added) −\(diff.removed)")
|
||
.font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
}
|
||
|
||
extension SessionStatus {
|
||
var label: String {
|
||
switch self {
|
||
case .idle: "Idle"
|
||
case .provisioning: "Provisioning"
|
||
case .running: "Running"
|
||
case .awaitingApproval: "Awaiting approval"
|
||
case .awaitingInput: "Awaiting input"
|
||
case .finished: "Finished"
|
||
case .interrupted: "Interrupted"
|
||
case .error: "Error"
|
||
}
|
||
}
|
||
|
||
var color: Color {
|
||
switch self {
|
||
case .idle, .provisioning: .gray
|
||
case .running: .blue
|
||
case .awaitingApproval, .awaitingInput: .orange
|
||
case .finished: .green
|
||
case .interrupted: .yellow
|
||
case .error: .red
|
||
}
|
||
}
|
||
}
|