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).
- 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.

View File

@@ -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<ChatSessionEntry>,
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")
}

View File

@@ -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(

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 {
NavigationStack {
ClawdisChatView(viewModel: self.viewModel, userAccent: self.userAccent)
ClawdisChatView(
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent)
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

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

View File

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

View File

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

View File

@@ -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<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]) {
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):

View File

@@ -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<ClawdisChatTransportEvent>
private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation
init(historyResponses: [ClawdisChatHistoryPayload]) {
init(
historyResponses: [ClawdisChatHistoryPayload],
sessionsResponses: [ClawdisChatSessionsListResponse] = [])
{
self.historyResponses = historyResponses
self.sessionsResponses = sessionsResponses
var cont: AsyncStream<ClawdisChatTransportEvent>.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(

View File

@@ -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(),

View File

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

View File

@@ -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) {
<div class="chat-header__left">
<label class="field chat-session">
<span>Session Key</span>
<input
<select
.value=${props.sessionKey}
?disabled=${!canInteract}
@input=${(e: Event) =>
props.onSessionKeyChange((e.target as HTMLInputElement).value)}
/>
@change=${(e: Event) =>
props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
>
${sessionOptions.map(
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
)}
</select>
</label>
<button
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 }) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";