fix(talk): align sessions and chat UI

This commit is contained in:
Peter Steinberger
2025-12-30 06:47:19 +01:00
parent afbd18e8df
commit 7612a83fa2
13 changed files with 181 additions and 60 deletions

View File

@@ -137,9 +137,10 @@ private struct ChatBubbleShape: InsettableShape {
struct ChatMessageBubble: View {
let message: ClawdisChatMessage
let style: ClawdisChatView.Style
let userAccent: Color?
var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style)
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
@@ -153,6 +154,7 @@ private struct ChatMessageBody: View {
let message: ClawdisChatMessage
let isUser: Bool
let style: ClawdisChatView.Style
let userAccent: Color?
var body: some View {
let text = self.primaryText
@@ -287,7 +289,7 @@ private struct ChatMessageBody: View {
private var bubbleFillColor: Color {
if self.isUser {
return ClawdisChatTheme.userBubble
return self.userAccent ?? ClawdisChatTheme.userBubble
}
if self.style == .onboarding {
return ClawdisChatTheme.onboardingAssistantBubble

View File

@@ -101,11 +101,7 @@ enum ClawdisChatTheme {
}
static var userBubble: Color {
#if os(macOS)
Color(nsColor: .systemBlue)
#else
Color(uiColor: .systemBlue)
#endif
Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
}
static var assistantBubble: Color {

View File

@@ -13,6 +13,7 @@ public struct ClawdisChatView: View {
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool
private let style: Style
private let userAccent: Color?
private enum Layout {
#if os(macOS)
@@ -37,11 +38,13 @@ public struct ClawdisChatView: View {
public init(
viewModel: ClawdisChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard)
style: Style = .standard,
userAccent: Color? = nil)
{
self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
self.userAccent = userAccent
}
public var body: some View {
@@ -74,7 +77,7 @@ public struct ClawdisChatView: View {
ScrollView {
LazyVStack(spacing: Layout.messageSpacing) {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style)
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)

View File

@@ -150,9 +150,36 @@ public final class ClawdisChatViewModel {
}
private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] {
raw.compactMap { item in
let decoded = raw.compactMap { item in
(try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self))
}
return Self.dedupeMessages(decoded)
}
private static func dedupeMessages(_ messages: [ClawdisChatMessage]) -> [ClawdisChatMessage] {
var result: [ClawdisChatMessage] = []
result.reserveCapacity(messages.count)
var seen = Set<String>()
for message in messages {
guard let key = Self.dedupeKey(for: message) else {
result.append(message)
continue
}
if seen.contains(key) { continue }
seen.insert(key)
result.append(message)
}
return result
}
private static func dedupeKey(for message: ClawdisChatMessage) -> String? {
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap { $0.text }.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
return "\(message.role)|\(timestamp)|\(text)"
}
private func performSend() async {

View File

@@ -117,6 +117,29 @@ private extension TestChatTransportState {
}
@Suite struct ChatViewModelTests {
@Test func dedupesDuplicateHistoryMessages() async throws {
let ts = Date().timeIntervalSince1970 * 1000
let duplicate = AnyCodable([
"role": "assistant",
"content": [["type": "text", "text": "Same message"]],
"timestamp": ts,
])
let history = ClawdisChatHistoryPayload(
sessionKey: "main",
sessionId: "sess-main",
messages: [duplicate, duplicate],
thinkingLevel: "off")
let transport = TestChatTransport(historyResponses: [history])
let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("bootstrap") { await MainActor.run { !vm.messages.isEmpty } }
#expect(await MainActor.run { vm.messages.count } == 1)
#expect(await MainActor.run { vm.messages.first?.role } == "assistant")
}
@Test func streamsAssistantAndClearsOnFinal() async throws {
let sessionId = "sess-main"
let history1 = ClawdisChatHistoryPayload(