feat: add recent session switchers
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user