Compare commits

..
Author SHA1 Message Date
buzzert 153aa5d2fa ios: share prompt field across chat and agent views
TestFlight / Build and upload (push) Successful in 1m44s
2026-10-14 15:08:34 -07:00
buzzert 4389f805f4 ios: add persistent Sybil Agent room
TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details.

Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
2026-10-14 13:34:37 -07:00
buzzert e83281c400 Rename the persistent agent integration to Sybil Agent
Use SYBIL_AGENT_BASE_URL and SYBIL_AGENT_API_KEY with the renamed sybil-agent
service. Update errors, API documentation, and test fixtures while retaining the
existing room protocol and browser storage keys for compatibility.

Validation: 44 server tests, 51 web tests, and both production builds.
2026-10-14 12:37:03 -07:00
buzzert ef3d79c428 Add persistent agent messaging and shared chat bubbles
Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
2026-10-14 12:20:11 -07:00
buzzert dd6cd1ea7b Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
2026-10-07 22:05:49 -07:00
buzzert 3f9c1409fe ios: remove animated workspace character
TestFlight / Build and upload (push) Successful in 1m40s
2026-10-04 21:48:45 -07:00
68 changed files with 6004 additions and 345 deletions
+7 -1
View File
@@ -1,3 +1,8 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 80;
server_name _;
@@ -19,7 +24,8 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location = /sw.js {
+81
View File
@@ -81,6 +81,8 @@ Behavior notes:
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
@@ -123,6 +125,15 @@ Behavior notes:
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
History search (`q`) notes:
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
## Chats
### `GET /v1/chats`
@@ -551,3 +562,73 @@ Behavior notes:
```
For streaming contracts, see `docs/api/streaming-chat.md`.
## Sybil Agent Room
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
background work, and history; these endpoints do not create ordinary Sybil chats or
copy the transcript into the Sybil message database. All endpoints use normal Sybil
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
### `GET /v1/agent`
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
configuration returns 503; an unreachable agent server or malformed upstream JSON
returns 502.
### `GET /v1/agent/messages`
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
history. `after` catches up forward; `before` loads earlier history.
```json
{
"conversationId": "1",
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
"cursor": "43",
"beforeCursor": "43",
"hasMore": false
}
```
Messages use `user`, `assistant`, and `tool` roles. Tools use
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
pages can update initiated tools to completed or failed. Internal worker reports,
event prompts, and model reasoning are excluded. Cursors refer to underlying
durable entries and can advance without visible messages. Use returned cursors;
do not derive them from message IDs. `limit` bounds source entries, not displayed
messages.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
Content must contain non-whitespace text and is limited to 40,000 characters;
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`.
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while the agent works. Attachments are not yet supported.
The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
Invalid receipts return 502; delivery remains uncertain, so retry using the original
`requestId` to avoid creating a duplicate message.
### `GET /v1/agent/read-state`
### `PUT /v1/agent/read-state`
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
a stale browser from lowering a cursor saved by another browser. Advance it only
after showing the corresponding history; poll it to reconcile unread indicators
across clients. Agent REST responses use `Cache-Control: no-store`.
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
+61 -1
View File
@@ -1,6 +1,6 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
Endpoint:
- `POST /v1/chat-completions/stream`
@@ -275,3 +275,63 @@ event: done
data: {"type":"done","text":"Hello world"}
```
## Sybil Agent WebSocket
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
connected after each assistant turn and receives later background results. Sending
messages uses `POST /v1/agent/messages`.
The browser must send this first frame within five seconds:
```json
{ "type": "auth", "token": "Sybil token", "after": "43" }
```
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
the last successfully merged cursor. Invalid credentials close with 4401, malformed
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
browser credentials are not forwarded to the agent.
Native `pi-room.v1` frames are forwarded after authentication. This protocol
identifier is retained for compatibility with existing clients:
```json
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
```
```json
{
"type": "messages",
"conversationId": "1",
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
"cursor": "44",
"beforeCursor": "44",
"hasMore": false
}
```
```json
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
```
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
ping/pong heartbeats detect dead connections. Other writes belong on REST.
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
cursor. Missing agent configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Sybil Agent. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+2 -1
View File
@@ -24,7 +24,8 @@ let package = Package(
name: "Sybil",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui")
]),
],
resources: [.process("Resources")]),
.testTarget(
name: "SybilTests",
dependencies: ["Sybil"]
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "character-still.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -106,6 +106,9 @@ public struct SplitView: View {
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -17,7 +17,7 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient: SybilAPIClienting {
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
private let configuration: APIConfiguration
private let session: URLSession
@@ -40,6 +40,48 @@ actor SybilAPIClient: SybilAPIClienting {
self.session = session
}
func agentDescriptor() async throws -> AgentDescriptor {
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
throw AgentFailure(message: "Unsupported Agent room protocol.")
}
return value
}
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
var query = [URLQueryItem(name: "limit", value: "200")]
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
try page.validate()
return page
}
func agentReadState() async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
}
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
try receipt.validate(requestID: requestID)
return receipt
}
func agentSocket(after: String) async throws -> any AgentSocket {
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
task.maximumMessageSize = 8 * 1024 * 1024
task.resume()
let socket = AgentURLSocket(task: task)
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
catch { await socket.close(); throw error }
return socket
}
func verifySession() async throws -> AuthSession {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
}
@@ -49,6 +91,15 @@ actor SybilAPIClient: SybilAPIClienting {
return response.items
}
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
try await request(
"/v1/workspace-items",
method: "GET",
queryItems: [URLQueryItem(name: "q", value: query)],
responseType: WorkspaceSearchResponse.self
)
}
func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats
@@ -290,9 +341,10 @@ actor SybilAPIClient: SybilAPIClienting {
_ path: String,
method: String,
body: AnyEncodable? = nil,
queryItems: [URLQueryItem] = [],
responseType: Response.Type
) async throws -> Response {
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
SybilLog.debug(
SybilLog.network,
@@ -344,12 +396,14 @@ actor SybilAPIClient: SybilAPIClienting {
path: String,
method: String,
body: AnyEncodable?,
acceptsSSE: Bool
acceptsSSE: Bool,
queryItems: [URLQueryItem] = []
) throws -> URLRequest {
let url = try buildURL(path: path)
let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 120
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
if acceptsSSE {
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@@ -369,7 +423,7 @@ actor SybilAPIClient: SybilAPIClienting {
return request
}
private func buildURL(path: String) throws -> URL {
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
throw APIError.invalidBaseURL
}
@@ -380,6 +434,9 @@ actor SybilAPIClient: SybilAPIClienting {
basePath.removeLast()
}
components.path = "\(basePath)/\(trimmedPath)"
if !queryItems.isEmpty {
components.queryItems = (components.queryItems ?? []) + queryItems
}
guard let url = components.url else {
throw APIError.invalidBaseURL
@@ -3,6 +3,7 @@ import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
func listChats() async throws -> [ChatSummary]
func createChat(
title: String?,
@@ -0,0 +1,241 @@
import Foundation
struct AgentFailure: LocalizedError, Sendable {
let message: String
var errorDescription: String? { message }
}
// Compare decimal strings without losing precision through Double or overflowing Int.
enum AgentCursor {
static func normalize(_ value: String) throws -> String {
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
throw AgentFailure(message: "Invalid Agent cursor.")
}
let trimmed = value.drop(while: { $0 == "0" })
return trimmed.isEmpty ? "0" : String(trimmed)
}
static func less(_ a: String, _ b: String) -> Bool {
a.count == b.count ? a < b : a.count < b.count
}
static func advance(_ current: String, _ incoming: String) throws -> String {
let value = try normalize(incoming)
return less(current, value) ? value : current
}
}
struct AgentActivity: Codable, Equatable, Sendable {
struct Tool: Codable, Equatable, Sendable {
var id: String
var name: String
var status: String
var output: String?
}
struct Wait: Codable, Equatable, Sendable {
var due: Double
var reason: String?
}
var state: String = "idle"
var isThinking = false
var queued = 0
var backgroundWorkers = 0
var tools: [Tool] = []
var queuedMessages: [Message] = []
var wait: Wait?
var error: String?
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
}
struct AgentDescriptor: Decodable, Sendable {
var id: String
var title: String
var type: String
var model: String
var `protocol`: String
var activity: AgentActivity
var websocket: String
}
struct AgentPage: Codable, Sendable {
var conversationId: String
var messages: [Message]
var cursor: String?
var beforeCursor: String?
var hasMore: Bool
func validate() throws {
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
throw AgentFailure(message: "Invalid Agent history.")
}
if let cursor { _ = try AgentCursor.normalize(cursor) }
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
}
}
struct AgentReadState: Codable, Sendable {
var cursor: String
var updatedAt: String?
}
struct AgentReceipt: Codable, Sendable {
var id: Int64
var status: String
var messageId: String
func validate(requestID: String) throws {
guard id > 0, id <= 9_007_199_254_740_991,
["queued", "placed", "done", "unanswered"].contains(status),
messageId == "inbound:sybil:\(requestID)" else {
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
}
}
}
enum AgentEvent: Sendable {
case ready(String?)
case messages(AgentPage)
case activity(String, AgentActivity)
case heartbeat
case error(String)
static func decode(_ data: Data) throws -> AgentEvent? {
struct Envelope: Decodable {
var type: String
var conversationId: String?
var activity: AgentActivity?
var error: String?
}
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
switch envelope.type {
case "ready": return .ready(envelope.conversationId)
case "messages":
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
try page.validate()
return .messages(page)
case "activity":
guard let id = envelope.conversationId, let activity = envelope.activity else {
throw AgentFailure(message: "Invalid Agent activity frame.")
}
return .activity(id, activity)
case "pong", "heartbeat": return .heartbeat
case "error": return .error(envelope.error ?? "Agent connection error.")
default: return nil
}
}
}
enum AgentCoding {
static func decoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: text) { return date }
formatter.formatOptions = [.withInternetDateTime]
guard let date = formatter.date(from: text) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
}
return date
}
return decoder
}
}
extension Message {
var agentEntryCursor: String? {
let value = metadata?.objectValue?["entryId"]
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
return String(Int64(number))
}
return nil
}
var isAgentPublicReply: Bool {
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
}
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
}
struct AgentRoomState: Equatable, Sendable {
var conversationID: String?
var messages: [Message] = []
var cursor = "0"
var readCursor = "0"
var activity = AgentActivity()
var unreadCount: Int {
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
}
mutating func checkConversation(_ id: String) throws {
guard conversationID == nil || conversationID == id else {
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
}
conversationID = id
}
mutating func apply(_ page: AgentPage) throws {
try page.validate()
var next = self
try next.checkConversation(page.conversationId)
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
next.messages = Self.merge(messages, page.messages)
self = next
}
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
for var message in incoming {
if let old = byID[message.id] {
let prior = old.metadata?.objectValue ?? [:]
let next = message.metadata?.objectValue ?? [:]
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
message.metadata = .object(merged)
message.createdAt = min(old.createdAt, message.createdAt)
if stale { message.content = old.content; message.name = old.name }
}
}
byID[message.id] = message
}
return byID.values.sorted {
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
return $0.id < $1.id
}
}
}
// The transcript latch is independent of the truthful sidebar/worker activity.
struct AgentPresentation {
private(set) var suppressed = false
private(set) var busySince: Date?
private var seenTools: Set<String> = []
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
switch event {
case let .messages(page):
let existing = Set(before.messages.map(\.id))
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
if !replay {
for message in fresh {
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
else if message.role == .user { suppressed = false }
else if message.toolCallMetadata?.status == "initiated",
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
}
}
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
case let .activity(_, activity):
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
seenTools.formUnion(activity.tools.map(\.id))
default: break
}
reconcile(activity: after.activity, now: now)
}
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
private mutating func reconcile(activity: AgentActivity, now: Date) {
if suppressed || !activity.isBusy { busySince = nil }
else if busySince == nil { busySince = now }
}
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
}
@@ -0,0 +1,41 @@
import CryptoKit
import Foundation
struct AgentOutboxRow: Codable, Equatable, Sendable {
var requestID: String
var content: String
var createdAt: Date
var status: String = "sending"
var seenOnServer = false
var confirmed = false
var error: String?
var id: String { "inbound:sybil:\(requestID)" }
var message: Message {
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
if let error { info["error"] = .string(error) }
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
}
}
struct AgentOutboxStorage: Sendable {
var read: @Sendable () throws -> [AgentOutboxRow]
var write: @Sendable ([AgentOutboxRow]) throws -> Void
static func file(baseURL: URL) -> Self {
// Separate installations, but never persist tokens in a filename or payload.
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
let url = directory.appendingPathComponent("\(key).json")
return Self(read: {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
}, write: { rows in
let data = try JSONEncoder().encode(rows)
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
throw AgentFailure(message: "Retry pending messages before sending more.")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
})
}
}
@@ -0,0 +1,371 @@
import Foundation
import Network
import Observation
@MainActor
@Observable
final class SybilAgentRoom {
struct Snapshot: Equatable {
var room = AgentRoomState()
var isTyping = false
var entranceIDs: Set<String> = []
var revision = 0
}
private(set) var snapshot = Snapshot()
private(set) var connection = "Offline"
private(set) var error: String?
private(set) var isLoading = false
private(set) var outbox: [AgentOutboxRow] = []
var composer = ""
var isObscured = false
var isModalPresented = false
var isSelected = false
private(set) var isActive = true
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentOutboxStorage?
@ObservationIgnored private var storageReadable = true
@ObservationIgnored private var pending = AgentRoomState()
@ObservationIgnored private var presenter = AgentPresentation()
@ObservationIgnored private var pendingEntrances: Set<String> = []
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var connectionTask: Task<Void, Never>?
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
@ObservationIgnored private var batchTask: Task<Void, Never>?
@ObservationIgnored private var typingTask: Task<Void, Never>?
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
@ObservationIgnored private var socket: (any AgentSocket)?
@ObservationIgnored private var monitor: NWPathMonitor?
@ObservationIgnored private var lastPacket = Date()
@ObservationIgnored private var socketStarted = Date()
@ObservationIgnored private var desiredRead = "0"
@ObservationIgnored private var acknowledgedRead = "0"
@ObservationIgnored private var hasLoaded = false
@ObservationIgnored private var syncing = false
@ObservationIgnored private var wasNetworkOffline = false
var isBusy: Bool { snapshot.room.activity.isBusy }
var unreadCount: Int { snapshot.room.unreadCount }
var messages: [Message] {
let committed = Set(snapshot.room.messages.map(\.id))
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
for row in outbox {
if let index = messages.firstIndex(where: { $0.id == row.id }) {
messages[index].createdAt = row.createdAt
if !row.confirmed {
var info = messages[index].metadata?.objectValue ?? [:]
info["status"] = .string(row.status)
if let error = row.error { info["error"] = .string(error) }
messages[index].metadata = .object(info)
}
} else { messages.append(row.message) }
}
return AgentRoomState.merge([], messages)
}
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
stop()
self.client = client
self.storage = storage
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
var restored = row
if !restored.seenOnServer {
restored.status = "failed"
restored.error = "Delivery was interrupted. Retry to confirm."
}
return restored
}
} catch {
outbox = []; storageReadable = false
self.error = "Could not restore pending messages: \(error.localizedDescription)"
}
start()
}
func disconnect() {
stop()
client = nil
}
func stop() {
generation = UUID()
connectionTask?.cancel(); connectionTask = nil
maintenanceTask?.cancel(); maintenanceTask = nil
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
readTask?.cancel(); readTask = nil
sends.values.forEach { $0.cancel() }; sends = [:]
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
outbox[index].status = "failed"
outbox[index].error = "Delivery was interrupted. Retry to confirm."
}
if !outbox.isEmpty { try? persist() }
monitor?.cancel(); monitor = nil
let old = socket; socket = nil
Task { await old?.close() }
syncing = false
pendingEntrances = []
connection = "Offline"
}
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
if active { start() }
else {
// Publish successfully merged input before cancelling; replay resumes here.
flush()
stop()
}
}
func retryConnection() { flush(); stop(); if isActive { start() } }
private func start() {
guard isActive, let client, connectionTask == nil else { return }
let run = generation
isLoading = !hasLoaded
connection = "Connecting"
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self, self.generation == run else { return }
let recovered = self.wasNetworkOffline && satisfied
self.wasNetworkOffline = !satisfied
if recovered { self.retryConnection() }
}
}
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
self.monitor = monitor
connectionTask = Task { [weak self] in
guard let self else { return }
var delay = 1
while !Task.isCancelled, self.generation == run {
do {
let descriptor = try await client.agentDescriptor()
guard self.generation == run else { return }
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
try await self.refreshRead(client: client, run: run)
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
guard self.generation == run, !Task.isCancelled else { return }
self.hasLoaded = true; self.isLoading = false; self.flush()
let socket = try await client.agentSocket(after: self.pending.cursor)
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
self.socket = socket
self.socketStarted = Date(); self.lastPacket = Date()
while !Task.isCancelled, self.generation == run {
let data = try await socket.receive()
guard self.generation == run, !Task.isCancelled else { return }
self.lastPacket = Date()
guard let event = try AgentEvent.decode(data) else { continue }
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
try self.receive(event)
}
} catch {
guard self.generation == run, !Task.isCancelled else { return }
let failedSocket = self.socket
let code = await failedSocket?.closeCode()
await failedSocket?.close()
guard self.generation == run, !Task.isCancelled else { return }
self.socket = nil
self.isLoading = false
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
self.connection = code == 4401 ? "Offline" : "Reconnecting"
if code == 4401 { return }
}
try? await Task.sleep(for: .seconds(delay))
delay = min(delay * 2, 30)
}
}
maintenanceTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard let self, self.generation == run, !Task.isCancelled else { return }
do {
try await self.refreshRead(client: client, run: run)
guard self.generation == run, !Task.isCancelled else { return }
self.flushRead()
if self.connection != "Connected" {
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
}
guard self.generation == run, !Task.isCancelled else { return }
if let socket = self.socket {
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
await socket.close()
} else { try await socket.send("{\"type\":\"ping\"}") }
}
} catch {
if self.generation == run { self.error = error.localizedDescription }
}
}
}
}
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
let run = run ?? generation
guard generation == run, !Task.isCancelled, !syncing else { return }
syncing = true
defer { if generation == run { syncing = false } }
var cursor = pending.cursor
while !Task.isCancelled, generation == run {
let page = try await client.agentMessages(after: cursor, before: nil)
guard generation == run, !Task.isCancelled else { return }
try page.validate()
let next = try AgentCursor.normalize(page.cursor ?? cursor)
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
throw AgentFailure(message: "Agent history did not advance its cursor.")
}
try receive(.messages(page), replay: replay)
cursor = next
if !page.hasMore { return }
}
}
func receive(_ event: AgentEvent, replay: Bool = false) throws {
let before = pending
var next = pending
switch event {
case let .messages(page): try next.apply(page)
case let .activity(id, activity):
try next.checkConversation(id)
next.activity = activity
case let .ready(id): if let id { try next.checkConversation(id) }
case let .error(message): throw AgentFailure(message: message)
case .heartbeat: return
}
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
pending = next
if !replay {
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
}
reconcileOutbox()
schedulePublish()
}
private func schedulePublish() {
guard batchTask == nil else { return }
batchTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
self?.flush()
}
}
func flush() {
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
pendingEntrances = []
if let start = presenter.busySince, !snapshot.isTyping {
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
typingTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
self?.flush()
}
}
}
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
let read = try await client.agentReadState()
guard generation == run else { return }
try applyRead(read)
}
func applyRead(_ read: AgentReadState) throws {
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
}
func markRead(renderedCursor: String) {
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured)
guard self.generation == run, !Task.isCancelled else { return }
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
throw AgentFailure(message: "Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch { if self.generation == run { self.error = error.localizedDescription } }
}
}
@discardableResult
func send(_ content: String) -> Bool {
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
error = "Enter a message of at most 40,000 characters."; return false
}
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
outbox.append(row)
pendingEntrances.insert(row.id)
presenter.sent(now: Date(), activity: pending.activity)
flush()
dispatch(row.id)
return true
}
func retry(_ id: String) { dispatch(id) }
private func persist() throws {
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
try storage.write(outbox.filter { !$0.confirmed })
}
private func dispatch(_ id: String) {
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
outbox[index].status = "sending"; outbox[index].error = nil
do { try persist() }
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
let row = outbox[index], run = generation
sends[id] = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.sends[id] = nil } }
do {
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
try receipt.validate(requestID: row.requestID)
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
self.outbox[index].seenOnServer = true
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
self.outbox[index].error = nil
try? self.persist()
} catch {
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
try? self.persist()
}
}
}
private func reconcileOutbox() {
let committed = Set(pending.messages.map(\.id))
let queued = Set(pending.activity.queuedMessages.map(\.id))
var changed = false
for index in outbox.indices {
if committed.contains(outbox[index].id) {
if !outbox[index].confirmed { changed = true }
outbox[index].confirmed = true; outbox[index].seenOnServer = true
outbox[index].status = "complete"; outbox[index].error = nil
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
changed = true
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
}
}
if changed { try? persist() }
}
}
@@ -0,0 +1,56 @@
import Foundation
protocol AgentSocket: Sendable {
func receive() async throws -> Data
func send(_ text: String) async throws
func close() async
func closeCode() async -> Int
}
protocol AgentAPIClienting: Sendable {
func agentDescriptor() async throws -> AgentDescriptor
func agentMessages(after: String?, before: String?) async throws -> AgentPage
func agentReadState() async throws -> AgentReadState
func agentMarkRead(cursor: String) async throws -> AgentReadState
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
func agentSocket(after: String) async throws -> any AgentSocket
}
actor AgentURLSocket: AgentSocket {
private let task: URLSessionWebSocketTask
init(task: URLSessionWebSocketTask) { self.task = task }
func receive() async throws -> Data {
switch try await task.receive() {
case let .string(text): return Data(text.utf8)
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
}
}
func send(_ text: String) async throws { try await task.send(.string(text)) }
func close() { task.cancel(with: .goingAway, reason: nil) }
func closeCode() -> Int { task.closeCode.rawValue }
}
extension APIConfiguration {
func agentWebSocketURL() throws -> URL {
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil,
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
parts.query = nil
parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
func agentAuthFrame(after: String) throws -> String {
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let value: JSONValue = .object([
"type": .string("auth"),
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
"after": .string(try AgentCursor.normalize(after))
])
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
}
}
@@ -0,0 +1,360 @@
import SwiftUI
struct SybilAgentView: View {
@Bindable var room: SybilAgentRoom
var topInset: CGFloat = 0
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@FocusState private var composerFocused: Bool
@State private var atLatest = true
@State private var isScrolling = false
@State private var historyLimit = 100
@State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition()
@State private var scrollMetrics = AgentScrollMetrics()
@State private var rowFrames: [String: CGRect] = [:]
@State private var restoreAnchor: AgentReadingAnchor?
@State private var appeared = false
private let bottomID = "agent-bottom"
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
var body: some View {
let rendered = room.snapshot
let allMessages = room.messages
let startIndex = historyStartID.flatMap { id in allMessages.firstIndex(where: { $0.id == id }) } ?? max(0, allMessages.count - historyLimit)
let displayed = Array(allMessages.dropFirst(startIndex))
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count {
Button("Load earlier messages") {
if let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
}
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
VStack(spacing: 14) {
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
Group {
if message.isToolCallLog {
AgentToolCard(message: message)
} else {
AgentMessageRow(message: message, retry: { room.retry(message.id) })
}
}
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
}
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.id(message.id)
}
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
Color.clear.frame(height: 1).id(bottomID)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
.padding(.vertical, 16)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.coordinateSpace(name: "agent-scroll")
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .initialOffset)
.defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
}
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y,
height: geometry.containerSize.height,
contentHeight: geometry.contentSize.height,
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top)
} action: { previous, next in
scrollMetrics = next
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
if atLatest, !isScrolling, restoreAnchor == nil,
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
proxy.scrollTo(bottomID, anchor: .bottom)
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion, tool
// expansion or text reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollMetrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollMetrics.topInset), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
let appliedShift = target - scrollMetrics.offset
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
restoreAnchor = nil
} else {
rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
}
}
.overlay(alignment: .bottomTrailing) {
if !atLatest {
Button {
atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
} label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold))
.padding(12)
.background(SybilTheme.surfaceStrong, in: Capsule())
.overlay(Capsule().stroke(SybilTheme.border))
}
.padding(14)
.accessibilityIdentifier("agent-jump-latest")
}
}
.onAppear {
appeared = true
if !room.isLoading { historyStartID = displayed.first?.id }
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
}
.onChange(of: allMessages.count) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
}
.onChange(of: composerFocused) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
}
}
statusBar
composer
}
.padding(.top, topInset)
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
.task(id: readKey) {
// This cursor belongs to the rendered snapshot. New network input cancels
// the task, and cannot be acknowledged while still entering the layout.
let cursor = rendered.room.cursor
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
@ViewBuilder private var statusBar: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
Spacer(minLength: 4)
Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold))
}.padding(.horizontal, 16).padding(.vertical, 8)
} else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil {
HStack(spacing: 10) {
if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") }
if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") }
if let wait = activity.wait {
Text(wait.reason ?? "Waiting")
Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time)
}
Spacer(minLength: 0)
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
.padding(.horizontal, 16).padding(.vertical, 6)
}
}
private var canSendComposer: Bool {
!room.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
}
private var composer: some View {
HStack(alignment: .bottom, spacing: 10) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
private func submitComposer() {
guard canSendComposer, room.send(room.composer) else { return }
room.composer = ""
atLatest = true
}
}
struct AgentAvatar: View {
var body: some View {
Image("character-still", bundle: .module).resizable().scaledToFill()
.frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3)))
.accessibilityHidden(true)
}
}
private struct AgentMessageRow: View {
var message: Message
var retry: () -> Void
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if !own { AgentAvatar().padding(.bottom, 23) }
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
HStack(spacing: 6) {
Text(own ? "You" : "Sybil")
Text(message.createdAt, style: .time)
if own && status == "sending" { Text("Sending…") }
if own && status == "queued" { Label("Queued", systemImage: "clock") }
if own && status == "failed" {
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
}
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
}
}
}
}
}
struct AgentToolCard: View {
var message: Message
@State private var expanded = false
var body: some View {
if let metadata = message.toolCallMetadata {
VStack(alignment: .leading, spacing: 0) {
Button {
expanded.toggle()
} label: {
HStack(spacing: 8) {
ToolCallActivityChip(metadata: metadata, fallbackContent: message.content, createdAt: message.createdAt, compactLayout: true)
.allowsHitTesting(false)
Spacer(minLength: 0)
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.system(size: 13, weight: .semibold))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("agent-tool-\(metadata.toolName ?? "tool")")
.accessibilityValue(expanded ? "Expanded" : "Collapsed")
if expanded {
VStack(alignment: .leading, spacing: 12) {
if let args = metadata.args { detail("Input", text: pretty(args)) }
if let error = metadata.error { detail("Error", text: error) }
if let result = metadata.result { detail("Result", text: pretty(result)) }
else if let preview = metadata.resultPreview { detail("Result", text: preview) }
if metadata.args == nil && metadata.result == nil && metadata.resultPreview == nil && metadata.error == nil {
Text(message.content).font(.sybil(.caption))
}
}.padding(.top, 8).frame(maxWidth: .infinity, alignment: .leading)
}
}
.foregroundStyle(SybilTheme.textMuted)
.padding(8)
.background(SybilTheme.surface.opacity(0.8), in: RoundedRectangle(cornerRadius: 14))
}
}
private func detail(_ title: String, text: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-tool-\(title.lowercased())")
Text(text).font(.system(size: 12, design: .monospaced)).textSelection(.enabled)
}
}
private func pretty(_ value: JSONValue) -> String {
if let text = value.stringValue { return text }
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return (try? encoder.encode(value)).map { String(decoding: $0, as: UTF8.self) } ?? ""
}
}
// Animate both occupied height and content, so incoming rows push older history up.
private struct AgentGrowingLayout: Layout {
var progress: CGFloat
var animatableData: CGFloat { get { progress } set { progress = newValue } }
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
}
}
private struct AgentEntrance: ViewModifier {
var animate: Bool
var reduceMotion: Bool
@State private var entered = false
func body(content: Content) -> some View {
let preparing = animate && !entered && !reduceMotion
AgentGrowingLayout(progress: preparing ? 0 : 1) {
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
}
.clipped()
.onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
}
}
}
private struct AgentScrollMetrics: Equatable {
var offset: CGFloat = 0
var height: CGFloat = 0
var contentHeight: CGFloat = 0
var bottomInset: CGFloat = 0
var topInset: CGFloat = 0
var atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
}
private struct AgentReadingAnchor {
var id: String
var offset: CGFloat
var expectedCount: Int? = nil
}
private struct AgentRowFrames: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
}
}
@@ -149,12 +149,13 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
return items
}
private struct MessageBubble: View {
struct MessageBubble: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var isForking: Bool
var onFork: (() -> Void)?
var agentStyle = false
private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata
@@ -229,14 +230,19 @@ private struct MessageBubble: View {
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
if isUser {
Text(message.content)
.font(.sybil(.body))
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
}
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
.padding(.vertical, isUser || agentStyle ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
@@ -247,6 +253,10 @@ private struct MessageBubble: View {
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
} else if agentStyle {
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
@@ -601,7 +611,7 @@ private struct ToolCallStackCard: View {
}
}
private struct ToolCallActivityChip: View {
struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
@@ -219,6 +219,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var searchSnippet: String? = nil
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
@@ -302,6 +303,7 @@ extension WorkspaceItem {
case id
case title
case query
case searchSnippet
case parentChatId
case titleGenerationPending
case createdAt
@@ -320,6 +322,7 @@ extension WorkspaceItem {
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
query = try container.decodeIfPresent(String.self, forKey: .query)
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
@@ -356,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue,
summary: object["summary"]?.stringValue,
durationMs: object["durationMs"]?.numberValue.map(Int.init),
durationMs: object["durationMs"]?.numberValue.flatMap { value in
value >= 0 && value < Double(Int.max) ? Int(value) : nil
},
error: object["error"]?.stringValue,
resultPreview: object["resultPreview"]?.stringValue
resultPreview: object["resultPreview"]?.stringValue,
args: object["args"],
result: object["result"],
startedAt: object["startedAt"]?.stringValue
)
}
@@ -375,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
public var durationMs: Int?
public var error: String?
public var resultPreview: String?
public var args: JSONValue? = nil
public var result: JSONValue? = nil
public var startedAt: String? = nil
}
public enum JSONValue: Codable, Hashable, Sendable {
@@ -730,6 +741,11 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary]
}
struct WorkspaceSearchResponse: Codable, Sendable {
var items: [WorkspaceItem]
var hasMore: Bool
}
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
@@ -2,6 +2,7 @@ import Observation
import SwiftUI
enum PhoneRoute: Hashable {
case agent
case chat(String)
case search(String)
case draftChat
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .settings:
return .settings
}
@@ -64,6 +66,7 @@ struct SybilPhoneShellView: View {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .draftChat, .draftSearch, .settings:
return nil
}
@@ -87,6 +90,10 @@ struct SybilPhoneShellView: View {
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
viewModel.agent.isObscured = covered
}
.onDisappear { viewModel.agent.isObscured = false }
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -242,6 +249,7 @@ struct SybilPhoneShellView: View {
}
private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = {
route = nextRoute
}
@@ -271,12 +279,19 @@ struct SybilPhoneShellView: View {
}
private func hideSidebarOverlay() {
dismissSidebarKeyboard()
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false
}
resetSidebarSwipe(animated: false)
}
private func dismissSidebarKeyboard() {
if isSidebarOverlayPresented {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return
@@ -517,7 +532,9 @@ private struct SybilPhoneSidebarRoot: View {
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
}
},
onSelectAgent: { onSelect(.agent) },
isAgentSelected: highlightedSelection == .agent
)
}
.background(SybilTheme.panelGradient)
@@ -621,6 +638,8 @@ private struct SybilPhoneDestinationView: View {
return
}
viewModel.select(.search(searchID))
case .agent:
viewModel.select(.agent)
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
@@ -0,0 +1,62 @@
import SwiftUI
struct SybilPromptField: View {
var placeholder: String = "Enter Prompt"
@Binding var text: String
@FocusState.Binding var focused: Bool
var selection: Binding<TextSelection?>? = nil
var axis: Axis = .vertical
var submit: () -> Void
var body: some View {
Group {
if let selection {
TextField(placeholder, text: $text, selection: selection, axis: axis)
} else {
TextField(placeholder, text: $text, axis: axis)
}
}
.focused($focused)
.font(.sybil(.body))
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submit)
.submitOnMacReturn(submit)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
}
}
struct SybilPromptSendButton: View {
var systemImage: String = "arrow.up"
var canSend: Bool
var submit: () -> Void
var body: some View {
Button(action: submit) {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
canSend
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!canSend)
.accessibilityLabel("Send message")
}
}
@@ -95,46 +95,19 @@ struct SybilQuickQuestionView: View {
private var composer: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .bottom, spacing: 10) {
TextField(
"Ask anything...",
SybilPromptField(
placeholder: "Ask anything...",
text: Binding(
get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) }
),
focused: $promptFocused,
selection: $promptSelection,
axis: .horizontal
axis: .horizontal,
submit: submitQuestion
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.submitLabel(.send)
.onSubmit(submitQuestion)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button(action: submitQuestion) {
Image(systemName: "arrow.up")
.font(.body.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendQuickQuestion
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
)
)
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendQuickQuestion)
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: submitQuestion)
.accessibilityLabel("Ask quick question")
}
@@ -113,8 +113,11 @@ struct SybilSidebarItemList: View {
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
var onOpenForkedChat: ((String) -> Void)? = nil
var onSelectAgent: (() -> Void)? = nil
var isAgentSelected: Bool? = nil
@State private var renameTarget: SidebarItem?
@State private var renameTitle = ""
@FocusState private var isSearchFocused: Bool
private var isRenameAlertPresented: Binding<Bool> {
Binding {
@@ -128,37 +131,122 @@ struct SybilSidebarItemList: View {
}
var body: some View {
VStack(spacing: 0) {
Button {
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
} label: {
HStack(spacing: 10) {
AgentAvatar()
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
Spacer()
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
if viewModel.agent.unreadCount > 0 {
Text(String(viewModel.agent.unreadCount))
.font(.sybil(.caption, weight: .semibold))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(SybilTheme.primary, in: Capsule())
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
}
}
.foregroundStyle(SybilTheme.text)
.padding(12)
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
}
.buttonStyle(.plain)
.accessibilityIdentifier("agent-sidebar")
.padding(.horizontal, 10).padding(.top, 10)
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(SybilTheme.textMuted)
TextField("Search chats and web searches", text: Binding(
get: { viewModel.sidebarSearchQuery },
set: { viewModel.setSidebarSearchQuery($0) }
))
.font(.sybil(.subheadline))
.foregroundStyle(SybilTheme.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($isSearchFocused)
.accessibilityLabel("Search conversation history and saved web searches")
.onSubmit {
isSearchFocused = false
Task { await viewModel.refreshSidebarSearch() }
}
if !viewModel.sidebarSearchQuery.isEmpty {
Button {
viewModel.setSidebarSearchQuery("")
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(SybilTheme.textMuted)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear history search")
}
}
.padding(12)
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
.padding(10)
itemList
}
}
private var itemList: some View {
Group {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
if viewModel.isSearchingSidebar ||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
VStack(spacing: 12) {
Text(error)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
.multilineTextAlignment(.center)
Button("Try again") {
Task { await viewModel.refreshSidebarSearch() }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else if viewModel.visibleSidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
if viewModel.isSidebarSearchActive {
Text("Try a title, a query, or words from a conversation.")
.font(.sybil(.caption))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
ForEach(viewModel.visibleSidebarItems) { item in
Button {
isSearchFocused = false
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
}
.buttonStyle(.plain)
.contextMenu {
@@ -201,9 +289,16 @@ struct SybilSidebarItemList: View {
}
}
}
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
Text("Showing the 100 most recent matches. Refine your search to find more.")
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.padding(12)
}
}
.padding(10)
}
.scrollDismissesKeyboard(.interactively)
.refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
@@ -257,6 +352,25 @@ struct SybilSidebarItemList: View {
struct SybilSidebarRow: View {
var item: SidebarItem
var isSelected: Bool
var searchQuery = ""
private func highlightedSnippet(_ snippet: String) -> AttributedString {
var highlighted = AttributedString(snippet)
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
guard let range = Range(match.range, in: searchQuery) else { return nil }
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
}.filter { !$0.isEmpty }
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
guard let range = Range(word.range, in: snippet) else { continue }
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
guard terms.contains(where: { normalized.hasPrefix($0) }),
let start = AttributedString.Index(range.lowerBound, within: highlighted),
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
}
return highlighted
}
private var isHighlighted: Bool {
isSelected
@@ -318,6 +432,15 @@ struct SybilSidebarRow: View {
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
if let snippet = item.searchSnippet, !snippet.isEmpty {
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.truncationMode(.middle)
.multilineTextAlignment(.leading)
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
@@ -7,6 +7,7 @@ enum DraftKind {
}
enum SidebarSelection: Hashable {
case agent
case chat(String)
case search(String)
case settings
@@ -17,6 +18,8 @@ enum SidebarSelection: Hashable {
return "chat:\(chatID)"
case let .search(searchID):
return "search:\(searchID)"
case .agent:
return "agent"
case .settings:
return "settings"
}
@@ -38,6 +41,7 @@ struct SidebarItem: Identifiable, Hashable {
var starredAt: Date?
var initiatedLabel: String?
var isRunning: Bool
var searchSnippet: String? = nil
}
enum StreamingAssistantContentUpdate: Equatable, Sendable {
@@ -152,6 +156,7 @@ private actor SearchStreamStatus {
@Observable
final class SybilViewModel {
let settings: SybilSettingsStore
let agent = SybilAgentRoom()
var isCheckingSession = true
var isAuthenticated = false
@@ -161,6 +166,15 @@ final class SybilViewModel {
var chats: [ChatSummary] = []
var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
private(set) var sidebarSearchQuery = ""
private(set) var sidebarSearchResults: [WorkspaceItem] = []
private(set) var isSearchingSidebar = false
private(set) var sidebarSearchError: String?
private(set) var sidebarSearchHasMore = false
@ObservationIgnored
private var sidebarSearchTask: Task<Void, Never>?
@ObservationIgnored
private var sidebarSearchRequestID = UUID()
var selectedItem: SidebarSelection?
var selectedChat: ChatDetail?
@@ -329,6 +343,8 @@ final class SybilViewModel {
}
return "Search"
case .agent:
return "Sybil Agent"
case .settings:
return "Settings"
}
@@ -368,6 +384,7 @@ final class SybilViewModel {
}
var showsComposer: Bool {
if selectedItem == .agent { return false }
if case .settings = selectedItem {
return false
}
@@ -482,7 +499,19 @@ final class SybilViewModel {
}
var sidebarItems: [SidebarItem] {
workspaceItems.map { item in
makeSidebarItems(workspaceItems)
}
var isSidebarSearchActive: Bool {
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var visibleSidebarItems: [SidebarItem] {
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
}
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
items.map { item in
switch item.type {
case .chat:
let initiatedLabel: String?
@@ -496,7 +525,8 @@ final class SybilViewModel {
initiatedLabel = nil
}
let starOwner = item.parentChatId.flatMap { parentChatID in
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == parentChatID })
} ?? item
return SidebarItem(
@@ -507,7 +537,8 @@ final class SybilViewModel {
starred: starOwner.starred,
starredAt: starOwner.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
isRunning: isChatRowRunning(item.id),
searchSnippet: item.searchSnippet
)
case .search:
@@ -519,20 +550,70 @@ final class SybilViewModel {
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
isRunning: isSearchRowRunning(item.id),
searchSnippet: item.searchSnippet
)
}
}
}
func setSidebarSearchQuery(_ query: String) {
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
sidebarSearchQuery = query
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
scheduleSidebarSearch()
}
func refreshSidebarSearch() async {
guard isSidebarSearchActive else { return }
scheduleSidebarSearch(debounce: false)
await sidebarSearchTask?.value
}
private func scheduleSidebarSearch(debounce: Bool = true) {
sidebarSearchTask?.cancel()
let requestID = UUID()
sidebarSearchRequestID = requestID
sidebarSearchResults = []
sidebarSearchError = nil
sidebarSearchHasMore = false
isSearchingSidebar = false
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, isAuthenticated else { return }
guard query.utf16.count <= 200 else {
sidebarSearchError = "Use 200 characters or fewer."
return
}
isSearchingSidebar = true
sidebarSearchTask = Task { [weak self] in
do {
if debounce { try await Task.sleep(for: .milliseconds(300)) }
guard let self, !Task.isCancelled else { return }
let response = try await self.client().searchWorkspaceItems(query: query)
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.sidebarSearchResults = response.items
self.sidebarSearchHasMore = response.hasMore
self.isSearchingSidebar = false
} catch {
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.isSearchingSidebar = false
if !self.isCancellation(error) {
self.sidebarSearchError = self.normalizeAPIError(error)
}
}
}
}
var selectedChatSummary: ChatSummary? {
guard case let .chat(chatID) = selectedItem else { return nil }
return chats.first(where: { $0.id == chatID })
return chats.first(where: { $0.id == chatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
}
var selectedSearchSummary: SearchSummary? {
guard case let .search(searchID) = selectedItem else { return nil }
return searches.first(where: { $0.id == searchID })
return searches.first(where: { $0.id == searchID }) ??
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
}
var hasRefreshableSelection: Bool {
@@ -543,7 +624,7 @@ final class SybilViewModel {
switch selectedItem {
case .chat, .search:
return true
case .settings:
case .settings, .agent:
return false
}
}
@@ -559,6 +640,8 @@ final class SybilViewModel {
}
func reconnect() async {
agent.disconnect()
setSidebarSearchQuery("")
isCheckingSession = true
authError = nil
errorMessage = nil
@@ -596,6 +679,10 @@ final class SybilViewModel {
)
await loadInitialData(using: client)
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
agent.setActive(isAppActive)
}
startActiveRunPolling()
} catch {
stopActiveRunPolling()
@@ -865,6 +952,15 @@ final class SybilViewModel {
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
// Search can open history outside the recent collection. Retain its
// summary for provider settings, starring, and subsequent refreshes.
if let item = sidebarSearchResults.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
}) {
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
}
if draftKind == nil, selectedItem == selection {
errorMessage = nil
if case .search = selection {
@@ -897,7 +993,7 @@ final class SybilViewModel {
selectedChat = nil
composerAttachments = []
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
return nil
@@ -915,7 +1011,7 @@ final class SybilViewModel {
}
private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems
let items = visibleSidebarItems
guard !items.isEmpty else {
return
}
@@ -935,6 +1031,7 @@ final class SybilViewModel {
}
func deleteItem(_ selection: SidebarSelection) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -947,10 +1044,11 @@ final class SybilViewModel {
try await client.deleteChat(chatID: chatID)
case let .search(searchID):
try await client.deleteSearch(searchID: searchID)
case .settings:
case .settings, .agent:
break
}
await refreshCollections(preferredSelection: nil)
await refreshSidebarSearch()
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
@@ -1006,6 +1104,7 @@ final class SybilViewModel {
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
applyChatSummary(updated, moveToFront: true)
await refreshSidebarSearch()
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
@@ -1013,6 +1112,7 @@ final class SybilViewModel {
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -1030,7 +1130,7 @@ final class SybilViewModel {
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
case .settings, .agent:
break
}
} catch {
@@ -1053,12 +1153,14 @@ final class SybilViewModel {
}
isAppActive = false
agent.setActive(false)
appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
}
func markAppActiveForNetwork() {
isAppActive = true
agent.setActive(true)
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
@@ -1105,6 +1207,10 @@ final class SybilViewModel {
}
func refreshSidebarCollectionsFromPullToRefresh() async {
if isSidebarSearchActive {
await refreshSidebarSearch()
return
}
guard isAuthenticated, !isCheckingSession else {
return
}
@@ -1382,7 +1488,7 @@ final class SybilViewModel {
if let nextSelection {
switch nextSelection {
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
case .chat, .search:
@@ -1445,7 +1551,15 @@ final class SybilViewModel {
async let activeRunsValue = client.getActiveRuns()
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
applyWorkspaceItems(nextWorkspaceItems)
var items = nextWorkspaceItems
// A selected historical search result may be outside the latest 100.
if let preferredSelection,
let selectedSummary = workspaceItems.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
items.append(selectedSummary)
}
applyWorkspaceItems(items)
applyActiveRuns(nextActiveRuns)
SybilLog.info(
@@ -1623,6 +1737,11 @@ final class SybilViewModel {
}
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
var updated = item
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
sidebarSearchResults[index] = updated
}
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex)
if moveToFront {
@@ -1646,6 +1765,9 @@ final class SybilViewModel {
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
return chatID
}
@@ -1677,7 +1799,7 @@ final class SybilViewModel {
await self?.attachToActiveSearchStream(searchID: searchID)
}
case .settings, nil:
case .settings, .agent, nil:
return
}
}
@@ -1886,12 +2008,13 @@ final class SybilViewModel {
return selectedChat?.id != chatID
case let .search(searchID):
return selectedSearch?.id != searchID
case .settings:
case .settings, .agent:
return false
}
}
private func refreshSelectionIfNeeded() async {
if selectedItem == .agent { isLoadingSelection = false; return }
guard let target = selectedItem else {
selectedChat = nil
selectedSearch = nil
@@ -1933,7 +2056,7 @@ final class SybilViewModel {
selectedSearch = search
selectedChat = nil
case .settings:
case .settings, .agent:
break
}
errorMessage = nil
@@ -2649,7 +2772,7 @@ final class SybilViewModel {
return chats.contains(where: { $0.id == chatID })
case let .search(searchID):
return searches.contains(where: { $0.id == searchID })
case .settings:
case .settings, .agent:
return true
}
}
@@ -1,4 +1,3 @@
import ImageIO
import Observation
import PhotosUI
import SwiftUI
@@ -156,8 +155,6 @@ struct SybilWorkspaceView: View {
workspaceContentStack
if showsCustomWorkspaceNavigation {
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
.allowsHitTesting(false)
customWorkspaceNavigationBar
}
}
@@ -176,6 +173,8 @@ struct SybilWorkspaceView: View {
if isSettingsSelected {
SybilSettingsView(viewModel: viewModel)
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.selectedItem == .agent {
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.isSearchMode {
SybilSearchResultsView(
search: viewModel.displayedSearch,
@@ -288,7 +287,7 @@ struct SybilWorkspaceView: View {
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
@@ -510,7 +509,7 @@ struct SybilWorkspaceView: View {
@ViewBuilder
private var workspaceNavigationTrailingControl: some View {
if isSettingsSelected {
if isSettingsSelected || viewModel.selectedItem == .agent {
EmptyView()
} else if viewModel.isSearchMode {
searchModeNavigationLabel
@@ -614,47 +613,19 @@ struct SybilWorkspaceView: View {
.accessibilityLabel("Attach file")
}
TextField(
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
SybilPromptField(
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
text: $viewModel.composer,
axis: .vertical
focused: $composerFocused,
submit: submitComposer
)
.focused($composerFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
submitComposer()
}
.submitOnMacReturn(submitComposer)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button {
submitComposer()
} label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendComposer
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendComposer)
SybilPromptSendButton(
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
canSend: viewModel.canSendComposer,
submit: submitComposer
)
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
}
}
.padding(.horizontal, 14)
@@ -1375,173 +1346,6 @@ private struct SybilNavigationFadeBackground: View {
}
}
private struct SybilWorkspaceCharacterBackdrop: View {
var isBusy: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.13),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 150
)
.frame(width: 136, height: 118)
.blur(radius: 7)
.offset(x: 28, y: -24)
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
.frame(width: 172, height: 172)
.opacity(0.92)
.mask {
LinearGradient(
colors: [
Color.black,
Color.black,
Color.black.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.98),
Color.black
],
startPoint: .leading,
endPoint: .trailing
)
}
.offset(x: 8, y: 4)
}
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
.clipped()
.ignoresSafeArea(edges: .top)
.accessibilityHidden(true)
}
}
private struct SybilAnimatedGIFView: UIViewRepresentable {
var resourceName: String
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let imageView = SybilAnimatedUIImageView()
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = false
imageView.isOpaque = false
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return imageView
}
func updateUIView(_ imageView: UIImageView, context: Context) {
guard context.coordinator.resourceName != resourceName else {
if !imageView.isAnimating {
imageView.startAnimating()
}
return
}
context.coordinator.resourceName = resourceName
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
imageView.startAnimating()
}
final class Coordinator {
var resourceName: String?
}
}
private final class SybilAnimatedUIImageView: UIImageView {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
}
@MainActor
private enum SybilAnimatedGIFCache {
private static var images: [String: UIImage] = [:]
static func image(named name: String) -> UIImage? {
if let cached = images[name] {
return cached
}
guard let image = loadImage(named: name) else {
return nil
}
images[name] = image
return image
}
private static func loadImage(named name: String) -> UIImage? {
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
Bundle.main.url(forResource: name, withExtension: "gif")
guard let url,
let data = try? Data(contentsOf: url),
let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 0 else {
return nil
}
var frames: [UIImage] = []
frames.reserveCapacity(frameCount)
var duration: TimeInterval = 0
for index in 0 ..< frameCount {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(UIImage(cgImage: cgImage))
duration += frameDuration(at: index, source: source)
}
guard !frames.isEmpty else {
return nil
}
if frames.count == 1 {
return frames[0]
}
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
}
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 0.08
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
return max(delay, 0.03)
}
}
private struct NewChatSwipeBackdrop: View {
var progress: CGFloat
var hasLatched: Bool
@@ -0,0 +1,327 @@
import Foundation
import Testing
@testable import Sybil
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
}
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
}
private final class MemoryOutbox: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage {
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
}
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
}
private actor ControlledAgentSocket: AgentSocket {
let stream: AsyncThrowingStream<Data, any Error>
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
private(set) var closed = false
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
func receive() async throws -> Data {
var iterator = stream.makeAsyncIterator()
guard let data = try await iterator.next() else { throw CancellationError() }
return data
}
func send(_ text: String) {}
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
func closeCode() -> Int { 0 }
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
}
private actor TestAgentClient: AgentAPIClienting {
var pages: [String: AgentPage]
var requests: [String] = []
var submissions: [(String, String)] = []
var read = "0"
var failRead = false
var failSubmit = false
var malformedReceipt = false
var socketAfters: [String] = []
var sockets: [ControlledAgentSocket] = []
var holdRead = false
var heldRead: CheckedContinuation<AgentReadState, Never>?
var beforeSubmit: (@Sendable () -> Bool)?
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
func agentDescriptor() -> AgentDescriptor {
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
}
func agentMessages(after: String?, before: String?) -> AgentPage {
let cursor = after ?? "0"; requests.append(cursor)
return pages[cursor] ?? agentPage([], cursor: cursor)
}
func agentReadState() async -> AgentReadState {
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
return AgentReadState(cursor: read)
}
func pauseRead() { holdRead = true }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
func hasHeldRead() -> Bool { heldRead != nil }
func agentMarkRead(cursor: String) throws -> AgentReadState {
if failRead { throw AgentFailure(message: "Offline") }
read = try AgentCursor.advance(read, cursor)
return AgentReadState(cursor: read)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append((content, requestID))
try await Task.sleep(for: .milliseconds(80))
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
}
func agentSocket(after: String) async -> any AgentSocket {
socketAfters.append(after)
let socket = ControlledAgentSocket(); sockets.append(socket)
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
return socket
}
func connectedAfters() -> [String] { socketAfters }
func latestSocket() -> ControlledAgentSocket? { sockets.last }
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func submittedIDs() -> [String] { submissions.map(\.1) }
func requestedCursors() -> [String] { requests }
}
@MainActor
private func eventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 {
if predicate() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
struct AgentTests {
@Test func cursorAndMalformedFrames() throws {
#expect(try AgentCursor.normalize("00012") == "12")
#expect(try AgentCursor.advance("9", "10") == "10")
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
let original = state
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
#expect(state == original)
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
#expect(state.cursor == "10")
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
}
@Test func terminalToolAndRestSocketRace() throws {
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
end.createdAt.addTimeInterval(5)
var state = AgentRoomState()
try state.apply(agentPage([start], cursor: "5"))
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
try state.apply(agentPage([start], cursor: "5"))
#expect(state.cursor == "9")
#expect(state.messages.count == 2)
let tool = try #require(state.messages.first?.toolCallMetadata)
#expect(tool.status == "failed")
#expect(tool.error == "fixture failure")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.startedAt == "start")
#expect(state.messages.first?.createdAt == start.createdAt)
}
@Test func unreadExcludesToolsAndUsers() throws {
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
state.readCursor = "9"
#expect(state.unreadCount == 1)
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
#expect(state.unreadCount == 0)
}
@Test func typingReplyAndStaleActivityAreAtomic() throws {
var presentation = AgentPresentation(), state = AgentRoomState()
let now = Date(timeIntervalSince1970: 100)
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
var after = state; after.activity = busy
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
try after.apply(reply)
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
#expect(state.activity.isBusy)
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
for name in AgentPresentation.quietTools {
busy.tools.append(.init(id: name, name: name, status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
}
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
}
@Test func socketURLAndAuthentication() throws {
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
#expect(frame.objectValue?["token"] == .string("token"))
#expect(frame.objectValue?["after"] == .string("9"))
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
#expect(openFrame.objectValue?["token"] == .null)
}
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(await client.requestedCursors() == ["0", "3", "7"])
#expect(room.snapshot.room.cursor == "8")
#expect(room.unreadCount == 1)
#expect(room.snapshot.entranceIDs.isEmpty)
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs == ["late"])
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs.isEmpty)
#expect(room.messages.count == 2)
}
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
let memory = MemoryOutbox(), client = TestAgentClient()
await client.setFailures(submit: true)
await client.requirePersisted { !memory.read().isEmpty }
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(room.send("identical")); #expect(room.send("identical"))
#expect(room.messages.count == 2)
let first = room.outbox[0], second = room.outbox[1]
#expect(first.requestID != second.requestID)
#expect(memory.read().count == 2)
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
room.flush()
#expect(await eventually { room.outbox[1].status == "failed" })
#expect(room.outbox[0].status == "complete")
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
room.retry(second.id)
#expect(await eventually { room.outbox[1].status == "failed" })
let ids = await client.submittedIDs()
#expect(ids.filter { $0 == second.requestID }.count == 2)
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
defer { restored.stop() }
#expect(restored.outbox.count == 1)
#expect(restored.outbox[0].requestID == second.requestID)
#expect(restored.outbox[0].createdAt == second.createdAt)
}
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
let client = TestAgentClient(); await client.setFailures(malformed: true)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.send("fixture")
#expect(await eventually { room.outbox.first?.status == "failed" })
#expect(room.outbox.first?.seenOnServer == false)
let failed = SybilAgentRoom()
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
defer { failed.stop() }
failed.send("must not submit")
#expect(failed.outbox.first?.status == "failed")
#expect(await client.submittedIDs().count == 1)
}
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.isSelected = true
await client.setFailures(read: true)
room.markRead(renderedCursor: "1")
#expect(await eventually { room.error != nil })
#expect(room.snapshot.room.readCursor == "0")
await client.setFailures()
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
#expect(room.unreadCount == 1)
room.isObscured = true; room.markRead(renderedCursor: "2")
try? await Task.sleep(for: .milliseconds(100))
#expect(room.snapshot.room.readCursor == "1")
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
#expect(room.unreadCount == 0)
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connecting" })
#expect(room.messages.count == 2)
}
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
let client = TestAgentClient(), memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.send("interrupted fixture")
let original = try #require(room.outbox.first)
room.setActive(false)
#expect(room.outbox.first?.status == "failed")
#expect(memory.read().first?.requestID == original.requestID)
room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
room.retry(original.id)
#expect(await eventually { room.outbox.first?.seenOnServer == true })
#expect(room.outbox.first?.createdAt == original.createdAt)
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
}
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
let old = TestAgentClient(); await old.pauseRead()
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
for _ in 0..<100 {
if await old.hasHeldRead() { break }
try? await Task.sleep(for: .milliseconds(10))
}
room.disconnect()
room.setActive(false); room.setActive(true)
#expect(await old.connectedAfters().isEmpty)
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
room.configure(client: next, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
await old.releaseRead()
try? await Task.sleep(for: .milliseconds(50))
try await room.catchUp(client: next)
#expect(room.messages.map(\.id) == ["new"])
#expect(await next.requestedCursors() == ["0", "2"])
#expect(await old.connectedAfters().isEmpty)
}
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
let client = TestAgentClient()
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let socket = try #require(await client.latestSocket())
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
await socket.push(frame)
#expect(await eventually { room.snapshot.room.cursor == "7" })
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
let next = try #require(await client.latestSocket())
await next.push(frame)
try? await Task.sleep(for: .milliseconds(150))
#expect(await client.connectedAfters() == ["0", "7"])
#expect(room.messages.count == 1)
#expect(await socket.closed)
}
}
@@ -9,6 +9,7 @@ import UIKit
private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var workspaceSearchQueries: [String] = []
var listChats = 0
var listSearches = 0
var createChat = 0
@@ -43,6 +44,7 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
@@ -86,11 +88,15 @@ private actor MockSybilClient: SybilAPIClienting {
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil,
workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in
WorkspaceSearchResponse(items: [], hasMore: false)
},
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
) {
self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.workspaceSearchHandler = workspaceSearchHandler
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
@@ -186,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting {
return workspaceItemsResponse
}
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
snapshot.workspaceSearchQueries.append(query)
return try await workspaceSearchHandler(query)
}
func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
@@ -1998,3 +2009,173 @@ private final class MockQuickQuestionLifecycle {
controller.stop()
}
#endif
// Deliberately ignores cancellation so stale-response protection is exercised.
private actor WorkspaceSearchGate {
private var pending: [String: CheckedContinuation<WorkspaceSearchResponse, any Error>] = [:]
private var observers: [String: CheckedContinuation<Void, Never>] = [:]
func response(for query: String) async throws -> WorkspaceSearchResponse {
try await withCheckedThrowingContinuation { continuation in
pending[query] = continuation
observers.removeValue(forKey: query)?.resume()
}
}
func waitUntilRequested(_ query: String) async {
if pending[query] != nil { return }
await withCheckedContinuation { observers[query] = $0 }
}
func complete(_ query: String, with response: WorkspaceSearchResponse) {
pending.removeValue(forKey: query)?.resume(returning: response)
}
func fail(_ query: String) {
pending.removeValue(forKey: query)?.resume(throwing: APIError.networkError(message: "Offline"))
}
}
@MainActor
@Test func historySearchKeepsRecentItemsAndOpensOlderChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let recent = makeChatSummary(id: "recent", date: date)
let old = makeChatSummary(id: "old", date: date.addingTimeInterval(-100))
var match = WorkspaceItem(chat: old)
match.searchSnippet = "The nebula we discussed"
let response = WorkspaceSearchResponse(items: [match, WorkspaceItem(search: makeSearchSummary(id: "web", date: date))], hasMore: true)
let client = MockSybilClient(
chatsResponse: [recent],
chatDetails: ["old": makeChatDetail(id: "old", date: date, body: "The nebula we discussed")],
workspaceSearchHandler: { _ in response }
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.workspaceItems = [WorkspaceItem(chat: recent)]
viewModel.chats = [recent]
viewModel.selectedItem = .chat("recent")
viewModel.setSidebarSearchQuery(" nebula ")
await viewModel.refreshSidebarSearch()
#expect(viewModel.selectedItem == .chat("recent"))
#expect(viewModel.workspaceItems == [WorkspaceItem(chat: recent)])
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("old"), .search("web")])
#expect(viewModel.visibleSidebarItems.first?.searchSnippet == "The nebula we discussed")
#expect(viewModel.sidebarSearchHasMore)
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
await viewModel.selectForNavigation(.chat("old"))
#expect(viewModel.selectedChat?.id == "old")
viewModel.setSidebarSearchQuery("")
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
#expect(viewModel.selectedItem == .chat("old"))
#expect(viewModel.selectedChat?.id == "old")
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat("old") }))
}
@MainActor
@Test func historySearchDebouncesAndClearingRestoresRecentItems() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
let recent = WorkspaceItem(chat: makeChatSummary(id: "recent", date: Date()))
viewModel.workspaceItems = [recent]
viewModel.setSidebarSearchQuery("n")
viewModel.setSidebarSearchQuery("ne")
viewModel.setSidebarSearchQuery("nebula")
#expect(viewModel.isSearchingSidebar)
#expect(await client.currentSnapshot().workspaceSearchQueries.isEmpty)
try await Task.sleep(for: .milliseconds(600))
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
#expect(viewModel.visibleSidebarItems.isEmpty)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery(" ")
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("recent")])
#expect(!viewModel.isSidebarSearchActive)
#expect(viewModel.sidebarSearchError == nil)
}
@MainActor
@Test func historySearchDiscardsOutOfOrderAndClearedResponses() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.setSidebarSearchQuery("first")
let first = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("first")
viewModel.setSidebarSearchQuery("second")
let second = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("second")
let match = WorkspaceItem(chat: makeChatSummary(id: "second", date: Date()))
await gate.complete("second", with: WorkspaceSearchResponse(items: [match], hasMore: false))
await second.value
await gate.fail("first")
await first.value
#expect(viewModel.sidebarSearchResults == [match])
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery("third")
let third = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("third")
viewModel.setSidebarSearchQuery("")
await gate.complete("third", with: WorkspaceSearchResponse(items: [match], hasMore: true))
await third.value
#expect(viewModel.sidebarSearchResults.isEmpty)
#expect(!viewModel.sidebarSearchHasMore)
#expect(!viewModel.isSearchingSidebar)
}
@MainActor
@Test func historySearchFailureCanRetryWithoutChangingSelection() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.selectedItem = .chat("current")
viewModel.setSidebarSearchQuery("nebula")
let failed = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.fail("nebula")
await failed.value
#expect(viewModel.sidebarSearchError != nil)
#expect(viewModel.selectedItem == .chat("current"))
let retry = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.complete("nebula", with: WorkspaceSearchResponse(items: [], hasMore: false))
await retry.value
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
#expect(viewModel.selectedItem == .chat("current"))
viewModel.setSidebarSearchQuery(String(repeating: "a", count: 201))
#expect(viewModel.sidebarSearchError == "Use 200 characters or fewer.")
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula", "nebula"])
}
private final class HistorySearchURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
#expect(url.path == "/api/v1/workspace-items")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "q", value: "café + AI & #ideas?")])
let json = #"{"items":[{"type":"chat","id":"old","title":"Notes","createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z","searchSnippet":"Some café ideas"}],"hasMore":false}"#
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(json.utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Test func historySearchEncodesQueryAndDecodesSnippet() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HistorySearchURLProtocol.self]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: APIConfiguration(baseURL: URL(string: "https://example.com/api")!, authToken: "test-token"), session: session)
let response = try await client.searchWorkspaceItems(query: "café + AI & #ideas?")
#expect(response.items.first?.searchSnippet == "Some café ideas")
#expect(!response.hasMore)
}
+66
View File
@@ -5,6 +5,72 @@ Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
release/archive signing settings are unchanged.
The conversation sidebar includes **Search chats and web searches** on iPhone,
iPad, and Mac. It searches all saved history by title, conversation text, and saved
web-search query/answer, with single-line excerpts that bold matching words and
truncate in the middle. Search waits briefly while typing,
cancels superseded requests, and leaves the current conversation open. Clear the
field to return to recent items. Searches use word prefixes (for example, `plan`
finds `planning`); if more than 100 items match, refine the query.
## Sybil Agent
The permanent **Sybil Agent** sidebar item opens the persistent room on both
phone and iPad. It uses the configured Sybil API URL (including an explicit
`/api` path) and token. The room stays subscribed while other chats are selected;
foreground and network recovery replay from the last merged cursor. Shared unread
counts include only public assistant replies and are acknowledged after the
visible transcript reaches the bottom and its entrance animation finishes.
Multiple messages can be queued while the Agent works. The local outbox is saved
atomically under Application Support before submission and is scoped to the API
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
its request ID and timestamp across relaunch. A canonical server echo confirms it
even if the HTTP acknowledgement is lost. Tool cards expand to show input, result,
and error details. Attachments and model selection belong to ordinary chats.
Agent contracts remain in `../docs/api/rest.md` and
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
### Agent regression and Simulator checks
Run these commands from this worktree's `ios/` directory:
```sh
just build
just test
just run
just screenshot build/agent-evidence/simulator.png
```
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
regressions along with the existing app tests through Xcode on iPhone 16e.
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
```sh
python3 Tests/Fixtures/agent_server.py
```
In a second terminal, run:
```sh
just generate
xcodebuild test -scheme SybilAgentUI \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-parallel-testing-enabled NO \
-resultBundlePath build/agent-evidence/phone-ui.xcresult
```
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
external services. UI tests exercise the real REST/WebSocket client and retain
screenshots in the result bundle. They cover queued submissions, uncertain
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
anchors and multiline keyboard layout. The launch arguments select the fixture
through ordinary API settings; no fixture mode is compiled into the application.
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
Simulator Reduce Motion enabled before release.
## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Local-only deterministic Agent contract fixture. Never contacts external services.
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
"""
import asyncio
import datetime
import json
from aiohttp import web
sockets = set()
messages = []
requests = {}
read_cursor = 0
cursor = 0
busy = False
attempts = {}
log = []
reply_tasks = set()
def stamp():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
def activity():
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
backgroundWorkers=0, tools=[], queuedMessages=[])
def message(content, role='assistant', id=None, info=None):
global cursor
cursor += 1
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
messages.append(row)
return row
def page(rows, position=None, more=False):
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
async def broadcast(frame):
for socket in list(sockets):
try:
await socket.send_json(frame)
except ConnectionError:
sockets.discard(socket)
async def emit(content):
row = message(content)
await broadcast(dict(type='messages', **page([row])))
return row
async def reply(content):
global busy, cursor
await asyncio.sleep(1.2)
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
await broadcast(dict(type='messages', **page([tool])))
await asyncio.sleep(0.8)
cursor += 1
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
await broadcast(dict(type='messages', **page([tool])))
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
await broadcast(dict(type='messages', **page([failed])))
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
# Deliberately stale busy frame after the public reply.
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
await asyncio.sleep(0.5)
busy = False
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
async def route(request):
global read_cursor, cursor, busy
path = request.path
log.append(dict(method=request.method, path=path, query=dict(request.query)))
if path == '/control':
body = await request.json()
action = body.get('action')
if action == 'reset':
for task in reply_tasks:
task.cancel()
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'reply':
await emit(body.get('content', 'Late asynchronous fixture reply'))
elif action == 'long-tool':
row = message('Local tool output', 'tool')
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
await broadcast(dict(type='messages', **page([row])))
elif action == 'busy':
busy = body.get('value', True)
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
elif action == 'read':
read_cursor = cursor
elif action == 'disconnect':
for socket in list(sockets):
await socket.close(code=1012)
elif action == 'malformed':
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
if path == '/api/v1/auth/session':
return web.json_response(dict(authenticated=True, mode='token'))
if path == '/api/v1/workspace-items':
return web.json_response(dict(items=[], hasMore=False))
if path == '/api/v1/chats':
return web.json_response(dict(chats=[]))
if path == '/api/v1/searches':
return web.json_response(dict(searches=[]))
if path == '/api/v1/models':
return web.json_response(dict(providers={}))
if path == '/api/v1/active-runs':
return web.json_response(dict(chats=[], searches=[]))
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
if request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(error='Fixture token missing'), status=401)
if path == '/api/v1/agent':
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
if path == '/api/v1/agent/read-state':
if request.method == 'PUT':
read_cursor = max(read_cursor, int((await request.json())['cursor']))
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
if path == '/api/v1/agent/messages':
if request.method == 'POST':
body = await request.json()
key, content = body['requestId'], body['content']
attempts[key] = attempts.get(key, 0) + 1
if content == 'retry fixture' and attempts[key] == 1:
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
if key not in requests:
requests[key] = receipt
row = message(content, 'user', id=receipt['messageId'])
busy = True
await broadcast(dict(type='messages', **page([row])))
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
task = asyncio.create_task(reply(content))
reply_tasks.add(task)
task.add_done_callback(reply_tasks.discard)
if content == 'echo wins fixture':
await asyncio.sleep(0.3)
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
return web.json_response(requests[key], status=202)
after = int(request.query.get('after', 0))
rows = [m for m in messages if m['metadata']['entryId'] > after]
limit = min(int(request.query.get('limit', 200)), 200)
selected = rows[:limit]
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
if path == '/api/v1/agent/ws':
if request.query:
return web.Response(status=400)
socket = web.WebSocketResponse(); await socket.prepare(request)
auth = await socket.receive_json(timeout=5)
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
await socket.close(code=4401); return socket
log.append(dict(authAfter=auth.get('after')))
sockets.add(socket)
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
try:
async for frame in socket:
if frame.type == web.WSMsgType.TEXT:
data = json.loads(frame.data)
if data.get('type') == 'ping':
await socket.send_json(dict(type='pong'))
finally:
sockets.discard(socket)
return socket
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
for i in range(3):
message(f'History message {i + 1}: local simulator fixture.')
app = web.Application()
app.router.add_route('*', '/{path:.*}', route)
web.run_app(app, host='127.0.0.1', port=8799)
+175
View File
@@ -0,0 +1,175 @@
import XCTest
import UIKit
@MainActor
final class AgentUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
try await control(["action": "reset", "count": 3])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
openAgent()
}
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists)
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private func send(_ text: String) {
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways
add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
func testSendQueueToolsAndRetry() async throws {
capture("agent-history")
send("first fixture")
send("second fixture")
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-replies-keyboard")
app.swipeDown()
send("retry fixture")
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
capture("agent-retry")
app.buttons["agent-retry"].tap()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
send("echo wins fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
composer.tap(); app.swipeDown()
let failures = app.buttons.matching(identifier: "agent-tool-fixture_error")
failures.element(boundBy: failures.count - 1).tap()
XCTAssertTrue(app.staticTexts["agent-tool-input"].waitForExistence(timeout: 3))
XCTAssertTrue(app.staticTexts["agent-tool-error"].exists)
capture("agent-tool-error")
let state = try await control(["action": "state"])
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
}
func testReduceMotionSend() async throws {
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
send("reduce motion fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-reduce-motion")
}
func testExpandedToolCollapseToShortHistory() async throws {
try await control(["action": "long-tool"])
let tool = app.buttons["agent-tool-fixture_long"]
XCTAssertTrue(tool.waitForExistence(timeout: 5))
tool.tap()
XCTAssertTrue(app.staticTexts["agent-tool-result"].waitForExistence(timeout: 3))
let scroll = app.scrollViews.firstMatch
for _ in 0..<12 {
if tool.isHittable { break }
scroll.swipeDown()
}
XCTAssertTrue(tool.isHittable)
tool.tap()
XCTAssertEqual(tool.value as? String, "Collapsed")
try await control(["action": "reply", "content": "Reply after collapsing a tall tool"])
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after collapsing a tall tool"].isHittable)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
capture("agent-collapsed-tool-short-history")
}
func testTabletSidebarAndInactiveChat() async throws {
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
XCUIDevice.shared.orientation = .landscapeLeft
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
app.buttons["New chat"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "iPad inactive chat reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-ipad-sidebar")
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
capture("agent-ipad-room-before-send")
send("iPad fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
capture("agent-ipad-room")
XCUIDevice.shared.orientation = .portrait
}
func testScrollPreservationAndKeyboard() async throws {
app.terminate()
try await control(["action": "reset", "count": 160])
app.launch(); openAgent()
let scroll = app.scrollViews.firstMatch
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown()
scroll.swipeDown()
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
let before = anchor.frame.minY
try await control(["action": "reply", "content": "Reply while reading earlier history"])
try await Task.sleep(for: .milliseconds(500))
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
capture("agent-reading-anchor")
for _ in 0..<25 {
if app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
app.buttons["Load earlier messages"].tap()
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
capture("agent-older-history-anchor")
app.buttons["agent-jump-latest"].tap()
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
capture("agent-multiline-keyboard")
}
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
app.buttons["Show sidebar"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-unread-sidebar")
let covered = try await control(["action": "state"])
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
try await control(["action": "read"])
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
await fulfillment(of: [cleared], timeout: 20)
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
try await control(["action": "disconnect"])
try await control(["action": "reply", "content": "Reconnect fixture reply"])
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
try await control(["action": "malformed"])
try await control(["action": "reply", "content": "Recovered malformed frame"])
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
XCUIDevice.shared.press(.home)
try await control(["action": "reply", "content": "Foreground fixture reply"])
app.activate()
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
capture("agent-foreground-replay")
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ generate:
xcodegen --spec project.yml
build: generate
if command -v xcbeautify >/dev/null 2>&1; then \
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
+27
View File
@@ -8,3 +8,30 @@ packages:
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
targets:
SybilAgentUITests:
type: bundle.ui-testing
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilUITests
dependencies:
- target: SybilApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
GENERATE_INFOPLIST_FILE: YES
TEST_TARGET_NAME: SybilApp
SWIFT_VERSION: 6.0
schemes:
SybilAgentUI:
build:
targets:
SybilApp: all
SybilAgentUITests: [test]
test:
config: Debug
targets:
- SybilAgentUITests
+24
View File
@@ -106,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
- User accounts + per-device API keys
- Postgres support + migrations for prod
- Attachments + embeddings + semantic search
## Sybil Agent
Configure the backend with:
```dotenv
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
```
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
`sybil-agent` resolves privately. Supply the key through a private server environment
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
`ADMIN_TOKEN` authentication for clients.
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent root conversation across devices. Sybil Agent remains the source of
truth for messages and background replies. Sybil stores only the shared,
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
its migration is applied automatically at startup. The bundled nginx configuration
forwards WebSocket upgrades. See `docs/api/rest.md` and
`docs/api/streaming-chat.md` at the repository root for the client contracts.
+33
View File
@@ -22,10 +22,12 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -876,6 +878,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -2241,6 +2253,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+2
View File
@@ -31,10 +31,12 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -0,0 +1,70 @@
-- External-content indexes avoid storing a second copy of conversation text.
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
title, content='Chat', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
-- Hidden system prompts and tool payloads are not searchable conversation text.
CREATE VIEW "MessageSearchContent" AS
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
content, content='MessageSearchContent', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
title, query, answerText, content='Search', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
END;
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
WHEN old.title IS NOT new.title BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
WHEN new.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
WHEN old.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
INSERT INTO "MessageSearch" (rowid, content)
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
END;
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
-- Index existing history during migration, then maintain it transactionally.
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
@@ -0,0 +1,5 @@
CREATE TABLE "AgentReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"cursor" TEXT NOT NULL DEFAULT '0',
"updatedAt" DATETIME NOT NULL
);
+9
View File
@@ -76,6 +76,7 @@ model Chat {
@@index([userId])
@@index([parentChatId])
@@index([updatedAt])
}
model Message {
@@ -214,3 +215,11 @@ model ProjectItem {
@@index([chatId])
@@index([searchId])
}
// Shared personal read position for the external persistent Sybil Agent room.
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
model AgentReadState {
id String @id
cursor String @default("0")
updatedAt DateTime @updatedAt
}
+171
View File
@@ -0,0 +1,171 @@
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import type { FastifyInstance } from 'fastify';
import { WebSocket, WebSocketServer } from 'ws';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { prisma } from './db.js';
import { env } from './env.js';
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
.transform(value => String(Number(value)));
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
.refine(value => !(value.after && value.before), 'Use after or before, not both');
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
const ReadBody = z.object({ cursor: Cursor });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string() });
type AgentOptions = { baseUrl?: string; apiKey?: string };
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
const result = schema.safeParse(value);
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
return result.data;
};
async function readState() {
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
}
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
try { data = await response.json(); }
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
return { status: response.status, data };
};
app.get('/v1/agent', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const result = await proxyJson('/api/conversations/root');
reply.code(result.status);
if (result.status !== 200) return result.data;
return { ...result.data as object, websocket: '/v1/agent/ws' };
});
app.get('/v1/agent/messages', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const query = parse(PageQuery, req.query);
const params = new URLSearchParams();
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
reply.code(result.status);
return result.data;
});
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const body = parse(MessageBody, req.body);
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
reply.code(result.status);
return result.data;
});
app.get('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
return readState();
});
app.put('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const { cursor } = parse(ReadBody, req.body);
// One SQLite statement makes concurrent tabs monotonic. Never write an older
// cursor over one saved by another connection between a read and an update.
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
ON CONFLICT("id") DO UPDATE SET
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
return readState();
});
attachAgentSocket(app, baseUrl, apiKey);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const cleanup = new Set<() => void>();
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
let url: URL;
try { url = new URL(request.url || '/', 'http://localhost'); }
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
// Credentials must never enter URLs and access logs.
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
sockets.handleUpgrade(request, socket, head, browser => {
let upstream: WebSocket | undefined;
let authenticated = false, disposed = false, alive = true;
const send = (value: unknown) => {
if (browser.readyState !== WebSocket.OPEN) return;
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
};
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
browser.on('pong', () => { alive = true; });
const dispose = () => {
if (disposed) return;
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
upstream?.terminate(); browser.terminate();
};
cleanup.add(dispose);
browser.on('close', dispose); browser.on('error', dispose);
browser.on('message', (raw, isBinary) => {
try {
if (isBinary) throw new Error('JSON text frames are required');
const frame = JSON.parse(raw.toString());
if (!authenticated) {
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
authenticated = true; clearTimeout(authTimeout);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
upstream.on('message', (data, binary) => {
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
send(data.toString());
});
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
return;
}
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
const subscribe = parse(SubscribeFrame, frame);
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
upstream.send(JSON.stringify(subscribe));
} catch (error) {
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
}
});
});
};
app.server.on('upgrade', upgrade);
app.addHook('preClose', async () => {
app.server.off('upgrade', upgrade);
for (const stop of cleanup) stop();
for (const client of sockets.clients) client.terminate();
sockets.close();
});
}
+2
View File
@@ -70,6 +70,8 @@ const EnvSchema = z.object({
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
+3 -1
View File
@@ -6,6 +6,7 @@ import sensible from "@fastify/sensible";
import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js";
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
import { registerAgentRoutes } from "./agent.js";
import { registerRoutes } from "./routes.js";
const app = Fastify({
@@ -26,7 +27,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, {
origin: true,
credentials: true,
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
});
await app.register(swagger, {
@@ -86,6 +87,7 @@ app.addHook("onClose", async () => {
});
await registerRoutes(app);
await registerAgentRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST });
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
+32
View File
@@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import { searchWorkspace } from "./workspace-search.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
@@ -927,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
app.get("/v1/workspace-items", async (req) => {
requireAdmin(req);
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
if (parsed.data.q) {
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
select: {
...chatSummarySelect,
parentChat: { select: { projectItems: starredProjectItemsSelect } },
},
}),
prisma.search.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
select: searchSummarySelect,
}),
]);
const summaries = new Map<string, Record<string, unknown>>([
...chats.map(({ parentChat, ...chat }) => [
`chat:${chat.id}`,
// Search omits unmatched roots, but sidebar fork stars belong to the root.
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
] as const),
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
]);
const items = hits.flatMap((hit) => {
const summary = summaries.get(`${hit.type}:${hit.id}`);
return summary ? [{ ...summary, ...hit }] : [];
});
return { items, hasMore };
}
return { items: await listWorkspaceItems() };
});
+74
View File
@@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./db.js";
export const WORKSPACE_SEARCH_LIMIT = 100;
// Treat input as literal Unicode words, never FTS operators/column expressions.
// Prefix matching supports type-ahead, including short terms such as "AI".
export function workspaceSearchExpression(query: string): string | null {
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
}
export type WorkspaceSearchHit = {
type: "chat" | "search";
id: string;
searchSnippet: string | null;
};
export function workspaceSearchSQL(expression: string) {
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
return Prisma.sql`
WITH chat_hits AS (
SELECT c.id, NULL AS message_rowid
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
WHERE "ChatSearch" MATCH ${expression}
UNION ALL
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
WHERE "MessageSearch" MATCH ${expression}
GROUP BY m.chatId
), matches AS (
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
NULL AS search_rowid
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
GROUP BY c.id
UNION ALL
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
WHERE "SavedSearchSearch" MATCH ${expression}
), limited AS MATERIALIZED (
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
), message_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
FROM "MessageSearch"
WHERE "MessageSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
AND +rowid IN (SELECT message_rowid FROM limited)
), search_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
FROM "SavedSearchSearch"
WHERE "SavedSearchSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
AND +rowid IN (SELECT search_rowid FROM limited)
)
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
FROM limited
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
ORDER BY updatedAt DESC, type, id
`;
}
export async function searchWorkspace(query: string) {
const expression = workspaceSearchExpression(query);
if (!expression) return { hits: [], hasMore: false };
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
// only for the bounded result set, never fetch full transcripts into JS.
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
}
+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:http';
import { createConnection } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import { WebSocket, WebSocketServer } from 'ws';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
});
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const privateKey = 'sybil-agent-private-key-for-test';
const requests: { method?: string; url?: string; body: any }[] = [];
const accepted = new Map<string, number>();
let upstreamReply: { status: number; body: string } | undefined;
let connections = 0;
const subscriptions: any[] = [];
const upstream = createServer(async (req, res) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
requests.push({ method: req.method, url: req.url, body });
res.setHeader('Content-Type', 'application/json');
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
if (req.method === 'POST') {
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
} else if (req.url?.includes('/messages')) {
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
});
const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
assert.equal(req.url, '/api/ws');
connections++;
sourceSockets.handleUpgrade(req, socket, head, ws => {
ws.on('message', raw => {
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
});
});
});
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
const upstreamAddress = upstream.address() as { port: number };
const app = Fastify({ logger: false }); await app.register(sensible);
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as { port: number };
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
test.after(async () => {
await app.close();
for (const socket of sourceSockets.clients) socket.terminate();
sourceSockets.close();
await new Promise<void>(resolve => upstream.close(() => resolve()));
await prisma.$disconnect();
rmSync(directory, { recursive: true, force: true });
});
async function eventually(check: () => boolean) {
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
assert.fail('Expected condition was not reached');
}
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
assert.equal(descriptor.statusCode, 200);
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
assert.ok(!descriptor.body.includes(privateKey));
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
assert.equal(page.json().messages[0].role, 'assistant');
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
});
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
assert.equal(initial.json().cursor, '0');
assert.equal(initial.headers['cache-control'], 'no-store');
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
await prisma.$disconnect();
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
});
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
try {
upstreamReply = { status: 200, body: '{truncated' };
for (const url of ['/v1/agent', '/v1/agent/messages']) {
const response = await app.inject({ url, headers: auth });
assert.equal(response.statusCode, 502);
assert.equal(response.headers['cache-control'], 'no-store');
}
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
upstreamReply = { status: 202, body };
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(response.statusCode, 502, body);
assert.equal(response.headers['cache-control'], 'no-store');
}
} finally { upstreamReply = undefined; }
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(retry.statusCode, 202);
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
});
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
const before = connections;
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
let response = '';
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
});
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
socket.on('data', chunk => { response += chunk.toString(); });
socket.on('error', reject);
socket.on('end', () => { socket.destroy(); resolve(response); });
});
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
assert.equal(connections, before);
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
});
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
const before = connections;
const denied = new WebSocket(wsUrl); await once(denied, 'open');
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
const [code] = await once(denied, 'close');
assert.equal(code, 4401); assert.equal(connections, before);
const browser = new WebSocket(wsUrl);
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
await eventually(() => frames.some(frame => frame.type === 'activity'));
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
assert.equal(frames[1].messages[0].content, 'Later result');
assert.equal(frames[2].activity.thinking, true);
assert.ok(!JSON.stringify(frames).includes(privateKey));
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
await eventually(() => subscriptions.at(-1)?.after === '43');
browser.close(); await once(browser, 'close');
await eventually(() => sourceSockets.clients.size === 0);
});
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try {
const browser = new WebSocket(wsUrl); const frames: any[] = [];
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
await eventually(() => frames.some(frame => frame.type === 'ready'));
browser.close(); await once(browser, 'close');
} finally { env.ADMIN_TOKEN = saved; }
});
+2 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
writeFileSync(join(databaseDir, "test.db"), "");
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { Prisma } from "@prisma/client";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
const migration = "20261007000000_add_workspace_full_text_search";
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
writeFileSync(join(databaseDir, "test.db"), "");
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
}
function migrate() {
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
cwd: serverRoot, env: process.env, stdio: "pipe",
});
}
migrate();
const { prisma } = await import("../src/db.js");
const oldChat = await prisma.chat.create({ data: {
title: "Old constellation notes",
messages: { create: [
{ role: "user", content: "We photographed a nebula before the migration." },
{ role: "system", content: "secretmigrationprompt" },
] },
} });
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
migrate();
const { registerRoutes } = await import("../src/routes.js");
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
async function search(query: string) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
test.afterEach(async () => {
await prisma.chat.deleteMany();
await prisma.search.deleteMany();
});
test.after(async () => {
await app.close();
await prisma.$disconnect();
rmSync(databaseDir, { recursive: true, force: true });
});
test("migration indexes existing titles, messages, queries, and answers", async () => {
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
assert.equal((await search("constellation")).items.length, 2);
assert.equal((await search("secretmigrationprompt")).items.length, 0);
});
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
const chat = await prisma.chat.create({ data: {
title: "Travel notes", updatedAt: new Date("2000-01-01"),
messages: { create: [
{ role: "user", content: "Let's discuss café planning in Zürich" },
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
{ role: "system", content: "hiddensystemvalue" },
{ role: "tool", content: "hiddentoolvalue" },
] },
} });
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
const result = await search("CAFE plan");
assert.equal(result.hasMore, false);
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
assert.match(result.items[0].searchSnippet, /café planning/i);
assert.equal(result.items[0].messages, undefined);
assert.equal((await search("zuri")).items.length, 1);
assert.equal((await search("hiddensystemvalue")).items.length, 0);
assert.equal((await search("hiddentoolvalue")).items.length, 0);
});
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
const chat = await prisma.chat.create({ data: {
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
}, include: { messages: true } });
const messageID = chat.messages[0].id;
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
assert.equal((await search("original")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
assert.equal((await search("replacementbody")).items.length, 0);
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
assert.equal((await search("replacementbody")).items.length, 1);
await assert.rejects(prisma.$transaction(async (tx) => {
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
throw new Error("rollback");
}));
assert.equal((await search("rolledbackvalue")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
assert.equal(fork.statusCode, 200, fork.body);
assert.equal((await search("replacementbody")).items.length, 2);
await prisma.message.delete({ where: { id: messageID } });
const onlyFork = (await search("replacementbody")).items;
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
assert.equal(starResponse.statusCode, 200, starResponse.body);
const starredFork = (await search("replacementbody")).items[0];
assert.equal(starredFork.starred, true);
assert.ok(starredFork.starredAt);
assert.equal(starredFork.parentChat, undefined);
await prisma.chat.delete({ where: { id: chat.id } });
assert.equal((await search("replacement")).items.length, 0);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
}
});
test("saved searches match query and answer, and update/delete their index", async () => {
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
assert.equal((await search("ai des")).items[0].type, "search");
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
assert.equal((await search("nebula")).items.length, 0);
assert.equal((await search("design")).items.length, 0);
assert.equal((await search("Bookmarks")).items.length, 0);
assert.equal((await search("newanswer")).items.length, 1);
await prisma.search.delete({ where: { id: saved.id } });
assert.equal((await search("new")).items.length, 0);
});
test("literal queries, validation, blank queries, and bounded snippets", async () => {
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
assert.equal((await search('"AI" OR safety*')).items.length, 1);
assert.equal((await search("AI OR other")).items.length, 0);
assert.equal((await search("AI")).items.length, 1);
assert.equal((await search("*** : () --")).items.length, 0);
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.deepEqual(await search(" "), recent);
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
assert.equal(response.statusCode, 400, response.body);
}
});
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
const date = new Date("2026-01-01");
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
const result = await search("common");
assert.equal(result.items.length, 100);
assert.equal(result.hasMore, true);
assert.equal(result.items[0].id, "saved");
assert.equal(result.items[1].id, "chat-000");
assert.equal(result.items.at(-1).id, "chat-098");
});
test("query plan uses all three FTS indexes", async () => {
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
}
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
});
test("vacuum preserves external-content index row identities", async () => {
const removed = await prisma.chat.create({ data: { title: "removed" } });
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
await prisma.chat.delete({ where: { id: removed.id } });
await prisma.$executeRaw`VACUUM`;
assert.equal((await search("kepttitle")).items[0].id, kept.id);
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
});
+7
View File
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
## UI
- Left panel: mixed list of chat conversations and Exa searches.
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
conversation messages, and web-search queries/answers. It waits 300 ms while
typing and cancels superseded requests. Results include a single-line matching excerpt
with matching words in bold and middle truncation
(or “Title match” for title-only chat matches), with up to 100 newest matches.
Clear the field or press Escape to return to recent items. Matching uses word
prefixes, so `plan` also finds `planning`.
- Right panel:
- Chat mode: transcript + provider/model controls.
- Search mode: top AI answer block + Google-style Exa results view.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+161 -42
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/ho
import type { TargetedTouchEvent } from "preact";
import {
Check,
Bot,
ChevronDown,
GitFork,
Globe2,
@@ -25,7 +26,9 @@ import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { AgentConversation } from "@/components/agent/agent-conversation";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character";
import {
createChat,
@@ -66,19 +69,22 @@ import {
type WorkspaceItem,
} from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth";
import { useAgentRoom } from "@/hooks/use-agent-room";
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
import {
getChatModelSelection,
getChatModelSelectionSyncKey,
} from "@/lib/chat-model-selection";
import {
filterSidebarItemsWithForkGroups,
getForkGroupPresentation,
groupForkedChatItems,
shouldRequestChatTitle,
} from "@/lib/chat-forking";
import {
retainSelectedHistoryItem,
resolveSidebarSelectionAfterRefresh,
type SidebarSelection,
type HistorySelection,
} from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
import {
@@ -94,8 +100,9 @@ import {
import { cn } from "@/lib/utils";
type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & {
type SidebarItem = HistorySelection & {
title: string;
searchSnippet: string | null;
parentChatId: string | null;
updatedAt: string;
createdAt: string;
@@ -137,6 +144,7 @@ type RefreshCollectionsOptions = {
function readSidebarSelectionFromUrl(): SidebarSelection | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
if (params.get("agent") === "root") return { kind: "agent", id: "root" };
const chatId = params.get("chat")?.trim();
if (chatId) {
return { kind: "chat", id: chatId };
@@ -153,8 +161,9 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
const params = new URLSearchParams(window.location.search);
params.delete("chat");
params.delete("search");
params.delete("agent");
if (selection) {
params.set(selection.kind === "chat" ? "chat" : "search", selection.id);
params.set(selection.kind, selection.id);
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ""}`;
@@ -843,6 +852,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
searchSnippet: chat.searchSnippet ?? null,
parentChatId: chat.parentChatId,
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
@@ -860,6 +870,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
searchSnippet: search.searchSnippet ?? null,
parentChatId: null,
updatedAt: search.updatedAt,
createdAt: search.createdAt,
@@ -959,6 +970,7 @@ export default function App() {
handleSignIn,
logout,
} = useSessionAuth();
const agentRoom = useAgentRoom(isAuthenticated);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
@@ -1052,6 +1064,10 @@ export default function App() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState("");
const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0);
const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision);
const isSidebarSearchActive = Boolean(sidebarQuery.trim());
const selectedHistoryItemRef = useRef<WorkspaceItem | null>(null);
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
const hasSyncedSelectionHistoryRef = useRef(false);
@@ -1117,19 +1133,16 @@ export default function App() {
}, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase();
if (!query) return groupForkedChatItems(sidebarItems).flat();
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => {
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
});
}, [sidebarItems, sidebarQuery]);
const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]);
const knownSidebarItems = useMemo(() => {
const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`));
return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))];
}, [sidebarItems, searchSidebarItems]);
const sidebarSections = useMemo(
() => buildSidebarSections(filteredSidebarItems, sidebarItems),
[filteredSidebarItems, sidebarItems]
() => isSidebarSearchActive
? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : [])
: buildSidebarSections(sidebarItems),
[isSidebarSearchActive, searchSidebarItems, sidebarItems]
);
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
@@ -1148,6 +1161,8 @@ export default function App() {
setChats([]);
setSearches([]);
setWorkspaceItems([]);
setSidebarQuery("");
selectedHistoryItemRef.current = null;
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
@@ -1199,6 +1214,10 @@ export default function App() {
resetWorkspaceState();
};
useEffect(() => {
if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error);
}, [sidebarSearch.error]);
const refreshCollections = async ({
initialSelection,
reportTransientError = true,
@@ -1211,12 +1230,14 @@ export default function App() {
};
setIsLoadingCollections(true);
try {
const nextWorkspaceItems = await listWorkspaceItems();
let nextWorkspaceItems = await listWorkspaceItems();
if (requestId !== collectionLoadRequestRef.current) return;
nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current);
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
setWorkspaceItems(nextWorkspaceItems);
setChats(nextChats);
setSearches(nextSearches);
setSidebarSearchRevision((current) => current + 1);
setSelectedItem((current) => {
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
@@ -1501,6 +1522,8 @@ export default function App() {
selectedItem?.kind === "search" &&
(!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isAgentMode = draftKind === null && selectedItem?.kind === "agent";
const isAgentBusy = !!agentRoom.activity?.isThinking || (agentRoom.activity?.backgroundWorkers ?? 0) > 0;
const isSearchRunning = !!selectedSearchIsActive;
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
const isActiveSelectionSending = isSearchMode ? isSearchRunning : isSendingActiveChat;
@@ -1512,16 +1535,16 @@ export default function App() {
const isItemRunning = (item: SidebarSelection) =>
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
const getChatFamilyItems = (chatId: string) => {
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
const chat = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
if (!chat) return [];
const rootId = chat.parentChatId ?? chat.id;
return sidebarItems.filter(
return knownSidebarItems.filter(
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
);
};
const isRootChat = (item: SidebarSelection) => {
if (item.kind !== "chat") return false;
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
const chat = knownSidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
return chat?.parentChatId === null;
};
const isDeleteBlocked = (item: SidebarSelection) => {
@@ -1530,9 +1553,10 @@ export default function App() {
};
const isSidebarTargetStarred = (item: SidebarSelection) => {
if (item.kind === "chat") {
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
const family = getChatFamilyItems(item.id);
return (family.find((familyItem) => familyItem.parentChatId === null) ?? family[0])?.starred ?? false;
}
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
return knownSidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
};
const isCurrentSelection = (item: SidebarSelection) => {
const current = selectedItemRef.current;
@@ -1562,9 +1586,10 @@ export default function App() {
setSelectedSearch(null);
return;
}
if (!selectedItem) {
if (!selectedItem || selectedItem.kind === "agent") {
setSelectedChat(null);
setSelectedSearch(null);
setIsLoadingSelection(false);
return;
}
@@ -1724,6 +1749,7 @@ export default function App() {
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
if (draftKind === "search") return "New search";
if (!selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
@@ -1736,6 +1762,7 @@ export default function App() {
const pageTitle = useMemo(() => {
if (draftKind || !selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent — Sybil";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
@@ -1910,7 +1937,7 @@ export default function App() {
};
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
if (!orderedSidebarItems.length) return;
const navigationItems: SidebarSelection[] = [{ kind: "agent", id: "root" }, ...orderedSidebarItems];
setError(null);
setContextMenu(null);
@@ -1919,19 +1946,29 @@ export default function App() {
setIsMobileSidebarOpen(false);
const current = selectedItemRef.current;
const currentIndex = current
? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
? navigationItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
: -1;
const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1;
const fallbackIndex = direction > 0 ? 0 : navigationItems.length - 1;
const nextIndex =
currentIndex < 0
? fallbackIndex
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
const nextItem = orderedSidebarItems[nextIndex];
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
: Math.min(navigationItems.length - 1, Math.max(0, currentIndex + direction));
const target = navigationItems[nextIndex];
rememberHistoryResult(target);
selectedItemRef.current = target;
setSelectedItem(target);
};
const rememberHistoryResult = (target: SidebarSelection) => {
const item = sidebarSearch.items.find((item) => item.type === target.kind && item.id === target.id);
if (!item) return;
selectedHistoryItemRef.current = item;
const { chats: matchedChats, searches: matchedSearches } = splitWorkspaceItems([item]);
if (matchedChats.length) setChats((current) => [...matchedChats, ...current.filter((chat) => chat.id !== item.id)]);
if (matchedSearches.length) setSearches((current) => [...matchedSearches, ...current.filter((search) => search.id !== item.id)]);
setWorkspaceItems((current) => upsertWorkspaceItem(current, item, false));
};
useEffect(() => {
if (!isAuthenticated) return;
@@ -1975,11 +2012,15 @@ export default function App() {
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
const summary = chats.find((chat) => chat.id === chatId);
if (summary) return getChatTitle(summary);
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
const sidebarItem = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
return sidebarItem?.title ?? "New chat";
};
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "chat" && selectedHistoryItemRef.current.id === updatedChat.id) {
selectedHistoryItemRef.current = chatWorkspaceItem(updatedChat);
}
supersedeCollectionLoad();
setChats((current) => {
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
@@ -2012,6 +2053,10 @@ export default function App() {
};
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "search" && selectedHistoryItemRef.current.id === updatedSearch.id) {
selectedHistoryItemRef.current = searchWorkspaceItem(updatedSearch);
}
supersedeCollectionLoad();
setSearches((current) => {
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
@@ -2198,7 +2243,7 @@ export default function App() {
const family = getChatFamilyItems(chatId);
const root = family.find((item) => item.parentChatId === null) ?? family[0];
if (!root) return;
applyChatSummary(await updateChatStar(root.id, !root.starred), false);
applyChatSummary(await updateChatStar(root.parentChatId ?? root.id, !root.starred), false);
};
const handleToggleStar = async (target: SidebarSelection) => {
@@ -2209,7 +2254,7 @@ export default function App() {
if (target.kind === "chat") {
await toggleChatFamilyStar(target.id);
} else {
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id);
const current = knownSidebarItems.find((item) => item.kind === "search" && item.id === target.id);
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
applySearchSummary(updatedSearch, false);
}
@@ -2301,6 +2346,11 @@ export default function App() {
} else {
await deleteSearch(target.id);
}
const retained = selectedHistoryItemRef.current;
if (retained && ((retained.type === target.kind && retained.id === target.id) ||
(retained.type === "chat" && target.kind === "chat" && retained.parentChatId === target.id))) {
selectedHistoryItemRef.current = null;
}
await refreshCollections({ selectFallback: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -3462,7 +3512,7 @@ export default function App() {
</div>
<SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
isBusy={isActiveSelectionSending || isQuickQuestionSending}
isBusy={isActiveSelectionSending || isQuickQuestionSending || isAgentBusy}
/>
</div>
@@ -3498,38 +3548,96 @@ export default function App() {
Shift {primaryShortcutModifier} J
</span>
</Button>
<button
type="button"
data-testid="agent-sidebar-item"
aria-current={isAgentMode ? "page" : undefined}
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
onClick={() => {
setContextMenu(null);
setError(null);
setIsChatSettingsOpen(false);
draftKindRef.current = null;
setDraftKind(null);
const target: SidebarSelection = { kind: "agent", id: "root" };
selectedItemRef.current = target;
setSelectedItem(target);
setIsMobileSidebarOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isAgentMode
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
)}
>
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border", isAgentMode ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/20 bg-violet-400/5 text-violet-200")}>
<Bot className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
</span>
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
{agentRoom.unreadCount > 0 ? (
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
{agentRoom.unreadCount}
</span>
) : null}
</button>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
<input
value={sidebarQuery}
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
placeholder="Search chats"
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
placeholder="Search chats and web searches"
aria-label="Search conversation history and saved web searches"
autoComplete="off"
spellcheck={false}
onKeyDown={(event) => {
if (event.key === "Escape") setSidebarQuery("");
}}
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-9 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
/>
{sidebarQuery ? (
<button type="button" onClick={() => setSidebarQuery("")} aria-label="Clear history search"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-violet-200/70 hover:text-violet-50">
<X className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<Separator className="bg-violet-300/10" />
<div className="flex-1 overflow-y-auto px-2 py-3">
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isLoadingCollections && sidebarItems.length === 0 ? (
{!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
<MessageSquare className="h-5 w-5" />
Start a chat or run your first search.
</div>
) : null}
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
{isSidebarSearchActive ? (
<div aria-live="polite" role="status">
{sidebarSearch.isLoading ? <p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground"><LoaderCircle className="h-4 w-4 animate-spin" />Searching history…</p> : null}
{sidebarSearch.error ? (
<div className="space-y-2 px-3 py-3 text-sm">
<p className="text-red-300">{sidebarSearch.error}</p>
<Button variant="secondary" onClick={() => setSidebarSearchRevision((current) => current + 1)}>Try again</Button>
</div>
) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? (
<p className="px-3 py-3 text-sm text-muted-foreground">No matching chats or web searches. Try a title, query, or words from a conversation.</p>
) : null}
</div>
) : null}
{sidebarSections.map((section) => (
<div key={section.label} className="mb-4">
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
{section.items.map((item) => {
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
const isForkChild = item.kind === "chat" && item.parentChatId !== null;
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null;
const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
const itemIsStarred =
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred;
const itemUpdatedAt = familyItems.reduce(
(newest, familyItem) =>
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
@@ -3555,6 +3663,7 @@ export default function App() {
draftKindRef.current = null;
setDraftKind(null);
const target = { kind: item.kind, id: item.id } as SidebarSelection;
rememberHistoryResult(target);
selectedItemRef.current = target;
setSelectedItem(target);
setIsMobileSidebarOpen(false);
@@ -3573,7 +3682,7 @@ export default function App() {
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
)}
>
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
{item.kind === "chat" ? (item.parentChatId ? <GitFork className="h-3.5 w-3.5" aria-label="Fork" /> : <MessageSquare className="h-3.5 w-3.5" />) : <Search className="h-3.5 w-3.5" />}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{item.title}</span>
@@ -3598,6 +3707,9 @@ export default function App() {
</span>
) : null}
</span>
{isSidebarSearchActive ? (
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
) : null}
</div>
)}
</button>
@@ -3605,6 +3717,9 @@ export default function App() {
})}
</div>
))}
{isSidebarSearchActive && sidebarSearch.hasMore ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
) : null}
</div>
</aside>
@@ -3615,6 +3730,9 @@ export default function App() {
onTouchEnd={handleMobileWorkspaceTouchEnd}
onTouchCancel={handleMobileWorkspaceTouchCancel}
>
{isAgentMode ? (
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
) : <>
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button
@@ -3752,6 +3870,7 @@ export default function App() {
/>
</div>
</footer>
</>}
</main>
</div>
{contextMenu ? (
@@ -0,0 +1,538 @@
.agent-room {
height:100%;
min-height:0;
min-width:0;
display:flex;
flex-direction:column;
background:radial-gradient(ellipse at 80% 0%,hsl(266 80% 30%/.12),transparent 42%),hsl(237 43% 6%/.72);
color:hsl(258 36% 96%);
}
.agent-room__body {
position:relative;
display:flex;
flex:1;
min-height:0;
min-width:0;
}
.agent-room__scroll {
width:100%;
overflow:auto;
overscroll-behavior:contain;
scrollbar-gutter:stable;
outline:none;
overflow-anchor:none;
}
.agent-room__scroll:focus-visible {
outline:1px solid hsl(264 80% 74%/.48);
outline-offset:-2px;
}
.agent-room__timeline {
display:flex;
flex-direction:column;
justify-content:flex-end;
gap:15px;
max-width:848px;
min-height:100%;
margin:0 auto;
padding:24px 30px 22px;
box-sizing:border-box;
}
.agent-room__entry {
flex-shrink:0;
min-width:0;
}
.agent-room__day {
display:flex;
align-items:center;
justify-content:center;
gap:18px;
margin:17px 0 24px;
color:hsl(255 19% 64%);
font-size:10px;
letter-spacing:.05em;
}
.agent-room__day:before,.agent-room__day:after {
content:"";
height:1px;
width:48px;
background:linear-gradient(90deg,transparent,hsl(260 48% 65%/.19));
}
.agent-room__day:after {
transform:rotate(180deg);
}
.agent-room__message {
display:flex;
align-items:flex-end;
gap:9px;
min-width:0;
max-width:89%;
}
.agent-room__message[data-animation="enter"] {
animation:agent-room-message-enter 240ms cubic-bezier(.2,.7,.25,1) both;
}
.agent-room__message.is-own {
margin-left:auto;
justify-content:flex-end;
max-width:78%;
}
.agent-room__message-avatar {
display:grid;
place-items:center;
flex:none;
align-self:flex-end;
width:26px;
height:26px;
margin-bottom:23px;
border:1px solid hsl(267 75% 73%/.2);
border-radius:9px;
background:hsl(261 52% 20%/.56);
color:hsl(269 72% 80%);
overflow:hidden;
}
.agent-room__message-avatar img {
display:block;
width:100%;
height:100%;
object-fit:cover;
}
.agent-room__message-content {
min-width:0;
}
.agent-room__plain-message {
margin:0;
white-space:pre-wrap;
}
.agent-room__message-meta {
display:flex;
align-items:center;
gap:7px;
margin:5px 3px 0;
color:hsl(253 18% 55%);
font-size:9px;
line-height:1.4;
}
.agent-room__message.is-own .agent-room__message-meta {
justify-content:flex-end;
}
.agent-room__message-meta>time:before {
content:"·";
margin-right:7px;
opacity:.6;
}
.agent-room__delivery {
display:inline-flex;
align-items:center;
gap:3px;
color:hsl(272 52% 77%);
white-space:nowrap;
}
.agent-room__delivery.is-error {
color:hsl(349 70% 78%);
}
.agent-room__delivery button {
border:0;
padding:0 0 0 3px;
background:transparent;
color:inherit;
font:inherit;
text-decoration:underline;
cursor:pointer;
}
.agent-room .md-content p {
margin-top:.55em;
margin-bottom:.55em;
}
.agent-room .md-content>:first-child {
margin-top:0;
}
.agent-room .md-content>:last-child {
margin-bottom:0;
}
.agent-room .md-content pre {
max-width:100%;
font-size:12px;
}
.agent-room .md-content h1 {
font-size:1.25em;
}
.agent-room .md-content h2 {
font-size:1.15em;
}
.agent-room .md-content h3 {
font-size:1.05em;
}
.agent-room .md-content a {
color:hsl(189 79% 75%);
}
.agent-room__tool {
margin:0 0 0 35px;
max-width:calc(89% - 35px);
border:1px solid hsl(256 35% 47%/.23);
border-radius:11px;
background:hsl(246 32% 12%/.56);
color:hsl(255 26% 75%);
font-size:11px;
}
.agent-room__tool summary {
display:flex;
align-items:center;
gap:9px;
min-height:37px;
padding:8px 11px;
cursor:pointer;
list-style:none;
}
.agent-room__tool summary::-webkit-details-marker {
display:none;
}
.agent-room__tool-icon {
display:grid;
place-items:center;
color:hsl(267 69% 76%);
}
.agent-room__tool-title {
min-width:0;
flex:1;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
text-transform:capitalize;
}
.agent-room__tool-state {
color:hsl(254 18% 51%);
font-size:9px;
}
.agent-room__tool.is-running .agent-room__tool-state {
color:hsl(184 54% 72%);
}
.agent-room__tool.is-failed .agent-room__tool-state {
color:hsl(351 71% 76%);
}
.agent-room__tool-chevron {
color:hsl(255 20% 52%);
transition:transform .15s;
}
.agent-room__tool[open] .agent-room__tool-chevron {
transform:rotate(180deg);
}
.agent-room__tool-body {
padding:0 11px 11px;
}
.agent-room__tool-body pre {
max-height:210px;
overflow:auto;
white-space:pre-wrap;
overflow-wrap:anywhere;
font:10px/1.6 ui-monospace,monospace;
margin:8px 0 0;
padding:9px;
border-radius:6px;
background:hsl(239 42% 6%/.7);
}
.agent-room__typing,.agent-room__waiting {
display:flex;
align-items:center;
gap:9px;
flex:none;
min-height:20px;
margin-left:36px;
color:hsl(254 22% 63%);
font-size:11px;
}
.agent-room__typing-dots {
display:flex;
align-items:center;
gap:3px;
padding:6px 8px;
border-radius:9px;
background:hsl(258 43% 22%/.42);
}
.agent-room__typing-dots i {
width:3px;
height:3px;
border-radius:50%;
background:hsl(268 72% 78%);
animation:agent-room-dot 1.4s ease-in-out infinite;
}
.agent-room__typing-dots i:nth-child(2) {
animation-delay:.16s;
}
.agent-room__typing-dots i:nth-child(3) {
animation-delay:.32s;
}
.agent-room__worker-count {
padding:2px 5px;
border-radius:5px;
background:hsl(265 58% 27%/.3);
color:hsl(268 58% 76%);
font-size:9px;
}
.agent-room__jump {
position:absolute;
z-index:2;
left:50%;
bottom:14px;
transform:translateX(-50%);
display:flex;
align-items:center;
gap:7px;
white-space:nowrap;
padding:9px 14px;
border:1px solid hsl(266 76% 72%/.43);
border-radius:99px;
background:hsl(256 46% 20%/.96);
color:hsl(263 60% 93%);
box-shadow:0 5px 24px hsl(240 70% 2%/.5);
font-size:11px;
cursor:pointer;
backdrop-filter:blur(14px);
}
.agent-room__jump:hover {
background:hsl(261 48% 27%);
}
.agent-room__composer-area {
flex:none;
border-top:1px solid hsl(253 40% 40%/.14);
padding:16px 30px max(13px,var(--safe-area-bottom));
background:linear-gradient(180deg,hsl(240 41% 7%/.72),hsl(240 43% 7%/.97));
}
.agent-room__composer-inner {
max-width:788px;
margin:0 auto;
}
.agent-room__composer {
display:flex;
align-items:flex-end;
gap:10px;
padding:7px 8px 7px 5px;
border:1px solid hsl(263 51% 58%/.3);
border-radius:17px;
background:hsl(247 35% 13%/.9);
box-shadow:inset 0 1px 0 hsl(266 80% 92%/.04),0 6px 24px hsl(239 80% 2%/.12);
transition:border-color .15s,box-shadow .15s;
}
.agent-room__composer:focus-within {
border-color:hsl(265 74% 69%/.65);
box-shadow:0 0 0 3px hsl(266 69% 55%/.08);
}
.agent-room__composer textarea {
display:block;
flex:1;
min-width:0;
min-height:46px;
max-height:156px;
margin:0;
resize:none;
overflow:auto;
border:0;
outline:0;
background:transparent;
padding:13px 11px;
font-size:14px;
line-height:20px;
color:hsl(259 44% 96%);
box-shadow:none;
}
.agent-room__composer textarea::placeholder {
color:hsl(255 23% 53%);
}
.agent-room__send {
display:grid;
place-items:center;
flex:none;
width:38px;
height:38px;
margin-bottom:4px;
border:1px solid hsl(265 86% 79%/.32);
border-radius:11px;
background:linear-gradient(140deg,hsl(264 80% 59%),hsl(270 75% 49%));
color:hsl(265 65% 98%);
box-shadow:0 3px 12px hsl(264 75% 45%/.22);
cursor:pointer;
transition:filter .15s,opacity .15s;
}
.agent-room__send:hover:not(:disabled) {
filter:brightness(1.17);
}
.agent-room__send:disabled {
opacity:.34;
box-shadow:none;
cursor:default;
}
.agent-room__error {
display:flex;
align-items:center;
gap:9px;
margin-bottom:11px;
padding:10px 12px;
border:1px solid hsl(350 62% 62%/.2);
border-radius:11px;
background:hsl(340 45% 22%/.22);
color:hsl(349 70% 85%);
font-size:11px;
}
.agent-room__error>span {
min-width:0;
flex:1;
}
.agent-room__error>svg {
flex:none;
}
.agent-room__error button {
flex:none;
border:1px solid hsl(350 57% 66%/.28);
border-radius:6px;
padding:4px 8px;
color:hsl(349 66% 90%);
font-size:10px;
cursor:pointer;
}
.agent-room__empty {
flex:none;
margin:auto;
max-width:360px;
padding:48px 15px;
text-align:center;
color:hsl(254 25% 65%);
}
.agent-room button:focus-visible,.agent-room summary:focus-visible {
outline:2px solid hsl(188 82% 71%);
outline-offset:3px;
}
@keyframes agent-room-dot {
0%,70%,100% {
opacity:.35;
transform:translateY(0);
}
35% {
opacity:1;
transform:translateY(-2px);
}
}
@keyframes agent-room-message-enter {
from {
opacity:0;
transform:translateY(10px);
}
to {
opacity:1;
transform:translateY(0);
}
}
@media(max-width:767px) {
.agent-room__timeline {
padding:17px 14px 20px;
gap:14px;
}
.agent-room__message {
max-width:96%;
gap:7px;
}
.agent-room__message.is-own {
max-width:87%;
}
.agent-room__message-avatar {
width:23px;
height:23px;
border-radius:8px;
}
.agent-room__tool {
margin-left:30px;
max-width:calc(96% - 30px);
}
.agent-room__typing,.agent-room__waiting {
margin-left:30px;
font-size:10px;
}
.agent-room__composer-area {
padding:12px 13px max(11px,var(--safe-area-bottom));
}
.agent-room__composer textarea {
font-size:16px;
line-height:22px;
}
.agent-room__empty {
max-width:300px;
}
}
@media(prefers-reduced-motion:reduce) {
.agent-room__message[data-animation="enter"] {
animation:none;
}
.agent-room__typing-dots i {
animation:none;
opacity:.7;
}
.agent-room * {
scroll-behavior:auto!important;
transition:none!important;
}
}
@@ -0,0 +1,350 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact";
import type { Message } from "@/lib/api";
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Button } from "@/components/ui/button";
import "./agent-conversation.css";
type Props = {
room: AgentRoomHookResult;
onOpenSidebar: () => void;
/** False while a mobile navigation overlay covers this pane. */
isVisible?: boolean;
};
type ReadingAnchor = { id: string; offset: number };
const DRAFT_KEY = "sybil.pi-root.draft";
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" });
function savedDraft() {
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
}
function saveDraft(value: string) {
try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ }
}
function metadata(message: Message): Record<string, unknown> {
return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
}
function dateOf(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date;
}
function dayKey(date: Date | null) {
return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older";
}
function dayLabel(date: Date | null) {
if (!date) return "Earlier";
const today = new Date();
if (dayKey(date) === dayKey(today)) return "Today";
today.setDate(today.getDate() - 1);
return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date);
}
function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
function AgentToolCard({ message }: { message: Message }) {
const info = metadata(message);
const active = info.status === "initiated";
const failed = info.status === "failed";
const title = toolName(info.toolName || message.name);
const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : "";
return (
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
<summary>
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
<span className="agent-room__tool-title">{title}</span>
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
</summary>
<div className="agent-room__tool-body">
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
</div>
</details>
);
}
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
const own = message.role === "user";
const info = metadata(message);
const status = typeof info.status === "string" ? info.status : "complete";
const failed = status === "failed" || status === "unconfirmed";
const date = dateOf(message.createdAt);
return (
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
<div className="agent-room__message-content">
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
</div>
<div className="agent-room__message-meta">
<span>{own ? "You" : "Sybil"}</span>
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
{own && status === "sending" ? <span className="agent-room__delivery" role="status">Sending…</span> : null}
{own && status === "queued" ? <span className="agent-room__delivery" role="status"><Clock3 size={11} aria-hidden="true" /> Queued</span> : null}
{own && failed ? <span className="agent-room__delivery is-error" title={typeof info.error === "string" ? info.error : undefined} role="status">
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}
<button type="button" onClick={() => onRetry(message.id)} aria-label="Retry message">Retry</button>
</span> : null}
</div>
</div>
</article>
);
}
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
const [draft, setDraft] = useState(savedDraft);
const draftRef = useRef(draft);
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
const enteringMessageIdsRef = useRef(new Set<string>());
const animatedEntryIdsRef = useRef(new Set<string>());
const entryAnimationsRef = useRef(new Set<Animation>());
const typingHeightRef = useRef(0);
const historyReadyRef = useRef(!room.isLoading);
const [atBottom, setAtBottom] = useState(true);
const scrollerRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pinnedRef = useRef(true);
const restoredScrollTopRef = useRef<number | null>(null);
const anchorRef = useRef<ReadingAnchor | null>(null);
const markReadRef = useRef(room.markRead);
const visibleRef = useRef(isVisible);
const loadingRef = useRef(room.isLoading);
const readFrameRef = useRef<number | null>(null);
const mountedRef = useRef(true);
markReadRef.current = room.markRead;
visibleRef.current = isVisible;
loadingRef.current = room.isLoading;
const distanceFromBottom = () => {
const element = scrollerRef.current;
return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity;
};
const scheduleRead = () => {
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
readFrameRef.current = requestAnimationFrame(() => {
readFrameRef.current = null;
if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) {
markReadRef.current();
}
});
};
const rememberAnchor = () => {
const element = scrollerRef.current;
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
const top = element.getBoundingClientRect().top;
// Anchor to layout, independently of the bubble's entrance transform.
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
};
const restoreViewport = () => {
const element = scrollerRef.current;
if (!element) return;
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
else if (anchorRef.current) {
const anchor = anchorRef.current;
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
}
restoredScrollTopRef.current = element.scrollTop;
rememberAnchor();
scheduleRead();
};
const jumpToLatest = () => {
pinnedRef.current = true;
anchorRef.current = null;
setAtBottom(true);
if (scrollerRef.current) {
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
}
scheduleRead();
};
useLayoutEffect(() => {
const timeline = timelineRef.current;
if (!timeline) return;
const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0;
const typing = timeline.querySelector<HTMLElement>(".agent-room__typing");
// Transfer the departing indicator's space to the reply before it grows.
// Removing that space first would briefly move the transcript downward.
let replacedTypingHeight = typing ? 0 : typingHeightRef.current;
typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
for (const entry of timeline.querySelectorAll<HTMLElement>("[data-agent-entry-id]")) {
const id = entry.dataset.agentEntryId!;
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
animatedEntryIdsRef.current.add(id);
if (reduceMotion) continue;
const height = entry.getBoundingClientRect().height;
const spacing = entry.previousElementSibling ? gap : 0;
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
if (isReply) replacedTypingHeight = 0;
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
// positive-Y transform from prematurely extending the scrollable area.
entry.style.overflow = "clip";
const animation = entry.animate([
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
{ height: `${height}px`, marginTop: "0px" },
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
entryAnimationsRef.current.add(animation);
animation.onfinish = () => {
entry.style.overflow = "";
entryAnimationsRef.current.delete(animation);
restoreViewport();
};
}
}, [room.messages, room.isTyping]);
useLayoutEffect(() => {
restoreViewport();
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
useEffect(() => {
mountedRef.current = true;
const observer = new ResizeObserver(restoreViewport);
if (scrollerRef.current) observer.observe(scrollerRef.current);
if (timelineRef.current) observer.observe(timelineRef.current);
const onVisibility = () => scheduleRead();
window.addEventListener("focus", onVisibility);
document.addEventListener("visibilitychange", onVisibility);
return () => {
mountedRef.current = false;
observer.disconnect();
for (const animation of entryAnimationsRef.current) animation.cancel();
entryAnimationsRef.current.clear();
window.removeEventListener("focus", onVisibility);
document.removeEventListener("visibilitychange", onVisibility);
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
};
}, []);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`;
}, [draft]);
const updateDraft = (value: string) => {
draftRef.current = value;
saveDraft(value);
setDraft(value);
};
const retryMessage = (id: string) => {
// Admission errors remain attached to their own bubble.
void room.retryMessage(id).catch(() => {});
};
const submit = () => {
const content = draftRef.current.trim();
if (!content) return;
const delivery = room.sendMessage(content);
updateDraft("");
jumpToLatest();
void delivery.catch(() => {});
textareaRef.current?.focus();
};
const messages = useMemo(() => {
let previousDay = "";
for (const message of room.messages) {
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") {
enteringMessageIdsRef.current.add(message.id);
}
seenMessageIdsRef.current.add(message.id);
}
if (!room.isLoading) historyReadyRef.current = true;
return room.messages.filter(message => message.role !== "system").map(message => {
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
previousDay = day;
return (
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
{message.role === "tool" ? <AgentToolCard message={message} /> : <AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />}
</div>
);
});
}, [room.messages, room.isLoading, room.retryMessage]);
const activity = room.activity;
const running = room.isTyping;
const waiting = activity?.state === "waiting";
const connected = room.connection === "connected";
const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting";
const activeTool = activity?.tools?.find(tool => tool.status !== "done");
const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}`
: activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : "";
return (
<section className="agent-room" aria-label="Sybil Agent conversation">
<header className="agent-room__header flex shrink-0 items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button type="button" size="icon" variant="ghost" className="workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick={onOpenSidebar} aria-label="Open sidebar">
<Menu className="h-4 w-4" />
</Button>
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">Sybil Agent</h1>
</div>
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
<span className="sr-only" role="status">{connectionLabel}</span>
{!connected ? <Button type="button" size="icon" variant="ghost" onClick={room.retry} aria-label="Reconnect to Sybil Agent" title={connectionLabel}><RefreshCw className="h-4 w-4" /></Button> : null}
</div>
</header>
<div className="agent-room__body">
<div
ref={scrollerRef}
className="agent-room__scroll"
tabIndex={0}
aria-label="Conversation messages"
onScroll={() => {
// A scroll event from our last resize correction can arrive after
// the next animation frame has already grown a tall reply. That
// temporary bottom distance does not mean the reader scrolled up.
if (restoredScrollTopRef.current !== null && scrollerRef.current
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
const bottom = distanceFromBottom() <= 48;
pinnedRef.current = bottom;
setAtBottom(current => current === bottom ? current : bottom);
rememberAnchor();
if (bottom) scheduleRead();
}}
>
<div ref={timelineRef} className="agent-room__timeline">
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
{messages}
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
</div>
</div>
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
</div>
<div className="agent-room__composer-area">
<div className="agent-room__composer-inner">
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
<textarea
ref={textareaRef}
value={draft}
rows={1}
onInput={event => updateDraft(event.currentTarget.value)}
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
placeholder="Message Sybil Agent…"
aria-label="Message Sybil Agent"
enterkeyhint="send"
/>
<button type="submit" className="agent-room__send" disabled={!draft.trim()} aria-label="Send message"><SendHorizontal size={19} /></button>
</form>
</div>
</div>
</section>
);
}
@@ -16,7 +16,7 @@ function getTextPreview(value: string) {
function getSurfaceClasses(tone: Props["tone"]) {
if (tone === "user") {
return "border-white/12 bg-black/16 text-fuchsia-50";
return "border-white/12 bg-black/16 text-inherit";
}
if (tone === "assistant") {
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
@@ -601,10 +601,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] space-y-3",
"min-w-0 space-y-3",
isUser
? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
: "max-w-[85%] text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
@@ -625,7 +625,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
/>
) : null}
</div>
@@ -0,0 +1,96 @@
/// <reference lib="es2022.intl" />
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
const text = source.replace(/\s+/gu, " ").trim();
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const ellipsisRef = useRef<HTMLSpanElement>(null);
const [kept, setKept] = useState<number | null>(null);
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
const runs = useMemo(() => {
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
const result: Array<{ start: number; end: number; match: boolean }> = [];
let cursor = 0;
for (const word of text.matchAll(words)) {
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
cursor = word.index + word[0].length;
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
}
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
return result;
}, [text, query]);
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
if (run.end <= start || run.start >= end) return [];
return [run.match
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
: <span key={`${start}:${run.start}`}>{value}</span>];
});
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
const ellipsis = ellipsisRef.current;
if (!container || !measure || !ellipsis) return;
let disposed = false;
const update = () => {
if (disposed) return;
const width = container.getBoundingClientRect().width;
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
// Measure the styled DOM, including bold runs, instead of guessing a
// character limit. Grapheme boundaries keep accents and emoji intact.
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
const range = document.createRange();
const measureRange = (start: number, end: number) => {
if (start >= end) return 0;
let offset = 0;
let started = false;
for (const node of nodes) {
const next = offset + node.length;
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
if (end <= next) { range.setEnd(node, end - offset); break; }
offset = next;
}
return range.getBoundingClientRect().width;
};
const count = boundaries.length - 1;
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
let low = 0;
let high = count - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const prefix = boundaries[Math.ceil(middle / 2)];
const suffix = boundaries[count - Math.floor(middle / 2)];
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
else high = middle - 1;
}
setKept(low);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
void document.fonts.ready.then(update);
document.fonts.addEventListener("loadingdone", update);
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
}, [text, runs, boundaries]);
const count = boundaries.length - 1;
const truncated = kept !== null && kept < count;
return (
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
</span>
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
</span>
</span>
);
}
+229
View File
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
import {
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
type AgentActivity, type AgentRoomState,
} from "@/lib/agent-room";
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
/** Mount once in App; selection controls reading, never the lifetime of this subscription. */
export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const [view, setView] = useState(() => ({ room: createAgentRoomState(), isTyping: false }));
const room = view.room;
const current = useRef(room);
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
const [error, setError] = useState<string | null>(null);
const [isLoading, setLoading] = useState(enabled);
const [revision, setRevision] = useState(0);
const client = useRef<AgentRoomClient | null>(null);
const mark = useRef<((cursor: string) => void) | null>(null);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
}, []);
const outbox = useRef<ReturnType<typeof createAgentOptimisticOutbox> | null>(null);
if (!outbox.current) {
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
outbox.current = createAgentOptimisticOutbox({
submit: (content, requestId) => {
if (!client.current) throw new Error("Agent connection is unavailable.");
return client.current.submit(content, requestId);
},
changed: () => setOutboxRevision(value => value + 1),
storage: {
read: () => stored(AGENT_OUTBOX_STORAGE_KEY),
legacyRows: () => stored("sybil.pi-root.outbox"),
legacyIds: () => stored(AGENT_ADMISSION_STORAGE_KEY),
write: rows => {
try {
// Keep [] as a migration marker; legacy keys are retired only once
// their replacement has been saved successfully.
sessionStorage.setItem(AGENT_OUTBOX_STORAGE_KEY, JSON.stringify(rows));
sessionStorage.removeItem("sybil.pi-root.outbox");
sessionStorage.removeItem(AGENT_ADMISSION_STORAGE_KEY);
} catch { throw new Error("Could not save this message for retry. Allow session storage or free browser storage, then retry."); }
},
},
});
}
useEffect(() => {
if (!enabled) {
client.current = null; mark.current = null;
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
let socketTimeout: ReturnType<typeof setTimeout> | undefined;
let reconnectAttempt = 0;
let started = false;
let maintaining = false;
let lastPacket = Date.now();
let socketAuthenticated = false;
let authFailed = false;
const controller = new AbortController();
const api = new AgentRoomClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
client.current = api;
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
publish: (next, isTyping) => {
if (!active) return;
outbox.current?.reconcile(next);
current.current = next;
setView({ room: next, isTyping });
},
});
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
// writer must observe that acknowledged cursor immediately to avoid a
// repeated PUT loop while the presenter waits to publish its batch.
let acknowledgedReadCursor = current.current.readCursor;
const readWriter = createAgentReadWriter({
current: () => acknowledgedReadCursor,
save: cursor => api.markRead(cursor, controller.signal),
apply: read => {
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
const refreshRead = async () => {
const read = await api.readState(controller.signal);
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
};
const connect = () => {
if (!active || authFailed) return;
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
socketAuthenticated = false;
const next = new WebSocket(api.websocketUrl(window.location.href));
socket = next;
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
next.onopen = () => {
if (!active || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
};
next.onmessage = ({ data }) => {
if (!active || socket !== next) return;
lastPacket = Date.now();
try {
const event = parseAgentRoomEvent(String(data));
if (!event) return;
if (event.type === "error") { reportError(new Error(event.error || event.message || "Agent connection error.")); return; }
if (event.type === "ready") {
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
setConnection("connected"); setError(null); return;
}
presenter.push(event);
} catch (failure) {
reportError(failure);
// A malformed message must not advance the replay cursor. Reconnect
// from the last applied page, rather than silently skipping that input.
next.close();
}
};
next.onerror = () => { if (active) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || socket !== next) return;
clearTimeout(socketTimeout); socketAuthenticated = false;
if (event.code === 4401) {
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
}
setConnection("reconnecting");
const wait = Math.min(30_000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
reconnectTimer = setTimeout(connect, wait);
};
};
const start = async () => {
try {
await Promise.all([refreshRead(), refreshActivity()]);
// The first start begins at zero, not the latest page. Unread counts
// therefore include older public replies even after a long absence.
await catchUp(!started);
if (!active) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readWriter.flush();
if (!socketAuthenticated) {
await Promise.all([catchUp(), refreshActivity()]);
} else if (foreground) await catchUp();
if (socket?.readyState === WebSocket.OPEN) {
if (Date.now() - lastPacket > 60_000) socket.close();
else socket.send(JSON.stringify({ type: "ping" }));
}
} catch (failure) { reportError(failure); }
finally { maintaining = false; }
};
const focus = () => { if (document.visibilityState === "visible") void maintain(true); };
const interval = setInterval(() => { void maintain(); }, 15_000);
window.addEventListener("focus", focus);
document.addEventListener("visibilitychange", focus);
void start();
return () => {
active = false; controller.abort();
presenter.dispose();
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
socket?.close();
if (client.current === api) client.current = null;
mark.current = null;
};
}, [enabled, revision, update]);
const renderedCursor = room.cursor;
const markRead = useCallback(() => {
if (!enabled || isLoading) return;
// This callback belongs to this rendered snapshot. Reading current.current
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
const sendMessage = useCallback(async (content: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content);
}, [enabled]);
const retryMessage = useCallback(async (messageId: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.retry(messageId);
}, [enabled]);
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "preact/hooks";
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
const EMPTY_ITEMS: WorkspaceItem[] = [];
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
const trimmed = query.trim();
const [state, setState] = useState<SearchState | null>(null);
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
useEffect(() => {
setState(null);
if (!enabled || !trimmed || validationError) return;
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
(result) => setState({ ...result, query: trimmed, revision, error: null }),
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
);
}, [trimmed, enabled, revision, validationError]);
// Hide obsolete results immediately, even before effect cleanup runs.
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
return {
items: current?.items ?? EMPTY_ITEMS,
hasMore: current?.hasMore ?? false,
error: validationError ?? current?.error ?? null,
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
};
}
+29 -5
View File
@@ -194,11 +194,35 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28);
}
.chat-user-message {
/* Preserve the original bubble radius independently of the square UI theme.
Browsers without corner-shape support fall back to circular corners. */
border-radius: 0.75rem;
corner-shape: squircle;
/* Shared by regular chats and the persistent agent transcript. */
.message-bubble {
min-width: 0;
padding: 13px 17px;
border: 1px solid hsl(256 37% 57% / .18);
border-radius: 18px 18px 18px 5px;
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
font-size: 14px;
line-height: 1.68;
overflow-wrap: anywhere;
}
.message-bubble--user {
border-color: hsl(266 80% 72% / .3);
border-radius: 18px 18px 5px 18px;
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
color: hsl(265 55% 97%);
}
.message-bubble .md-content {
font-size: inherit;
line-height: inherit;
}
@media (max-width: 767px) {
.message-bubble {
padding: 11px 14px;
}
}
.tool-call-stack-shell {
+166
View File
@@ -0,0 +1,166 @@
import type { Message } from "./api";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
legacyRows?(): unknown; legacyIds?(): unknown;
};
type Receipt = { status: string; messageId?: string };
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
now?: () => number;
}) {
const newId = options.newId || (() => crypto.randomUUID());
const now = options.now || Date.now;
const rows = new Map<string, AgentOutboxRow>();
const inFlight = new Map<string, Promise<void>>();
const rejectedStorage = new Set<string>();
const confirmedEchoes = new Map<string, Message>();
let latestTime = 0;
const timestamp = () => {
latestTime = Math.max(now(), latestTime + 1);
return new Date(latestTime).toISOString();
};
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
} else {
// Join the former UI-only failed cards to the old sender's receipt keys.
// Neither representation is retried automatically during migration.
const oldRows = options.storage.legacyRows?.();
const oldIds = options.storage.legacyIds?.();
const ids = new Map<string, string>();
if (Array.isArray(oldIds)) for (const value of oldIds) {
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
}
const contents = new Map<string, { content: string; status?: string; error?: string }>();
if (Array.isArray(oldRows)) for (const value of oldRows) {
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
}
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
for (const value of [...contents.values()].slice(0, 100)) {
const requestId = ids.get(value.content) || newId();
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
}
}
const persist = () => {
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
options.storage.write(pending.map(row => ({ ...row })));
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const dispatch = (row: AgentOutboxRow): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
row.status = "sending"; delete row.error;
rejectedStorage.delete(row.id);
try { persist(); }
catch (error) {
// A refused new draft must not prevent older persisted drafts from
// draining their outbox. The refused row remains visibly retryable.
rejectedStorage.add(row.id);
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
row.status = receipt.status === "queued" ? "queued" : "complete";
delete row.error; saveBestEffort(); publish();
}
}).catch(error => {
// A canonical echo wins over a lost HTTP response. The message has already
// been admitted, so a transport error must not paint it failed or resend it.
if (row.confirmed || row.seenOnServer) return;
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
throw error;
}).finally(() => { inFlight.delete(row.id); });
inFlight.set(row.id, pending);
return pending;
};
// Complete migration even when the user leaves restored drafts untouched:
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string): Promise<void> {
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const committed = new Map(room.messages.map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
confirmedEchoes.set(row.id, committed.get(row.id)!);
if (!row.confirmed || row.status !== "complete" || row.error) {
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
}
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
}
}
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
return [message.id, { ...message, createdAt: row.createdAt,
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
}));
for (const row of rows.values()) if (!displayed.has(row.id)) {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
}
+120
View File
@@ -0,0 +1,120 @@
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
import type { Message } from './api';
type Scheduler = {
now(): number;
schedule(callback: () => void, delay: number): unknown;
cancel(handle: unknown): void;
};
export type AgentRoomPresenterOptions = {
initial: AgentRoomState;
reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState;
/** Publish room and transcript typing together in one UI state update. */
publish(state: AgentRoomState, isTyping: boolean): void;
batchMs?: number;
typingDelayMs?: number;
clock?: Scheduler;
};
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
const entryId = (message: Message): bigint | undefined => {
const value = data(message).entryId;
return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
? BigInt(value) : undefined;
};
const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id;
/**
* Sybil Agent sends messages and activity as separate frames. This adapter publishes them
* as one room snapshot and latches transcript typing off when a new public reply
* arrives. An unchanged busy frame cannot restore the old turn's indicator.
* Actual activity remains untouched for the sidebar, workers, and queue display.
*/
export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) {
const clock: Scheduler = options.clock || {
now: () => Date.now(),
schedule: (callback, delay) => setTimeout(callback, delay),
cancel: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
};
const batchMs = options.batchMs ?? 100;
const typingDelayMs = options.typingDelayMs ?? 250;
let state = options.initial;
let stopped = false;
let batchTimer: unknown;
let typingTimer: unknown;
let suppressTyping = false;
let candidateSince: number | undefined;
const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId));
for (const tool of state.activity?.tools || []) seenTools.add(tool.id);
const reconcile = () => {
const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0);
if (!work || suppressTyping) candidateSince = undefined;
else if (candidateSince === undefined) candidateSince = clock.now();
};
const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; };
const flush = () => {
if (stopped) return;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
reconcile();
const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs;
options.publish(state, isTyping);
if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince)));
};
const schedule = () => {
reconcile();
// A fixed maximum batch window avoids starving publication during bursts.
if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs);
};
const release = () => { suppressTyping = false; };
const latch = () => {
suppressTyping = true;
candidateSince = undefined;
clearTypingTimer();
};
return {
/** replay:true is for initial historical loading, not ongoing catch-up. */
push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) {
if (stopped) return;
const before = state;
const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state.
state = next;
if (event.type === 'messages') {
const existing = new Set(before.messages.map(message => message.id));
const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor))
.sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1);
if (!hints.replay) for (const message of fresh) {
const info = data(message);
if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch();
else if (suppressTyping && message.role === 'user') release();
else if (suppressTyping && message.role === 'tool' && info.status === 'initiated'
&& !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release();
if (message.role === 'tool') seenTools.add(toolId(message));
}
for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message));
} else if (event.type === 'activity') {
const activity = event.activity;
if (suppressTyping && (!activity.isThinking
|| activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release();
for (const tool of activity.tools) seenTools.add(tool.id);
}
schedule();
},
/** Read-position updates share the pending snapshot; they never lose queued frames. */
update(change: (state: AgentRoomState) => AgentRoomState) {
if (stopped) return;
state = change(state); schedule();
},
flush,
dispose() {
stopped = true;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
// Unpublished input is deliberately discarded; reconnect replays from the
// last published cursor, so neither delivery nor read tracking gets ahead.
},
};
}
+216
View File
@@ -0,0 +1,216 @@
import type { Message } from "./api";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
isThinking: boolean;
queued: number;
backgroundWorkers: number;
tools: Array<{ id: string; name: string; status: string; output?: string }>;
queuedMessages: Message[];
wait?: { due: number; reason?: string };
error?: string;
};
export type AgentMessagesPage = {
conversationId: string; messages: Message[]; cursor: string | null;
beforeCursor: string | null; hasMore: boolean;
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string;
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentRoomState = {
conversationId: string | null; messages: Message[]; cursor: string;
readCursor: string; activity: AgentActivity | null;
};
export type AgentRoomEvent =
| ({ type: "messages" } & AgentMessagesPage)
| { type: "activity"; conversationId: string; activity: AgentActivity }
| { type: "ready"; conversationId?: string }
| { type: "pong" | "heartbeat" }
| { type: "error"; error?: string; message?: string };
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const defined = (value: Record<string, unknown>) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
export function normalizeAgentCursor(value: unknown): string {
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("Invalid agent message cursor.");
return BigInt(value).toString();
}
export function advanceAgentCursor(current: string, incoming: string): string {
const a = normalizeAgentCursor(current), b = normalizeAgentCursor(incoming);
return BigInt(a) >= BigInt(b) ? a : b;
}
function metadata(message: Message) { return object(message.metadata); }
function messageCursor(message: Message): string | null {
const entryId = metadata(message)?.entryId;
if (typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0) return String(entryId);
return typeof entryId === "string" && /^\d+$/.test(entryId) ? normalizeAgentCursor(entryId) : null;
}
function mergeMessage(previous: Message, incoming: Message): Message {
const prior = metadata(previous), next = metadata(incoming);
// A delayed activity snapshot must not demote an already placed user message.
if (prior?.status === "complete" && next?.status === "queued") return previous;
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
const terminal = prior.status === "completed" || prior.status === "failed";
const staleStart = terminal && next.status === "initiated";
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
if (!next.startedAt && prior.startedAt) merged.startedAt = prior.startedAt;
const oldArgs = object(prior.args), newArgs = object(next.args);
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
const messages = new Map(previous.map(message => [message.id, message]));
for (const message of incoming) {
const prior = messages.get(message.id);
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
}
return [...messages.values()].sort((a, b) => {
const byTime = a.createdAt.localeCompare(b.createdAt);
if (byTime) return byTime;
const left = messageCursor(a), right = messageCursor(b);
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
return a.id.localeCompare(b.id);
});
}
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
});
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
}
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
}
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
if (event.type === "messages") return applyAgentPage(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: event.activity };
}
export function agentUnreadCount(state: AgentRoomState): number {
return state.messages.filter(message => {
if (message.role !== "assistant" || metadata(message)?.kind !== "message") return false;
const cursor = messageCursor(message);
return cursor !== null && BigInt(cursor) > BigInt(state.readCursor);
}).length;
}
export function agentDisplayMessages(state: AgentRoomState): Message[] {
const committed = new Set(state.messages.map(message => message.id));
return mergeAgentMessages(state.messages, (state.activity?.queuedMessages || []).filter(message => !committed.has(message.id)));
}
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
const event = object(JSON.parse(raw));
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
if (event.type === "messages") {
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
for (const item of event.messages) {
const message = object(item);
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
}
}
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string };
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
export class AgentRoomClient {
private readonly baseUrl: string;
private readonly fetcher: typeof globalThis.fetch;
private readonly token: () => string | null;
constructor(options: AgentRoomClientOptions = {}) {
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
this.token = options.token || (() => null);
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init.body !== undefined) headers.set("Content-Type", "application/json");
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
if (!response.ok) {
const error = object(value);
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
}
return value as T;
}
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
}
async submit(content: string, requestId: string): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId }) }));
// An HTTP success alone does not acknowledge delivery. Keep the original
// outbox receipt key retryable if a proxy or upstream returns malformed data.
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|| receipt.messageId !== `inbound:sybil:${requestId}`) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
return receipt as AgentSubmission;
}
websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
}
/** Read every newer page, including pages containing only invisible internal entries. */
export async function syncAgentHistory(client: Pick<AgentRoomClient, "messages">, from: string,
apply: (page: AgentMessagesPage) => void, signal?: AbortSignal): Promise<void> {
let cursor = normalizeAgentCursor(from);
while (!signal?.aborted) {
const page = await client.messages(cursor, signal);
if (signal?.aborted) return;
const next = normalizeAgentCursor(page.cursor ?? cursor);
if (BigInt(next) < BigInt(cursor) || (page.hasMore && next === cursor)) throw new Error("Agent history did not advance its cursor.");
apply(page);
cursor = next;
if (!page.hasMore) return;
}
}
// Retained only to migrate receipt IDs from the former admission sender.
export const AGENT_ADMISSION_STORAGE_KEY = "sybil.agent.pending-admissions.v1";
/** Only successfully persisted, explicitly observed cursors become read state. */
export function createAgentReadWriter(options: {
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
}) {
let desired = "0";
let running: Promise<void> | undefined;
const flush = (): Promise<void> => {
if (running) return running;
running = (async () => {
while (BigInt(desired) > BigInt(options.current())) {
const captured = desired;
const saved = await options.save(captured);
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
}
})().finally(() => { running = undefined; });
return running;
};
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
}
+15 -1
View File
@@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
export type Message = {
id: string;
@@ -228,6 +230,14 @@ export function getConfiguredToken() {
return ENV_ADMIN_TOKEN;
}
export function getAuthToken() {
return authToken;
}
export function getApiBaseUrl() {
return API_BASE_URL;
}
export function setAuthToken(token: string | null) {
authToken = token?.trim() || null;
}
@@ -272,6 +282,10 @@ export async function listWorkspaceItems() {
return data.items;
}
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
}
export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
}
+15 -2
View File
@@ -1,6 +1,17 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
export type HistorySelection = { kind: "chat" | "search"; id: string };
export type SidebarSelection = HistorySelection | { kind: "agent"; id: "root" };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type WorkspaceSelectionItem = { type: HistorySelection["kind"]; id: string };
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
items: T[], selection: SidebarSelection | null, retained: T | null
): T[] {
if (retained && selection?.kind === retained.type && selection.id === retained.id &&
!items.some((item) => item.type === retained.type && item.id === retained.id)) {
return [...items, retained];
}
return items;
}
type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection;
@@ -14,6 +25,8 @@ export function resolveSidebarSelectionAfterRefresh(
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
// The persistent agent is always available, independent of saved history.
if (candidate.kind === "agent") return true;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};
+24
View File
@@ -0,0 +1,24 @@
import type { WorkspaceSearchResponse } from "./api";
// Cleanup cancels both the debounce and fetch, and also rejects late responses
// from transports that finish after cancellation.
export function scheduleWorkspaceSearch(
query: string,
request: (query: string, signal: AbortSignal) => Promise<WorkspaceSearchResponse>,
onResult: (result: WorkspaceSearchResponse) => void,
onError: (error: unknown) => void
) {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const result = await request(query, controller.signal);
if (!controller.signal.aborted) onResult(result);
} catch (error) {
if (!controller.signal.aborted) onError(error);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") => ({
id, content, createdAt: time, role: "user", name: "buzzert",
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
});
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
function fixture(submit, legacy = {}) {
let stored = null;
let serial = 0;
const changes = [];
let box;
const storage = { read: () => structuredClone(stored), write: rows => { stored = structuredClone(rows); }, ...legacy };
const open = () => {
box = createAgentOptimisticOutbox({ submit, storage, newId: () => `request-${++serial}`, now: () => Date.parse("2026-10-14T12:00:00.000Z"), changed: () => changes.push(box?.snapshot()) });
return box;
};
return { open, changes, stored: () => stored, storage };
}
test("send publishes and persists one canonical row before POST, retaining it through receipt and echo", async () => {
const network = deferred(); let called = false;
const state = fixture(async (_content, requestId) => { called = true; assert.equal(requestId, "request-1"); return network.promise; });
const box = state.open();
const done = box.send("Hello");
const first = box.display([])[0];
assert.equal(called, false, "network starts after synchronous local publication");
assert.equal(first.id, "inbound:sybil:request-1");
assert.equal(first.metadata.status, "sending");
assert.equal(state.stored()[0].id, first.id);
assert.equal(state.changes.length, 1);
network.resolve({ status: "queued", messageId: first.id }); await done;
assert.equal(box.display([])[0].metadata.status, "queued");
assert.equal(box.display([])[0].createdAt, first.createdAt);
const echo = canonical(first.id);
box.reconcile({ messages: [echo] });
const displayed = box.display([echo]);
assert.equal(displayed.length, 1);
assert.equal(displayed[0].id, first.id);
assert.equal(displayed[0].createdAt, first.createdAt, "server receipt time cannot reorder the optimistic bubble");
assert.equal(displayed[0].metadata.status, "complete");
assert.deepEqual(state.stored(), []);
assert.equal(box.display([]).length, 1, "even a stale display snapshot cannot create an acknowledgment gap");
});
test("canonical echo before an HTTP failure wins and never paints a delivered bubble failed", async () => {
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
const done = box.send("Hello"); const row = box.display([])[0];
const echo = canonical(row.id);
box.reconcile({ messages: [echo] });
network.reject(new Error("response lost"));
await done;
assert.equal(box.display([echo])[0].metadata.status, "complete");
assert.equal(box.display([echo])[0].metadata.error, undefined);
assert.deepEqual(state.stored(), []);
});
test("queue echo before HTTP acknowledgment preserves one row when the queue snapshot disappears", async () => {
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
const done = box.send("Hello"); const row = box.display([])[0];
const queued = { ...canonical(row.id), metadata: { kind: "message", status: "queued" } };
box.reconcile({ messages: [], activity: { queuedMessages: [queued] } });
assert.equal(box.display([queued]).length, 1);
box.reconcile({ messages: [], activity: { queuedMessages: [] } });
assert.equal(box.display([])[0].id, row.id, "queue-to-history transfer must not hide the bubble");
network.reject(new Error("HTTP acknowledgment lost")); await done;
assert.equal(box.display([])[0].metadata.status, "queued");
});
test("failed retry and reload preserve the message ID, timestamp, and original receipt key", async () => {
const requests = []; let fail = true;
const state = fixture(async (content, requestId) => {
requests.push({ content, requestId });
if (fail) throw new Error("network down");
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
});
let box = state.open();
await assert.rejects(box.send("Hello"), /network down/);
const failed = box.display([])[0];
assert.equal(failed.metadata.status, "failed");
assert.equal(failed.metadata.error, "network down");
box = state.open();
assert.equal(requests.length, 1, "restoring drafts never sends automatically");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].createdAt, failed.createdAt);
fail = false;
const retry = box.retry(failed.id);
assert.equal(box.display([])[0].metadata.status, "sending");
await retry;
assert.deepEqual(requests.map(row => row.requestId), ["request-1", "request-1"]);
assert.equal(box.display([]).length, 1);
assert.equal(box.display([])[0].metadata.status, "complete");
assert.equal(box.display([])[0].createdAt, failed.createdAt);
});
test("in-flight reload becomes unconfirmed and two identical sends remain independent rows", async () => {
const first = deferred(), second = deferred(); let calls = 0;
const state = fixture(() => ++calls === 1 ? first.promise : second.promise); const box = state.open();
const send1 = box.send("Same content"), send2 = box.send("Same content");
const rows = box.display([]);
assert.equal(rows.length, 2);
assert.notEqual(rows[0].id, rows[1].id);
assert.ok(rows[0].createdAt < rows[1].createdAt, "same-millisecond sends retain insertion order");
const reloaded = state.open();
assert.deepEqual(reloaded.display([]).map(row => row.metadata.status), ["unconfirmed", "unconfirmed"]);
assert.deepEqual(reloaded.display([]).map(row => row.id), rows.map(row => row.id));
first.resolve({ status: "queued" }); second.resolve({ status: "queued" });
await Promise.all([send1, send2]);
});
test("legacy failed cards join their saved receipt keys without automatic delivery", () => {
const state = fixture(() => assert.fail("migration must not submit"), {
legacyRows: () => [{ content: "old message", status: "failed", error: "old error" }],
legacyIds: () => [{ content: "old message", requestId: "old-request" }],
});
const box = state.open();
assert.equal(box.display([])[0].id, "inbound:sybil:old-request");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].metadata.error, "old error");
const migrated = state.stored()[0];
assert.equal(migrated.id, "inbound:sybil:old-request");
assert.equal(state.open().display([])[0].createdAt, migrated.createdAt, "migration is saved before any retry");
});
test("storage failures still leave a failed inline row and prevent untracked POST", async () => {
let calls = 0, visible = 0;
const box = createAgentOptimisticOutbox({ submit: async () => { calls++; return { status: "queued" }; },
storage: { read: () => null, write: () => { throw new Error("storage unavailable"); } },
newId: () => "request", changed: () => visible++,
});
const sending = box.send("Do not lose this text");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].content, "Do not lose this text");
assert.equal(visible, 1);
await assert.rejects(sending, /storage unavailable/);
assert.equal(calls, 0);
});
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAgentEvent, applyAgentReadState, createAgentRoomState } from '../src/lib/agent-room.ts';
import { createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role,
content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra });
const page = (cursor, messages) => ({ type: 'messages', conversationId: '1', messages, cursor: String(cursor), beforeCursor: '0', hasMore: false });
const activity = (isThinking, extra = {}) => ({ type: 'activity', conversationId: '1', activity: {
state: isThinking ? 'thinking' : 'idle', isThinking, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [], ...extra,
} });
function fixture(initial = createAgentRoomState()) {
let now = 0, sequence = 0;
const tasks = new Map();
const frames = [];
const clock = { now: () => now, schedule(callback, delay) { const id = ++sequence; tasks.set(id, { at: now + delay, callback }); return id; }, cancel(id) { tasks.delete(id); } };
const presenter = createAgentRoomPresenter({ initial, reduceEvent: applyAgentEvent, publish: (room, isTyping) => frames.push({ room, isTyping }), clock });
const advance = ms => {
const end = now + ms;
while (true) {
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
if (!next || next[1].at > end) break;
now = next[1].at; tasks.delete(next[0]); next[1].callback();
}
now = end;
};
return { presenter, advance, frames, last: () => frames.at(-1) };
}
test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(2, [message(2)])); f.advance(100);
assert.equal(f.last().room.messages.length, 1); assert.equal(f.last().isTyping, false);
assert.equal(f.last().room.activity.isThinking, true, 'raw sidebar activity remains unchanged');
f.presenter.push(activity(true)); f.advance(1000); assert.equal(f.last().isTyping, false, 'identical busy snapshots cannot revive stale typing');
f.presenter.push(activity(false)); f.advance(100);
assert.ok(f.frames.every(frame => !frame.room.messages.length || !frame.isTyping));
});
test('split messages/activity frames batch without reply-plus-thinking flashes or transient typing starts', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(50);
f.presenter.push(page(2, [message(1, 'user'), message(2)])); f.advance(20);
f.presenter.push(activity(false)); f.advance(30);
assert.equal(f.frames.length, 1); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().isTyping, false);
f.advance(1000); assert.equal(f.frames.length, 1);
});
test('new user work and new tools can start typing again, but delivery tools cannot', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(250);
f.presenter.push(page(2, [message(2)])); f.advance(100);
f.presenter.push(activity(true, { tools: [{ id: 'send-reply', name: 'user_message', status: 'running' }] }));
f.advance(300); assert.equal(f.last().isTyping, false);
f.presenter.push(page(3, [message(3, 'user')])); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(4, [message(4)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(true, { tools: [{ id: 'new-browser', name: 'browser', status: 'running' }] }));
f.advance(250); assert.equal(f.last().isTyping, true);
});
test('initial history and duplicate replay do not suppress genuine new activity', () => {
const f = fixture();
f.presenter.push(activity(true));
f.presenter.push(page(7, [message(5), message(7)]), { replay: true });
f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(7, [message(5), message(7)])); f.advance(100);
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.messages.length, 2);
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(false)); f.advance(100);
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, true);
});
test('independent background work remains visible after the root yields', () => {
const f = fixture();
f.presenter.push(activity(true, { backgroundWorkers: 1 })); f.advance(250);
f.presenter.push(page(2, [message(2)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(false, { backgroundWorkers: 1 })); f.advance(250);
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.activity.isThinking, false);
});
test('bursts preserve users, tools, queued messages, cursor, and read state in bounded publications', () => {
const f = fixture();
f.presenter.push(page(1, [message(1, 'user')])); f.advance(50);
const tool = message(2, 'tool', { metadata: { kind: 'tool_call', toolCallId: 'browser-1', toolName: 'browser', status: 'initiated', entryId: 2 } });
f.presenter.push(page(2, [tool])); f.advance(40);
const queued = message(3, 'user', { metadata: { kind: 'message', status: 'queued' } });
f.presenter.push(activity(true, { queued: 1, queuedMessages: [queued] }));
f.presenter.update(state => applyAgentReadState(state, { cursor: '1', updatedAt: null })); f.advance(10);
assert.equal(f.frames.length, 1); assert.equal(f.last().room.cursor, '2'); assert.equal(f.last().room.readCursor, '1');
assert.equal(f.last().room.messages.length, 2); assert.deepEqual(f.last().room.activity.queuedMessages, [queued]);
f.presenter.push(page(4, [{ ...tool, metadata: { ...tool.metadata, status: 'completed', entryId: 4 } }]));
f.presenter.flush(); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().room.cursor, '4');
});
test('disposing an unpublished batch leaves delivery and read cursors at the last rendered snapshot', () => {
const f = fixture(); f.presenter.push(page(2, [message(2)])); f.presenter.dispose(); f.advance(1000);
assert.equal(f.frames.length, 0);
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
} from "../src/lib/agent-room.ts";
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
const message = (id, role = "assistant", extra = {}) => ({
id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`,
role, content: `message ${id}`, name: role === "assistant" ? "Sybil Agent" : "buzzert",
metadata: { kind: "message", entryId: id, status: "complete" }, ...extra,
});
const page = (cursor, messages = [], hasMore = false) => ({
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
});
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
test("tool starts and results merge across replay without downgrading completed cards", () => {
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
const messages = mergeAgentMessages(mergeAgentMessages([], [start]), [done, start]);
assert.equal(messages.length, 1);
assert.equal(messages[0].metadata.status, "completed");
assert.equal(messages[0].metadata.resultPreview, "found");
assert.deepEqual(messages[0].metadata.args, { query: "cookie" });
assert.equal(messages[0].metadata.entryId, 3);
assert.equal(messages[0].createdAt, start.createdAt);
});
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
const requests = [];
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
let state = createAgentRoomState();
await syncAgentHistory({ messages: async after => { requests.push(after); return responses.shift(); } }, "0", update => { state = applyAgentPage(state, update); });
assert.deepEqual(requests, ["0", "2", "4"]);
assert.equal(state.cursor, "7");
assert.equal(agentUnreadCount(state), 3);
state = applyAgentReadState(state, { cursor: "5", updatedAt: null });
assert.equal(agentUnreadCount(state), 1);
state = applyAgentReadState(state, { cursor: "1", updatedAt: null });
assert.equal(state.readCursor, "5", "a stale tab response cannot regress read state");
assert.equal(agentUnreadCount(state), 1);
});
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
assert.equal(agentDisplayMessages(state).length, 1);
assert.equal(state.cursor, "0", "pending rows never advance replay or read cursors");
assert.equal(agentUnreadCount(state), 0);
state = applyAgentPage(state, page("4", [message(4, "user", { id: pending.id })]));
assert.equal(agentDisplayMessages(state).length, 1);
assert.equal(agentDisplayMessages(state)[0].metadata.status, "complete");
state = applyAgentEvent(state, { type: "activity", conversationId: "1", activity: activity() });
assert.equal(agentDisplayMessages(state).length, 1, "committed rows survive queue removal");
const onlyPending = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
assert.deepEqual(agentDisplayMessages(applyAgentEvent(onlyPending, { type: "activity", conversationId: "1", activity: activity() })), []);
});
test("failed or stalled page delivery preserves the last applied cursor for replay", async () => {
let state = createAgentRoomState();
let calls = 0;
await assert.rejects(syncAgentHistory({ messages: async () => {
if (++calls === 1) return page("10", [message(10)], true);
throw new Error("network unavailable");
} }, "0", next => { state = applyAgentPage(state, next); }), /network unavailable/);
assert.equal(state.cursor, "10");
await assert.rejects(syncAgentHistory({ messages: async () => page("10", [], true) }, state.cursor, next => { state = applyAgentPage(state, next); }), /did not advance/);
assert.equal(state.cursor, "10");
assert.throws(() => applyAgentPage(state, page("bad", [message(11)])), /cursor/);
assert.equal(state.messages.length, 1);
assert.throws(() => applyAgentPage(state, { ...page("11"), conversationId: "different" }), /conversation changed/);
assert.equal(advanceAgentCursor("9007199254740993", "9007199254740992"), "9007199254740993");
assert.throws(() => normalizeAgentCursor("-1"), /cursor/);
});
test("read acknowledgments use only the rendered cursor and failed persistence retains unread replies", async () => {
let state = applyAgentPage(createAgentRoomState(), page("5", [message(5)]));
const renderedCursor = state.cursor;
state = applyAgentPage(state, page("6", [message(6)]));
const saved = [];
let fail = true;
const writer = createAgentReadWriter({
current: () => state.readCursor,
save: async cursor => { saved.push(cursor); if (fail) throw new Error("database offline"); return { cursor, updatedAt: null }; },
apply: read => { state = applyAgentReadState(state, read); },
});
await assert.rejects(writer.request(renderedCursor), /database offline/);
assert.equal(state.readCursor, "0");
assert.equal(agentUnreadCount(state), 2);
fail = false;
await writer.flush();
assert.deepEqual(saved, ["5", "5"]);
assert.equal(state.readCursor, "5");
assert.equal(agentUnreadCount(state), 1, "message6 arrived after render and must remain unread");
});
test("read acknowledgments coalesce concurrent seen snapshots and reject unacknowledged cursors", async () => {
let cursor = "0";
let release;
const gate = new Promise(resolve => { release = resolve; });
const saves = [];
const writer = createAgentReadWriter({ current: () => cursor, save: async value => {
saves.push(value); if (value === "3") await gate; return { cursor: value, updatedAt: null };
}, apply: read => { cursor = read.cursor; } });
const first = writer.request("3"), second = writer.request("7");
release(); await Promise.all([first, second]);
assert.deepEqual(saves, ["3", "7"]);
assert.equal(cursor, "7");
const broken = createAgentReadWriter({ current: () => "0", save: async () => ({ cursor: "0", updatedAt: null }), apply: () => assert.fail("must not apply a failed acknowledgment") });
await assert.rejects(broken.request("2"), /did not acknowledge/);
});
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
let stored = [], writes = 0;
const storage = { read: () => structuredClone(stored), write: rows => {
if (++writes === 3) throw new Error("storage unavailable after acknowledgment");
stored = structuredClone(rows);
} };
const requests = [];
const open = () => createAgentOptimisticOutbox({
storage, changed: () => {}, newId: () => "stable-request",
submit: async (_content, requestId) => {
requests.push(requestId);
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
},
});
const original = open();
await original.send("retry me");
const accepted = original.display([])[0];
assert.equal(accepted.metadata.status, "complete", "a storage error cannot undo server acceptance");
assert.equal(stored[0].status, "sending", "the pre-admission receipt remains durable if its update fails");
const restored = open();
assert.deepEqual(requests, ["stable-request"], "reload never retries automatically");
const uncertain = restored.display([])[0];
assert.equal(uncertain.metadata.status, "unconfirmed");
assert.equal(uncertain.id, accepted.id);
assert.equal(uncertain.createdAt, accepted.createdAt);
await restored.retry(uncertain.id);
assert.deepEqual(requests, ["stable-request", "stable-request"], "manual retry cannot create a second admission");
restored.reconcile({ messages: [message(1, "user", { id: accepted.id, content: "retry me" })] });
assert.deepEqual(stored, [], "canonical echo finishes local cleanup");
});
test("client uses Sybil auth and routes while websocket credentials remain in the first frame", async () => {
const calls = [];
let token = "first-session-token";
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
} });
await client.messages("0");
token = "second-session-token";
await client.markRead("42");
await client.submit("hi", "request-1");
assert.equal(calls[0].url, "https://sybil.example/api/v1/agent/messages?after=0&limit=200");
assert.equal(calls[0].init.headers.get("Authorization"), "Bearer first-session-token");
assert.equal(calls[1].init.headers.get("Authorization"), "Bearer second-session-token");
assert.equal(calls[1].init.method, "PUT");
assert.deepEqual(JSON.parse(calls[1].init.body), { cursor: "42" });
assert.deepEqual(JSON.parse(calls[2].init.body), { content: "hi", requestId: "request-1" });
const url = client.websocketUrl("https://sybil.example/");
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
assert.equal(new URL(url).search, "");
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
});
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
const requests = [];
let response = {};
const client = new AgentRoomClient({ fetch: async (_url, init) => {
requests.push(JSON.parse(init.body));
return new Response(JSON.stringify(response), { status: 202 });
} });
const box = createAgentOptimisticOutbox({ submit: (content, requestId) => client.submit(content, requestId),
storage: { read: () => [], write: () => {} }, changed: () => {}, newId: () => "retry-id",
});
await assert.rejects(box.send("Keep this request"), /invalid message receipt/);
const row = box.display([])[0];
assert.equal(row.metadata.status, "failed");
assert.equal(box.snapshot()[0].seenOnServer, false);
const valid = { id: 1, status: "queued", messageId: row.id };
for (const malformed of [null, { error: "Invalid Sybil Agent response" }, { ...valid, id: 0 }, { ...valid, id: 1.5 },
{ ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] },
{ ...valid, messageId: "inbound:sybil:other" }]) {
response = malformed;
await assert.rejects(box.retry(row.id), /invalid message receipt/);
assert.equal(box.display([])[0].metadata.status, "failed");
}
response = valid;
await box.retry(row.id);
assert.equal(box.display([])[0].metadata.status, "queued");
assert.ok(requests.every(request => request.requestId === "retry-id"), "uncertain responses cannot mint a duplicate admission");
for (const status of ["placed", "done", "unanswered"]) {
response = { ...valid, status };
assert.equal((await client.submit("Keep this request", "retry-id")).status, status);
}
});
test("protocol ignores unknown frames and rejects malformed message cursors", () => {
assert.equal(parseAgentRoomEvent('{"type":"future-frame"}'), null);
assert.deepEqual(parseAgentRoomEvent('{"type":"pong"}'), { type: "pong" });
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...page("oops") })), /cursor/);
const state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: { ...activity(), state: "thinking", isThinking: true } });
assert.equal(state.activity.isThinking, true);
assert.deepEqual(state.messages, [], "a thinking indicator never becomes a chat message");
});
+8
View File
@@ -8,6 +8,14 @@ const workspaceItems = [
{ type: "search", id: "selected-search" },
];
test("persistent agent selection survives history refreshes and empty collections", () => {
const agent = { kind: "agent", id: "root" };
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, workspaceItems, { selectFallback: true }), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, []), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { initialSelection: agent, selectFallback: true }), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems, { initialSelection: agent }), { kind: "chat", id: "selected-chat" });
});
test("a collection refresh preserves the current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { scheduleWorkspaceSearch } from "../src/lib/workspace-search.ts";
import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
test("typing is debounced and superseded requests never start", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const queries = [];
const results = [];
const request = async (query) => { queries.push(query); return { items: [], hasMore: false }; };
const reportError = (error) => assert.fail(String(error));
const cancelFirst = scheduleWorkspaceSearch("ne", request, (result) => results.push(result), reportError);
t.mock.timers.tick(299);
assert.deepEqual(queries, []);
cancelFirst();
const cancelLast = scheduleWorkspaceSearch("nebula", request, (result) => results.push(result), reportError);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(queries, ["nebula"]);
assert.deepEqual(results, [{ items: [], hasMore: false }]);
cancelLast();
});
test("cancellation aborts the transport and discards stale results and errors", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let resolveFirst;
let rejectSecond;
let signal;
const results = [];
const errors = [];
const first = scheduleWorkspaceSearch("first", (_query, value) => {
signal = value;
return new Promise((resolve) => { resolveFirst = resolve; });
}, (result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
first();
assert.equal(signal.aborted, true);
const second = scheduleWorkspaceSearch("second", () => new Promise((_resolve, reject) => { rejectSecond = reject; }),
(result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
second();
resolveFirst({ items: [{ id: "stale" }], hasMore: true });
rejectSecond(new Error("stale failure"));
await flush();
assert.deepEqual(results, []);
assert.deepEqual(errors, []);
});
test("search results retain excerpts and truncation info; failures reach the UI", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const expected = { items: [{ type: "chat", id: "old", searchSnippet: "A nebula in the sky" }], hasMore: true };
let received;
let error;
const cancel = scheduleWorkspaceSearch("nebula", async () => expected, (result) => { received = result; }, assert.fail);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(received, expected);
cancel();
const cancelFailure = scheduleWorkspaceSearch("nebula", async () => { throw new Error("Offline"); }, assert.fail, (result) => { error = result; });
t.mock.timers.tick(300);
await flush();
assert.equal(error.message, "Offline");
cancelFailure();
});
test("opening older chat/search results survives collection refresh without restoring deleted or unrelated items", () => {
const recent = [{ type: "chat", id: "recent" }];
for (const kind of ["chat", "search"]) {
const selection = { kind, id: "old" };
const retained = { type: kind, id: "old" };
const refreshed = retainSelectedHistoryItem(recent, selection, retained);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(selection, refreshed), selection);
assert.equal(retainSelectedHistoryItem(refreshed, selection, retained), refreshed);
assert.equal(retainSelectedHistoryItem(recent, { kind, id: "different" }, retained), recent);
assert.equal(retainSelectedHistoryItem(recent, selection, null), recent);
assert.equal(retainSelectedHistoryItem(recent, null, retained), recent);
}
});