Files
nucleic/Sources/NucleicApp/RootView.swift
T
abkslmandClaude Opus 4.8 6c46010429 Fix no-response hang; per-turn-resume chat; direct-to-chat UX + Stop
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]>
2026-06-12 02:16:25 -07:00

119 lines
4.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}