feat: add recent session switchers

This commit is contained in:
Peter Steinberger
2026-01-01 23:45:56 +01:00
parent c7c13f2d5e
commit 7c0379ce05
14 changed files with 427 additions and 35 deletions

View File

@@ -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). - 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`). - 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. - 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. - 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. - 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. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile 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.Refresh
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -39,10 +38,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.steipete.clawdis.node.chat.ChatSessionEntry
@Composable @Composable
fun ChatComposer( fun ChatComposer(
sessionKey: String, sessionKey: String,
sessions: List<ChatSessionEntry>,
healthOk: Boolean, healthOk: Boolean,
thinkingLevel: String, thinkingLevel: String,
pendingRunCount: Int, pendingRunCount: Int,
@@ -51,13 +52,16 @@ fun ChatComposer(
onPickImages: () -> Unit, onPickImages: () -> Unit,
onRemoveAttachment: (id: String) -> Unit, onRemoveAttachment: (id: String) -> Unit,
onSetThinkingLevel: (level: String) -> Unit, onSetThinkingLevel: (level: String) -> Unit,
onShowSessions: () -> Unit, onSelectSession: (sessionKey: String) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onAbort: () -> Unit, onAbort: () -> Unit,
onSend: (text: String) -> Unit, onSend: (text: String) -> Unit,
) { ) {
var input by rememberSaveable { mutableStateOf("") } var input by rememberSaveable { mutableStateOf("") }
var showThinkingMenu by remember { mutableStateOf(false) } 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 val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
@@ -73,6 +77,34 @@ fun ChatComposer(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, 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 { Box {
FilledTonalButton( FilledTonalButton(
onClick = { showThinkingMenu = true }, onClick = { showThinkingMenu = true },
@@ -91,10 +123,6 @@ fun ChatComposer(
Spacer(modifier = Modifier.weight(1f)) 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)) { FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh") Icon(Icons.Default.Refresh, contentDescription = "Refresh")
} }

View File

@@ -14,10 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -40,10 +38,9 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState() val sessions by viewModel.chatSessions.collectAsState()
var showSessions by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadChat("main") viewModel.loadChat("main")
viewModel.refreshChatSessions(limit = 200)
} }
val context = LocalContext.current val context = LocalContext.current
@@ -87,6 +84,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
ChatComposer( ChatComposer(
sessionKey = sessionKey, sessionKey = sessionKey,
sessions = sessions,
healthOk = healthOk, healthOk = healthOk,
thinkingLevel = thinkingLevel, thinkingLevel = thinkingLevel,
pendingRunCount = pendingRunCount, pendingRunCount = pendingRunCount,
@@ -95,8 +93,11 @@ fun ChatSheetContent(viewModel: MainViewModel) {
onPickImages = { pickImages.launch("image/*") }, onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } }, onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) }, onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
onShowSessions = { showSessions = true }, onSelectSession = { key -> viewModel.switchChatSession(key) },
onRefresh = { viewModel.refreshChat() }, onRefresh = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 200)
},
onAbort = { viewModel.abortChat() }, onAbort = { viewModel.abortChat() },
onSend = { text -> onSend = { text ->
val outgoing = 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( data class PendingImageAttachment(

View File

@@ -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<ChatSessionEntry>,
nowMs: Long = System.currentTimeMillis(),
): List<ChatSessionEntry> {
val current = currentSessionKey.trim()
val cutoff = nowMs - RECENT_WINDOW_MS
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
recent.add(entry)
}
val result = mutableListOf<ChatSessionEntry>()
val included = mutableSetOf<String>()
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
}

View File

@@ -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)
}
}

View File

@@ -17,7 +17,10 @@ struct ChatSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent) ClawdisChatView(
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent)
.navigationTitle("Chat") .navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@@ -156,7 +156,10 @@ final class WebChatSwiftUIWindowController {
self.presentation = presentation self.presentation = presentation
let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport) let vm = ClawdisChatViewModel(sessionKey: sessionKey, transport: transport)
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) 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.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
} }

View File

@@ -11,6 +11,7 @@ import UniformTypeIdentifiers
struct ClawdisChatComposer: View { struct ClawdisChatComposer: View {
@Bindable var viewModel: ClawdisChatViewModel @Bindable var viewModel: ClawdisChatViewModel
let style: ClawdisChatView.Style let style: ClawdisChatView.Style
let showsSessionSwitcher: Bool
#if !os(macOS) #if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = [] @State private var pickerItems: [PhotosPickerItem] = []
@@ -23,6 +24,9 @@ struct ClawdisChatComposer: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if self.showsToolbar { if self.showsToolbar {
HStack(spacing: 6) { HStack(spacing: 6) {
if self.showsSessionSwitcher {
self.sessionPicker
}
self.thinkingPicker self.thinkingPicker
Spacer() Spacer()
self.refreshButton self.refreshButton
@@ -91,6 +95,26 @@ struct ClawdisChatComposer: View {
.frame(maxWidth: 140, alignment: .leading) .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 @ViewBuilder
private var attachmentPicker: some View { private var attachmentPicker: some View {
#if os(macOS) #if os(macOS)

View File

@@ -58,7 +58,10 @@ public struct ClawdisChatView: View {
VStack(spacing: Layout.stackSpacing) { VStack(spacing: Layout.stackSpacing) {
self.messageList self.messageList
.padding(.horizontal, Layout.outerPaddingHorizontal) .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(.horizontal, Layout.composerPaddingHorizontal)
} }
.padding(.vertical, Layout.outerPaddingVertical) .padding(.vertical, Layout.outerPaddingVertical)

View File

@@ -99,6 +99,42 @@ public final class ClawdisChatViewModel {
Task { await self.performSwitchSession(to: sessionKey) } 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<String>()
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<String>()
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]) { public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) } Task { await self.loadAttachments(urls: urls) }
} }
@@ -301,6 +337,23 @@ public final class ClawdisChatViewModel {
await self.bootstrap() 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) { private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
switch evt { switch evt {
case let .health(ok): case let .health(ok):

View File

@@ -26,6 +26,7 @@ private func waitUntil(
private actor TestChatTransportState { private actor TestChatTransportState {
var historyCallCount: Int = 0 var historyCallCount: Int = 0
var sessionsCallCount: Int = 0
var sentRunIds: [String] = [] var sentRunIds: [String] = []
var abortedRunIds: [String] = [] var abortedRunIds: [String] = []
} }
@@ -33,12 +34,17 @@ private actor TestChatTransportState {
private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport { private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport {
private let state = TestChatTransportState() private let state = TestChatTransportState()
private let historyResponses: [ClawdisChatHistoryPayload] private let historyResponses: [ClawdisChatHistoryPayload]
private let sessionsResponses: [ClawdisChatSessionsListResponse]
private let stream: AsyncStream<ClawdisChatTransportEvent> private let stream: AsyncStream<ClawdisChatTransportEvent>
private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation
init(historyResponses: [ClawdisChatHistoryPayload]) { init(
historyResponses: [ClawdisChatHistoryPayload],
sessionsResponses: [ClawdisChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
var cont: AsyncStream<ClawdisChatTransportEvent>.Continuation! var cont: AsyncStream<ClawdisChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in self.stream = AsyncStream { c in
cont = c cont = c
@@ -81,7 +87,17 @@ private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport
} }
func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse { 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 { func requestHealth(timeoutMs _: Int) async throws -> Bool {
@@ -107,6 +123,10 @@ private extension TestChatTransportState {
self.historyCallCount = v self.historyCallCount = v
} }
func setSessionsCallCount(_ v: Int) {
self.sessionsCallCount = v
}
func sentRunIdsAppend(_ v: String) { func sentRunIdsAppend(_ v: String) {
self.sentRunIds.append(v) self.sentRunIds.append(v)
} }
@@ -243,6 +263,132 @@ private extension TestChatTransportState {
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) #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 { @Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main" let sessionId = "sess-main"
let history = ClawdisChatHistoryPayload( let history = ClawdisChatHistoryPayload(

View File

@@ -324,7 +324,11 @@ export function renderApp(state: AppViewState) {
sessionKey: state.sessionKey, sessionKey: state.sessionKey,
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
state.sessionKey = next; state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
state.chatRunId = null;
state.applySettings({ ...state.settings, sessionKey: next }); state.applySettings({ ...state.settings, sessionKey: next });
void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
loading: state.chatLoading, loading: state.chatLoading,
@@ -335,6 +339,7 @@ export function renderApp(state: AppViewState) {
connected: state.connected, connected: state.connected,
canSend: state.connected && hasConnectedMobileNode, canSend: state.connected && hasConnectedMobileNode,
disabledReason: chatDisabledReason, disabledReason: chatDisabledReason,
sessions: state.sessionsResult,
onRefresh: () => loadChatHistory(state), onRefresh: () => loadChatHistory(state),
onDraftChange: (next) => (state.chatMessage = next), onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(), onSend: () => state.handleSendChat(),

View File

@@ -363,7 +363,7 @@ export class ClawdisApp extends LitElement {
if (this.tab === "skills") await loadSkills(this); if (this.tab === "skills") await loadSkills(this);
if (this.tab === "nodes") await loadNodes(this); if (this.tab === "nodes") await loadNodes(this);
if (this.tab === "chat") { if (this.tab === "chat") {
await loadChatHistory(this); await Promise.all([loadChatHistory(this), loadSessions(this)]);
this.scheduleChatScroll(); this.scheduleChatScroll();
} }
if (this.tab === "config") await loadConfig(this); if (this.tab === "config") await loadConfig(this);

View File

@@ -1,5 +1,7 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { SessionsListResult } from "../types";
export type ChatProps = { export type ChatProps = {
sessionKey: string; sessionKey: string;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
@@ -12,6 +14,7 @@ export type ChatProps = {
connected: boolean; connected: boolean;
canSend: boolean; canSend: boolean;
disabledReason: string | null; disabledReason: string | null;
sessions: SessionsListResult | null;
onRefresh: () => void; onRefresh: () => void;
onDraftChange: (next: string) => void; onDraftChange: (next: string) => void;
onSend: () => void; onSend: () => void;
@@ -20,6 +23,7 @@ export type ChatProps = {
export function renderChat(props: ChatProps) { export function renderChat(props: ChatProps) {
const canInteract = props.connected; const canInteract = props.connected;
const canCompose = props.canSend && !props.sending; const canCompose = props.canSend && !props.sending;
const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions);
const composePlaceholder = (() => { const composePlaceholder = (() => {
if (!props.connected) return "Connect to the gateway to start chatting…"; 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…"; if (!props.canSend) return "Connect an iOS/Android node to enable Web Chat + Talk…";
@@ -32,12 +36,16 @@ export function renderChat(props: ChatProps) {
<div class="chat-header__left"> <div class="chat-header__left">
<label class="field chat-session"> <label class="field chat-session">
<span>Session Key</span> <span>Session Key</span>
<input <select
.value=${props.sessionKey} .value=${props.sessionKey}
?disabled=${!canInteract} ?disabled=${!canInteract}
@input=${(e: Event) => @change=${(e: Event) =>
props.onSessionKeyChange((e.target as HTMLInputElement).value)} props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
/> >
${sessionOptions.map(
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
)}
</select>
</label> </label>
<button <button
class="btn" class="btn"
@@ -104,6 +112,55 @@ export function renderChat(props: ChatProps) {
`; `;
} }
type SessionOption = {
key: string;
updatedAt?: number | null;
};
function resolveSessionOptions(
currentKey: string,
sessions: SessionsListResult | null,
) {
const now = Date.now();
const cutoff = now - 24 * 60 * 60 * 1000;
const entries = Array.isArray(sessions?.sessions) ? sessions?.sessions ?? [] : [];
const sorted = [...entries].sort(
(a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0),
);
const recent: SessionOption[] = [];
const seen = new Set<string>();
for (const entry of sorted) {
if (seen.has(entry.key)) continue;
seen.add(entry.key);
if ((entry.updatedAt ?? 0) < cutoff) continue;
recent.push(entry);
}
const result: SessionOption[] = [];
const included = new Set<string>();
const mainKey = "main";
const mainEntry = sorted.find((entry) => entry.key === mainKey);
if (mainEntry) {
result.push(mainEntry);
included.add(mainKey);
} else if (currentKey === mainKey) {
result.push({ key: mainKey, updatedAt: null });
included.add(mainKey);
}
for (const entry of recent) {
if (included.has(entry.key)) continue;
result.push(entry);
included.add(entry.key);
}
if (!included.has(currentKey)) {
result.push({ key: currentKey, updatedAt: null });
}
return result;
}
function renderMessage(message: unknown, opts?: { streaming?: boolean }) { function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";