fix(talk): align sessions and chat UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user