chore: merge origin/main
This commit is contained in:
@@ -469,7 +469,8 @@ class ChatController(
|
||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||
if (key.isEmpty()) return@mapNotNull null
|
||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ data class ChatPendingToolCall(
|
||||
data class ChatSessionEntry(
|
||||
val key: String,
|
||||
val updatedAtMs: Long?,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
data class ChatHistory(
|
||||
|
||||
@@ -62,6 +62,8 @@ fun ChatComposer(
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
@@ -82,13 +84,13 @@ fun ChatComposer(
|
||||
onClick = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("Session: $sessionKey")
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.key) },
|
||||
text = { Text(entry.displayName ?: entry.key) },
|
||||
onClick = {
|
||||
onSelectSession(entry.key)
|
||||
showSessionMenu = false
|
||||
|
||||
@@ -82,7 +82,7 @@ private fun SessionRow(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (isCurrent) {
|
||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -90,4 +90,3 @@ private fun SessionRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ struct ContextMenuCardView: View {
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
Text(row.label)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -63,9 +63,11 @@ final class ControlChannel {
|
||||
self.logger.info("control channel state -> connecting")
|
||||
case .disconnected:
|
||||
self.logger.info("control channel state -> disconnected")
|
||||
self.scheduleRecovery(reason: "disconnected")
|
||||
case let .degraded(message):
|
||||
let detail = message.isEmpty ? "degraded" : "degraded: \(message)"
|
||||
self.logger.info("control channel state -> \(detail, privacy: .public)")
|
||||
self.scheduleRecovery(reason: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +76,8 @@ final class ControlChannel {
|
||||
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "control")
|
||||
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var recoveryTask: Task<Void, Never>?
|
||||
private var lastRecoveryAt: Date?
|
||||
|
||||
private init() {
|
||||
self.startEventStream()
|
||||
@@ -231,7 +235,43 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription
|
||||
return "Gateway error: \(detail)"
|
||||
let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("gateway error:") { return trimmed }
|
||||
return "Gateway error: \(trimmed)"
|
||||
}
|
||||
|
||||
private func scheduleRecovery(reason: String) {
|
||||
let now = Date()
|
||||
if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return }
|
||||
guard self.recoveryTask == nil else { return }
|
||||
self.lastRecoveryAt = now
|
||||
|
||||
self.recoveryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let mode = await MainActor.run { AppStateStore.shared.connectionMode }
|
||||
guard mode != .unconfigured else {
|
||||
self.recoveryTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reasonText = trimmedReason.isEmpty ? "unknown" : trimmedReason
|
||||
self.logger.info(
|
||||
"control channel recovery starting mode=\(String(describing: mode), privacy: .public) reason=\(reasonText, privacy: .public)")
|
||||
if mode == .local {
|
||||
GatewayProcessManager.shared.setActive(true)
|
||||
}
|
||||
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
|
||||
@@ -114,6 +114,7 @@ final class HealthStore {
|
||||
guard !self.isRefreshing else { return }
|
||||
self.isRefreshing = true
|
||||
defer { self.isRefreshing = false }
|
||||
let previousError = self.lastError
|
||||
|
||||
do {
|
||||
let data = try await ControlChannel.shared.health(timeout: 15)
|
||||
@@ -121,13 +122,23 @@ final class HealthStore {
|
||||
self.snapshot = decoded
|
||||
self.lastSuccess = Date()
|
||||
self.lastError = nil
|
||||
if previousError != nil {
|
||||
Self.logger.info("health refresh recovered")
|
||||
}
|
||||
} else {
|
||||
self.lastError = "health output not JSON"
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != self.lastError {
|
||||
Self.logger.warning("health refresh failed: output not JSON")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.lastError = error.localizedDescription
|
||||
let desc = error.localizedDescription
|
||||
self.lastError = desc
|
||||
if onDemand { self.snapshot = nil }
|
||||
if previousError != desc {
|
||||
Self.logger.error("health refresh failed \(desc, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -374,6 +374,10 @@ struct MenuContent: View {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
@@ -106,13 +106,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
let width = self.initialWidth(for: menu)
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(self.makeMessageItem(
|
||||
text: self.controlChannelStatusText,
|
||||
symbolName: "wifi.slash",
|
||||
width: width), at: insertIndex)
|
||||
return
|
||||
}
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
guard let snapshot = self.cachedSnapshot else {
|
||||
let headerItem = NSMenuItem()
|
||||
@@ -195,17 +189,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
menu.insertItem(topSeparator, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: self.controlChannelStatusText, symbolName: "wifi.slash", width: width),
|
||||
at: cursor)
|
||||
if let gatewayEntry = self.gatewayEntry() {
|
||||
let gatewayItem = self.makeNodeItem(entry: gatewayEntry, width: width)
|
||||
menu.insertItem(gatewayItem, at: cursor)
|
||||
cursor += 1
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.tag = self.nodesTag
|
||||
menu.insertItem(separator, at: cursor)
|
||||
return
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else { return }
|
||||
|
||||
if let error = self.nodesStore.lastError?.nonEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
@@ -229,15 +220,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
cursor += 1
|
||||
} else {
|
||||
for entry in entries.prefix(8) {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
let item = self.makeNodeItem(entry: entry, width: width)
|
||||
menu.insertItem(item, at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
@@ -265,27 +248,56 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
private var controlChannelStatusText: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected:
|
||||
return "Connected"
|
||||
case .connecting:
|
||||
return "Connecting to gateway…"
|
||||
case let .degraded(reason):
|
||||
if self.shouldShowConnecting { return "Connecting to gateway…" }
|
||||
return reason.nonEmpty ?? "No connection to gateway"
|
||||
case .disconnected:
|
||||
return self.shouldShowConnecting ? "Connecting to gateway…" : "No connection to gateway"
|
||||
private func gatewayEntry() -> NodeInfo? {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let isConnected = self.isControlChannelConnected
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
var host: String?
|
||||
var platform: String?
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
}
|
||||
|
||||
return NodeInfo(
|
||||
nodeId: "gateway",
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
caps: nil,
|
||||
commands: nil,
|
||||
permissions: nil,
|
||||
paired: nil,
|
||||
connected: isConnected)
|
||||
}
|
||||
|
||||
private var shouldShowConnecting: Bool {
|
||||
switch GatewayProcessManager.shared.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return true
|
||||
case .stopped, .failed:
|
||||
return false
|
||||
}
|
||||
private func makeNodeItem(entry: NodeInfo, width: CGFloat) -> NSMenuItem {
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.nodesTag
|
||||
item.target = self
|
||||
item.action = #selector(self.copyNodeSummary(_:))
|
||||
item.representedObject = NodeMenuEntryFormatter.summaryText(entry)
|
||||
item.view = HighlightedMenuItemHostView(
|
||||
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
|
||||
width: width)
|
||||
item.submenu = self.buildNodeSubmenu(entry: entry, width: width)
|
||||
return item
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem {
|
||||
@@ -293,8 +305,9 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
Label(text, systemImage: symbolName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
@@ -2,15 +2,30 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct NodeMenuEntryFormatter {
|
||||
static func isGateway(_ entry: NodeInfo) -> Bool {
|
||||
entry.nodeId == "gateway"
|
||||
}
|
||||
|
||||
static func isConnected(_ entry: NodeInfo) -> Bool {
|
||||
entry.isConnected
|
||||
}
|
||||
|
||||
static func primaryName(_ entry: NodeInfo) -> String {
|
||||
entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
if self.isGateway(entry) {
|
||||
return entry.displayName?.nonEmpty ?? "Gateway"
|
||||
}
|
||||
return entry.displayName?.nonEmpty ?? entry.nodeId
|
||||
}
|
||||
|
||||
static func summaryText(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
let role = self.roleText(entry)
|
||||
let name = self.primaryName(entry)
|
||||
var parts = ["\(name) · \(role)"]
|
||||
if let ip = entry.remoteIp?.nonEmpty { parts.append("host \(ip)") }
|
||||
if let platform = self.platformText(entry) { parts.append(platform) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
let name = self.primaryName(entry)
|
||||
var prefix = "Node: \(name)"
|
||||
if let ip = entry.remoteIp?.nonEmpty {
|
||||
@@ -112,6 +127,11 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
return self.safeSystemSymbol(
|
||||
"antenna.radiowaves.left.and.right",
|
||||
fallback: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") {
|
||||
return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer")
|
||||
|
||||
@@ -75,10 +75,26 @@ final class NodesStore {
|
||||
self.lastError = nil
|
||||
self.statusMessage = nil
|
||||
} catch {
|
||||
if Self.isCancelled(error) {
|
||||
self.logger.debug("node.list cancelled; keeping last nodes")
|
||||
if self.nodes.isEmpty {
|
||||
self.statusMessage = "Refreshing devices…"
|
||||
}
|
||||
self.lastError = nil
|
||||
return
|
||||
}
|
||||
self.logger.error("node.list failed \(error.localizedDescription, privacy: .public)")
|
||||
self.nodes = []
|
||||
self.lastError = error.localizedDescription
|
||||
self.statusMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isCancelled(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ enum PermissionManager {
|
||||
await self.ensureMicrophone(interactive: interactive)
|
||||
case .speechRecognition:
|
||||
await self.ensureSpeechRecognition(interactive: interactive)
|
||||
case .camera:
|
||||
await self.ensureCamera(interactive: interactive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +116,24 @@ enum PermissionManager {
|
||||
return SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
}
|
||||
|
||||
private static func ensureCamera(interactive: Bool) async -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
return await AVCaptureDevice.requestAccess(for: .video)
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
CameraPermissionHelper.openSettings()
|
||||
}
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func voiceWakePermissionsGranted() -> Bool {
|
||||
let mic = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
let speech = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
@@ -153,6 +173,9 @@ enum PermissionManager {
|
||||
|
||||
case .speechRecognition:
|
||||
results[cap] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -189,6 +212,21 @@ enum MicrophonePermissionHelper {
|
||||
}
|
||||
}
|
||||
|
||||
enum CameraPermissionHelper {
|
||||
static func openSettings() {
|
||||
let candidates = [
|
||||
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
|
||||
"x-apple.systempreferences:com.apple.preference.security",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppleScriptPermission {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Screen Recording"
|
||||
case .microphone: "Microphone"
|
||||
case .speechRecognition: "Speech Recognition"
|
||||
case .camera: "Camera"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +133,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "Capture the screen for context or screenshots"
|
||||
case .microphone: "Allow Voice Wake and audio capture"
|
||||
case .speechRecognition: "Transcribe Voice Wake trigger phrases on-device"
|
||||
case .camera: "Capture photos and video from the camera"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ struct PermissionRow: View {
|
||||
case .screenRecording: "display"
|
||||
case .microphone: "mic"
|
||||
case .speechRecognition: "waveform"
|
||||
case .camera: "camera"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ struct GatewaySessionDefaultsRecord: Codable {
|
||||
|
||||
struct GatewaySessionEntryRecord: Codable {
|
||||
let key: String
|
||||
let displayName: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
let updatedAt: Double?
|
||||
let sessionId: String?
|
||||
let systemSent: Bool?
|
||||
@@ -65,6 +70,11 @@ struct SessionRow: Identifiable {
|
||||
let id: String
|
||||
let key: String
|
||||
let kind: SessionKind
|
||||
let displayName: String?
|
||||
let surface: String?
|
||||
let subject: String?
|
||||
let room: String?
|
||||
let space: String?
|
||||
let updatedAt: Date?
|
||||
let sessionId: String?
|
||||
let thinkingLevel: String?
|
||||
@@ -75,6 +85,7 @@ struct SessionRow: Identifiable {
|
||||
let model: String?
|
||||
|
||||
var ageText: String { relativeAge(from: self.updatedAt) }
|
||||
var label: String { self.displayName ?? self.key }
|
||||
|
||||
var flagLabels: [String] {
|
||||
var flags: [String] = []
|
||||
@@ -92,6 +103,8 @@ enum SessionKind {
|
||||
static func from(key: String) -> SessionKind {
|
||||
if key == "global" { return .global }
|
||||
if key.hasPrefix("group:") { return .group }
|
||||
if key.contains(":group:") { return .group }
|
||||
if key.contains(":channel:") { return .group }
|
||||
if key == "unknown" { return .unknown }
|
||||
return .direct
|
||||
}
|
||||
@@ -127,6 +140,11 @@ extension SessionRow {
|
||||
id: "direct-1",
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-90),
|
||||
sessionId: "sess-direct-1234",
|
||||
thinkingLevel: "low",
|
||||
@@ -137,8 +155,13 @@ extension SessionRow {
|
||||
model: "claude-3.5-sonnet"),
|
||||
SessionRow(
|
||||
id: "group-1",
|
||||
key: "group:engineering",
|
||||
key: "discord:channel:release-squad",
|
||||
kind: .group,
|
||||
displayName: "discord:#release-squad",
|
||||
surface: "discord",
|
||||
subject: nil,
|
||||
room: "#release-squad",
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-3600),
|
||||
sessionId: "sess-group-4321",
|
||||
thinkingLevel: "medium",
|
||||
@@ -151,6 +174,11 @@ extension SessionRow {
|
||||
id: "global",
|
||||
key: "global",
|
||||
kind: .global,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date().addingTimeInterval(-86400),
|
||||
sessionId: nil,
|
||||
thinkingLevel: nil,
|
||||
@@ -269,6 +297,11 @@ enum SessionLoader {
|
||||
id: entry.key,
|
||||
key: entry.key,
|
||||
kind: SessionKind.from(key: entry.key),
|
||||
displayName: entry.displayName,
|
||||
surface: entry.surface,
|
||||
subject: entry.subject,
|
||||
room: entry.room,
|
||||
space: entry.space,
|
||||
updatedAt: updated,
|
||||
sessionId: entry.sessionId,
|
||||
thinkingLevel: entry.thinkingLevel,
|
||||
|
||||
@@ -36,7 +36,7 @@ struct SessionMenuLabelView: View {
|
||||
height: self.barHeight)
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.row.key)
|
||||
Text(self.row.label)
|
||||
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
|
||||
.foregroundStyle(self.primaryTextColor)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -89,7 +89,7 @@ struct SessionsSettings: View {
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
Text(row.label)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -11,6 +11,7 @@ public enum Capability: String, Codable, CaseIterable, Sendable {
|
||||
case screenRecording
|
||||
case microphone
|
||||
case speechRecognition
|
||||
case camera
|
||||
}
|
||||
|
||||
public enum CameraFacing: String, Codable, Sendable {
|
||||
|
||||
@@ -29,6 +29,11 @@ struct MenuSessionsInjectorTests {
|
||||
id: "main",
|
||||
key: "main",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: "s1",
|
||||
thinkingLevel: "low",
|
||||
@@ -38,9 +43,14 @@ struct MenuSessionsInjectorTests {
|
||||
tokens: SessionTokenStats(input: 10, output: 20, total: 30, contextTokens: 200_000),
|
||||
model: "claude-opus-4-5"),
|
||||
SessionRow(
|
||||
id: "group:alpha",
|
||||
key: "group:alpha",
|
||||
id: "discord:group:alpha",
|
||||
key: "discord:group:alpha",
|
||||
kind: .group,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(timeIntervalSinceNow: -60),
|
||||
sessionId: "s2",
|
||||
thinkingLevel: "high",
|
||||
|
||||
@@ -6,7 +6,7 @@ import Testing
|
||||
struct SessionDataTests {
|
||||
@Test func sessionKindFromKeyDetectsCommonKinds() {
|
||||
#expect(SessionKind.from(key: "global") == .global)
|
||||
#expect(SessionKind.from(key: "group:engineering") == .group)
|
||||
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
|
||||
#expect(SessionKind.from(key: "unknown") == .unknown)
|
||||
#expect(SessionKind.from(key: "user@example.com") == .direct)
|
||||
}
|
||||
@@ -27,6 +27,11 @@ struct SessionDataTests {
|
||||
id: "x",
|
||||
key: "user@example.com",
|
||||
kind: .direct,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: Date(),
|
||||
sessionId: nil,
|
||||
thinkingLevel: "high",
|
||||
@@ -41,4 +46,3 @@ struct SessionDataTests {
|
||||
#expect(row.flagLabels.contains("aborted"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ struct WorkActivityStoreTests {
|
||||
@Test func mainSessionJobPreemptsOther() {
|
||||
let store = WorkActivityStore()
|
||||
|
||||
store.handleJob(sessionKey: "group:1", state: "started")
|
||||
store.handleJob(sessionKey: "discord:group:1", state: "started")
|
||||
#expect(store.iconState == .workingOther(.job))
|
||||
#expect(store.current?.sessionKey == "group:1")
|
||||
#expect(store.current?.sessionKey == "discord:group:1")
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "started")
|
||||
#expect(store.iconState == .workingMain(.job))
|
||||
@@ -18,9 +18,9 @@ struct WorkActivityStoreTests {
|
||||
|
||||
store.handleJob(sessionKey: "main", state: "finished")
|
||||
#expect(store.iconState == .workingOther(.job))
|
||||
#expect(store.current?.sessionKey == "group:1")
|
||||
#expect(store.current?.sessionKey == "discord:group:1")
|
||||
|
||||
store.handleJob(sessionKey: "group:1", state: "finished")
|
||||
store.handleJob(sessionKey: "discord:group:1", state: "finished")
|
||||
#expect(store.iconState == .idle)
|
||||
#expect(store.current == nil)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ struct ClawdisChatComposer: View {
|
||||
set: { next in self.viewModel.switchSession(to: next) }))
|
||||
{
|
||||
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
|
||||
Text(session.key)
|
||||
Text(session.displayName ?? session.key)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.tag(session.key)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable
|
||||
|
||||
public let key: String
|
||||
public let kind: String?
|
||||
public let displayName: String?
|
||||
public let surface: String?
|
||||
public let subject: String?
|
||||
public let room: String?
|
||||
public let space: String?
|
||||
public let updatedAt: Double?
|
||||
public let sessionId: String?
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ struct ChatSessionsSheet: View {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(session.key)
|
||||
Text(session.displayName ?? session.key)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
|
||||
@@ -23,7 +23,7 @@ public struct ClawdisChatView: View {
|
||||
static let composerPaddingHorizontal: CGFloat = 0
|
||||
static let stackSpacing: CGFloat = 0
|
||||
static let messageSpacing: CGFloat = 6
|
||||
static let messageListPaddingTop: CGFloat = 0
|
||||
static let messageListPaddingTop: CGFloat = 12
|
||||
static let messageListPaddingBottom: CGFloat = 16
|
||||
static let messageListPaddingHorizontal: CGFloat = 6
|
||||
#else
|
||||
@@ -32,7 +32,7 @@ public struct ClawdisChatView: View {
|
||||
static let composerPaddingHorizontal: CGFloat = 6
|
||||
static let stackSpacing: CGFloat = 6
|
||||
static let messageSpacing: CGFloat = 12
|
||||
static let messageListPaddingTop: CGFloat = 4
|
||||
static let messageListPaddingTop: CGFloat = 10
|
||||
static let messageListPaddingBottom: CGFloat = 6
|
||||
static let messageListPaddingHorizontal: CGFloat = 8
|
||||
#endif
|
||||
|
||||
@@ -341,6 +341,11 @@ public final class ClawdisChatViewModel {
|
||||
ClawdisChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: nil,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
|
||||
@@ -282,6 +282,11 @@ private extension TestChatTransportState {
|
||||
ClawdisChatSessionEntry(
|
||||
key: "recent-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
@@ -296,6 +301,11 @@ private extension TestChatTransportState {
|
||||
ClawdisChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
@@ -310,6 +320,11 @@ private extension TestChatTransportState {
|
||||
ClawdisChatSessionEntry(
|
||||
key: "recent-2",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recentOlder,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
@@ -324,6 +339,11 @@ private extension TestChatTransportState {
|
||||
ClawdisChatSessionEntry(
|
||||
key: "old-1",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: stale,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
@@ -365,6 +385,11 @@ private extension TestChatTransportState {
|
||||
ClawdisChatSessionEntry(
|
||||
key: "main",
|
||||
kind: nil,
|
||||
displayName: nil,
|
||||
surface: nil,
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: recent,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
|
||||
Reference in New Issue
Block a user