Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ struct AccountPanelView: View {
accountRows
}
}
.animation(PanelMotion.state, value: accountRunLayoutKey)
}

private var accountRows: some View {
Expand Down Expand Up @@ -594,6 +595,14 @@ struct AccountPanelView: View {
accountListContentHeight > accountListAvailableHeight + 1
}

private var accountRunLayoutKey: String {
store.accounts.map { account in
let runIDs = operatorRuns(for: account).map(\.id).joined(separator: ",")
return "\(account.id):\(runIDs)"
}
.joined(separator: "|")
}

private var accountListAvailableHeight: CGFloat {
let visibleHeight = AccountPanelLayout.activeScreenVisibleHeight()
let availableHeight = visibleHeight - accountPanelChromeHeight
Expand Down Expand Up @@ -836,6 +845,7 @@ struct AccountRowView: View {

if runs.isEmpty == false {
AccountRunSummaryView(runs: runs)
.transition(runSummaryTransition)
}

if account.hasUsageSummary {
Expand All @@ -858,6 +868,28 @@ struct AccountRowView: View {
.animation(PanelMotion.state, value: account.selected)
.animation(PanelMotion.state, value: account.codexActive)
.animation(PanelMotion.state, value: isLogoutArmed)
.animation(reduceMotion ? .easeOut(duration: 0.12) : PanelMotion.state, value: runIdentityKey)
}

@Environment(\.accessibilityReduceMotion) private var reduceMotion

private var runIdentityKey: String {
runs.map(\.id).joined(separator: "|")
}

private var runSummaryTransition: AnyTransition {
if reduceMotion {
return .opacity
}

return .asymmetric(
insertion: .opacity
.combined(with: .move(edge: .top))
.combined(with: .scale(scale: 0.98, anchor: .topLeading)),
removal: .opacity
.combined(with: .move(edge: .top))
.combined(with: .scale(scale: 0.98, anchor: .topLeading))
)
}

private var routeHelp: String {
Expand Down Expand Up @@ -885,12 +917,14 @@ struct AccountRowView: View {

struct AccountRunSummaryView: View {
let runs: [OperatorRunStatus]
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(runs) { run in
AccountRunChipView(run: run)
.transition(runChipTransition)
}
}
.padding(.trailing, 1)
Expand All @@ -899,6 +933,25 @@ struct AccountRunSummaryView: View {
.frame(height: AccountRunChipLayout.height)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityLabel("\(runs.count) running lane\(runs.count == 1 ? "" : "s")")
.animation(reduceMotion ? .easeOut(duration: 0.12) : PanelMotion.state, value: runIdentityKey)
}

private var runIdentityKey: String {
runs.map(\.id).joined(separator: "|")
}

private var runChipTransition: AnyTransition {
if reduceMotion {
return .opacity
}

return .asymmetric(
insertion: .opacity
.combined(with: .scale(scale: 0.88, anchor: .leading))
.combined(with: .move(edge: .leading)),
removal: .opacity
.combined(with: .scale(scale: 0.94, anchor: .leading))
)
}
}

Expand Down
82 changes: 82 additions & 0 deletions apps/decodex/src/orchestrator/operator_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -3519,6 +3519,26 @@
}
}

@keyframes stable-list-item-enter {
from {
opacity: 0;
transform: translateY(-5px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

.is-list-entering {
animation: stable-list-item-enter var(--medium) var(--ease) both;
transform-origin: top left;
}

.is-size-animating {
overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
*,
*::before,
Expand Down Expand Up @@ -5423,6 +5443,7 @@ <h2 id="recent-title">Run History</h2>
} else {
const clone = nextChild.cloneNode(true);
current.insertBefore(clone, cursor);
markStableListEnter(clone);
used.add(clone);
}
}
Expand All @@ -5434,11 +5455,72 @@ <h2 id="recent-title">Run History</h2>
}
}

function markStableListEnter(node) {
if (reducedMotionQuery.matches || !(node instanceof HTMLElement)) {
return;
}

node.classList.add("is-list-entering");
const clear = () => {
node.classList.remove("is-list-entering");
};
node.addEventListener("animationend", clear, { once: true });
window.setTimeout(clear, 360);
}

function animateStableListSize(container, startHeight) {
if (reducedMotionQuery.matches) {
return;
}

const endHeight = container.getBoundingClientRect().height;
if (Math.abs(endHeight - startHeight) < 1) {
return;
}

const previousHeight = container.style.height;
const previousOverflow = container.style.overflow;
const previousTransition = container.style.transition;
let cleaned = false;

const cleanup = (event) => {
if (event && event.propertyName !== "height") {
return;
}
if (cleaned) {
return;
}
cleaned = true;
container.classList.remove("is-size-animating");
container.style.height = previousHeight;
container.style.overflow = previousOverflow;
container.style.transition = previousTransition;
};

container.classList.add("is-size-animating");
container.style.height = `${startHeight}px`;
container.style.overflow = "hidden";
void container.offsetHeight;

window.requestAnimationFrame(() => {
container.style.transition = [previousTransition, "height var(--medium) var(--ease)"]
.filter(Boolean)
.join(", ");
container.style.height = `${endHeight}px`;
container.addEventListener("transitionend", cleanup, { once: true });
window.setTimeout(cleanup, 360);
});
}

function renderStableList(container, html) {
const template = document.createElement("template");
template.innerHTML = html.trim();
const startHeight = reducedMotionQuery.matches
? 0
: container.getBoundingClientRect().height;

patchChildNodes(container, template.content);
animateStableListSize(container, startHeight);
}

function pluralLabel(count, singular, plural = `${singular}s`) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,16 @@ fn operator_dashboard_patches_active_run_cards_without_replacing_the_list() {
let response = dashboard_response();

assert!(response.contains("function renderStableList(container, html)"));
assert!(response.contains("function animateStableListSize(container, startHeight)"));
assert!(response.contains("function markStableListEnter(node)"));
assert!(response.contains("function patchChildNodes(current, next)"));
assert!(response.contains("function activeRunRenderKey(run)"));
assert!(response.contains("data-render-key=\"${escapeHtml(renderKey)}\""));
assert!(response.contains("renderStableList(\n\t\t\t\t\tnodes.activeRuns,"));
assert!(response.contains("markStableListEnter(clone);"));
assert!(response.contains("container.style.height = `${startHeight}px`;"));
assert!(response.contains(".is-list-entering"));
assert!(response.contains("@keyframes stable-list-item-enter"));
assert!(!response.contains("nodes.activeRuns.innerHTML = runs"));
assert!(response.contains("return node.dataset.renderKey || node.dataset.detailKey || \"\";"));
assert!(response.contains("current.closest(\"details.is-animating\")"));
Expand Down