Tabs: '+' opens brov-chat or term.macOS (max 6, split around the camera); real zsh terminals via SwiftTerm; per-tab chat history and claude session; hover 3 s to close with confirmation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-07 15:27:02 +03:00
parent 0d40b78b26
commit 56367ef9cd
11 changed files with 452 additions and 35 deletions
+23 -19
View File
@@ -97,13 +97,13 @@ struct IslandContainer: View {
Rectangle().frame(width: islandWidth,
height: state.mode == .expanded ? 320 : islandHeight)
}
.opacity(uploadActive || greetingActive ? 0 : 1)
.opacity(uploadActive || greetingActive || (state.mode == .expanded && state.view == .terminal) ? 0 : 1)
.animation(.easeInOut(duration: 0.25), value: uploadActive || greetingActive)
CountdownBar(state: state, islandW: islandWidth)
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
if state.mode == .expanded && [.prompt, .overview, .empty].contains(state.view) {
if state.mode == .expanded && [.prompt, .terminal, .overview, .empty].contains(state.view) {
ChatResizeHandle(state: state)
.position(x: islandWidth / 2, y: islandHeight - 7)
.transition(.opacity)
@@ -130,7 +130,7 @@ struct IslandContainer: View {
let tr: CGFloat = 0
withAnimation(anim) {
islandWidth = w
islandHeight = (newMode == .expanded && state.view == .prompt) ? chatPromptHeight : h
islandHeight = (newMode == .expanded && state.view.isTall) ? chatPromptHeight : h
cornerRadius = cr
islandTopRadius = tr
}
@@ -147,7 +147,7 @@ struct IslandContainer: View {
nw: state.notchWidth, nh: state.notchHeight)
withAnimation(openSpring) {
islandWidth = w
islandHeight = newView == .prompt ? chatPromptHeight : h
islandHeight = newView.isTall ? chatPromptHeight : h
}
}
.onChange(of: state.chatHistory.count) { _, _ in
@@ -155,15 +155,15 @@ struct IslandContainer: View {
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatDragHeight) { _, h in
guard state.mode == .expanded, state.view == .prompt, let h else { return }
guard state.mode == .expanded, state.view.isTall, let h else { return }
islandHeight = h // follow the pointer, no spring
}
.onChange(of: state.chatDetent) { _, _ in
guard state.mode == .expanded, state.view == .prompt else { return }
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandHeight = chatPromptHeight }
}
.onChange(of: state.chatWidth) { _, w in
guard state.mode == .expanded, state.view == .prompt else { return }
guard state.mode == .expanded, state.view.isTall else { return }
withAnimation(openSpring) { islandWidth = w }
}
.onAppear {
@@ -171,7 +171,7 @@ struct IslandContainer: View {
progress: state.uploadProgress,
nw: state.notchWidth, nh: state.notchHeight)
islandWidth = w
islandHeight = state.view == .prompt ? chatPromptHeight : h
islandHeight = state.view.isTall ? chatPromptHeight : h
cornerRadius = state.mode == .expanded ? IslandConst.expandedCorner : IslandConst.roundedCorner
islandTopRadius = 0
}
@@ -503,7 +503,7 @@ struct IslandContentView: View {
// Views that fill available height instead of the fixed 98pt content frame:
// chat (prompt) is always flexible; mail is flexible only when active so
// it doesn't push the ZStack taller when inactive.
let isTall = v == .prompt || (v == .mail && active)
let isTall = v == .prompt || ((v == .mail || v == .terminal) && active)
let anim: Animation = active
? .spring(response: 0.4, dampingFraction: 0.8).delay(0.16)
: .easeIn(duration: 0.16)
@@ -530,20 +530,18 @@ struct IslandContentView: View {
struct IslandHeader: View {
@ObservedObject var state: AppState
private var leftTabs: [BroTab] { Array(state.tabs.prefix(3)) }
private var rightTabs: [BroTab] { Array(state.tabs.dropFirst(3)) }
var body: some View {
HStack(spacing: 0) {
// Left: tab capsules
// Tabs are split around the camera: three to its left, the rest to its right,
// with "+" right after the last tab.
HStack(spacing: 5) {
TabButton(icon: "house.fill", view: .overview, state: state)
TabButton(icon: "bubble.left.fill", view: .prompt, state: state, preAction: {
#if !APPSTORE
if state.promptContext == nil {
state.promptContext = WindowContextCapture.captureActive(from: state.lastExternalApp)
}
#endif
})
TabButton(icon: "plus", view: .upload, state: state)
ForEach(leftTabs) { TabChip(tab: $0, state: state) }
if rightTabs.isEmpty { NewTabButton(state: state) }
}
.padding(.leading, 14)
@@ -551,12 +549,17 @@ struct IslandHeader: View {
// Right: plan pill (GitHub build, home view only) + action icons
HStack(spacing: 8) {
#if !APPSTORE
if !rightTabs.isEmpty {
HStack(spacing: 5) {
ForEach(rightTabs) { TabChip(tab: $0, state: state) }
NewTabButton(state: state)
}
}
// Plan limits live inside the Claude / ChatGPT pills (AppGridView):
// here they ran under the camera.
#endif
HStack(spacing: 14) {
// Fold everything back into the notch (and the chat to its normal size).
if state.view.isTall {
Button(action: {
state.chatDragHeight = nil
state.chatDetent = 0
@@ -570,6 +573,7 @@ struct IslandHeader: View {
}
.buttonStyle(.plain)
.help("Свернуть в чёлку")
}
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {