Merge nucleic/ivory-iris-heron-iipz into dev

This commit is contained in:
2026-08-02 16:57:33 -07:00
parent 89618d85b0
commit beeb46c5d6
2 changed files with 58 additions and 33 deletions
+18
View File
@@ -261,6 +261,24 @@ enum ComposerGlass {
}
}
/// Vertical motion shared by the in-session composer's multiline field and the user-input
/// surfaces that attach to its top edge. Keeping this separate from panel motion gives the
/// composer a calmer, more tightly damped response than large rearrangeable panes.
enum ComposerMotion {
static func layout(_ reduceMotion: Bool) -> Animation {
reduceMotion
? .easeInOut(duration: 0.14)
: .spring(response: 0.36, dampingFraction: 0.86)
}
static func inputTransition(_ reduceMotion: Bool) -> AnyTransition {
guard !reduceMotion else { return .opacity }
return .asymmetric(
insertion: .offset(y: 12).combined(with: .opacity),
removal: .offset(y: 8).combined(with: .opacity))
}
}
/// Safari's sidebar glass: the system `.sidebar` material blurring *behind-window* content only
/// (the desktop the host window is made non-opaque by `WindowTranslucencyConfigurator`). It
/// deliberately never samples in-window content, the way `NavigationSplitView`'s built-in Liquid
+40 -33
View File
@@ -2917,12 +2917,13 @@ struct SessionDetailView: View {
return .black.opacity(0.42)
}
/// The pane's floating bottom furniture: a pending approval card (when there is one) above the
/// composer, both hovering over the transcript on glass. Measured as one block the height the
/// transcript reserves beneath its last message and anchors its fade to so the two stay in
/// step however the cluster grows.
/// The pane's floating bottom furniture. A pending permission/question is rendered inside the
/// composer's glass shell, so it reads as the composer growing an input section rather than a
/// separate card appearing above it. The whole shell is measured as one block the height the
/// transcript reserves beneath its last message and anchors its fade to so the transcript
/// stays in step throughout the grow/collapse animation.
private var floatingBottomCluster: some View {
VStack(spacing: 8) {
VStack(spacing: 0) {
if let approval = store.openApprovals.first {
Group {
if approval.toolName == AskUserQuestion.toolName,
@@ -2933,17 +2934,34 @@ struct SessionDetailView: View {
ApprovalBar(request: approval)
}
}
// Both bars are lightly tinted cards legible over the pane, but not over live
// prose so floating them puts them on the same glass as the composer below,
// with their own tint and border riding on top of it.
.padding(8)
.glassEffect(.regular, in: .rect(cornerRadius: 16))
.contentShape(.rect(cornerRadius: 16))
.shadow(color: .black.opacity(0.18), radius: 12, x: 0, y: 4)
.chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
// Keep the attention-tinted request as a focused inset, but let the composer own
// the only outer material, corners, and shadow. Its stable identity also resets a
// question wizard cleanly when one pending request is replaced by another.
.padding(.horizontal, 8)
.padding(.top, 8)
.id(approval.id)
.transition(ComposerMotion.inputTransition(reduceMotion))
}
composer
}
.background(inactiveGlassScrim, in: .rect(cornerRadius: 16))
.glassEffect(.regular.tint(composerGlassTint), in: .rect(cornerRadius: 16))
.animation(.easeInOut(duration: 0.15), value: controlActiveState)
// The glass itself does not claim pointer events. The solid shape prevents clicks in the
// request/composer breathing room from landing on the transcript behind the floating card.
.contentShape(.rect(cornerRadius: 16))
.shadow(color: .black.opacity(0.18), radius: 12, x: 0, y: 4)
.chatColumn(
maxWidth: contentMaxWidth, inset: transcriptInset,
resizeWidth: columnWidth, settling: transcriptSettling)
// Do not animate the inherited approval of a chat that is merely being opened; live
// requests animate once the transcript has completed its first stable layout.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: store.openApprovals.first?.id)
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: composerHeight)
.padding(.bottom, 12)
// The one measurement both the transcript's tail spacer and its fade are placed from.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { floatingComposerHeight = $0 }
@@ -3159,27 +3177,10 @@ struct SessionDetailView: View {
// row ends in a caption line ("GPT-5.6 Sol · Extra") whose descender space already reads
// as padding, so matching the top's inset there left the card bottom-heavy.
.padding(.horizontal, 14)
.padding(.top, 10)
// An attached request already contributes the card's top inset; close the space between
// the two sections so they read as one surface. The same layout spring eases this value.
.padding(.top, store.openApprovals.first == nil ? 10 : 5)
.padding(.bottom, 4)
// Backed by the system Liquid Glass material, exactly like the floating new-chat
// composer: the input area is a card hovering over the conversation, sampling and
// refracting the transcript that scrolls behind it no opaque backing, no divider.
// Tinted a little darker than the bare material (see `composerGlassTint`), with a scrim
// between the material and these contents for the window's unfocused state, where the
// system swaps the glass for a flat bright slab (see `inactiveGlassScrim`).
.background(inactiveGlassScrim, in: .rect(cornerRadius: 16))
.glassEffect(.regular.tint(composerGlassTint), in: .rect(cornerRadius: 16))
.animation(.easeInOut(duration: 0.15), value: controlActiveState)
// The glass renders but doesn't hit-test, so give the card a solid hit shape. It now
// floats over live transcript, so this is load-bearing: without it a click in the gap
// between two controls would land on the message underneath.
.contentShape(.rect(cornerRadius: 16))
// A soft drop shadow lifts the card off the prose behind it, matching the floating
// panel's treatment at a calmer weight this one is always on screen.
.shadow(color: .black.opacity(0.18), radius: 12, x: 0, y: 4)
// Match the transcript's centered column so the input area and its
// accessories never extend wider than the chat text above them.
.chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
// Stream this composer's draft to the mesh (throttled in the store) so every other
// device viewing this chat sees it live and locks its own composer; an emptied field
// including the clear in `send()` ends the typing and unlocks them.
@@ -4134,6 +4135,7 @@ private struct AnimatedEllipsis: View {
struct ApprovalBar: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let request: ApprovalRequest
/// When set, the request belongs to a specific (off-screen) session the ephemeral popup
/// window so answers route there rather than to the single open session. nil = open session.
@@ -4444,6 +4446,7 @@ struct ApprovalBar: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
.animation(ComposerMotion.layout(reduceMotion), value: revisingPlan)
.onChange(of: request.id) { _, _ in
revisingPlan = false
planRevision = ""
@@ -4514,6 +4517,7 @@ private struct ApprovalDetailHeightKey: PreferenceKey {
struct AskUserQuestionBar: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let request: ApprovalRequest
let questions: [AskUserQuestion.Question]
/// See `ApprovalBar.sessionID` routes answers to the ephemeral popup's session. nil = open.
@@ -4546,6 +4550,8 @@ struct AskUserQuestionBar: View {
}
otherRow(question)
}
.id(step)
.transition(.opacity)
}
HStack {
Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
@@ -4570,6 +4576,7 @@ struct AskUserQuestionBar: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
.animation(ComposerMotion.layout(reduceMotion), value: step)
}
/// The question for the active step, or `nil` if the index is somehow out of range.