From beeb46c5d6a2183eb9bb0adef9f4c64d4d3178f5 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sun, 2 Aug 2026 16:57:33 -0700 Subject: [PATCH] Merge nucleic/ivory-iris-heron-iipz into dev --- Sources/NucleicApp/AppTheme.swift | 18 ++++++ Sources/NucleicApp/SessionDetailView.swift | 73 ++++++++++++---------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/Sources/NucleicApp/AppTheme.swift b/Sources/NucleicApp/AppTheme.swift index 855ec878..5cd5731f 100644 --- a/Sources/NucleicApp/AppTheme.swift +++ b/Sources/NucleicApp/AppTheme.swift @@ -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 diff --git a/Sources/NucleicApp/SessionDetailView.swift b/Sources/NucleicApp/SessionDetailView.swift index 3d8cc9a7..3c1562be 100644 --- a/Sources/NucleicApp/SessionDetailView.swift +++ b/Sources/NucleicApp/SessionDetailView.swift @@ -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.