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
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import SwiftUI
import VellumAssistantShared

/// Skeleton placeholder for the chat area while a thread is loading.
/// Mimics the real `ChatBubble` layout — a short user message followed by a
/// multi-line assistant response — so the transition to real content feels seamless.
struct ChatLoadingSkeleton: View {
@State private var appearance = AvatarAppearanceManager.shared

/// Line widths for the multi-line assistant text block.
/// Varying lengths look more natural than uniform bones.
private let assistantLineWidths: [CGFloat] = [0.92, 0.85, 0.78, 0.95, 0.70, 0.45]

/// Darker bone that uses a subtler shimmer to avoid the bright white sweep.
private func chatBone(width: CGFloat? = nil, height: CGFloat = 14) -> some View {
RoundedRectangle(cornerRadius: VRadius.sm)
.fill(VColor.textMuted.opacity(0.15))
.frame(width: width, height: height)
.vShimmer(highlightColor: VColor.textMuted.opacity(0.1))
}

var body: some View {
VStack(alignment: .leading, spacing: VSpacing.md) {
userMessage
assistantMessage
Spacer(minLength: 0)
}
.frame(maxWidth: VSpacing.chatColumnMaxWidth, alignment: .leading)
}

// MARK: - User Message

/// Right-aligned user bubble with two text lines inside,
/// matching real ChatBubble user styling (fill + padding + corner radius).
private var userMessage: some View {
VStack(alignment: .trailing, spacing: VSpacing.xs) {
chatBone(height: 14)
chatBone(height: 14)
.frame(maxWidth: VSpacing.chatBubbleMaxWidth * 0.45, alignment: .trailing)
}
.padding(.horizontal, VSpacing.lg)
.padding(.vertical, VSpacing.md)
.background(
RoundedRectangle(cornerRadius: VRadius.lg)
.fill(VColor.userBubble)
)
.frame(maxWidth: VSpacing.chatBubbleMaxWidth * 0.65)
.frame(maxWidth: .infinity, alignment: .trailing)
}

// MARK: - Assistant Message

/// Left-aligned assistant block with real avatar and six text lines inside
/// a subtle bubble, matching real ChatBubble assistant layout.
private var assistantMessage: some View {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: VSpacing.xs) {
ForEach(assistantLineWidths.indices, id: \.self) { idx in
chatBone(height: 14)
.frame(
maxWidth: VSpacing.chatBubbleMaxWidth * assistantLineWidths[idx],
alignment: .leading
)
}
}
.frame(maxWidth: VSpacing.chatBubbleMaxWidth, alignment: .leading)
.overlay(alignment: .topLeading) {
Image(nsImage: appearance.chatAvatarImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 28, height: 28)
.clipShape(Circle())
.overlay(
Circle()
.strokeBorder(VColor.textMuted.opacity(0.2), lineWidth: 1)
)
.offset(x: -(28 + VSpacing.sm), y: 0)
}
.padding(.leading, 28 + VSpacing.sm)

Spacer(minLength: 0)
}
}
}

#if DEBUG
#Preview("ChatLoadingSkeleton") {
ZStack {
VColor.background.ignoresSafeArea()
ChatLoadingSkeleton()
.padding(VSpacing.lg)
}
.frame(width: 700, height: 400)
}
#endif
37 changes: 15 additions & 22 deletions clients/macos/vellum-assistant/Features/Chat/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,11 @@ struct ChatView: View {
APIKeyBanner(onOpenSettings: onOpenSettings)
}
if messages.isEmpty && !isHistoryLoaded {
Spacer()
HStack {
Spacer()
ProgressView()
.controlSize(.small)
Spacer()
}
Spacer()
ChatLoadingSkeleton()
.padding(VSpacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Loading chat history")
} else if isEmptyState && isBootstrapping {
// During first-launch bootstrap, suppress the empty state
// and show a simple loading panel until the first assistant
Expand Down Expand Up @@ -404,21 +401,17 @@ private struct ChatBootstrapLoadingView: View {
@State private var visible = false

var body: some View {
VStack(spacing: VSpacing.lg) {
Spacer()
VLoadingIndicator(size: 24, color: VColor.accent)
Text("Getting ready...")
.font(VFont.body)
.foregroundColor(VColor.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(visible ? 1 : 0)
.onAppear {
withAnimation(VAnimation.standard) {
visible = true
ChatLoadingSkeleton()
.padding(VSpacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Getting ready")
.opacity(visible ? 1 : 0)
.onAppear {
withAnimation(VAnimation.standard) {
visible = true
}
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import SwiftUI
import VellumAssistantShared

/// Blank page with a centered loading spinner, shown over the chat area
/// while waiting for the daemon to connect.
/// Skeleton placeholder shown over the chat area while waiting for the
/// daemon to connect.
struct DaemonLoadingChatSkeleton: View {
var body: some View {
ZStack {
VColor.backgroundSubtle
.clipShape(RoundedRectangle(cornerRadius: VRadius.xl))
VLoadingIndicator(size: 24, color: VColor.textMuted)
ChatLoadingSkeleton()
.padding(VSpacing.lg)
}
.accessibilityHidden(true)
}
Expand Down