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
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Successful in 1m54s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
buzzert 26d917e96a Polish streaming thinking traces
TestFlight / Build and upload (push) Successful in 1m41s
2026-08-30 15:26:31 -07:00
buzzert 922172fc60 Add dedicated quick question API
TestFlight / Build and upload (push) Successful in 1m42s
2026-08-27 19:31:23 -07:00
buzzert 5f9fc82b86 adds quick question UI for mac catalyst target 2026-08-21 17:28:08 -07:00
91 changed files with 9903 additions and 600 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 {
+83 -2
View File
@@ -31,7 +31,7 @@ Chat upload limits:
```json
{
"providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
@@ -39,7 +39,7 @@ Chat upload limits:
}
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
@@ -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.
+90 -3
View File
@@ -1,21 +1,22 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions.
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`
- `POST /v1/chats/:chatId/stream/attach`
- `POST /v1/quick-questions/stream`
Transport:
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
- Events are emitted in SSE format (`event: ...`, `data: ...`)
- Request body is JSON
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
Authentication:
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
## Request Body
## Chat Completion Request Body
```json
{
@@ -90,6 +91,32 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
## Quick Question Endpoint
`POST /v1/quick-questions/stream`
Request body:
```json
{
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string",
"question": "What is the capital of France?",
"enabledTools": ["web_search", "fetch_url"],
"userLocation": "optional city, region, country",
"temperature": 0.2,
"maxTokens": 256
}
```
Behavior notes:
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
- `question` is required, trimmed by the server, and must not be empty.
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
## Event Stream Contract
Event order:
@@ -248,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.
+3
View File
@@ -2,6 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+21 -1
View File
@@ -8,7 +8,7 @@ struct SybilApp: App
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
WindowGroup(id: SybilCommands.workspaceWindowID) {
SplitView()
}
.commands {
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
SybilHomeScreenQuickActionHandler.configureQuickActions()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.start()
#endif
return true
}
#if targetEnvironment(macCatalyst)
func applicationWillTerminate(_ application: UIApplication) {
SybilMacQuickQuestionController.shared.stop()
}
#endif
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
#if targetEnvironment(macCatalyst)
if scene is UIWindowScene, session.role == .windowApplication {
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
}
#endif
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
}
#if targetEnvironment(macCatalyst)
func sceneDidDisconnect(_ scene: UIScene) {
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
}
#endif
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
+8
View File
@@ -11,6 +11,13 @@ targets:
dependencies:
- package: Sybil
product: Sybil
- target: SybilMacQuickQuestion
destinationFilters: [macCatalyst]
embed: true
link: false
codeSign: true
copy:
destination: plugins
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
@@ -29,6 +36,7 @@ targets:
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
@@ -0,0 +1,229 @@
import AppKit
import Carbon
// AppKit is unavailable to Catalyst targets. This native bundle uses only
// public AppKit/Carbon APIs and communicates with the client through Foundation.
@MainActor
@objc(SybilMacQuickQuestionPlugin)
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
private var panel: QuickQuestionPanel?
private var panelView: QuickQuestionPanelView?
private var hotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
private var outsideClickMonitor: Any?
private var hotKeyState = SybilQuickQuestionHotKeyState()
private var isDismissing = false
private var shortcutAvailable = false
private var windowlessActivity: NSObjectProtocol?
private var latestState = SybilQuickQuestionPanelState()
private var previousApplication: NSRunningApplication?
required override init() {
super.init()
}
var isVisible: Bool { panel?.isVisible == true }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
self.delegate = delegate
if windowlessActivity == nil {
// Keep the hotkey service alive with no windows. This option does
// not prevent sleep/App Nap or intercept an explicit Quit command.
windowlessActivity = ProcessInfo.processInfo.beginActivity(
options: .automaticTerminationDisabled,
reason: "Quick Question global shortcut"
)
}
guard eventHandler == nil else { return noErr }
var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
]
let context = Unmanaged.passUnretained(self).toOpaque()
let handlerStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, context in
guard let event, let context else { return OSStatus(eventNotHandledErr) }
var identifier = EventHotKeyID()
let status = GetEventParameter(
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
)
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
return OSStatus(eventNotHandledErr)
}
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
// Application event handlers run on the main event loop.
MainActor.assumeIsolated {
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
plugin.delegate?.quickQuestionToggleRequested()
}
}
return noErr
},
eventTypes.count, &eventTypes, context, &eventHandler
)
guard handlerStatus == noErr else { return handlerStatus }
let status = RegisterEventHotKey(
UInt32(kVK_Space), UInt32(optionKey),
EventHotKeyID(signature: 0x53595151, id: 1),
GetApplicationEventTarget(), 0, &hotKey
)
shortcutAvailable = status == noErr
return status
}
func updateState(_ data: Data) {
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
// Updating a background request must not create a non-hosting window.
// The controller creates/orders the panel only after UIKit is foreground.
latestState = state
panelView?.update(state, shortcutAvailable: shortcutAvailable)
if isVisible { resizePanel() }
}
func show() {
createPanelIfNeeded()
guard let panel, let panelView else { return }
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let width = min(680, visibleFrame.width - 48)
panel.setContentSize(NSSize(width: width, height: 240))
panelView.layoutSubtreeIfNeeded()
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
let y = max(visibleFrame.minY + 24, top - height)
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
// The controller has verified UIKit foreground readiness. A
// nonactivating panel can then take focus without raising a workspace.
panel.makeKeyAndOrderFront(nil)
panel.makeFirstResponder(panelView.promptView)
panelView.preparePromptForPresentation()
installOutsideClickMonitor()
}
func hide() {
dismissPanel(restoringPreviousApplication: true)
}
func activateForQuickQuestion() {
if let frontmost = NSWorkspace.shared.frontmostApplication,
frontmost != NSRunningApplication.current {
previousApplication = frontmost
}
// Do not order a window here. Catalyst must finish its own foreground
// transition before the controller calls show().
NSApp.activate()
}
func activateMainWindow() {
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
previousApplication = nil
NSApp.activate()
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
}
private func dismissPanel(restoringPreviousApplication: Bool) {
guard !isDismissing else { return }
let wasVisible = isVisible
isDismissing = true
if wasVisible { panel?.orderOut(nil) }
removeOutsideClickMonitor()
isDismissing = false
let applicationToRestore = previousApplication
previousApplication = nil
if restoringPreviousApplication, NSApp.isActive,
let applicationToRestore, !applicationToRestore.isTerminated {
NSApp.yieldActivation(to: applicationToRestore)
applicationToRestore.activate(options: [])
}
if wasVisible { delegate?.quickQuestionPanelDismissed() }
}
func stop() {
previousApplication = nil
hide()
removeOutsideClickMonitor()
if let hotKey { UnregisterEventHotKey(hotKey) }
if let eventHandler { RemoveEventHandler(eventHandler) }
hotKey = nil
eventHandler = nil
hotKeyState = SybilQuickQuestionHotKeyState()
if let windowlessActivity {
ProcessInfo.processInfo.endActivity(windowlessActivity)
}
windowlessActivity = nil
delegate = nil
}
func windowDidResignKey(_ notification: Notification) {
// Focus is already moving to a window/app the user chose.
dismissPanel(restoringPreviousApplication: false)
}
private func createPanelIfNeeded() {
guard panel == nil else { return }
let panel = QuickQuestionPanel(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
)
panel.title = "Quick Question"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = false
panel.worksWhenModal = true
panel.isReleasedWhenClosed = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isMovableByWindowBackground = true
panel.appearance = NSAppearance(named: .darkAqua)
panel.delegate = self
panel.onDismiss = { [weak self] in self?.hide() }
let view = QuickQuestionPanelView()
view.delegate = delegate
view.onDismiss = { [weak self] in self?.hide() }
panel.contentView = view
panelView = view
self.panel = panel
}
private func resizePanel() {
guard let panel, let panelView else { return }
panelView.layoutSubtreeIfNeeded()
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
guard abs(height - panel.frame.height) > 1 else { return }
var frame = panel.frame
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
frame.size.height = height
panel.setFrame(frame, display: true)
}
private func installOutsideClickMonitor() {
guard outsideClickMonitor == nil else { return }
// Mouse-only monitoring does not require Accessibility or Input
// Monitoring permission. No global keyboard event tap is installed.
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
Task { @MainActor [weak self] in self?.hide() }
}
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
}
@MainActor
private final class QuickQuestionPanel: NSPanel {
var onDismiss: (() -> Void)?
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func cancelOperation(_ sender: Any?) { onDismiss?() }
}
@@ -0,0 +1,300 @@
import AppKit
@MainActor
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var onDismiss: (() -> Void)?
let promptView = QuickQuestionPromptView()
private let stack = NSStackView()
private let answerView = NSTextView()
private let answerScroll = NSScrollView()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let providerPopup = NSPopUpButton()
private let modelPopup = NSPopUpButton()
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
private let sendButton = NSButton()
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
private var panelState = SybilQuickQuestionPanelState()
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
material = .hudWindow
blendingMode = .behindWindow
state = .active
wantsLayer = true
layer?.cornerRadius = 18
layer?.masksToBounds = true
layer?.borderWidth = 1
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.detachesHiddenViews = true
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
])
addRow(makeHeader())
configureTextView(promptView, fontSize: 22)
promptView.delegate = self
promptView.isRichText = false
promptView.allowsUndo = true
promptView.setAccessibilityLabel("Quick question prompt")
promptView.onSubmit = { [weak self] in self?.submit() }
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
let promptScroll = makeScrollView(documentView: promptView)
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
addRow(promptScroll)
configureTextView(answerView, fontSize: 15)
answerView.isEditable = false
answerView.isSelectable = true
answerView.setAccessibilityLabel("Quick question answer")
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
answerScroll.drawsBackground = false
answerScroll.hasVerticalScroller = true
answerScroll.autohidesScrollers = true
answerScroll.documentView = answerView
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
addRow(answerScroll)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.maximumNumberOfLines = 3
addRow(statusLabel)
addRow(makeControls())
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
hint.font = .systemFont(ofSize: 11)
hint.textColor = .secondaryLabelColor
addRow(hint)
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
let previous = panelState
panelState = next
if promptView.string != next.prompt, !promptView.hasMarkedText() {
promptView.string = next.prompt
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
}
promptView.isEditable = !next.isBusy
promptView.needsDisplay = true
if previous.answer != next.answer {
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
}
answerScroll.isHidden = next.answer.isEmpty
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
providerPopup.removeAllItems()
for provider in next.providers {
providerPopup.addItem(withTitle: provider.title)
providerPopup.lastItem?.representedObject = provider.id
}
}
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
providerPopup.selectItem(at: index)
}
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
modelPopup.removeAllItems()
var models = next.models
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
modelPopup.addItems(withTitles: models)
modelPopup.selectItem(withTitle: next.model)
}
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
convertButton.isEnabled = next.canConvert
sendButton.isEnabled = next.canSend
openAppButton.isHidden = next.isAuthenticated
convertButton.isHidden = !next.isAuthenticated
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}
@@ -0,0 +1,25 @@
targets:
SybilMacQuickQuestion:
type: bundle
platform: macOS
deploymentTarget: "15.0"
sources:
- Sources
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
dependencies:
- sdk: AppKit.framework
- sdk: Carbon.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
PRODUCT_NAME: SybilMacQuickQuestion
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
SUPPORTS_MACCATALYST: NO
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
MACH_O_TYPE: mh_bundle
SKIP_INSTALL: YES
+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

@@ -78,6 +78,7 @@ public struct SplitView: View {
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
#if !targetEnvironment(macCatalyst)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
@@ -85,7 +86,11 @@ public struct SplitView: View {
)
.presentationDragIndicator(.visible)
}
#endif
.task {
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
await viewModel.bootstrap()
presentPendingQuickQuestionIfPossible()
}
@@ -101,12 +106,18 @@ 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:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
@@ -151,8 +162,13 @@ public struct SplitView: View {
}
hasPendingQuickQuestionPresentation = false
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
SybilMacQuickQuestionController.shared.show()
#else
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
#endif
}
private func handleQuickQuestionDismissed() {
@@ -161,23 +177,57 @@ public struct SplitView: View {
}
public struct SybilCommands: Commands {
public static let workspaceWindowID = "sybil-workspace"
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
#if targetEnvironment(macCatalyst)
@Environment(\.openWindow) private var openWindow
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
#endif
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
#if targetEnvironment(macCatalyst)
windowCommands.newChatOrWindow(
newChat: keyboardActions?.newChat,
openWindow: openNewWindow
)
#else
keyboardActions?.newChat()
#endif
}
.keyboardShortcut("n", modifiers: .command)
#if targetEnvironment(macCatalyst)
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
#else
.disabled(keyboardActions == nil)
#endif
#if targetEnvironment(macCatalyst)
Button("New Window", action: openNewWindow)
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
Button("New Search") {
keyboardActions?.newSearch()
}
#if targetEnvironment(macCatalyst)
.keyboardShortcut("n", modifiers: [.command, .option])
#else
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
.disabled(keyboardActions == nil)
#if targetEnvironment(macCatalyst)
Divider()
Button("Quick Question") {
SybilMacQuickQuestionController.shared.toggle()
}
.keyboardShortcut(.space, modifiers: .option)
#endif
}
CommandMenu("Conversation") {
@@ -194,6 +244,13 @@ public struct SybilCommands: Commands {
.disabled(keyboardActions == nil)
}
}
#if targetEnvironment(macCatalyst)
private func openNewWindow() {
// Targeting a WindowGroup without a value creates a fresh scene every
// time, even when another workspace window is already open.
openWindow(id: Self.workspaceWindowID)
}
#endif
}
private struct SybilKeyboardActions {
@@ -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
@@ -74,6 +125,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/fork",
method: "POST",
body: AnyEncodable(ChatForkBody(messageId: messageID)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)",
@@ -183,6 +244,29 @@ actor SybilAPIClient: SybilAPIClienting {
SybilLog.info(SybilLog.network, "Chat stream completed")
}
func runQuickQuestionStream(
body: QuickQuestionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/quick-questions/stream",
method: "POST",
body: AnyEncodable(body),
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Quick question stream completed")
}
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -257,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,
@@ -311,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")
@@ -336,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
}
@@ -347,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
@@ -664,6 +754,12 @@ struct CompletionStreamRequest: Codable, Sendable {
var userLocation: String? = nil
}
struct QuickQuestionStreamRequest: Codable, Sendable {
var provider: Provider
var model: String
var question: String
}
private struct ChatCreateBody: Encodable {
var title: String?
var provider: Provider?
@@ -675,6 +771,10 @@ private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct ChatForkBody: Encodable {
var messageId: String?
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
@@ -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?,
@@ -11,6 +12,7 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws
@@ -27,6 +29,10 @@ protocol SybilAPIClienting: Sendable {
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func runQuickQuestionStream(
body: QuickQuestionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -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 })
}
}
@@ -1,13 +1,17 @@
import MarkdownUI
import SwiftUI
import UIKit
struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
var isForking: Bool = false
var onForkResponse: ((String) -> Void)? = nil
@State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = []
@@ -41,7 +45,15 @@ struct SybilChatTranscriptView: View {
ForEach(renderItems) { item in
switch item {
case let .message(message):
MessageBubble(message: message, isSending: isSending)
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
ToolCallStackView(
@@ -137,9 +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
@@ -149,13 +165,46 @@ private struct MessageBubble: View {
message.role == .user
}
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var isStreamingAssistant: Bool {
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
}
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
private var canFork: Bool {
message.role == .assistant &&
!message.id.hasPrefix("temp-") &&
onFork != nil
}
var body: some View {
Group {
if canFork {
bubbleContent
.contextMenu {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onFork?()
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(isForking)
}
} else {
bubbleContent
}
}
}
private var bubbleContent: some View {
HStack(alignment: .top, spacing: 0) {
leadingSpacer
@@ -174,25 +223,26 @@ private struct MessageBubble: View {
)
}
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
if isStreamingAssistant {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
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 {
@@ -203,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)
@@ -232,6 +286,123 @@ private struct MessageBubble: View {
}
}
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct StreamingAssistantContentView: View {
private enum Phase: Hashable {
case pending
case trace(Int)
case answer
}
var content: String
var isSending: Bool
var presentation: StreamingAssistantPresentation?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var retainedTraceHeight: CGFloat = 0
private var traceText: String? {
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
!trace.isEmpty
else {
return nil
}
return trace
}
private var hasAnswer: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var phase: Phase {
if hasAnswer {
return .answer
}
if traceText != nil {
return .trace(presentation?.traceRevision ?? 0)
}
return .pending
}
private var traceTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .asymmetric(
insertion: .opacity.combined(with: .offset(y: 5)),
removal: .opacity.combined(with: .offset(y: -5))
)
}
private var answerTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .opacity.combined(with: .offset(y: 3))
}
var body: some View {
ZStack(alignment: .topLeading) {
if let traceText {
Text(traceText)
.font(.sybil(size: 15))
.italic()
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
.opacity(0.78)
.textSelection(.disabled)
.allowsHitTesting(false)
.accessibilityLabel("Assistant is working")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: StreamingTraceHeightPreferenceKey.self,
value: geometry.size.height
)
}
}
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
.transition(traceTransition)
.zIndex(1)
}
if hasAnswer {
Markdown(content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
.textSelection(.enabled)
.id("streaming-assistant-answer")
.transition(answerTransition)
.zIndex(2)
}
if isSending && !hasAnswer && traceText == nil {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
.transition(reduceMotion ? .identity : .opacity)
}
}
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
retainedTraceHeight = measuredHeight
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
}
}
private struct ToolCallStackView: View {
private struct CardLayout {
var x: CGFloat
@@ -440,7 +611,7 @@ private struct ToolCallStackCard: View {
}
}
private struct ToolCallActivityChip: View {
struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
@@ -0,0 +1,71 @@
import Foundation
// This Foundation-only contract is compiled into both the Catalyst client and the
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
@MainActor
@objc(SybilQuickQuestionPanelBridging)
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
init()
var isVisible: Bool { get }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
func updateState(_ data: Data)
func show()
func hide()
func activateForQuickQuestion()
func activateMainWindow()
func stop()
}
@MainActor
@objc(SybilQuickQuestionPanelDelegate)
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
func quickQuestionToggleRequested()
func quickQuestionPromptChanged(_ prompt: String)
func quickQuestionSubmitRequested()
func quickQuestionProviderChanged(_ provider: String)
func quickQuestionModelChanged(_ model: String)
func quickQuestionConvertRequested()
func quickQuestionOpenAppRequested()
func quickQuestionPanelDismissed()
}
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
struct ProviderOption: Codable, Equatable, Sendable {
var id: String
var title: String
}
var prompt = ""
var answer = ""
var toolSummaries: [String] = []
var provider = ""
var providers: [ProviderOption] = []
var model = ""
var models: [String] = []
var isSending = false
var isConverting = false
var canSend = false
var canConvert = false
var isAuthenticated = false
var isCheckingSession = true
var error: String?
var isBusy: Bool { isSending || isConverting }
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
}
// Carbon can deliver multiple presses while a key is held. Consume one toggle
// per press/release pair, without suppressing a second deliberate press.
struct SybilQuickQuestionHotKeyState {
private var isPressed = false
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
if !isKeyDown {
isPressed = false
return false
}
guard !isPressed else { return false }
isPressed = true
return true
}
}
@@ -0,0 +1,257 @@
#if targetEnvironment(macCatalyst)
import Combine
import Foundation
import Observation
import UIKit
@MainActor
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
public static let shared = SybilMacQuickQuestionController()
private var pluginBundle: Bundle?
private var bridge: (any SybilQuickQuestionPanelBridging)?
private var viewModel: SybilViewModel?
private var observationGeneration = 0
private var isPresentationRequested = false
private var activationRequested = false
private var presentationTask: Task<Void, Never>?
private var foregroundObservation: AnyCancellable?
private var notificationCenter = NotificationCenter.default
private var isApplicationInForeground: @MainActor () -> Bool = {
UIApplication.shared.applicationState != .background
}
private override init() {
super.init()
}
// Inject foreground readiness as well as the panel so tests can exercise
// windowless activation without activating apps or showing real windows.
init(
bridge: any SybilQuickQuestionPanelBridging,
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
notificationCenter: NotificationCenter = NotificationCenter()
) {
self.bridge = bridge
self.isApplicationInForeground = isApplicationInForeground
self.notificationCenter = notificationCenter
super.init()
_ = bridge.start(delegate: self)
observeForegroundTransitionsIfNeeded()
}
public func start() {
observeForegroundTransitionsIfNeeded()
guard bridge == nil else { return }
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
let bundle = Bundle(url: url)
else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
return
}
do {
try bundle.loadAndReturnError()
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
return
}
let plugin = pluginClass.init()
pluginBundle = bundle
bridge = plugin
let status = plugin.start(delegate: self)
if status != 0 {
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
}
} catch {
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
}
}
func attach(_ viewModel: SybilViewModel) {
guard self.viewModel !== viewModel else { return }
// A window becoming active must not replace an in-flight quick question.
if self.viewModel != nil,
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
return
}
self.viewModel = viewModel
if bridge?.isVisible == true { updateAndObserve() }
}
public func toggle() {
start()
if isPresentationRequested || bridge?.isVisible == true {
cancelPresentation()
bridge?.hide()
} else {
show()
}
}
public func show() {
start()
isPresentationRequested = true
schedulePresentationIfNeeded()
}
public func stop() {
cancelPresentation()
foregroundObservation = nil
viewModel?.cancelQuickQuestion()
bridge?.stop()
bridge = nil
// Keep the loaded bundle alive for the lifetime of its code.
}
private func observeForegroundTransitionsIfNeeded() {
guard foregroundObservation == nil else { return }
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
.sink { [weak self] _ in
Task { @MainActor [weak self] in
self?.schedulePresentationIfNeeded()
}
}
}
private func schedulePresentationIfNeeded() {
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
// Leave the Carbon/NSApplication event callback before touching windows.
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
// is not a substitute for checking UIKit's foreground lifecycle state.
presentationTask = Task { @MainActor [weak self] in
guard let self, !Task.isCancelled else { return }
self.presentationTask = nil
guard self.isPresentationRequested else { return }
guard self.isApplicationInForeground() else {
guard !self.activationRequested else { return }
self.activationRequested = true
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
self.bridge?.activateForQuickQuestion()
// Also handle an activation that completes synchronously. If it
// has not completed, subsequent UIKit notifications resume us.
self.schedulePresentationIfNeeded()
return
}
self.activationRequested = false
self.updateAndObserve()
self.bridge?.show()
}
}
private func cancelPresentation() {
isPresentationRequested = false
activationRequested = false
presentationTask?.cancel()
presentationTask = nil
observationGeneration += 1
}
private func updateAndObserve() {
guard let bridge else { return }
observationGeneration += 1
let generation = observationGeneration
let state = withObservationTracking {
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
self.updateAndObserve()
}
}
do {
bridge.updateState(try JSONEncoder().encode(state))
} catch {
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
}
}
func quickQuestionToggleRequested() {
toggle()
}
func quickQuestionPromptChanged(_ prompt: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.updateQuickQuestionPrompt(prompt)
updateAndObserve()
}
func quickQuestionSubmitRequested() {
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
viewModel.sendQuickQuestion()
updateAndObserve()
}
func quickQuestionProviderChanged(_ provider: String) {
guard let viewModel, let provider = Provider(rawValue: provider),
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionProvider(provider)
updateAndObserve()
}
func quickQuestionModelChanged(_ model: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionModel(model)
updateAndObserve()
}
func quickQuestionConvertRequested() {
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if await viewModel.convertQuickQuestionToChat() {
self.cancelPresentation()
self.openMainWindow()
self.bridge?.hide()
} else {
self.updateAndObserve()
}
}
}
func quickQuestionOpenAppRequested() {
cancelPresentation()
openMainWindow()
bridge?.hide()
}
private func openMainWindow() {
let session = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.session
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
bridge?.activateMainWindow()
}
func quickQuestionPanelDismissed() {
// Toggling out preserves the draft and lets an answer finish. Reopening
// reads the latest state, without starting a new request.
cancelPresentation()
}
}
extension SybilViewModel {
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
SybilQuickQuestionPanelState(
prompt: quickQuestionPrompt,
answer: quickQuestionAnswerText,
toolSummaries: quickQuestionMessages.compactMap { message in
guard let metadata = message.toolCallMetadata else { return nil }
return metadata.summary ?? message.content
},
provider: quickQuestionProvider.rawValue,
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
model: quickQuestionModel,
models: quickQuestionProviderModelOptions,
isSending: isQuickQuestionSending,
isConverting: isConvertingQuickQuestion,
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
canConvert: canConvertQuickQuestion,
isAuthenticated: isAuthenticated,
isCheckingSession: isCheckingSession,
error: quickQuestionError
)
}
}
#endif
@@ -0,0 +1,37 @@
#if targetEnvironment(macCatalyst)
import Combine
// Scene lifetime is independent of focused commands: a connected window may
// still be authenticating, inactive, or minimized. The native Quick Question
// panel is not a workspace scene and does not count as an open window here.
@MainActor
public final class SybilMacWindowCommands: ObservableObject {
public static let shared = SybilMacWindowCommands()
@Published private var connectedWindowIDs: Set<String> = []
init() {}
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
public func windowConnected(sessionID: String) {
connectedWindowIDs.insert(sessionID)
}
public func windowDisconnected(sessionID: String) {
connectedWindowIDs.remove(sessionID)
}
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
!hasOpenWindows || hasFocusedActions
}
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
if hasOpenWindows {
newChat?()
} else {
openWindow()
}
}
}
#endif
@@ -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:
@@ -130,7 +137,10 @@ struct SybilPhoneShellView: View {
route: route,
onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay
onShowSidebar: showSidebarOverlay,
onOpenForkedChat: { chatID in
showRoute(.chat(chatID))
}
)
.background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
@@ -239,6 +249,7 @@ struct SybilPhoneShellView: View {
}
private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = {
route = nextRoute
}
@@ -268,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
@@ -511,7 +529,12 @@ private struct SybilPhoneSidebarRoot: View {
},
onSelect: { item in
onSelect(item.selection)
}
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
},
onSelectAgent: { onSelect(.agent) },
isAgentSelected: highlightedSelection == .agent
)
}
.background(SybilTheme.panelGradient)
@@ -585,6 +608,7 @@ private struct SybilPhoneDestinationView: View {
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
let onOpenForkedChat: (String) -> Void
var body: some View {
SybilWorkspaceView(
@@ -593,7 +617,8 @@ private struct SybilPhoneDestinationView: View {
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
onRequestNewChat: onRequestNewChat,
onOpenForkedChat: onOpenForkedChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
@@ -613,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")
}
}
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
@Environment(\.dismiss) private var dismiss
@FocusState private var promptFocused: Bool
@State private var promptSelection: TextSelection?
private var hasAnswerContent: Bool {
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
return
}
promptFocused = true
let prompt = viewModel.quickQuestionPrompt
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
}
}
@@ -59,7 +62,11 @@ struct SybilQuickQuestionView: View {
VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
QuickQuestionMessageView(
message: message,
isSending: viewModel.isQuickQuestionSending,
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
)
}
if let error = viewModel.quickQuestionError {
@@ -88,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) }
),
axis: .vertical
focused: $promptFocused,
selection: $promptSelection,
axis: .horizontal,
submit: submitQuestion
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.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")
}
@@ -259,11 +239,11 @@ private struct QuickQuestionPickerPill: View {
private struct QuickQuestionMessageView: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-quick-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
var body: some View {
@@ -272,15 +252,12 @@ private struct QuickQuestionMessageView: View {
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking...")
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if message.id.hasPrefix("temp-assistant-quick-") {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.font(.body)
@@ -0,0 +1,35 @@
import SwiftUI
// A vertical TextField treats Return as editing, not form submission. Handle
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
// keys to the native text field. iOS keeps its software-keyboard behavior.
@MainActor
enum SybilReturnKeySubmission {
static func handle(
phase: KeyPress.Phases,
modifiers: EventModifiers,
submit: () -> Void
) -> KeyPress.Result {
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
return .ignored
}
if phase == .down {
submit()
}
// Consume autorepeat without submitting or inserting a newline.
return .handled
}
}
extension View {
@ViewBuilder
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
#if targetEnvironment(macCatalyst)
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
}
#else
self
#endif
}
}
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel
@@ -111,8 +112,12 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
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 {
@@ -126,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 {
@@ -168,13 +258,26 @@ struct SybilSidebarItemList: View {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
if item.kind == .chat {
if case let .chat(chatID) = item.selection {
Button {
forkChat(item)
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(viewModel.isForkingChat || item.isRunning)
Button {
renameTarget = item
renameTitle = item.title
} label: {
Label("Rename", systemImage: "pencil")
}
Button {
UIPasteboard.general.string = chatID
} label: {
Label("Copy Session ID", systemImage: "doc.on.doc")
}
}
Button(role: .destructive) {
@@ -186,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()
}
@@ -215,11 +325,52 @@ struct SybilSidebarItemList: View {
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private func forkChat(_ item: SidebarItem) {
guard case let .chat(chatID) = item.selection else {
return
}
let selectionAtStart = viewModel.selectedItem
let draftKindAtStart = viewModel.draftKind
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == draftKindAtStart else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
}
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
@@ -281,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,11 +41,75 @@ struct SidebarItem: Identifiable, Hashable {
var starredAt: Date?
var initiatedLabel: String?
var isRunning: Bool
var searchSnippet: String? = nil
}
enum StreamingAssistantContentUpdate: Equatable, Sendable {
case unchanged
case replace(String)
}
struct StreamingAssistantState: Equatable, Sendable {
private(set) var segmentText = ""
private(set) var initiatedToolCallIDs: Set<String> = []
private(set) var ephemeralTrace: String?
private(set) var traceRevision = 0
mutating func receiveDelta(_ text: String) -> StreamingAssistantContentUpdate {
guard !text.isEmpty else { return .unchanged }
segmentText += text
ephemeralTrace = nil
return .replace(segmentText)
}
mutating func receiveToolCall(id: String, status: String) -> StreamingAssistantContentUpdate {
guard status.lowercased() == "initiated", initiatedToolCallIDs.insert(id).inserted else {
return .unchanged
}
let trace = segmentText.trimmingCharacters(in: .whitespacesAndNewlines)
segmentText = ""
if !trace.isEmpty {
ephemeralTrace = trace
traceRevision += 1
}
return .replace("")
}
mutating func receiveDone(_ text: String) -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
return .replace(text)
}
mutating func receiveError() -> StreamingAssistantContentUpdate {
segmentText = ""
ephemeralTrace = nil
return .unchanged
}
}
struct StreamingAssistantPresentation: Equatable, Sendable {
var messageID: String
var ephemeralTrace: String?
var traceRevision: Int
}
private struct PendingChatState {
var chatID: String?
var messages: [Message]
var streamingAssistantState = StreamingAssistantState()
var streamingAssistantPresentation: StreamingAssistantPresentation? {
guard let messageID = messages.last(where: { $0.id.hasPrefix("temp-assistant-") })?.id else {
return nil
}
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: streamingAssistantState.ephemeralTrace,
traceRevision: streamingAssistantState.traceRevision
)
}
}
private enum ActiveSendContext: Hashable {
@@ -89,6 +156,7 @@ private actor SearchStreamStatus {
@Observable
final class SybilViewModel {
let settings: SybilSettingsStore
let agent = SybilAgentRoom()
var isCheckingSession = true
var isAuthenticated = false
@@ -98,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?
@@ -107,6 +184,7 @@ final class SybilViewModel {
var isLoadingCollections = false
var isLoadingSelection = false
var isCreatingSearchChat = false
var isForkingChat = false
var chatBottomPinRequestID = 0
var errorMessage: String?
@@ -125,6 +203,7 @@ final class SybilViewModel {
var quickQuestionSubmittedModel: String?
var isQuickQuestionSending = false
var isConvertingQuickQuestion = false
private var quickQuestionStreamingAssistantState = StreamingAssistantState()
@ObservationIgnored
private var hasBootstrapped = false
@@ -157,7 +236,7 @@ final class SybilViewModel {
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"],
.openai: ["gpt-4.1-mini", "chat-latest"],
.anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"],
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -264,6 +343,8 @@ final class SybilViewModel {
}
return "Search"
case .agent:
return "Sybil Agent"
case .settings:
return "Settings"
}
@@ -303,6 +384,7 @@ final class SybilViewModel {
}
var showsComposer: Bool {
if selectedItem == .agent { return false }
if case .settings = selectedItem {
return false
}
@@ -360,6 +442,30 @@ final class SybilViewModel {
return canonical
}
var displayedStreamingAssistantPresentation: StreamingAssistantPresentation? {
if case let .chat(chatID) = selectedItem,
let pending = pendingChatStates[chatID] {
return pending.streamingAssistantPresentation
}
if draftKind == .chat, let pending = pendingDraftChatState {
return pending.streamingAssistantPresentation
}
return nil
}
var quickQuestionStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard let messageID = quickQuestionMessages.last(where: { $0.id.hasPrefix("temp-assistant-quick-") })?.id else {
return nil
}
return StreamingAssistantPresentation(
messageID: messageID,
ephemeralTrace: quickQuestionStreamingAssistantState.ephemeralTrace,
traceRevision: quickQuestionStreamingAssistantState.traceRevision
)
}
var displayedSearch: SearchDetail? {
if case let .search(searchID) = selectedItem,
let activeSearch = activeSearchDetails[searchID] {
@@ -393,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?
@@ -407,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(
@@ -418,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:
@@ -430,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 {
@@ -454,7 +624,7 @@ final class SybilViewModel {
switch selectedItem {
case .chat, .search:
return true
case .settings:
case .settings, .agent:
return false
}
}
@@ -470,6 +640,8 @@ final class SybilViewModel {
}
func reconnect() async {
agent.disconnect()
setSidebarSearchQuery("")
isCheckingSession = true
authError = nil
errorMessage = nil
@@ -507,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()
@@ -620,6 +796,7 @@ final class SybilViewModel {
quickQuestionTask = nil
quickQuestionRunID = nil
isQuickQuestionSending = false
quickQuestionStreamingAssistantState = StreamingAssistantState()
}
@discardableResult
@@ -636,9 +813,12 @@ final class SybilViewModel {
}
cancelQuickQuestion()
// Lock submission before the task starts, including repeated Return
// events delivered in the same main-actor turn.
isQuickQuestionSending = true
let selectedProvider = quickQuestionProvider
let task = Task { [weak self] in
guard let self else {
guard let self, !Task.isCancelled else {
return
}
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
@@ -772,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 {
@@ -804,7 +993,7 @@ final class SybilViewModel {
selectedChat = nil
composerAttachments = []
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
return nil
@@ -822,7 +1011,7 @@ final class SybilViewModel {
}
private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems
let items = visibleSidebarItems
guard !items.isEmpty else {
return
}
@@ -842,6 +1031,7 @@ final class SybilViewModel {
}
func deleteItem(_ selection: SidebarSelection) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -854,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)
@@ -866,6 +1057,36 @@ final class SybilViewModel {
}
}
@discardableResult
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
guard isAuthenticated, !isForkingChat else {
return nil
}
guard messageID != nil || !isChatRowRunning(chatID) else {
errorMessage = "Wait for the response to finish before forking this chat."
return nil
}
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
errorMessage = nil
isForkingChat = true
defer {
isForkingChat = false
}
do {
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
applyChatSummary(forkedChat, moveToFront: true)
return forkedChat
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
return nil
}
}
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
@@ -883,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)
@@ -890,6 +1112,7 @@ final class SybilViewModel {
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -907,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 {
@@ -930,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 {
@@ -982,6 +1207,10 @@ final class SybilViewModel {
}
func refreshSidebarCollectionsFromPullToRefresh() async {
if isSidebarSearchActive {
await refreshSidebarSearch()
return
}
guard isAuthenticated, !isCheckingSession else {
return
}
@@ -1137,6 +1366,7 @@ final class SybilViewModel {
quickQuestionSubmittedPrompt = prompt
quickQuestionSubmittedProvider = provider
quickQuestionSubmittedModel = model
quickQuestionStreamingAssistantState = StreamingAssistantState()
quickQuestionMessages = [
Message(
id: "temp-assistant-quick-\(UUID().uuidString)",
@@ -1159,13 +1389,11 @@ final class SybilViewModel {
let streamStatus = CompletionStreamStatus()
do {
try await client().runCompletionStream(
body: CompletionStreamRequest(
chatId: nil,
persist: false,
try await client().runQuickQuestionStream(
body: QuickQuestionStreamRequest(
provider: provider,
model: model,
messages: [CompletionRequestMessage(role: .user, content: prompt)]
question: prompt
)
) { [weak self] event in
guard let self else { return }
@@ -1183,6 +1411,8 @@ final class SybilViewModel {
return
}
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
quickQuestionError = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Quick question failed", error: error)
}
@@ -1197,17 +1427,16 @@ final class SybilViewModel {
upsertQuickQuestionToolCallMessage(payload)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutateQuickQuestionAssistantMessage { existing in
existing + payload.text
}
let update = quickQuestionStreamingAssistantState.receiveDelta(payload.text)
applyQuickQuestionAssistantContentUpdate(update)
case let .done(payload):
mutateQuickQuestionAssistantMessage { _ in
payload.text
}
let update = quickQuestionStreamingAssistantState.receiveDone(payload.text)
applyQuickQuestionAssistantContentUpdate(update)
case let .error(payload):
let update = quickQuestionStreamingAssistantState.receiveError()
applyQuickQuestionAssistantContentUpdate(update)
await streamStatus.setError(payload.message)
case .ignored:
@@ -1259,7 +1488,7 @@ final class SybilViewModel {
if let nextSelection {
switch nextSelection {
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
case .chat, .search:
@@ -1322,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(
@@ -1500,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 {
@@ -1523,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
}
@@ -1554,7 +1799,7 @@ final class SybilViewModel {
await self?.attachToActiveSearchStream(searchID: searchID)
}
case .settings, nil:
case .settings, .agent, nil:
return
}
}
@@ -1606,21 +1851,28 @@ final class SybilViewModel {
}
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
await refreshCollections(preferredSelection: selectedItem, refreshSelection: false, attachVisibleActiveRun: false)
if selectedItem == selection, draftKind == nil {
selectedChat = try await client.getChat(chatID: chatID)
selectedSearch = nil
let refreshedChat = try await client.getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil
}
}
pendingChatStates[chatID] = nil
} catch {
serverActiveChatIDs.remove(chatID)
pendingChatStates[chatID] = nil
defer {
pendingChatStates[chatID] = nil
}
if isCancellation(error) {
return
}
applyPendingAssistantError(chatID: chatID)
if isActiveStreamNotFound(error) {
SybilLog.info(SybilLog.app, "Active chat stream \(chatID) no longer exists")
} else if shouldSuppressInactiveTransportError(error) {
@@ -1634,8 +1886,11 @@ final class SybilViewModel {
if selectedItem == selection, draftKind == nil {
do {
selectedChat = try await client().getChat(chatID: chatID)
selectedSearch = nil
let refreshedChat = try await client().getChat(chatID: chatID)
if selectedItem == selection, draftKind == nil {
selectedChat = refreshedChat
selectedSearch = nil
}
} catch {
SybilLog.warning(SybilLog.app, "Chat refresh after attach failure failed: \(SybilLog.describe(error))")
}
@@ -1753,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
@@ -1800,7 +2056,7 @@ final class SybilViewModel {
selectedSearch = search
selectedChat = nil
case .settings:
case .settings, .agent:
break
}
errorMessage = nil
@@ -1855,6 +2111,13 @@ final class SybilViewModel {
} else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
}
defer {
if let resolvedChatID = chatID {
pendingChatStates[resolvedChatID] = nil
} else {
clearPendingChatState(for: sendContext)
}
}
requestChatBottomPin()
if chatID == nil {
@@ -2044,17 +2307,13 @@ final class SybilViewModel {
upsertPendingToolCallMessage(payload, chatID: chatID)
case let .delta(payload):
guard !payload.text.isEmpty else { return }
mutatePendingAssistantMessage(chatID: chatID) { existing in
existing + payload.text
}
applyPendingAssistantDelta(payload.text, chatID: chatID)
case let .done(payload):
mutatePendingAssistantMessage(chatID: chatID) { _ in
payload.text
}
finalizePendingAssistant(payload.text, chatID: chatID)
case let .error(payload):
applyPendingAssistantError(chatID: chatID)
await streamStatus.setError(payload.message)
case .ignored:
@@ -2232,29 +2491,68 @@ final class SybilViewModel {
}
}
private func mutatePendingAssistantMessage(chatID: String, _ transform: (String) -> String) {
private func applyPendingAssistantDelta(_ text: String, chatID: String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return
}
let index = pending.messages.indices.last { pending.messages[$0].id.hasPrefix("temp-assistant-") }
guard let index else {
return
}
var message = pending.messages[index]
message.content = transform(message.content)
pending.messages[index] = message
let update = pending.streamingAssistantState.receiveDelta(text)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func mutateQuickQuestionAssistantMessage(_ transform: (String) -> String) {
let index = quickQuestionMessages.indices.last { quickQuestionMessages[$0].id.hasPrefix("temp-assistant-quick-") }
guard let index else {
private func finalizePendingAssistant(_ text: String, chatID: String) {
guard var pending = pendingChatStates[chatID], !pending.messages.isEmpty else {
return
}
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
let update = pending.streamingAssistantState.receiveDone(text)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func applyPendingAssistantError(chatID: String) {
guard var pending = pendingChatStates[chatID] else {
return
}
let update = pending.streamingAssistantState.receiveError()
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
pendingChatStates[chatID] = pending
}
private func applyQuickQuestionAssistantContentUpdate(_ update: StreamingAssistantContentUpdate) {
applyStreamingAssistantContentUpdate(
update,
to: &quickQuestionMessages,
assistantMessagePrefix: "temp-assistant-quick-"
)
}
private func applyStreamingAssistantContentUpdate(
_ update: StreamingAssistantContentUpdate,
to messages: inout [Message],
assistantMessagePrefix: String
) {
guard case let .replace(content) = update,
let index = messages.indices.last(where: { messages[$0].id.hasPrefix(assistantMessagePrefix) })
else {
return
}
messages[index].content = content
}
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
@@ -2262,6 +2560,16 @@ final class SybilViewModel {
return
}
let update = pending.streamingAssistantState.receiveToolCall(
id: payload.toolCallId,
status: payload.status
)
applyStreamingAssistantContentUpdate(
update,
to: &pending.messages,
assistantMessagePrefix: "temp-assistant-"
)
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
pendingChatStates[chatID] = pending
@@ -2280,6 +2588,12 @@ final class SybilViewModel {
}
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
let update = quickQuestionStreamingAssistantState.receiveToolCall(
id: payload.toolCallId,
status: payload.status
)
applyQuickQuestionAssistantContentUpdate(update)
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
return
@@ -2429,7 +2743,8 @@ final class SybilViewModel {
private func clearPendingChatState(for context: ActiveSendContext) {
switch context {
case .draftChat:
case let .draftChat(identity):
guard draftKind == .chat, draftIdentity == identity else { return }
pendingDraftChatState = nil
case let .chat(chatID):
pendingChatStates[chatID] = nil
@@ -2457,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
@@ -19,6 +18,7 @@ struct SybilWorkspaceView: View {
var onShowSidebar: (() -> Void)? = nil
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
var onRequestNewChat: (() -> Void)? = nil
var onOpenForkedChat: ((String) -> Void)? = nil
@FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false
@@ -155,8 +155,6 @@ struct SybilWorkspaceView: View {
workspaceContentStack
if showsCustomWorkspaceNavigation {
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
.allowsHitTesting(false)
customWorkspaceNavigationBar
}
}
@@ -175,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,
@@ -193,9 +193,12 @@ struct SybilWorkspaceView: View {
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat,
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID
bottomPinRequestID: viewModel.chatBottomPinRequestID,
isForking: viewModel.isForkingChat,
onForkResponse: forkResponse
)
.id(transcriptScrollContextID)
}
@@ -229,6 +232,28 @@ struct SybilWorkspaceView: View {
}
}
private func forkResponse(messageID: String) {
guard viewModel.draftKind == nil,
case let .chat(chatID) = viewModel.selectedItem else {
return
}
let selectionAtStart = viewModel.selectedItem
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == nil else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
private var customWorkspaceNavigationBar: some View {
HStack(spacing: 14) {
workspaceNavigationLeadingControl
@@ -262,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)
@@ -484,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
@@ -588,46 +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()
}
.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)
@@ -1348,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)
}
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
# Sybil for iOS and Mac Catalyst
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
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
Question panel, including over other applications. The prompt is focused as soon
as it opens, with any existing prompt selected so typing replaces it. The same
select-on-open behavior applies to Quick Question on iPhone and iPad.
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace.
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
provider/model selection takes precedence and is separate from regular chat preferences.
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
Question, while the normal chat/search composer continues to use Return for a
new line.
Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
build opts out of automatic termination, and the native shortcut service holds a
matching automatic-termination activity until it stops. No hidden keep-alive
window or sleep-preventing activity is used. Mac scene support also allows the
workspace to reopen after its last window is closed.
When Catalyst is backgrounded, opening Quick Question first requests app
activation and waits for UIKit to enter the foreground before creating or ordering
the native panel. Presentation is deferred out of the hotkey event callback.
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
that required activation returns focus to the previous app; explicitly opening
the workspace keeps focus in Sybil.
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
workspace window when none are open. **Command+Shift+N** always opens a new window,
including while signed out. **Command+Option+N** starts a new search; iOS retains
its existing Command+Shift+N search shortcut.
The shortcut is also shown under **File → Quick Question**. If another application
already owns Option+Space, the panel reports that the shortcut is unavailable;
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
needed for the registered hotkey.
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
provider/model preferences, streaming, and conversion to chat remain in the
existing `SybilViewModel`; there is no separate backend or API contract. The panel
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
Catalyst process.
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
action, and does not build or embed the macOS helper.
## Verification
- `just test`: existing Swift package tests on the iPhone simulator.
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
- For a manual Mac check, open the panel from another app, type immediately,
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
returns input to the previous app. Check a second display and a full-screen Space
when available.
- Close every Mac window, invoke Option+Space, and verify the saved question and
answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. On iOS, verify Return submits Quick Question while Return in the normal
prompt adds a newline. In the Mac workspace, verify Return submits and
Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window.
- With all workspace windows closed, switch to another app and repeatedly open
and dismiss Quick Question, including two quick Option+Space presses while it
is opening. Check that no late panel appears after cancellation and that no
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
+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")
}
}
+7 -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}}'; \
@@ -24,6 +24,12 @@ run: generate
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2
build-mac: generate
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
run-mac: build-mac
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
beta:
fastlane ios beta
+28
View File
@@ -7,3 +7,31 @@ packages:
path: Packages/Sybil
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
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
LOG_DIR="$ROOT/.devbox/logs"
PID_DIR="$ROOT/.devbox/pids"
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env OPENAI_API_KEY
require_env EXA_API_KEY
mkdir -p "$LOG_DIR" "$PID_DIR"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8787}"
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
echo "Installing server dependencies..."
npm ci --prefix "$ROOT/server" --no-audit --no-fund
echo "Installing web dependencies..."
npm ci --prefix "$ROOT/web" --no-audit --no-fund
if command -v tmux >/dev/null 2>&1; then
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
tmux set-environment -t "$SESSION" HOST "$HOST"
tmux set-environment -t "$SESSION" PORT "$PORT"
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
fi
for name in \
CHAT_CODEX_TOOL_ENABLED \
CHAT_CODEX_REMOTE_HOST \
CHAT_CODEX_REMOTE_USER \
CHAT_CODEX_REMOTE_PORT \
CHAT_CODEX_REMOTE_WORKDIR \
CHAT_CODEX_SSH_KEY_PATH \
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
CHAT_CODEX_EXEC_TIMEOUT_MS \
CHAT_SHELL_TOOL_ENABLED \
CHAT_SHELL_EXEC_TIMEOUT_MS; do
if [[ -n "${!name:-}" ]]; then
tmux set-environment -t "$SESSION" "$name" "${!name}"
fi
done
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
"npm run dev -- --host 0.0.0.0 --port 5173"
echo "Started Sybil in tmux session '$SESSION'."
echo "Server: http://127.0.0.1:8787"
echo "Web: http://127.0.0.1:5173"
exit 0
fi
echo "tmux not found; starting background processes with nohup."
if [[ -f "$PID_DIR/server.pid" ]]; then
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
fi
if [[ -f "$PID_DIR/web.pid" ]]; then
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
fi
(
cd "$ROOT/server"
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
) >"$LOG_DIR/server.log" 2>&1 &
echo "$!" >"$PID_DIR/server.pid"
(
cd "$ROOT/web"
exec npm run dev -- --host 0.0.0.0 --port 5173
) >"$LOG_DIR/web.log" 2>&1 &
echo "$!" >"$PID_DIR/web.pid"
echo "Started Sybil background processes."
echo "Server log: $LOG_DIR/server.log"
echo "Web log: $LOG_DIR/web.log"
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="${REPO:-Sybil-2}"
WORKLOAD="${WORKLOAD:-sybil-dev}"
CLUSTER="${CLUSTER:-viper}"
NAMESPACE="${NAMESPACE:-buzzert}"
QUOTA="${QUOTA:-research-api}"
SECRET="${SECRET:-sybil-dev-env}"
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
INGRESS_PORT="${INGRESS_PORT:-5173}"
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
brix create \
--project-root "$(dirname "$ROOT")" \
--path "$REPO=$ROOT" \
--repositories "$REPO" \
--include-untracked \
--reconcile-policy continuous \
-- \
"cluster=$CLUSTER" \
"namespace=$NAMESPACE" \
"name=$WORKLOAD" \
"pool.spec.quota=$QUOTA" \
"pool.spec.size.resource=cpu" \
"pool.spec.size.quantity=1" \
"pool.spec.priority=low"
if [[ "$REPO" != "openai" ]]; then
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
fi
fi
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
exit 1
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
--from-file=id_ed25519="$tmpdir/id_ed25519" \
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
--arg host "$CODEX_SERVICE" \
--arg port "$CODEX_SSH_PORT" \
--arg workdir "$CODEX_WORKDIR" \
--arg keyPath "$CODEX_SSH_KEY_PATH" \
'{
stringData: {
CHAT_CODEX_TOOL_ENABLED: "true",
CHAT_CODEX_REMOTE_HOST: $host,
CHAT_CODEX_REMOTE_USER: "root",
CHAT_CODEX_REMOTE_PORT: $port,
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
CHAT_SHELL_TOOL_ENABLED: "true"
}
}')"
ensure_codex_deployment() {
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
apiVersion: apps/v1
kind: Deployment
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: $CODEX_SERVICE
template:
metadata:
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
tolerations:
- key: openai.com/team
operator: Exists
effect: NoSchedule
containers:
- name: $CODEX_CONTAINER
image: $CODEX_IMAGE
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v sshd >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
rm -rf /var/lib/apt/lists/*
fi
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex
fi
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
{
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
fi
if [[ -n "\${EXA_API_KEY:-}" ]]; then
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
fi
} >/root/.sybil-codex-env
chmod 600 /root/.sybil-codex-env
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
#!/usr/bin/env bash
set -euo pipefail
if [[ -f /root/.sybil-codex-env ]]; then
source /root/.sybil-codex-env
fi
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
fi
exec /bin/bash -l
SSH_COMMAND
chmod 700 /usr/local/bin/sybil-codex-ssh-command
ssh-keygen -A
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
Port $CODEX_SSH_PORT
ListenAddress 0.0.0.0
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
AcceptEnv OPENAI_API_KEY EXA_API_KEY
AuthorizedKeysFile .ssh/authorized_keys
PermitUserEnvironment no
ForceCommand /usr/local/bin/sybil-codex-ssh-command
AllowTcpForwarding no
X11Forwarding no
Subsystem sftp internal-sftp
PidFile /run/sshd/sybil-codex.pid
SSHD_CONFIG
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
ports:
- name: ssh
containerPort: $CODEX_SSH_PORT
envFrom:
- secretRef:
name: $SECRET
resources:
requests:
cpu: "1"
memory: 4Gi
limits:
cpu: "1"
memory: 4Gi
volumeMounts:
- name: sybil-codex-ssh
mountPath: /run/secrets/sybil-codex-ssh
readOnly: true
- name: sybil-codex-workspace
mountPath: /workspace
volumes:
- name: sybil-codex-ssh
secret:
secretName: $CODEX_SSH_SECRET
defaultMode: 384
- name: sybil-codex-workspace
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: $CODEX_SERVICE
ports:
- name: ssh
port: $CODEX_SSH_PORT
targetPort: ssh
KUBE_MANIFEST
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
}
ensure_codex_deployment
ensure_env_from() {
local resource="$1"
local name="$2"
local jsonpath="$3"
local patch_path="$4"
local existing_env_from
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
return
fi
local patch
if [[ -z "$existing_env_from" ]]; then
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
else
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
}
ensure_env_from \
workload \
"$WORKLOAD" \
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
ensure_env_from \
pool \
"$WORKLOAD" \
"{.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/workers/template/spec/containers/0/envFrom"
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
name: "sybil-codex-ssh",
secret: {
secretName: $secret,
defaultMode: 384
}
}')"
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
patch_pool_codex_mount() {
local pool_json containers volumes patch
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
containers="$(jq \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
(.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName))
' <<<"$pool_json")"
volumes="$(jq \
--argjson sshVolume "$codex_ssh_volume_json" \
'
(.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume]
' <<<"$pool_json")"
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
spec: {
workers: {
template: {
spec: {
containers: $containers,
volumes: $volumes
}
}
}
}
}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount() {
local workload_json pools patch
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
pools="$(jq \
--arg groupName "$WORKLOAD" \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
--argjson sshVolume "$codex_ssh_volume_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
.spec.pools
| map(
if .groupName == $groupName then
.template.template.spec.workers.template.spec.containers =
((.template.template.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName)))
| .template.template.spec.workers.template.spec.volumes =
((.template.template.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume])
else
.
end
)
' <<<"$workload_json")"
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount
patch_pool_codex_mount
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
deadline=$((SECONDS + 300))
while true; do
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
break
fi
if (( SECONDS >= deadline )); then
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
exit 1
fi
sleep 5
done
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
echo "Devbox update complete: $url"
+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}`);
+1 -1
View File
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id);
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
}
function isLikelyGeminiChatModel(model: string) {
+30
View File
@@ -0,0 +1,30 @@
import type { MultiplexRequest, Provider } from "./types.js";
export const QUICK_QUESTION_SYSTEM_PROMPT =
"You are answering a quick question in a one-shot experience intended to replace a quick Google search. " +
"Give a succinct, direct, self-contained answer. Do not ask follow-up questions, invite the user to continue, " +
"or offer additional help. If the question is ambiguous, make the most reasonable assumption and state it briefly only when needed.";
export type QuickQuestionRequest = {
provider: Provider;
model: string;
question: string;
enabledTools?: string[];
userLocation?: string;
temperature?: number;
maxTokens?: number;
};
export function buildQuickQuestionMultiplexRequest({
question,
...request
}: QuickQuestionRequest): MultiplexRequest {
return {
...request,
persist: false,
messages: [
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
{ role: "user", content: question },
],
};
}
+65 -1
View File
@@ -11,10 +11,12 @@ import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream, type StreamEvent } from "./llm/streaming.js";
import { getAvailableChatTools, normalizeEnabledChatTools } from "./llm/chat-tools.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { buildQuickQuestionMultiplexRequest } from "./llm/quick-question.js";
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"]);
@@ -205,6 +207,16 @@ const CompletionStreamBody = z
}
});
const QuickQuestionStreamBody = z.object({
provider: ProviderSchema,
model: z.string().min(1),
question: z.string().trim().min(1),
enabledTools: EnabledToolsSchema.optional(),
userLocation: z.string().trim().min(1).max(200).optional(),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
if (!attachments?.length) return metadata as any;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
@@ -916,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() };
});
@@ -1564,7 +1607,28 @@ export async function registerRoutes(app: FastifyInstance) {
};
});
// Streaming SSE endpoint.
// One-shot, non-persistent Quick Question SSE endpoint.
app.post("/v1/quick-questions/stream", async (req, reply) => {
requireAdmin(req);
const parsed = QuickQuestionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = withRequestUserLocation(parsed.data, req);
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(buildQuickQuestionMultiplexRequest(body))) {
writeSseEvent(reply, mapChatStreamEvent(ev));
}
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
reply.raw.end();
}
return reply;
});
// General chat completion SSE endpoint.
app.post("/v1/chat-completions/stream", async (req, reply) => {
requireAdmin(req);
+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;
+45 -1
View File
@@ -1,6 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
import { env } from "../src/env.js";
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
for (const { name, listedModels, expectedModels } of [
{
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
listedModels: [
"gpt-4.1-mini",
"chat-latest",
"o3",
"chatgpt-4o-latest",
"chat-latest",
"text-embedding-3-small",
"omni-moderation-latest",
"gpt-4o-audio-preview",
"gpt-realtime",
"gpt-4o-transcribe",
"gpt-4o-mini-tts",
"gpt-image-1",
"chatgpt-image-latest",
"gpt-4o-search-preview",
"computer-use-preview",
"sora-2",
],
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
},
{
name: "OpenAI catalog does not invent models absent from the provider response",
listedModels: ["gpt-4.1-mini"],
expectedModels: ["gpt-4.1-mini"],
},
]) {
test(name, async (t) => {
const originalKey = env.OPENAI_API_KEY;
env.OPENAI_API_KEY = "test-openai-key";
t.after(() => { env.OPENAI_API_KEY = originalKey; });
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
assert.equal(url, "https://api.openai.com/v1/models");
return Response.json({ data: listedModels.map((id) => ({ id })) });
});
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
});
}
test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), {
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildQuickQuestionMultiplexRequest,
QUICK_QUESTION_SYSTEM_PROMPT,
} from "../src/llm/quick-question.js";
test("quick question requests receive the server-owned one-shot system prompt", () => {
const request = buildQuickQuestionMultiplexRequest({
provider: "openai",
model: "gpt-4.1-mini",
question: "How do I reset my password?",
enabledTools: ["web_search"],
userLocation: "Los Angeles, CA",
});
assert.equal(request.persist, false);
assert.equal(request.chatId, undefined);
assert.deepEqual(request.messages, [
{ role: "system", content: QUICK_QUESTION_SYSTEM_PROMPT },
{ role: "user", content: "How do I reset my password?" },
]);
assert.deepEqual(request.enabledTools, ["web_search"]);
assert.equal(request.userLocation, "Los Angeles, CA");
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /succinct, direct, self-contained answer/i);
assert.match(QUICK_QUESTION_SYSTEM_PROMPT, /do not ask follow-up questions/i);
});
+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

