Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ struct LoopWorkspaceFeature {
/// derived from `projectPath`'s last component so it matches the sidebar exactly,
/// including the global graph — whose path is a reserved URL, not a folder.
var projectName: String
/// Nod's chat, for a loop whose backend opens in one (`AgentSurface.chat`). Built when
/// the agent pane first appears rather than here, so every place that makes this
/// state gets it without knowing what a chat is.
var nodChat: NodChatFeature.State?

var id: UUID { node.id }
}
Expand Down Expand Up @@ -138,6 +142,8 @@ struct LoopWorkspaceFeature {
case restartLoopTapped
case showInGraphTapped
case railTargetTapped(UUID)
case chatSurfaceAppeared
case nodChat(NodChatFeature.Action)
}

@Dependency(\.terminalLayoutStore) var terminalLayoutStore
Expand All @@ -146,6 +152,7 @@ struct LoopWorkspaceFeature {
/// `TerminalSurfaceStore` — so without telling it here, a closed pane's terminal would
/// linger until it aged out of the cache.
@Dependency(\.terminalSurfaceClient) var terminalSurfaceClient
@Dependency(\.nodSettings) var nodSettings

var body: some ReducerOf<Self> {
Reduce { state, action in
Expand Down Expand Up @@ -342,12 +349,22 @@ struct LoopWorkspaceFeature {
}
return .none

case .chatSurfaceAppeared:
return chatSurfaceAppeared(&state)

case .nodChat(.delegate(let delegate)):
return nodChatDelegate(&state, delegate)

case .nodChat:
return .none

case .stopLoopTapped, .restartLoopTapped, .showInGraphTapped, .railTargetTapped,
.primaryExitAcknowledged, .lastTabClosed:
// Handled by `AppFeature`'s parent `Reduce` — see the actions' own doc comment.
return .none
}
}
.ifLet(\.nodChat, action: \.nodChat) { NodChatFeature() }
}

private func stepPaneFocus(_ state: inout State, by offset: Int) {
Expand All @@ -372,3 +389,43 @@ struct LoopWorkspaceFeature {
terminalLayoutStore.save(state.layout, forNode: state.node.id)
}
}

extension LoopWorkspaceFeature {
func chatSurfaceAppeared(_ state: inout State) -> Effect<Action> {
guard state.node.backend.surface == .chat else { return .none }
let node = state.node
if state.nodChat == nil {
let settings = nodSettings.current()
var chat = NodChatFeature.State(
nodeID: node.id, loopTitle: node.title, loopType: node.loopType,
branch: node.worktreeBinding?.branch, goal: node.goal?.summary)
chat.defaultEngine = settings.engine
chat.defaultModel = settings.resolvedModel(for: node.loopType, tier: node.modelTier).id
chat.editPolicy = settings.editsInWorktree
state.nodChat = chat
} else {
state.nodChat?.loopTitle = node.title
state.nodChat?.goal = node.goal?.summary
}
return .none
}

/// The chat's requests that are about panes and tabs. The rest — the goal, sign-in,
/// forks into siblings, plans as Composites — belong to the levels that own those, and
/// pass through untouched.
func nodChatDelegate(_ state: inout State, _ delegate: NodChatFeature.Action.Delegate)
-> Effect<Action>
{
guard case .openInShellTab(let command) = delegate else { return .none }
let shellTab = state.layout.tabs.first { tab in
!tab.surfaces.contains(where: \.launchesClaudeCode)
}
let tab = shellTab ?? TabLayout(primary: SurfaceRef(id: UUID(), launchesClaudeCode: false))
if shellTab == nil { state.layout.tabs.append(tab) }
state.layout.selectedTabID = tab.id
persist(state)
// Typed, not run: the human sees the command at the prompt and presses ⏎ themselves.
terminalSurfaceClient.typeText(tab.focusedSurface.id, command)
return .none
}
}
27 changes: 25 additions & 2 deletions graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ struct LoopWorkspaceView: View {
.onChange(of: store.id) { _, _ in
store.send(.mailroomContentChanged(hasContent: mailroomHasContent))
}
.onChange(of: store.node.goal?.summary) { _, _ in
if store.nodChat != nil { store.send(.chatSurfaceAppeared) }
}
.onDisappear { store.send(.workspaceLeft) }
// The folder header goes in the toolbar, not in the `VStack` above, and the pane
// does *not* claim the titlebar inset. Both were tried: `.ignoresSafeArea(.top)`
Expand Down Expand Up @@ -318,12 +321,16 @@ struct LoopWorkspaceView: View {
// 22pt of "which pane is this and does it have the keyboard". The veil below says
// only the second half, and two identical black rectangles said neither.
PaneHeaderView(
title: ref.launchesClaudeCode ? "agent" : "shell",
title: ref.launchesClaudeCode ? (isChat ? "chat" : "agent") : "shell",
isFocused: isFocused && tab.id == store.layout.selectedTabID,
detail: ref.launchesClaudeCode ? store.node.backend.displayName.lowercased() : "zsh",
canClose: tab.isSplit,
onClose: { store.send(.paneClosed(tabID: tab.id, surfaceID: ref.id)) })
terminal(tab: tab, ref: ref)
if ref.launchesClaudeCode && isChat {
chat(tab: tab, ref: ref)
} else {
terminal(tab: tab, ref: ref)
}
}
// `ref.id` (not just this slot's structural position) is a surface's real
// identity — without this, collapsing a split (which reassigns `tab.primary` to
Expand All @@ -332,6 +339,22 @@ struct LoopWorkspaceView: View {
.id(ref.id)
}

/// Nod's loops open in a chat where a CLI's terminal would be — the same tab strip and
/// splits around it, so a zsh tab is still one ⌘-number away.
private var isChat: Bool { store.node.backend.surface == .chat }

@ViewBuilder
private func chat(tab: TabLayout, ref: SurfaceRef) -> some View {
if let chatStore = store.scope(state: \.nodChat, action: \.nodChat) {
NodChatPaneView(store: chatStore, projectName: store.projectName)
.simultaneousGesture(
TapGesture().onEnded { store.send(.paneFocused(tabID: tab.id, surfaceID: ref.id)) })
} else {
NodStyle.paneBackground
.onAppear { store.send(.chatSurfaceAppeared) }
}
}

private func terminal(tab: TabLayout, ref: SurfaceRef) -> some View {
GhosttyTerminalView(
// The pane's own id, which is also the key its live surface is held under while
Expand Down
Loading
Loading