From 7c0379ce0582ea9a2e5a7eff75f4fe0b25fccfb8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 23:45:56 +0100 Subject: [PATCH] feat: add recent session switchers --- CHANGELOG.md | 1 + .../clawdis/node/ui/chat/ChatComposer.kt | 40 ++++- .../clawdis/node/ui/chat/ChatSheetContent.kt | 26 +-- .../clawdis/node/ui/chat/SessionFilters.kt | 46 ++++++ .../node/ui/chat/SessionFiltersTest.kt | 35 ++++ apps/ios/Sources/Chat/ChatSheet.swift | 5 +- .../Sources/Clawdis/WebChatSwiftUI.swift | 5 +- .../Sources/ClawdisChatUI/ChatComposer.swift | 24 +++ .../Sources/ClawdisChatUI/ChatView.swift | 5 +- .../Sources/ClawdisChatUI/ChatViewModel.swift | 53 +++++++ .../ClawdisKitTests/ChatViewModelTests.swift | 150 +++++++++++++++++- ui/src/ui/app-render.ts | 5 + ui/src/ui/app.ts | 2 +- ui/src/ui/views/chat.ts | 65 +++++++- 14 files changed, 427 insertions(+), 35 deletions(-) create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/SessionFilters.kt create mode 100644 apps/android/app/src/test/java/com/steipete/clawdis/node/ui/chat/SessionFiltersTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 572250594..d3dec2512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. +- Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI. - Tests: add a Z.AI live test gate for smoke validation when keys are present. - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt index c278a20f9..af958d71c 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatComposer.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.ButtonDefaults @@ -39,10 +38,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.steipete.clawdis.node.chat.ChatSessionEntry @Composable fun ChatComposer( sessionKey: String, + sessions: List, healthOk: Boolean, thinkingLevel: String, pendingRunCount: Int, @@ -51,13 +52,16 @@ fun ChatComposer( onPickImages: () -> Unit, onRemoveAttachment: (id: String) -> Unit, onSetThinkingLevel: (level: String) -> Unit, - onShowSessions: () -> Unit, + onSelectSession: (sessionKey: String) -> Unit, onRefresh: () -> Unit, onAbort: () -> Unit, onSend: (text: String) -> Unit, ) { var input by rememberSaveable { mutableStateOf("") } var showThinkingMenu by remember { mutableStateOf(false) } + var showSessionMenu by remember { mutableStateOf(false) } + + val sessionOptions = resolveSessionChoices(sessionKey, sessions) val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk @@ -73,6 +77,34 @@ fun ChatComposer( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + Box { + FilledTonalButton( + onClick = { showSessionMenu = true }, + contentPadding = ButtonDefaults.ContentPadding, + ) { + Text("Session: $sessionKey") + } + + DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) { + for (entry in sessionOptions) { + DropdownMenuItem( + text = { Text(entry.key) }, + onClick = { + onSelectSession(entry.key) + showSessionMenu = false + }, + trailingIcon = { + if (entry.key == sessionKey) { + Text("✓") + } else { + Spacer(modifier = Modifier.width(10.dp)) + } + }, + ) + } + } + } + Box { FilledTonalButton( onClick = { showThinkingMenu = true }, @@ -91,10 +123,6 @@ fun ChatComposer( Spacer(modifier = Modifier.weight(1f)) - FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) { - Icon(Icons.Default.FolderOpen, contentDescription = "Sessions") - } - FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt index 35372ddc7..40d6c5035 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/ChatSheetContent.kt @@ -14,10 +14,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -40,10 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) { val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val sessions by viewModel.chatSessions.collectAsState() - var showSessions by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { viewModel.loadChat("main") + viewModel.refreshChatSessions(limit = 200) } val context = LocalContext.current @@ -87,6 +84,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { ChatComposer( sessionKey = sessionKey, + sessions = sessions, healthOk = healthOk, thinkingLevel = thinkingLevel, pendingRunCount = pendingRunCount, @@ -95,8 +93,11 @@ fun ChatSheetContent(viewModel: MainViewModel) { onPickImages = { pickImages.launch("image/*") }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, - onShowSessions = { showSessions = true }, - onRefresh = { viewModel.refreshChat() }, + onSelectSession = { key -> viewModel.switchChatSession(key) }, + onRefresh = { + viewModel.refreshChat() + viewModel.refreshChatSessions(limit = 200) + }, onAbort = { viewModel.abortChat() }, onSend = { text -> val outgoing = @@ -113,19 +114,6 @@ fun ChatSheetContent(viewModel: MainViewModel) { }, ) } - - if (showSessions) { - ChatSessionsDialog( - currentSessionKey = sessionKey, - sessions = sessions, - onDismiss = { showSessions = false }, - onRefresh = { viewModel.refreshChatSessions(limit = 50) }, - onSelect = { key -> - viewModel.switchChatSession(key) - showSessions = false - }, - ) - } } data class PendingImageAttachment( diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/SessionFilters.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/SessionFilters.kt new file mode 100644 index 000000000..5ee51fbe1 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/chat/SessionFilters.kt @@ -0,0 +1,46 @@ +package com.steipete.clawdis.node.ui.chat + +import com.steipete.clawdis.node.chat.ChatSessionEntry + +private const val MAIN_SESSION_KEY = "main" +private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L + +fun resolveSessionChoices( + currentSessionKey: String, + sessions: List, + nowMs: Long = System.currentTimeMillis(), +): List { + val current = currentSessionKey.trim() + val cutoff = nowMs - RECENT_WINDOW_MS + val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L } + val recent = mutableListOf() + val seen = mutableSetOf() + for (entry in sorted) { + if (!seen.add(entry.key)) continue + if ((entry.updatedAtMs ?: 0L) < cutoff) continue + recent.add(entry) + } + + val result = mutableListOf() + val included = mutableSetOf() + val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY } + if (mainEntry != null) { + result.add(mainEntry) + included.add(MAIN_SESSION_KEY) + } else if (current == MAIN_SESSION_KEY) { + result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null)) + included.add(MAIN_SESSION_KEY) + } + + for (entry in recent) { + if (included.add(entry.key)) { + result.add(entry) + } + } + + if (current.isNotEmpty() && !included.contains(current)) { + result.add(ChatSessionEntry(key = current, updatedAtMs = null)) + } + + return result +} diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/ui/chat/SessionFiltersTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/ui/chat/SessionFiltersTest.kt new file mode 100644 index 000000000..c88e8b18b --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/ui/chat/SessionFiltersTest.kt @@ -0,0 +1,35 @@ +package com.steipete.clawdis.node.ui.chat + +import com.steipete.clawdis.node.chat.ChatSessionEntry +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionFiltersTest { + @Test + fun sessionChoicesPreferMainAndRecent() { + val now = 1_700_000_000_000L + val recent1 = now - 2 * 60 * 60 * 1000L + val recent2 = now - 5 * 60 * 60 * 1000L + val stale = now - 26 * 60 * 60 * 1000L + val sessions = + listOf( + ChatSessionEntry(key = "recent-1", updatedAtMs = recent1), + ChatSessionEntry(key = "main", updatedAtMs = stale), + ChatSessionEntry(key = "old-1", updatedAtMs = stale), + ChatSessionEntry(key = "recent-2", updatedAtMs = recent2), + ) + + val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key } + assertEquals(listOf("main", "recent-1", "recent-2"), result) + } + + @Test + fun sessionChoicesIncludeCurrentWhenMissing() { + val now = 1_700_000_000_000L + val recent = now - 10 * 60 * 1000L + val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent)) + + val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key } + assertEquals(listOf("main", "custom"), result) + } +} diff --git a/apps/ios/Sources/Chat/ChatSheet.swift b/apps/ios/Sources/Chat/ChatSheet.swift index 1d2d059bb..b7519a877 100644 --- a/apps/ios/Sources/Chat/ChatSheet.swift +++ b/apps/ios/Sources/Chat/ChatSheet.swift @@ -17,7 +17,10 @@ struct ChatSheet: View { var body: some View { NavigationStack { - ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent) + ClawdisChatView( + viewModel: self.viewModel, + showsSessionSwitcher: true, + userAccent: self.userAccent) .navigationTitle("Chat") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index d396bb286..8e7af9c4a 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -156,7 +156,10 @@ final class WebChatSwiftUIWindowController { self.presentation = presentation let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport) let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) - self.hosting = NSHostingController(rootView: ClawdisChatView(viewModel: vm, userAccent: accent)) + self.hosting = NSHostingController(rootView: ClawdisChatView( + viewModel: vm, + showsSessionSwitcher: true, + userAccent: accent)) self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) } diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift index bc6b08db4..2ea691c43 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatComposer.swift @@ -11,6 +11,7 @@ import UniformTypeIdentifiers struct ClawdisChatComposer: View { @Bindable var viewModel: ClawdisChatViewModel let style: ClawdisChatView.Style + let showsSessionSwitcher: Bool #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @@ -23,6 +24,9 @@ struct ClawdisChatComposer: View { VStack(alignment: .leading, spacing: 4) { if self.showsToolbar { HStack(spacing: 6) { + if self.showsSessionSwitcher { + self.sessionPicker + } self.thinkingPicker Spacer() self.refreshButton @@ -91,6 +95,26 @@ struct ClawdisChatComposer: View { .frame(maxWidth: 140, alignment: .leading) } + private var sessionPicker: some View { + Picker( + "Session", + selection: Binding( + get: { self.viewModel.sessionKey }, + set: { next in self.viewModel.switchSession(to: next) })) + { + ForEach(self.viewModel.sessionChoices, id: \.key) { session in + Text(session.key) + .font(.system(.caption, design: .monospaced)) + .tag(session.key) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .frame(maxWidth: 160, alignment: .leading) + .help("Session") + } + @ViewBuilder private var attachmentPicker: some View { #if os(macOS) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index a95fcfc98..b2f76932d 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -58,7 +58,10 @@ public struct ClawdisChatView: View { VStack(spacing: Layout.stackSpacing) { self.messageList .padding(.horizontal, Layout.outerPaddingHorizontal) - ClawdisChatComposer(viewModel: self.viewModel, style: self.style) + ClawdisChatComposer( + viewModel: self.viewModel, + style: self.style, + showsSessionSwitcher: self.showsSessionSwitcher) .padding(.horizontal, Layout.composerPaddingHorizontal) } .padding(.vertical, Layout.outerPaddingVertical) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift index 14ce2091a..025e91ca6 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatViewModel.swift @@ -99,6 +99,42 @@ public final class ClawdisChatViewModel { Task { await self.performSwitchSession(to: sessionKey) } } + public var sessionChoices: [ClawdisChatSessionEntry] { + let now = Date().timeIntervalSince1970 * 1000 + let cutoff = now - (24 * 60 * 60 * 1000) + let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + var seen = Set() + var recent: [ClawdisChatSessionEntry] = [] + for entry in sorted { + guard !seen.contains(entry.key) else { continue } + seen.insert(entry.key) + guard (entry.updatedAt ?? 0) >= cutoff else { continue } + recent.append(entry) + } + + let mainKey = "main" + var result: [ClawdisChatSessionEntry] = [] + var included = Set() + if let main = sorted.first(where: { $0.key == mainKey }) { + result.append(main) + included.insert(mainKey) + } else if self.sessionKey == mainKey { + result.append(self.placeholderSession(key: mainKey)) + included.insert(mainKey) + } + + for entry in recent where !included.contains(entry.key) { + result.append(entry) + included.insert(entry.key) + } + + if !included.contains(self.sessionKey) { + result.append(self.placeholderSession(key: self.sessionKey)) + } + + return result + } + public func addAttachments(urls: [URL]) { Task { await self.loadAttachments(urls: urls) } } @@ -301,6 +337,23 @@ public final class ClawdisChatViewModel { await self.bootstrap() } + private func placeholderSession(key: String) -> ClawdisChatSessionEntry { + ClawdisChatSessionEntry( + key: key, + kind: nil, + updatedAt: nil, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil) + } + private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) { switch evt { case let .health(ok): diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift index c5d8ebaf9..acdcd7a6c 100644 --- a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -26,6 +26,7 @@ private func waitUntil( private actor TestChatTransportState { var historyCallCount: Int = 0 + var sessionsCallCount: Int = 0 var sentRunIds: [String] = [] var abortedRunIds: [String] = [] } @@ -33,12 +34,17 @@ private actor TestChatTransportState { private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport { private let state = TestChatTransportState() private let historyResponses: [ClawdisChatHistoryPayload] + private let sessionsResponses: [ClawdisChatSessionsListResponse] private let stream: AsyncStream private let continuation: AsyncStream.Continuation - init(historyResponses: [ClawdisChatHistoryPayload]) { + init( + historyResponses: [ClawdisChatHistoryPayload], + sessionsResponses: [ClawdisChatSessionsListResponse] = []) + { self.historyResponses = historyResponses + self.sessionsResponses = sessionsResponses var cont: AsyncStream.Continuation! self.stream = AsyncStream { c in cont = c @@ -81,7 +87,17 @@ private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport } func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse { - ClawdisChatSessionsListResponse(ts: nil, path: nil, count: 0, defaults: nil, sessions: []) + let idx = await self.state.sessionsCallCount + await self.state.setSessionsCallCount(idx + 1) + if idx < self.sessionsResponses.count { + return self.sessionsResponses[idx] + } + return self.sessionsResponses.last ?? ClawdisChatSessionsListResponse( + ts: nil, + path: nil, + count: 0, + defaults: nil, + sessions: []) } func requestHealth(timeoutMs _: Int) async throws -> Bool { @@ -107,6 +123,10 @@ private extension TestChatTransportState { self.historyCallCount = v } + func setSessionsCallCount(_ v: Int) { + self.sessionsCallCount = v + } + func sentRunIdsAppend(_ v: String) { self.sentRunIds.append(v) } @@ -243,6 +263,132 @@ private extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } + @Test func sessionChoicesPreferMainAndRecent() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (2 * 60 * 60 * 1000) + let recentOlder = now - (5 * 60 * 60 * 1000) + let stale = now - (26 * 60 * 60 * 1000) + let history = ClawdisChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let sessions = ClawdisChatSessionsListResponse( + ts: now, + path: nil, + count: 4, + defaults: nil, + sessions: [ + ClawdisChatSessionEntry( + key: "recent-1", + kind: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ClawdisChatSessionEntry( + key: "main", + kind: nil, + updatedAt: stale, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ClawdisChatSessionEntry( + key: "recent-2", + kind: nil, + updatedAt: recentOlder, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ClawdisChatSessionEntry( + key: "old-1", + kind: nil, + updatedAt: stale, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ]) + + let transport = TestChatTransport( + historyResponses: [history], + sessionsResponses: [sessions]) + let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) } + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["main", "recent-1", "recent-2"]) + } + + @Test func sessionChoicesIncludeCurrentWhenMissing() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (30 * 60 * 1000) + let history = ClawdisChatHistoryPayload( + sessionKey: "custom", + sessionId: "sess-custom", + messages: [], + thinkingLevel: "off") + let sessions = ClawdisChatSessionsListResponse( + ts: now, + path: nil, + count: 1, + defaults: nil, + sessions: [ + ClawdisChatSessionEntry( + key: "main", + kind: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + model: nil, + contextTokens: nil), + ]) + + let transport = TestChatTransport( + historyResponses: [history], + sessionsResponses: [sessions]) + let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "custom", transport: transport) } + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["main", "custom"]) + } + @Test func clearsStreamingOnExternalErrorEvent() async throws { let sessionId = "sess-main" let history = ClawdisChatHistoryPayload( diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 46d229e7f..9fa1afcd6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -324,7 +324,11 @@ export function renderApp(state: AppViewState) { sessionKey: state.sessionKey, onSessionKeyChange: (next) => { state.sessionKey = next; + state.chatMessage = ""; + state.chatStream = null; + state.chatRunId = null; state.applySettings({ ...state.settings, sessionKey: next }); + void loadChatHistory(state); }, thinkingLevel: state.chatThinkingLevel, loading: state.chatLoading, @@ -335,6 +339,7 @@ export function renderApp(state: AppViewState) { connected: state.connected, canSend: state.connected && hasConnectedMobileNode, disabledReason: chatDisabledReason, + sessions: state.sessionsResult, onRefresh: () => loadChatHistory(state), onDraftChange: (next) => (state.chatMessage = next), onSend: () => state.handleSendChat(), diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 6a9fffdf7..ffac48fcc 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -363,7 +363,7 @@ export class ClawdisApp extends LitElement { if (this.tab === "skills") await loadSkills(this); if (this.tab === "nodes") await loadNodes(this); if (this.tab === "chat") { - await loadChatHistory(this); + await Promise.all([loadChatHistory(this), loadSessions(this)]); this.scheduleChatScroll(); } if (this.tab === "config") await loadConfig(this); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 4ed1a279b..7fca2f9bd 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,5 +1,7 @@ import { html, nothing } from "lit"; +import type { SessionsListResult } from "../types"; + export type ChatProps = { sessionKey: string; onSessionKeyChange: (next: string) => void; @@ -12,6 +14,7 @@ export type ChatProps = { connected: boolean; canSend: boolean; disabledReason: string | null; + sessions: SessionsListResult | null; onRefresh: () => void; onDraftChange: (next: string) => void; onSend: () => void; @@ -20,6 +23,7 @@ export type ChatProps = { export function renderChat(props: ChatProps) { const canInteract = props.connected; const canCompose = props.canSend && !props.sending; + const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); const composePlaceholder = (() => { if (!props.connected) return "Connect to the gateway to start chatting…"; if (!props.canSend) return "Connect an iOS/Android node to enable Web Chat + Talk…"; @@ -32,12 +36,16 @@ export function renderChat(props: ChatProps) {