+298 -158
View File
File diff suppressed because it is too large Load Diff
@@ -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";
+149 -9
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { ComponentChildren, JSX } from "preact";
import { cn } from "@/lib/utils";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { getMessageAttachments, type Message } from "@/lib/api";
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
@@ -377,13 +378,13 @@ function ToolCallStack({
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
@@ -396,8 +397,144 @@ function ToolCallStack({
);
}
type RenderedThinkingTrace = {
text: string;
revision: number;
};
const THINKING_TRACE_EXIT_MS = 220;
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
const metadata = asStreamingAssistantMetadata(message.metadata);
const traceText = metadata?.ephemeralTrace?.trim() || null;
const traceRevision = metadata?.traceRevision ?? 0;
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
traceText ? { text: traceText, revision: traceRevision } : null
);
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
const activeTraceRef = useRef<HTMLDivElement | null>(null);
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
const exitTimerRef = useRef<number | null>(null);
useLayoutEffect(() => {
const isSameTrace =
(!activeTrace && !traceText) ||
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
if (isSameTrace) return;
if (exitTimerRef.current !== null) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
if (activeTrace) {
setOutgoingTrace(activeTrace);
exitTimerRef.current = window.setTimeout(() => {
setOutgoingTrace(null);
exitTimerRef.current = null;
}, THINKING_TRACE_EXIT_MS);
} else {
setOutgoingTrace(null);
}
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
}, [traceRevision, traceText]);
useEffect(() => {
return () => {
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
};
}, []);
useLayoutEffect(() => {
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
(element): element is HTMLDivElement => element !== null
);
if (!traceElements.length) return;
const reserveMeasuredHeight = () => {
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
if (measuredHeight > 0) {
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
}
};
reserveMeasuredHeight();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(reserveMeasuredHeight);
for (const element of traceElements) observer.observe(element);
return () => observer.disconnect();
}, [activeTrace, outgoingTrace]);
const hasAnswer = message.content.trim().length > 0;
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
return (
<div
className="streaming-assistant-slot"
data-streaming-assistant="true"
data-streaming-phase={phase}
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
>
{outgoingTrace ? (
<div
ref={outgoingTraceRef}
key={`outgoing-trace-${outgoingTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
data-thinking-trace="true"
data-thinking-trace-state="outgoing"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
</div>
) : null}
{activeTrace ? (
<div
ref={activeTraceRef}
key={`active-trace-${activeTrace.revision}`}
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
data-thinking-trace="true"
data-thinking-trace-state="active"
aria-hidden="true"
>
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
</div>
) : null}
{activeTrace && !hasAnswer ? (
<span className="sr-only" role="status" aria-live="polite">
Assistant is working
</span>
) : null}
{hasAnswer ? (
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
/>
</div>
) : null}
{showTyping ? (
<span
className="streaming-assistant-layer inline-flex items-center gap-1"
data-streaming-pending="true"
aria-label="Assistant is typing"
role="status"
>
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
</span>
) : null}
</div>
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
@@ -457,16 +594,17 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
}
const isUser = message.role === "user";
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
const attachments = getMessageAttachments(message.metadata);
return (
<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
? "rounded-xl 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
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
}
>
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{isPendingAssistant ? (
{streamingAssistantMetadata ? (
<StreamingAssistantPresentation message={message} isSending={isSending} />
) : isPendingAssistant ? (
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
@@ -485,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,
};
}
+163 -26
View File
@@ -31,7 +31,7 @@
--secondary-foreground: 254 38% 94%;
--accent: 188 86% 50%;
--accent-foreground: 230 45% 8%;
--radius: 0.5rem;
--radius: 0px;
}
* {
@@ -72,6 +72,7 @@ select,
textarea {
font: inherit;
color-scheme: dark;
border-radius: var(--radius);
}
::selection {
@@ -113,13 +114,39 @@ textarea {
max(0.75rem, var(--safe-area-left));
}
.chat-settings-header {
padding:
calc(0.5rem + var(--safe-area-top))
max(0.75rem, var(--safe-area-right))
0.5rem
max(0.75rem, var(--safe-area-left));
}
.chat-settings-content {
padding:
1rem
max(1rem, var(--safe-area-right))
max(1.5rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
scroll-padding-block: 1rem;
}
.chat-settings-error {
padding:
0.75rem
max(1rem, var(--safe-area-right))
max(0.75rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
}
@media (min-width: 768px) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
.chat-settings-header,
.chat-settings-content {
padding: 1.25rem;
}
.chat-settings-error {
padding: 0.75rem 1.25rem;
}
.app-search-safe-pad {
@@ -132,19 +159,11 @@ textarea {
}
@media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell {
display: grid;
grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
}
@@ -153,14 +172,10 @@ textarea {
width: 100%;
max-width: none;
transform: none;
border-width: 1px;
border-radius: 1rem;
}
.workspace-content {
min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto;
}
@@ -179,6 +194,37 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28);
}
/* 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 {
perspective: 900px;
transform-style: preserve-3d;
@@ -321,6 +367,97 @@ textarea {
}
}
.streaming-assistant-slot {
display: grid;
min-width: 0;
min-height: 1.82em;
align-items: start;
}
.streaming-assistant-layer {
grid-area: 1 / 1;
min-width: 0;
transform-origin: top left;
}
.ephemeral-thinking-trace {
position: relative;
z-index: 1;
color: hsl(252 35% 88% / 0.8);
font-style: italic;
opacity: 0.78;
filter: saturate(0.72);
will-change: opacity, transform, filter;
}
.ephemeral-thinking-trace-enter {
animation: ephemeral-thinking-trace-enter 200ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
}
.ephemeral-thinking-trace-exit {
pointer-events: none;
animation: ephemeral-thinking-trace-exit 220ms cubic-bezier(0.4, 0, 0.8, 0.28) both;
}
.streaming-assistant-answer {
position: relative;
z-index: 2;
animation: streaming-assistant-answer-enter 180ms cubic-bezier(0.2, 0.78, 0.24, 1) both;
}
@keyframes ephemeral-thinking-trace-enter {
from {
opacity: 0;
filter: saturate(0.72) blur(1px);
transform: translate3d(0, 0.32rem, 0);
}
to {
opacity: 0.78;
filter: saturate(0.72) blur(0);
transform: translate3d(0, 0, 0);
}
}
@keyframes ephemeral-thinking-trace-exit {
from {
opacity: 0.78;
filter: saturate(0.72) blur(0);
transform: translate3d(0, 0, 0);
}
to {
opacity: 0;
filter: saturate(0.72) blur(1px);
transform: translate3d(0, -0.32rem, 0);
}
}
@keyframes streaming-assistant-answer-enter {
from {
opacity: 0;
transform: translate3d(0, 0.18rem, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ephemeral-thinking-trace-enter,
.ephemeral-thinking-trace-exit,
.streaming-assistant-answer {
animation: none;
transform: none;
}
.ephemeral-thinking-trace-exit {
opacity: 0;
}
}
.md-content {
word-break: break-word;
}
@@ -339,7 +476,7 @@ textarea {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
@@ -358,7 +495,7 @@ textarea {
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
border-radius: var(--radius);
background: hsl(263 78% 72% / 0.34);
}
@@ -470,7 +607,7 @@ textarea {
.md-content code {
background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem;
border-radius: var(--radius);
padding: 0.05rem 0.3rem;
font-size: 0.86em;
box-decoration-break: clone;
@@ -480,7 +617,7 @@ textarea {
.md-content pre {
overflow-x: auto;
border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(249 40% 10% / 0.82);
padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
@@ -504,7 +641,7 @@ textarea {
.md-cite-token {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border-radius: var(--radius);
border: 1px solid hsl(274 40% 55%);
background: hsl(277 33% 21%);
color: hsl(304 42% 96%);
+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 };
}
+51 -14
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;
@@ -191,6 +193,14 @@ type CompletionStreamHandlers = {
onError?: (payload: { message: string }) => void;
};
function dispatchCompletionStreamEvent(handlers: CompletionStreamHandlers, eventName: string, payload: any) {
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
}
type CreateChatRequest = {
title?: string;
provider?: Provider;
@@ -220,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;
}
@@ -264,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");
}
@@ -627,13 +649,34 @@ export async function runCompletionStream(
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
}
export async function runQuickQuestionStream(
body: {
provider: Provider;
model: string;
question: string;
},
handlers: CompletionStreamHandlers,
options?: { signal?: AbortSignal }
) {
const headers = new Headers({
Accept: "text/event-stream",
"Content-Type": "application/json",
});
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}/v1/quick-questions/stream`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
}
export async function attachCompletionStream(chatId: string, handlers: CompletionStreamHandlers, options?: { signal?: AbortSignal }) {
@@ -650,11 +693,5 @@ export async function attachCompletionStream(chatId: string, handlers: Completio
signal: options?.signal,
});
await readSseStream(response, (eventName, payload) => {
if (eventName === "meta") handlers.onMeta?.(payload);
else if (eventName === "tool_call") handlers.onToolCall?.(payload);
else if (eventName === "delta") handlers.onDelta?.(payload);
else if (eventName === "done") handlers.onDone?.(payload);
else if (eventName === "error") handlers.onError?.(payload);
});
await readSseStream(response, (eventName, payload) => dispatchCompletionStreamEvent(handlers, eventName, payload));
}
+139
View File
@@ -0,0 +1,139 @@
import type { Message, ToolCallEvent } from "@/lib/api";
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
export type StreamingAssistantMetadata = {
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
ephemeralTrace: string | null;
traceRevision: number;
};
export type StreamingAttemptState = {
segmentText: string;
initiatedToolCallIds: string[];
};
export type StreamingToolCallTransition = {
attempt: StreamingAttemptState;
startedNewToolCall: boolean;
traceText: string | null;
};
export function createStreamingAssistantMetadata(): StreamingAssistantMetadata {
return {
kind: STREAMING_ASSISTANT_METADATA_KIND,
ephemeralTrace: null,
traceRevision: 0,
};
}
export function asStreamingAssistantMetadata(value: unknown): StreamingAssistantMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.kind !== STREAMING_ASSISTANT_METADATA_KIND) return null;
return {
kind: STREAMING_ASSISTANT_METADATA_KIND,
ephemeralTrace: typeof record.ephemeralTrace === "string" ? record.ephemeralTrace : null,
traceRevision:
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
? Math.max(0, Math.floor(record.traceRevision))
: 0,
};
}
export function createStreamingAttemptState(): StreamingAttemptState {
return {
segmentText: "",
initiatedToolCallIds: [],
};
}
export function appendStreamingDelta(attempt: StreamingAttemptState, delta: string): StreamingAttemptState {
if (!delta) return attempt;
return {
...attempt,
segmentText: attempt.segmentText + delta,
};
}
export function transitionStreamingToolCall(
attempt: StreamingAttemptState,
event: Pick<ToolCallEvent, "toolCallId" | "status">
): StreamingToolCallTransition {
if (event.status !== "initiated" || attempt.initiatedToolCallIds.includes(event.toolCallId)) {
return { attempt, startedNewToolCall: false, traceText: null };
}
const traceText = attempt.segmentText.trim();
return {
attempt: {
segmentText: "",
initiatedToolCallIds: attempt.initiatedToolCallIds.concat(event.toolCallId),
},
startedNewToolCall: true,
traceText: traceText || null,
};
}
function updateStreamingAssistant(
messages: Message[],
assistantMessagePrefix: string,
update: (message: Message, metadata: StreamingAssistantMetadata) => Message
) {
let didUpdate = false;
const nextMessages = messages.map((message, index, all) => {
const isTarget = index === all.length - 1 && message.id.startsWith(assistantMessagePrefix);
if (!isTarget) return message;
didUpdate = true;
return update(message, asStreamingAssistantMetadata(message.metadata) ?? createStreamingAssistantMetadata());
});
return didUpdate ? nextMessages : messages;
}
export function setStreamingAssistantSegment(messages: Message[], assistantMessagePrefix: string, segmentText: string) {
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,
content: segmentText,
metadata: {
...metadata,
ephemeralTrace: null,
} satisfies StreamingAssistantMetadata,
}));
}
export function promoteStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string, traceText: string) {
const normalizedTrace = traceText.trim();
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,
content: "",
metadata: normalizedTrace
? ({
...metadata,
ephemeralTrace: normalizedTrace,
traceRevision: metadata.traceRevision + 1,
} satisfies StreamingAssistantMetadata)
: metadata,
}));
}
export function finalizeStreamingAssistant(messages: Message[], assistantMessagePrefix: string, finalText: string) {
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,
content: finalText,
metadata: {
...metadata,
ephemeralTrace: null,
} satisfies StreamingAssistantMetadata,
}));
}
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,
metadata: {
...metadata,
ephemeralTrace: null,
} satisfies StreamingAssistantMetadata,
}));
}
+25
View File
@@ -0,0 +1,25 @@
import type { Provider } from "./api";
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
if (preferred?.trim()) return preferred.trim();
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
return options[0] ?? "";
}
export function buildQuickQuestionRequest({
provider,
model,
content,
}: {
provider: Provider;
model: string;
content: string;
}) {
return {
provider,
model,
question: content,
};
}
+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();
};
}
+7 -2
View File
@@ -29,9 +29,14 @@ const config: Config = {
},
},
borderRadius: {
// Keep surfaces square at every size; rounded-full is reserved for dots.
DEFAULT: "var(--radius)",
sm: "var(--radius)",
md: "var(--radius)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "var(--radius)",
"2xl": "var(--radius)",
"3xl": "var(--radius)",
},
},
},
+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");
});
+148
View File
@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
appendStreamingDelta,
asStreamingAssistantMetadata,
clearStreamingAssistantTrace,
createStreamingAssistantMetadata,
createStreamingAttemptState,
finalizeStreamingAssistant,
promoteStreamingAssistantTrace,
setStreamingAssistantSegment,
transitionStreamingToolCall,
} from "../src/lib/chat-stream-presentation.ts";
function assistantMessage(content = "") {
return {
id: "temp-assistant-test",
createdAt: "2026-08-30T00:00:00.000Z",
role: "assistant",
content,
name: null,
metadata: createStreamingAssistantMetadata(),
};
}
function metadata(messages) {
return asStreamingAssistantMetadata(messages.at(-1)?.metadata);
}
test("a tool boundary promotes the current segment and the next delta starts the answer in its place", () => {
let messages = [assistantMessage()];
let attempt = appendStreamingDelta(createStreamingAttemptState(), "I'll search for that.");
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
const toolTransition = transitionStreamingToolCall(attempt, {
toolCallId: "search-1",
status: "initiated",
});
attempt = toolTransition.attempt;
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", toolTransition.traceText ?? "");
assert.equal(messages.at(-1).content, "");
assert.deepEqual(metadata(messages), {
kind: "streaming_assistant",
ephemeralTrace: "I'll search for that.",
traceRevision: 1,
});
attempt = appendStreamingDelta(attempt, "Here is the answer");
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
assert.equal(messages.at(-1).content, "Here is the answer");
assert.equal(metadata(messages)?.ephemeralTrace, null);
messages = finalizeStreamingAssistant(messages, "temp-assistant-", "Here is the answer.");
assert.equal(messages.at(-1).content, "Here is the answer.");
assert.equal(metadata(messages)?.ephemeralTrace, null);
});
test("a later tool round replaces the previous trace and parallel initiated calls do not erase it", () => {
let messages = [assistantMessage("First trace")];
let attempt = appendStreamingDelta(createStreamingAttemptState(), "First trace");
let transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-1", status: "initiated" });
attempt = transition.attempt;
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
attempt = appendStreamingDelta(attempt, "I need one more lookup.");
messages = setStreamingAssistantSegment(messages, "temp-assistant-", attempt.segmentText);
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-2", status: "initiated" });
attempt = transition.attempt;
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
assert.equal(metadata(messages)?.traceRevision, 2);
transition = transitionStreamingToolCall(attempt, { toolCallId: "tool-3", status: "initiated" });
assert.equal(transition.startedNewToolCall, true);
assert.equal(transition.traceText, null);
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", transition.traceText ?? "");
assert.equal(metadata(messages)?.ephemeralTrace, "I need one more lookup.");
assert.equal(metadata(messages)?.traceRevision, 2);
});
test("terminal and duplicate tool events never promote or reset the in-progress segment", () => {
const initial = appendStreamingDelta(createStreamingAttemptState(), "candidate");
const completed = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "completed" });
assert.equal(completed.startedNewToolCall, false);
assert.strictEqual(completed.attempt, initial);
const initiated = transitionStreamingToolCall(initial, { toolCallId: "tool-1", status: "initiated" });
const withNextSegment = appendStreamingDelta(initiated.attempt, "next segment");
const duplicate = transitionStreamingToolCall(withNextSegment, { toolCallId: "tool-1", status: "initiated" });
assert.equal(duplicate.startedNewToolCall, false);
assert.strictEqual(duplicate.attempt, withNextSegment);
assert.equal(duplicate.attempt.segmentText, "next segment");
});
test("a fresh per-attempt accumulator can replay and reclassify an already-seen tool round", () => {
let messages = [assistantMessage("stale partial answer")];
let replayAttempt = appendStreamingDelta(createStreamingAttemptState(), "Replayed trace");
messages = setStreamingAssistantSegment(messages, "temp-assistant-", replayAttempt.segmentText);
const replayedTool = transitionStreamingToolCall(replayAttempt, { toolCallId: "same-tool-id", status: "initiated" });
messages = promoteStreamingAssistantTrace(messages, "temp-assistant-", replayedTool.traceText ?? "");
assert.equal(replayedTool.startedNewToolCall, true);
assert.equal(messages.at(-1).content, "");
assert.equal(metadata(messages)?.ephemeralTrace, "Replayed trace");
});
test("message helpers preserve identity when no matching temporary assistant exists", () => {
const messages = [
{
id: "persisted-assistant",
createdAt: "2026-08-30T00:00:00.000Z",
role: "assistant",
content: "Persisted",
name: null,
metadata: null,
},
];
assert.strictEqual(setStreamingAssistantSegment(messages, "temp-assistant-", "new"), messages);
assert.strictEqual(promoteStreamingAssistantTrace(messages, "temp-assistant-", "trace"), messages);
assert.strictEqual(finalizeStreamingAssistant(messages, "temp-assistant-", "done"), messages);
assert.strictEqual(clearStreamingAssistantTrace(messages, "temp-assistant-"), messages);
});
test("a terminal error clears the ephemeral trace without discarding a partial answer", () => {
let messages = [
{
...assistantMessage("Partial answer"),
metadata: {
...createStreamingAssistantMetadata(),
ephemeralTrace: "Still checking one source.",
traceRevision: 1,
},
},
];
messages = clearStreamingAssistantTrace(messages, "temp-assistant-");
assert.equal(messages.at(-1).content, "Partial answer");
assert.equal(metadata(messages)?.ephemeralTrace, null);
});
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
test("quick questions default to chat-latest before and after the model catalog loads", () => {
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
const model = pickQuickQuestionModel("openai", options);
assert.equal(model, "chat-latest");
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
provider: "openai",
model: "chat-latest",
question: "What is UTC?",
});
}
});
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
assert.equal(pickQuickQuestionModel("anthropic", []), "");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
});
test("quick question requests use the dedicated server endpoint shape", () => {
const request = buildQuickQuestionRequest({
provider: "openai",
model: "gpt-4.1-mini",
content: "How do I reset my password?",
});
assert.deepEqual(request, {
provider: "openai",
model: "gpt-4.1-mini",
question: "How do I reset my password?",
});
assert.equal("additionalSystemPrompt" in request, false);
});
+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);
}
});
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/quick-question.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}