Compare commits
4
Commits
b540f01759
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9fc82b86 | ||
|
|
eb2b0d3ca0 | ||
|
|
42022bf055 | ||
|
|
1ef491f39a |
+32
-3
@@ -89,6 +89,8 @@ Behavior notes:
|
|||||||
"type": "chat",
|
"type": "chat",
|
||||||
"id": "chat-id",
|
"id": "chat-id",
|
||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
"starred": true,
|
"starred": true,
|
||||||
@@ -117,7 +119,8 @@ Behavior notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
- The response currently combines up to 100 chats and up to 100 searches.
|
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
|
||||||
|
- 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.
|
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
@@ -148,15 +151,32 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `provider` and `model` must be supplied together when present.
|
- `provider` and `model` must be supplied together when present.
|
||||||
|
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
|
||||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
||||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
||||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
|
### `POST /v1/chats/:chatId/fork`
|
||||||
|
- Body: `{ "messageId"?: string }`
|
||||||
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
- Source chat not found: `404 { "message": "chat not found" }`
|
||||||
|
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
|
||||||
|
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
|
||||||
|
|
||||||
|
Behavior notes:
|
||||||
|
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
|
||||||
|
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
|
||||||
|
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
|
||||||
|
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
|
||||||
|
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
|
||||||
|
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
||||||
|
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
|
||||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
||||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
||||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
- Updating chat fields changes the returned chat's `updatedAt`.
|
||||||
@@ -183,14 +203,19 @@ Behavior notes:
|
|||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
|
||||||
|
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
|
||||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||||
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
|
||||||
|
|
||||||
### `DELETE /v1/chats/:chatId`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
- Not found: `404 { "message": "chat not found" }`
|
- Not found: `404 { "message": "chat not found" }`
|
||||||
|
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
|
||||||
|
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
|
||||||
|
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
|
||||||
|
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
|
||||||
|
|
||||||
### `GET /v1/chats/:chatId`
|
### `GET /v1/chats/:chatId`
|
||||||
- Response: `{ "chat": ChatDetail }`
|
- Response: `{ "chat": ChatDetail }`
|
||||||
@@ -429,6 +454,8 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
@@ -481,6 +508,8 @@ Behavior notes:
|
|||||||
{
|
{
|
||||||
"id": "...",
|
"id": "...",
|
||||||
"title": null,
|
"title": null,
|
||||||
|
"parentChatId": null,
|
||||||
|
"titleGenerationPending": false,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
"starred": false,
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ Notes:
|
|||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
|
||||||
|
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
|
||||||
- While running, `GET /v1/active-runs` includes the `chatId`.
|
- While running, `GET /v1/active-runs` includes the `chatId`.
|
||||||
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
|
||||||
|
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
|
||||||
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
|
||||||
|
|
||||||
## Attach Endpoint
|
## Attach Endpoint
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||||
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
<false/>
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct SybilApp: App
|
|||||||
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup(id: SybilCommands.workspaceWindowID) {
|
||||||
SplitView()
|
SplitView()
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
@@ -24,9 +24,18 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.start()
|
||||||
|
#endif
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
SybilMacQuickQuestionController.shared.stop()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
configurationForConnecting connectingSceneSession: UISceneSession,
|
configurationForConnecting connectingSceneSession: UISceneSession,
|
||||||
@@ -56,11 +65,22 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
willConnectTo session: UISceneSession,
|
willConnectTo session: UISceneSession,
|
||||||
options connectionOptions: UIScene.ConnectionOptions
|
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 {
|
if let shortcutItem = connectionOptions.shortcutItem {
|
||||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
func sceneDidDisconnect(_ scene: UIScene) {
|
||||||
|
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func windowScene(
|
func windowScene(
|
||||||
_ windowScene: UIWindowScene,
|
_ windowScene: UIWindowScene,
|
||||||
performActionFor shortcutItem: UIApplicationShortcutItem,
|
performActionFor shortcutItem: UIApplicationShortcutItem,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- package: Sybil
|
- package: Sybil
|
||||||
product: Sybil
|
product: Sybil
|
||||||
|
- target: SybilMacQuickQuestion
|
||||||
|
destinationFilters: [macCatalyst]
|
||||||
|
embed: true
|
||||||
|
link: false
|
||||||
|
codeSign: true
|
||||||
|
copy:
|
||||||
|
destination: plugins
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
|
||||||
@@ -29,6 +36,7 @@ targets:
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
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
|
||||||
@@ -78,6 +78,7 @@ public struct SplitView: View {
|
|||||||
.font(.sybil(.body))
|
.font(.sybil(.body))
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
|
||||||
SybilQuickQuestionView(
|
SybilQuickQuestionView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -85,7 +86,11 @@ public struct SplitView: View {
|
|||||||
)
|
)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.task {
|
.task {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
await viewModel.bootstrap()
|
await viewModel.bootstrap()
|
||||||
presentPendingQuickQuestionIfPossible()
|
presentPendingQuickQuestionIfPossible()
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,9 @@ public struct SplitView: View {
|
|||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
viewModel.markAppInactiveForNetwork()
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
#endif
|
||||||
viewModel.markAppActiveForNetwork()
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
@@ -151,8 +159,13 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasPendingQuickQuestionPresentation = false
|
hasPendingQuickQuestionPresentation = false
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||||
|
SybilMacQuickQuestionController.shared.show()
|
||||||
|
#else
|
||||||
quickQuestionFocusRequest += 1
|
quickQuestionFocusRequest += 1
|
||||||
isQuickQuestionPresented = true
|
isQuickQuestionPresented = true
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleQuickQuestionDismissed() {
|
private func handleQuickQuestionDismissed() {
|
||||||
@@ -161,23 +174,57 @@ public struct SplitView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct SybilCommands: Commands {
|
public struct SybilCommands: Commands {
|
||||||
|
public static let workspaceWindowID = "sybil-workspace"
|
||||||
|
|
||||||
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var body: some Commands {
|
public var body: some Commands {
|
||||||
CommandGroup(replacing: .newItem) {
|
CommandGroup(replacing: .newItem) {
|
||||||
Button("New Chat") {
|
Button("New Chat") {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
windowCommands.newChatOrWindow(
|
||||||
|
newChat: keyboardActions?.newChat,
|
||||||
|
openWindow: openNewWindow
|
||||||
|
)
|
||||||
|
#else
|
||||||
keyboardActions?.newChat()
|
keyboardActions?.newChat()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
|
||||||
|
#else
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Button("New Window", action: openNewWindow)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
|
|
||||||
Button("New Search") {
|
Button("New Search") {
|
||||||
keyboardActions?.newSearch()
|
keyboardActions?.newSearch()
|
||||||
}
|
}
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
.keyboardShortcut("n", modifiers: [.command, .option])
|
||||||
|
#else
|
||||||
.keyboardShortcut("n", modifiers: [.command, .shift])
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
||||||
|
#endif
|
||||||
.disabled(keyboardActions == nil)
|
.disabled(keyboardActions == nil)
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
Divider()
|
||||||
|
Button("Quick Question") {
|
||||||
|
SybilMacQuickQuestionController.shared.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.space, modifiers: .option)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandMenu("Conversation") {
|
CommandMenu("Conversation") {
|
||||||
@@ -194,6 +241,13 @@ public struct SybilCommands: Commands {
|
|||||||
.disabled(keyboardActions == nil)
|
.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 {
|
private struct SybilKeyboardActions {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -154,6 +154,8 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
|
|||||||
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
public var parentChatId: String? = nil
|
||||||
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -164,6 +166,39 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var lastUsedModel: String?
|
public var lastUsedModel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ChatSummary {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
case parentChatId
|
||||||
|
case titleGenerationPending
|
||||||
|
case createdAt
|
||||||
|
case updatedAt
|
||||||
|
case starred
|
||||||
|
case starredAt
|
||||||
|
case initiatedProvider
|
||||||
|
case initiatedModel
|
||||||
|
case lastUsedProvider
|
||||||
|
case lastUsedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||||
|
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)
|
||||||
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||||
|
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||||
|
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||||
|
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||||
|
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||||
|
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||||
|
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
@@ -184,6 +219,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
public var query: String?
|
public var query: String?
|
||||||
|
public var parentChatId: String? = nil
|
||||||
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -198,6 +235,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = chat.id
|
self.id = chat.id
|
||||||
self.title = chat.title
|
self.title = chat.title
|
||||||
self.query = nil
|
self.query = nil
|
||||||
|
self.parentChatId = chat.parentChatId
|
||||||
|
self.titleGenerationPending = chat.titleGenerationPending
|
||||||
self.createdAt = chat.createdAt
|
self.createdAt = chat.createdAt
|
||||||
self.updatedAt = chat.updatedAt
|
self.updatedAt = chat.updatedAt
|
||||||
self.starred = chat.starred
|
self.starred = chat.starred
|
||||||
@@ -213,6 +252,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.id = search.id
|
self.id = search.id
|
||||||
self.title = search.title
|
self.title = search.title
|
||||||
self.query = search.query
|
self.query = search.query
|
||||||
|
self.parentChatId = nil
|
||||||
|
self.titleGenerationPending = false
|
||||||
self.createdAt = search.createdAt
|
self.createdAt = search.createdAt
|
||||||
self.updatedAt = search.updatedAt
|
self.updatedAt = search.updatedAt
|
||||||
self.starred = search.starred
|
self.starred = search.starred
|
||||||
@@ -228,6 +269,8 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
return ChatSummary(
|
return ChatSummary(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
|
parentChatId: parentChatId,
|
||||||
|
titleGenerationPending: titleGenerationPending,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
starred: starred,
|
starred: starred,
|
||||||
@@ -253,6 +296,43 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension WorkspaceItem {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
case query
|
||||||
|
case parentChatId
|
||||||
|
case titleGenerationPending
|
||||||
|
case createdAt
|
||||||
|
case updatedAt
|
||||||
|
case starred
|
||||||
|
case starredAt
|
||||||
|
case initiatedProvider
|
||||||
|
case initiatedModel
|
||||||
|
case lastUsedProvider
|
||||||
|
case lastUsedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
type = try container.decode(WorkspaceItemType.self, forKey: .type)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||||
|
query = try container.decodeIfPresent(String.self, forKey: .query)
|
||||||
|
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)
|
||||||
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||||
|
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||||
|
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||||
|
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||||
|
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||||
|
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||||
|
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
public struct Message: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
@@ -391,6 +471,8 @@ public enum JSONValue: Codable, Hashable, Sendable {
|
|||||||
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var title: String?
|
public var title: String?
|
||||||
|
public var parentChatId: String? = nil
|
||||||
|
public var titleGenerationPending = false
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
public var starred = false
|
||||||
@@ -402,6 +484,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var messages: [Message]
|
public var messages: [Message]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ChatDetail {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case title
|
||||||
|
case parentChatId
|
||||||
|
case titleGenerationPending
|
||||||
|
case createdAt
|
||||||
|
case updatedAt
|
||||||
|
case starred
|
||||||
|
case starredAt
|
||||||
|
case initiatedProvider
|
||||||
|
case initiatedModel
|
||||||
|
case lastUsedProvider
|
||||||
|
case lastUsedModel
|
||||||
|
case messages
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||||
|
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)
|
||||||
|
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||||
|
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
|
||||||
|
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
|
||||||
|
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
|
||||||
|
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
|
||||||
|
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
|
||||||
|
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
|
||||||
|
messages = try container.decode([Message].self, forKey: .messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
|
||||||
public var id: String
|
public var id: String
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@FocusState private var promptFocused: Bool
|
@FocusState private var promptFocused: Bool
|
||||||
|
@State private var promptSelection: TextSelection?
|
||||||
|
|
||||||
private var hasAnswerContent: Bool {
|
private var hasAnswerContent: Bool {
|
||||||
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
|
||||||
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
promptFocused = true
|
promptFocused = true
|
||||||
|
let prompt = viewModel.quickQuestionPrompt
|
||||||
|
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +97,7 @@ struct SybilQuickQuestionView: View {
|
|||||||
get: { viewModel.quickQuestionPrompt },
|
get: { viewModel.quickQuestionPrompt },
|
||||||
set: { viewModel.updateQuickQuestionPrompt($0) }
|
set: { viewModel.updateQuickQuestionPrompt($0) }
|
||||||
),
|
),
|
||||||
|
selection: $promptSelection,
|
||||||
axis: .vertical
|
axis: .vertical
|
||||||
)
|
)
|
||||||
.focused($promptFocused)
|
.focused($promptFocused)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -406,14 +406,17 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
initiatedLabel = nil
|
initiatedLabel = nil
|
||||||
}
|
}
|
||||||
|
let starOwner = item.parentChatId.flatMap { parentChatID in
|
||||||
|
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
|
||||||
|
} ?? item
|
||||||
|
|
||||||
return SidebarItem(
|
return SidebarItem(
|
||||||
selection: .chat(item.id),
|
selection: .chat(item.id),
|
||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: item.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
starred: item.starred,
|
starred: starOwner.starred,
|
||||||
starredAt: item.starredAt,
|
starredAt: starOwner.starredAt,
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -633,9 +636,12 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelQuickQuestion()
|
cancelQuickQuestion()
|
||||||
|
// Lock submission before the task starts, including repeated Return
|
||||||
|
// events delivered in the same main-actor turn.
|
||||||
|
isQuickQuestionSending = true
|
||||||
let selectedProvider = quickQuestionProvider
|
let selectedProvider = quickQuestionProvider
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
guard let self else {
|
guard let self, !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
await self.runQuickQuestion(prompt: content, provider: selectedProvider, model: selectedModel)
|
||||||
@@ -687,6 +693,8 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
|
parentChatId: chat.parentChatId,
|
||||||
|
titleGenerationPending: chat.titleGenerationPending,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -896,7 +904,8 @@ final class SybilViewModel {
|
|||||||
let client = try client()
|
let client = try client()
|
||||||
switch selection {
|
switch selection {
|
||||||
case let .chat(chatID):
|
case let .chat(chatID):
|
||||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
let rootChatID = chatFamilyRootID(for: chatID)
|
||||||
|
let updated = try await client.updateChatStar(chatID: rootChatID, starred: starred)
|
||||||
applyChatSummary(updated, moveToFront: false)
|
applyChatSummary(updated, moveToFront: false)
|
||||||
case let .search(searchID):
|
case let .search(searchID):
|
||||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
||||||
@@ -1454,6 +1463,8 @@ final class SybilViewModel {
|
|||||||
|
|
||||||
if selectedChat?.id == chat.id {
|
if selectedChat?.id == chat.id {
|
||||||
selectedChat?.title = chat.title
|
selectedChat?.title = chat.title
|
||||||
|
selectedChat?.parentChatId = chat.parentChatId
|
||||||
|
selectedChat?.titleGenerationPending = chat.titleGenerationPending
|
||||||
selectedChat?.updatedAt = chat.updatedAt
|
selectedChat?.updatedAt = chat.updatedAt
|
||||||
selectedChat?.starred = chat.starred
|
selectedChat?.starred = chat.starred
|
||||||
selectedChat?.starredAt = chat.starredAt
|
selectedChat?.starredAt = chat.starredAt
|
||||||
@@ -1505,6 +1516,19 @@ final class SybilViewModel {
|
|||||||
workspaceItems.insert(item, at: 0)
|
workspaceItems.insert(item, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func chatFamilyRootID(for chatID: String) -> String {
|
||||||
|
if let selectedChat, selectedChat.id == chatID, let parentChatID = selectedChat.parentChatId {
|
||||||
|
return parentChatID
|
||||||
|
}
|
||||||
|
if let parentChatID = chats.first(where: { $0.id == chatID })?.parentChatId {
|
||||||
|
return parentChatID
|
||||||
|
}
|
||||||
|
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
|
||||||
|
return parentChatID
|
||||||
|
}
|
||||||
|
return chatID
|
||||||
|
}
|
||||||
|
|
||||||
private func attachToVisibleActiveRunIfNeeded() {
|
private func attachToVisibleActiveRunIfNeeded() {
|
||||||
guard draftKind == nil else {
|
guard draftKind == nil else {
|
||||||
return
|
return
|
||||||
@@ -1854,6 +1878,8 @@ final class SybilViewModel {
|
|||||||
selectedChat = ChatDetail(
|
selectedChat = ChatDetail(
|
||||||
id: created.id,
|
id: created.id,
|
||||||
title: created.title,
|
title: created.title,
|
||||||
|
parentChatId: created.parentChatId,
|
||||||
|
titleGenerationPending: created.titleGenerationPending,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
starred: created.starred,
|
starred: created.starred,
|
||||||
@@ -1912,7 +1938,7 @@ final class SybilViewModel {
|
|||||||
let streamLifecycleGeneration = appLifecycleGeneration
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
let streamStartedWhileInactive = !isAppActive
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
if isUntitledChat(chatID: chatID, detail: currentSelectedChat) {
|
if shouldRequestChatTitle(baseChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
do {
|
do {
|
||||||
@@ -2623,20 +2649,10 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isUntitledChat(chatID: String, detail: ChatDetail?) -> Bool {
|
private func shouldRequestChatTitle(_ chat: ChatDetail) -> Bool {
|
||||||
if let detail, detail.id == chatID {
|
if chat.titleGenerationPending {
|
||||||
if let title = detail.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
return chat.title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true
|
||||||
if let summary = chats.first(where: { $0.id == chatID }) {
|
|
||||||
if let title = summary.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,6 +601,7 @@ struct SybilWorkspaceView: View {
|
|||||||
.onSubmit {
|
.onSubmit {
|
||||||
submitComposer()
|
submitComposer()
|
||||||
}
|
}
|
||||||
|
.submitOnMacReturn(submitComposer)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
import Testing
|
import Testing
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
@testable import Sybil
|
@testable import Sybil
|
||||||
|
|
||||||
private struct MockClientCallSnapshot: Sendable {
|
private struct MockClientCallSnapshot: Sendable {
|
||||||
@@ -10,7 +14,10 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var updateChatTitle = 0
|
var updateChatTitle = 0
|
||||||
|
var suggestChatTitle = 0
|
||||||
var updateChatStar = 0
|
var updateChatStar = 0
|
||||||
|
var lastUpdateChatStarID: String?
|
||||||
|
var lastUpdateChatStarred: Bool?
|
||||||
var updateSearchStar = 0
|
var updateSearchStar = 0
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
@@ -36,6 +43,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
private let updateChatTitleResponses: [String: ChatSummary]
|
private let updateChatTitleResponses: [String: ChatSummary]
|
||||||
|
private let suggestChatTitleResponses: [String: ChatSummary]
|
||||||
private let updateChatStarResponses: [String: ChatSummary]
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
private let updateSearchStarResponses: [String: SearchSummary]
|
private let updateSearchStarResponses: [String: SearchSummary]
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
@@ -64,6 +72,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
updateChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
|
suggestChatTitleResponses: [String: ChatSummary] = [:],
|
||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
@@ -76,6 +85,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
self.updateChatTitleResponses = updateChatTitleResponses
|
self.updateChatTitleResponses = updateChatTitleResponses
|
||||||
|
self.suggestChatTitleResponses = suggestChatTitleResponses
|
||||||
self.updateChatStarResponses = updateChatStarResponses
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
self.updateSearchStarResponses = updateSearchStarResponses
|
self.updateSearchStarResponses = updateSearchStarResponses
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
@@ -204,6 +214,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
|
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
||||||
snapshot.updateChatStar += 1
|
snapshot.updateChatStar += 1
|
||||||
|
snapshot.lastUpdateChatStarID = chatID
|
||||||
|
snapshot.lastUpdateChatStarred = starred
|
||||||
guard let summary = updateChatStarResponses[chatID] else {
|
guard let summary = updateChatStarResponses[chatID] else {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -215,7 +227,11 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
|
||||||
throw UnexpectedClientCall()
|
snapshot.suggestChatTitle += 1
|
||||||
|
guard let summary = suggestChatTitleResponses[chatID] else {
|
||||||
|
throw UnexpectedClientCall()
|
||||||
|
}
|
||||||
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
func listSearches() async throws -> [SearchSummary] {
|
func listSearches() async throws -> [SearchSummary] {
|
||||||
@@ -420,6 +436,46 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func chatForkMetadataDecodesBackwardCompatiblyAndSurvivesWorkspaceConversions() throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let legacySummary = try decoder.decode(
|
||||||
|
ChatSummary.self,
|
||||||
|
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
||||||
|
)
|
||||||
|
let legacyWorkspaceItem = try decoder.decode(
|
||||||
|
WorkspaceItem.self,
|
||||||
|
from: Data(#"{"type":"chat","id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1}"#.utf8)
|
||||||
|
)
|
||||||
|
let legacyDetail = try decoder.decode(
|
||||||
|
ChatDetail.self,
|
||||||
|
from: Data(#"{"id":"legacy-chat","title":"Legacy","createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
||||||
|
)
|
||||||
|
let forkDetail = try decoder.decode(
|
||||||
|
ChatDetail.self,
|
||||||
|
from: Data(#"{"id":"fork-chat","title":"Fork of Legacy","parentChatId":"root-chat","titleGenerationPending":true,"createdAt":0,"updatedAt":1,"messages":[]}"#.utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(legacySummary.parentChatId == nil)
|
||||||
|
#expect(!legacySummary.titleGenerationPending)
|
||||||
|
#expect(legacyWorkspaceItem.parentChatId == nil)
|
||||||
|
#expect(!legacyWorkspaceItem.titleGenerationPending)
|
||||||
|
#expect(legacyDetail.parentChatId == nil)
|
||||||
|
#expect(!legacyDetail.titleGenerationPending)
|
||||||
|
#expect(forkDetail.parentChatId == "root-chat")
|
||||||
|
#expect(forkDetail.titleGenerationPending)
|
||||||
|
|
||||||
|
var fork = legacySummary
|
||||||
|
fork.parentChatId = "root-chat"
|
||||||
|
fork.titleGenerationPending = true
|
||||||
|
|
||||||
|
let workspaceItem = WorkspaceItem(chat: fork)
|
||||||
|
let restoredSummary = try #require(workspaceItem.chatSummary)
|
||||||
|
#expect(workspaceItem.parentChatId == "root-chat")
|
||||||
|
#expect(workspaceItem.titleGenerationPending)
|
||||||
|
#expect(restoredSummary.parentChatId == "root-chat")
|
||||||
|
#expect(restoredSummary.titleGenerationPending)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
@Test func transcriptRenderItemsGroupAdjacentToolCalls() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
let user = Message(id: "user-1", createdAt: date, role: .user, content: "Search this", name: nil)
|
||||||
@@ -567,18 +623,16 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
||||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
var original = makeChatSummary(id: "chat-rename", date: date)
|
||||||
let renamed = ChatSummary(
|
original.parentChatId = "root-chat"
|
||||||
id: "chat-rename",
|
original.titleGenerationPending = true
|
||||||
title: "Renamed chat",
|
var renamed = original
|
||||||
createdAt: date,
|
renamed.title = "Renamed chat"
|
||||||
updatedAt: date.addingTimeInterval(60),
|
renamed.titleGenerationPending = false
|
||||||
initiatedProvider: .openai,
|
renamed.updatedAt = date.addingTimeInterval(60)
|
||||||
initiatedModel: "gpt-4.1-mini",
|
var detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
||||||
lastUsedProvider: .openai,
|
detail.parentChatId = original.parentChatId
|
||||||
lastUsedModel: "gpt-4.1-mini"
|
detail.titleGenerationPending = original.titleGenerationPending
|
||||||
)
|
|
||||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
|
||||||
let client = MockSybilClient(
|
let client = MockSybilClient(
|
||||||
chatsResponse: [original],
|
chatsResponse: [original],
|
||||||
updateChatTitleResponses: ["chat-rename": renamed]
|
updateChatTitleResponses: ["chat-rename": renamed]
|
||||||
@@ -596,7 +650,13 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
let snapshot = await client.currentSnapshot()
|
let snapshot = await client.currentSnapshot()
|
||||||
#expect(snapshot.updateChatTitle == 1)
|
#expect(snapshot.updateChatTitle == 1)
|
||||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
||||||
|
#expect(viewModel.chats.first?.parentChatId == "root-chat")
|
||||||
|
#expect(viewModel.chats.first?.titleGenerationPending == false)
|
||||||
|
#expect(viewModel.workspaceItems.first?.parentChatId == "root-chat")
|
||||||
|
#expect(viewModel.workspaceItems.first?.titleGenerationPending == false)
|
||||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
||||||
|
#expect(viewModel.selectedChat?.parentChatId == "root-chat")
|
||||||
|
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
||||||
#expect(viewModel.errorMessage == nil)
|
#expect(viewModel.errorMessage == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +695,54 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func unstarringForkTargetsRootWithoutReplacingSelectedChild() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_180)
|
||||||
|
var root = makeChatSummary(id: "chat-root", date: date)
|
||||||
|
root.starred = true
|
||||||
|
root.starredAt = date.addingTimeInterval(5)
|
||||||
|
|
||||||
|
var child = makeChatSummary(id: "chat-child", date: date.addingTimeInterval(1))
|
||||||
|
child.parentChatId = root.id
|
||||||
|
child.titleGenerationPending = true
|
||||||
|
|
||||||
|
var childDetail = makeChatDetail(id: child.id, date: date, body: "forked transcript")
|
||||||
|
childDetail.title = child.title
|
||||||
|
childDetail.parentChatId = root.id
|
||||||
|
childDetail.titleGenerationPending = true
|
||||||
|
|
||||||
|
var unstarredRoot = root
|
||||||
|
unstarredRoot.starred = false
|
||||||
|
unstarredRoot.starredAt = nil
|
||||||
|
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [root, child],
|
||||||
|
updateChatStarResponses: [root.id: unstarredRoot]
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [root, child]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: root), WorkspaceItem(chat: child)]
|
||||||
|
viewModel.selectedItem = .chat(child.id)
|
||||||
|
viewModel.selectedChat = childDetail
|
||||||
|
|
||||||
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == true)
|
||||||
|
|
||||||
|
await viewModel.setItemStarred(.chat(child.id), starred: false)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.updateChatStar == 1)
|
||||||
|
#expect(snapshot.lastUpdateChatStarID == root.id)
|
||||||
|
#expect(snapshot.lastUpdateChatStarred == false)
|
||||||
|
#expect(viewModel.chats.first(where: { $0.id == root.id })?.starred == false)
|
||||||
|
#expect(viewModel.chats.first(where: { $0.id == child.id }) == child)
|
||||||
|
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat(child.id) })?.starred == false)
|
||||||
|
#expect(viewModel.selectedItem == .chat(child.id))
|
||||||
|
#expect(viewModel.selectedChat == childDetail)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
@@ -781,6 +889,142 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func firstPromptInPendingForkRequestsAndAppliesGeneratedTitle() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_247)
|
||||||
|
var fork = makeChatSummary(id: "chat-fork", date: date)
|
||||||
|
fork.title = "Fork of Original chat"
|
||||||
|
fork.parentChatId = "root-chat"
|
||||||
|
fork.titleGenerationPending = true
|
||||||
|
|
||||||
|
var forkDetail = makeChatDetail(id: fork.id, date: date, body: "forked transcript")
|
||||||
|
forkDetail.title = fork.title
|
||||||
|
forkDetail.parentChatId = fork.parentChatId
|
||||||
|
forkDetail.titleGenerationPending = true
|
||||||
|
|
||||||
|
var titledFork = fork
|
||||||
|
titledFork.title = "Investigating the follow-up"
|
||||||
|
titledFork.titleGenerationPending = false
|
||||||
|
titledFork.updatedAt = date.addingTimeInterval(1)
|
||||||
|
|
||||||
|
var titledForkDetail = forkDetail
|
||||||
|
titledForkDetail.title = titledFork.title
|
||||||
|
titledForkDetail.titleGenerationPending = false
|
||||||
|
titledForkDetail.updatedAt = titledFork.updatedAt
|
||||||
|
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [titledFork],
|
||||||
|
chatDetails: [fork.id: titledForkDetail],
|
||||||
|
suggestChatTitleResponses: [fork.id: titledFork]
|
||||||
|
)
|
||||||
|
await client.setCompletionStreamEvents(
|
||||||
|
[.done(CompletionStreamDone(text: "Follow-up answer"))],
|
||||||
|
delayNanoseconds: 100_000_000
|
||||||
|
)
|
||||||
|
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [fork]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: fork)]
|
||||||
|
viewModel.selectedItem = .chat(fork.id)
|
||||||
|
viewModel.selectedChat = forkDetail
|
||||||
|
viewModel.composer = "Investigate this follow-up"
|
||||||
|
|
||||||
|
let sendTask = Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..<20 {
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
if snapshot.suggestChatTitle == 1,
|
||||||
|
viewModel.selectedChat?.title == titledFork.title,
|
||||||
|
viewModel.selectedChat?.titleGenerationPending == false {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 5_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleSnapshot = await client.currentSnapshot()
|
||||||
|
#expect(titleSnapshot.suggestChatTitle == 1)
|
||||||
|
#expect(viewModel.chats.first?.title == titledFork.title)
|
||||||
|
#expect(viewModel.workspaceItems.first?.title == titledFork.title)
|
||||||
|
#expect(viewModel.selectedChat?.title == titledFork.title)
|
||||||
|
#expect(viewModel.selectedChat?.titleGenerationPending == false)
|
||||||
|
|
||||||
|
await sendTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func composerReturnSubmitsOnceAndConsumesRepeats() {
|
||||||
|
var submissions = 0
|
||||||
|
let initial = SybilReturnKeySubmission.handle(phase: .down, modifiers: []) { submissions += 1 }
|
||||||
|
let repeated = SybilReturnKeySubmission.handle(phase: .repeat, modifiers: []) { submissions += 1 }
|
||||||
|
#expect(initial == .handled)
|
||||||
|
#expect(repeated == .handled)
|
||||||
|
#expect(submissions == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func composerModifiedReturnPreservesNativeEditing() {
|
||||||
|
var submissions = 0
|
||||||
|
for modifiers: EventModifiers in [.shift, .command, .control, .option, [.shift, .command]] {
|
||||||
|
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: modifiers) { submissions += 1 }
|
||||||
|
#expect(result == .ignored)
|
||||||
|
}
|
||||||
|
#expect(submissions == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func composerReturnStillSubmitsWithCapsLock() {
|
||||||
|
var submissions = 0
|
||||||
|
let result = SybilReturnKeySubmission.handle(phase: .down, modifiers: .capsLock) { submissions += 1 }
|
||||||
|
#expect(result == .handled)
|
||||||
|
#expect(submissions == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func quickQuestionHotKeyTogglesOncePerPress() {
|
||||||
|
var keyState = SybilQuickQuestionHotKeyState()
|
||||||
|
let keyDownEvents = [true, true, true, false, true, false, false, true]
|
||||||
|
let toggles = keyDownEvents.map { keyState.shouldToggle(isKeyDown: $0) }
|
||||||
|
#expect(toggles == [true, false, false, false, true, false, false, true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionRejectsDuplicateSubmissionBeforeTaskStarts() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
await client.setCompletionStreamEvents([.done(CompletionStreamDone(text: "One answer."))])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.quickQuestionPrompt = "One question"
|
||||||
|
|
||||||
|
let first = viewModel.sendQuickQuestion()
|
||||||
|
let duplicate = viewModel.sendQuickQuestion()
|
||||||
|
#expect(first != nil)
|
||||||
|
#expect(duplicate == nil)
|
||||||
|
#expect(viewModel.isQuickQuestionSending)
|
||||||
|
await first?.value
|
||||||
|
|
||||||
|
let calls = await client.currentSnapshot()
|
||||||
|
#expect(calls.runCompletionStream == 1)
|
||||||
|
#expect(viewModel.quickQuestionAnswerText == "One answer.")
|
||||||
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionCancelledBeforeTaskStartsDoesNotSend() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.quickQuestionPrompt = "Do not send this"
|
||||||
|
|
||||||
|
let task = viewModel.sendQuickQuestion()
|
||||||
|
viewModel.cancelQuickQuestion()
|
||||||
|
await task?.value
|
||||||
|
|
||||||
|
let calls = await client.currentSnapshot()
|
||||||
|
#expect(calls.runCompletionStream == 0)
|
||||||
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
@@ -1050,3 +1294,365 @@ private func makeToolCallMessage(id: String, date: Date, summary: String = "Ran
|
|||||||
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
#expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
|
||||||
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
#expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
@MainActor
|
||||||
|
@Test func macNewChatCommandOpensWindowWhenNoWorkspacesRemain() {
|
||||||
|
let commands = SybilMacWindowCommands()
|
||||||
|
var chats = 0
|
||||||
|
var windows = 0
|
||||||
|
|
||||||
|
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||||
|
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||||
|
// A focused value can briefly outlive a closing scene. It must not route
|
||||||
|
// Command+N back into a closed workspace.
|
||||||
|
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||||
|
#expect(windows == 2)
|
||||||
|
#expect(chats == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macNewChatCommandUsesExistingWorkspace() {
|
||||||
|
let commands = SybilMacWindowCommands()
|
||||||
|
commands.windowConnected(sessionID: "first")
|
||||||
|
var chats = 0
|
||||||
|
var windows = 0
|
||||||
|
|
||||||
|
#expect(commands.canStartNewChat(hasFocusedActions: true))
|
||||||
|
commands.newChatOrWindow(newChat: { chats += 1 }, openWindow: { windows += 1 })
|
||||||
|
#expect(chats == 1)
|
||||||
|
#expect(windows == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macNewChatCommandDoesNotMistakeUnavailableActionsForNoWindows() {
|
||||||
|
let commands = SybilMacWindowCommands()
|
||||||
|
commands.windowConnected(sessionID: "authenticating")
|
||||||
|
var windows = 0
|
||||||
|
|
||||||
|
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||||
|
commands.newChatOrWindow(newChat: nil, openWindow: { windows += 1 })
|
||||||
|
#expect(windows == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macWindowCommandsTrackLastWindowClosureAndReconnection() {
|
||||||
|
let commands = SybilMacWindowCommands()
|
||||||
|
commands.windowConnected(sessionID: "first")
|
||||||
|
commands.windowConnected(sessionID: "first")
|
||||||
|
commands.windowConnected(sessionID: "second")
|
||||||
|
commands.windowDisconnected(sessionID: "first")
|
||||||
|
#expect(commands.hasOpenWindows)
|
||||||
|
commands.windowDisconnected(sessionID: "first")
|
||||||
|
#expect(commands.hasOpenWindows)
|
||||||
|
commands.windowDisconnected(sessionID: "second")
|
||||||
|
#expect(!commands.hasOpenWindows)
|
||||||
|
#expect(commands.canStartNewChat(hasFocusedActions: false))
|
||||||
|
commands.windowConnected(sessionID: "third")
|
||||||
|
#expect(commands.hasOpenWindows)
|
||||||
|
#expect(!commands.canStartNewChat(hasFocusedActions: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockQuickQuestionPanel: NSObject, SybilQuickQuestionPanelBridging {
|
||||||
|
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
|
||||||
|
var isVisible = false
|
||||||
|
var state = SybilQuickQuestionPanelState()
|
||||||
|
var presentationCount = 0
|
||||||
|
var activationCount = 0
|
||||||
|
var stateUpdateCount = 0
|
||||||
|
var events: [String] = []
|
||||||
|
var onShow: (() -> Void)?
|
||||||
|
|
||||||
|
required override init() { super.init() }
|
||||||
|
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
|
||||||
|
self.delegate = delegate
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
func updateState(_ data: Data) {
|
||||||
|
state = try! JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data)
|
||||||
|
stateUpdateCount += 1
|
||||||
|
events.append("update")
|
||||||
|
}
|
||||||
|
func show() {
|
||||||
|
onShow?()
|
||||||
|
isVisible = true
|
||||||
|
presentationCount += 1
|
||||||
|
events.append("show")
|
||||||
|
}
|
||||||
|
func hide() {
|
||||||
|
isVisible = false
|
||||||
|
delegate?.quickQuestionPanelDismissed()
|
||||||
|
}
|
||||||
|
func activateForQuickQuestion() {
|
||||||
|
activationCount += 1
|
||||||
|
events.append("activate")
|
||||||
|
}
|
||||||
|
func activateMainWindow() {}
|
||||||
|
func stop() { hide() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func waitForQuickQuestion(_ condition: @MainActor () -> Bool) async throws {
|
||||||
|
for _ in 0..<100 {
|
||||||
|
if condition() { return }
|
||||||
|
try await Task.sleep(for: .milliseconds(2))
|
||||||
|
}
|
||||||
|
try #require(condition())
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockQuickQuestionLifecycle {
|
||||||
|
var isForeground = false
|
||||||
|
let notifications = NotificationCenter()
|
||||||
|
|
||||||
|
func notify(_ name: Notification.Name) {
|
||||||
|
notifications.post(name: name, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionWaitsForUIKitForegroundBeforeOpeningPanel() async throws {
|
||||||
|
let lifecycle = MockQuickQuestionLifecycle()
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||||
|
viewModel.quickQuestionPrompt = "Original prompt"
|
||||||
|
let controller = SybilMacQuickQuestionController(
|
||||||
|
bridge: panel,
|
||||||
|
isApplicationInForeground: { lifecycle.isForeground },
|
||||||
|
notificationCenter: lifecycle.notifications
|
||||||
|
)
|
||||||
|
defer { controller.stop() }
|
||||||
|
controller.attach(viewModel)
|
||||||
|
panel.onShow = { #expect(lifecycle.isForeground) }
|
||||||
|
|
||||||
|
controller.toggle()
|
||||||
|
// Never create/order a window inline in the hotkey's event callback.
|
||||||
|
#expect(panel.presentationCount == 0)
|
||||||
|
#expect(panel.activationCount == 0)
|
||||||
|
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||||
|
#expect(panel.stateUpdateCount == 0)
|
||||||
|
#expect(!panel.isVisible)
|
||||||
|
|
||||||
|
// "Will enter" is not proof of readiness: UIKit can still be backgrounded.
|
||||||
|
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
#expect(panel.activationCount == 1)
|
||||||
|
#expect(panel.presentationCount == 0)
|
||||||
|
#expect(panel.stateUpdateCount == 0)
|
||||||
|
|
||||||
|
viewModel.quickQuestionPrompt = "The latest prompt while waiting"
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.events == ["activate", "update", "show"])
|
||||||
|
#expect(panel.state.prompt == "The latest prompt while waiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionSecondToggleCancelsPendingActivation() async throws {
|
||||||
|
let lifecycle = MockQuickQuestionLifecycle()
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(
|
||||||
|
bridge: panel,
|
||||||
|
isApplicationInForeground: { lifecycle.isForeground },
|
||||||
|
notificationCenter: lifecycle.notifications
|
||||||
|
)
|
||||||
|
defer { controller.stop() }
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||||
|
controller.toggle()
|
||||||
|
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
#expect(!panel.isVisible)
|
||||||
|
#expect(panel.presentationCount == 0)
|
||||||
|
#expect(panel.stateUpdateCount == 0)
|
||||||
|
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.presentationCount == 1)
|
||||||
|
#expect(panel.activationCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionRapidTogglesKeepOnlyLatestPresentation() async throws {
|
||||||
|
let lifecycle = MockQuickQuestionLifecycle()
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(
|
||||||
|
bridge: panel,
|
||||||
|
isApplicationInForeground: { lifecycle.isForeground },
|
||||||
|
notificationCenter: lifecycle.notifications
|
||||||
|
)
|
||||||
|
defer { controller.stop() }
|
||||||
|
controller.toggle()
|
||||||
|
controller.toggle()
|
||||||
|
controller.toggle()
|
||||||
|
#expect(panel.presentationCount == 0)
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.presentationCount == 1)
|
||||||
|
#expect(panel.activationCount == 0)
|
||||||
|
|
||||||
|
// Lifecycle notifications must not present/select the prompt a second time.
|
||||||
|
lifecycle.notify(UIApplication.willEnterForegroundNotification)
|
||||||
|
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
#expect(panel.presentationCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionStopCancelsPendingPresentation() async throws {
|
||||||
|
let lifecycle = MockQuickQuestionLifecycle()
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(
|
||||||
|
bridge: panel,
|
||||||
|
isApplicationInForeground: { lifecycle.isForeground },
|
||||||
|
notificationCenter: lifecycle.notifications
|
||||||
|
)
|
||||||
|
controller.show()
|
||||||
|
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||||
|
controller.stop()
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||||
|
try await Task.sleep(for: .milliseconds(10))
|
||||||
|
#expect(panel.presentationCount == 0)
|
||||||
|
#expect(panel.stateUpdateCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionRechecksForegroundAfterLeavingHotKeyCallback() async throws {
|
||||||
|
let lifecycle = MockQuickQuestionLifecycle()
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(
|
||||||
|
bridge: panel,
|
||||||
|
isApplicationInForeground: { lifecycle.isForeground },
|
||||||
|
notificationCenter: lifecycle.notifications
|
||||||
|
)
|
||||||
|
defer { controller.stop() }
|
||||||
|
controller.show()
|
||||||
|
lifecycle.isForeground = false
|
||||||
|
try await waitForQuickQuestion { panel.activationCount == 1 }
|
||||||
|
#expect(!panel.isVisible)
|
||||||
|
#expect(panel.stateUpdateCount == 0)
|
||||||
|
lifecycle.isForeground = true
|
||||||
|
lifecycle.notify(UIApplication.didBecomeActiveNotification)
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.presentationCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionTogglePreservesDraftAndUsesLatestAnswer() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.quickQuestionPrompt = "Keep this draft"
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||||
|
controller.attach(viewModel)
|
||||||
|
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.isVisible)
|
||||||
|
#expect(panel.state.prompt == "Keep this draft")
|
||||||
|
#expect(panel.state.canSend)
|
||||||
|
controller.toggle()
|
||||||
|
#expect(!panel.isVisible)
|
||||||
|
|
||||||
|
viewModel.quickQuestionMessages = [
|
||||||
|
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "An answer arrived while hidden.", name: nil)
|
||||||
|
]
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.isVisible)
|
||||||
|
#expect(panel.presentationCount == 2)
|
||||||
|
#expect(panel.state.prompt == "Keep this draft")
|
||||||
|
#expect(panel.state.answer == "An answer arrived while hidden.")
|
||||||
|
let calls = await client.currentSnapshot()
|
||||||
|
#expect(calls.runCompletionStream == 0)
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionObservesStreamingAndKeepsItsOwner() async throws {
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in MockSybilClient() }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.quickQuestionPrompt = "Original window"
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||||
|
controller.attach(viewModel)
|
||||||
|
controller.show()
|
||||||
|
|
||||||
|
let otherWindow = SybilViewModel(settings: testSettings(named: #function + "-other")) { _ in MockSybilClient() }
|
||||||
|
otherWindow.quickQuestionPrompt = "Other window"
|
||||||
|
controller.attach(otherWindow)
|
||||||
|
viewModel.quickQuestionMessages = [
|
||||||
|
Message(id: "temp-assistant-quick-test", createdAt: Date(), role: .assistant, content: "Streaming update", name: nil)
|
||||||
|
]
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if panel.state.answer == "Streaming update" { break }
|
||||||
|
try await Task.sleep(for: .milliseconds(5))
|
||||||
|
}
|
||||||
|
#expect(panel.state.answer == "Streaming update")
|
||||||
|
#expect(panel.state.prompt == "Original window")
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionRetainsStateAfterLastWorkspaceCloses() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
var windowViewModel: SybilViewModel? = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
windowViewModel?.isAuthenticated = true
|
||||||
|
windowViewModel?.isCheckingSession = false
|
||||||
|
windowViewModel?.quickQuestionPrompt = "A question from the closed window"
|
||||||
|
windowViewModel?.quickQuestionMessages = [
|
||||||
|
Message(id: "temp-assistant-quick-windowless", createdAt: Date(), role: .assistant, content: "Keep this answer too.", name: nil)
|
||||||
|
]
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||||
|
controller.attach(try #require(windowViewModel))
|
||||||
|
|
||||||
|
// Releasing the workspace must not release the global shortcut's model.
|
||||||
|
windowViewModel = nil
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.isVisible)
|
||||||
|
#expect(panel.state.prompt == "A question from the closed window")
|
||||||
|
#expect(panel.state.answer == "Keep this answer too.")
|
||||||
|
controller.toggle()
|
||||||
|
#expect(!panel.isVisible)
|
||||||
|
controller.toggle()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
#expect(panel.presentationCount == 2)
|
||||||
|
#expect(panel.state.canSend)
|
||||||
|
let calls = await client.currentSnapshot()
|
||||||
|
#expect(calls.runCompletionStream == 0)
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func macQuickQuestionCannotSubmitBeforeAuthentication() async throws {
|
||||||
|
let client = MockSybilClient()
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = false
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
let panel = MockQuickQuestionPanel()
|
||||||
|
let controller = SybilMacQuickQuestionController(bridge: panel)
|
||||||
|
controller.attach(viewModel)
|
||||||
|
controller.show()
|
||||||
|
try await waitForQuickQuestion { panel.isVisible }
|
||||||
|
controller.quickQuestionPromptChanged("A signed-out question")
|
||||||
|
controller.quickQuestionSubmitRequested()
|
||||||
|
|
||||||
|
#expect(!panel.state.canSend)
|
||||||
|
#expect(panel.state.prompt == "A signed-out question")
|
||||||
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
|
let calls = await client.currentSnapshot()
|
||||||
|
#expect(calls.runCompletionStream == 0)
|
||||||
|
controller.stop()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||||
|
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
||||||
|
|
||||||
|
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. 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.
|
||||||
@@ -24,6 +24,12 @@ run: generate
|
|||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
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:
|
beta:
|
||||||
fastlane ios beta
|
fastlane ios beta
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ packages:
|
|||||||
path: Packages/Sybil
|
path: Packages/Sybil
|
||||||
include:
|
include:
|
||||||
- Apps/Sybil/project.yml
|
- Apps/Sybil/project.yml
|
||||||
|
- Apps/SybilMacQuickQuestion/project.yml
|
||||||
|
|||||||
Executable
+90
@@ -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"
|
||||||
Executable
+357
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add durable root grouping and one-time fork title generation state.
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
||||||
@@ -51,7 +51,8 @@ model Chat {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
title String?
|
title String?
|
||||||
|
titleGenerationPending Boolean @default(false)
|
||||||
|
|
||||||
initiatedProvider Provider?
|
initiatedProvider Provider?
|
||||||
initiatedModel String?
|
initiatedModel String?
|
||||||
@@ -64,11 +65,17 @@ model Chat {
|
|||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
|
// Forks always point directly to the single root chat, never to another fork.
|
||||||
|
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
||||||
|
parentChatId String?
|
||||||
|
childChats Chat[] @relation("ChatForks")
|
||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
projectItems ProjectItem[]
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([parentChatId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
|
|||||||
+194
-48
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
@@ -18,6 +19,7 @@ import type { ChatAttachment } from "./llm/types.js";
|
|||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||||
|
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
||||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||||
|
|
||||||
type IncomingChatMessage = {
|
type IncomingChatMessage = {
|
||||||
@@ -94,7 +96,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
|||||||
|
|
||||||
const existing = await prisma.message.findMany({
|
const existing = await prisma.message.findMany({
|
||||||
where: { chatId },
|
where: { chatId },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
select: { role: true, content: true, name: true, metadata: true },
|
select: { role: true, content: true, name: true, metadata: true },
|
||||||
});
|
});
|
||||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||||
@@ -317,6 +319,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
|||||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
|
||||||
|
if (messageContent !== undefined) {
|
||||||
|
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
|
||||||
|
return `Fork of '${snippet}'`;
|
||||||
|
}
|
||||||
|
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyForkMessageMetadata(metadata: unknown) {
|
||||||
|
if (metadata === null || metadata === undefined) return undefined;
|
||||||
|
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
||||||
|
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
|
||||||
|
return Object.keys(copied).length ? copied : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function generateChatTitle(content: string) {
|
async function generateChatTitle(content: string) {
|
||||||
const systemPrompt =
|
const systemPrompt =
|
||||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||||
@@ -415,6 +432,7 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
|||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
const activeChatStreamRequestIds = new Map<string, string>();
|
const activeChatStreamRequestIds = new Map<string, string>();
|
||||||
|
const chatDeletionRoots = new Set<string>();
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
const STARRED_PROJECT_ID = "starred";
|
const STARRED_PROJECT_ID = "starred";
|
||||||
|
|
||||||
@@ -427,6 +445,8 @@ const starredProjectItemsSelect = {
|
|||||||
const chatSummarySelect = {
|
const chatSummarySelect = {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
titleGenerationPending: true,
|
||||||
|
parentChatId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
initiatedProvider: true,
|
initiatedProvider: true,
|
||||||
@@ -507,6 +527,29 @@ async function getSearchSummary(searchId: string) {
|
|||||||
return search ? serializeSearchLike(search) : null;
|
return search ? serializeSearchLike(search) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listRecentChatsWithRoots() {
|
||||||
|
const chats = await prisma.chat.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: chatSummarySelect,
|
||||||
|
});
|
||||||
|
const includedIds = new Set(chats.map((chat) => chat.id));
|
||||||
|
const missingRootIds = [
|
||||||
|
...new Set(
|
||||||
|
chats
|
||||||
|
.map((chat) => chat.parentChatId)
|
||||||
|
.filter((id): id is string => id !== null && !includedIds.has(id))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!missingRootIds.length) return chats;
|
||||||
|
|
||||||
|
const roots = await prisma.chat.findMany({
|
||||||
|
where: { id: { in: missingRootIds } },
|
||||||
|
select: chatSummarySelect,
|
||||||
|
});
|
||||||
|
return [...chats, ...roots].sort(compareUpdatedAtDesc);
|
||||||
|
}
|
||||||
|
|
||||||
async function setChatStarred(chatId: string, starred: boolean) {
|
async function setChatStarred(chatId: string, starred: boolean) {
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||||
if (!exists) return null;
|
if (!exists) return null;
|
||||||
@@ -545,11 +588,7 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
|||||||
|
|
||||||
async function listWorkspaceItems() {
|
async function listWorkspaceItems() {
|
||||||
const [chats, searches] = await Promise.all([
|
const [chats, searches] = await Promise.all([
|
||||||
prisma.chat.findMany({
|
listRecentChatsWithRoots(),
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
}),
|
|
||||||
prisma.search.findMany({
|
prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
@@ -605,7 +644,7 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
|||||||
return { event: ev.type, data: ev };
|
return { event: ev.type, data: ev };
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||||
const stream = new ActiveSseStream();
|
const stream = new ActiveSseStream();
|
||||||
activeChatStreams.set(chatId, stream);
|
activeChatStreams.set(chatId, stream);
|
||||||
if (clientRequestId) {
|
if (clientRequestId) {
|
||||||
@@ -616,7 +655,7 @@ function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
|||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||||
if (activeChatStreams.get(chatId) !== stream) return;
|
if (activeChatStreams.get(chatId) !== stream) return;
|
||||||
activeChatStreams.delete(chatId);
|
activeChatStreams.delete(chatId);
|
||||||
activeChatStreamRequestIds.delete(chatId);
|
activeChatStreamRequestIds.delete(chatId);
|
||||||
@@ -647,12 +686,6 @@ function executeActiveChatStream(chatId: string, body: z.infer<typeof Completion
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
|
||||||
const stream = registerActiveChatStream(chatId, body.clientRequestId);
|
|
||||||
executeActiveChatStream(chatId, body, stream);
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMetadataClientRequestId(metadata: unknown) {
|
function getMetadataClientRequestId(metadata: unknown) {
|
||||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||||
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||||
@@ -888,11 +921,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get("/v1/chats", async (req) => {
|
app.get("/v1/chats", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const chats = await prisma.chat.findMany({
|
const chats = await listRecentChatsWithRoots();
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -951,6 +980,80 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return { chat: serializeChatLike(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/v1/chats/:chatId/fork", async (req) => {
|
||||||
|
requireAdmin(req);
|
||||||
|
const Params = z.object({ chatId: z.string() });
|
||||||
|
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
|
||||||
|
const { chatId } = Params.parse(req.params);
|
||||||
|
const parsed = Body.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const source = await tx.chat.findUnique({
|
||||||
|
where: { id: chatId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
parentChatId: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
userId: true,
|
||||||
|
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!source) return { status: "chat-not-found" as const };
|
||||||
|
|
||||||
|
let messages = source.messages;
|
||||||
|
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||||
|
if (parsed.data.messageId) {
|
||||||
|
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||||
|
if (selectedIndex < 0) return { status: "message-not-found" as const };
|
||||||
|
selectedMessage = messages[selectedIndex];
|
||||||
|
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
|
||||||
|
messages = messages.slice(0, selectedIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forkMessageIdPrefix = `fork-${randomUUID()}`;
|
||||||
|
const chat = await tx.chat.create({
|
||||||
|
data: {
|
||||||
|
title: buildForkTitle(source.title, selectedMessage?.content),
|
||||||
|
titleGenerationPending: true,
|
||||||
|
parentChatId: source.parentChatId ?? source.id,
|
||||||
|
initiatedProvider: source.initiatedProvider,
|
||||||
|
initiatedModel: source.initiatedModel,
|
||||||
|
lastUsedProvider: source.lastUsedProvider,
|
||||||
|
lastUsedModel: source.lastUsedModel,
|
||||||
|
additionalSystemPrompt: source.additionalSystemPrompt,
|
||||||
|
enabledTools: (source.enabledTools ?? undefined) as any,
|
||||||
|
userId: source.userId,
|
||||||
|
messages: messages.length
|
||||||
|
? {
|
||||||
|
create: messages.map((message, index) => ({
|
||||||
|
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
name: message.name,
|
||||||
|
metadata: copyForkMessageMetadata(message.metadata) as any,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: chatSummarySelect,
|
||||||
|
});
|
||||||
|
return { status: "created" as const, chat };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
|
||||||
|
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
|
||||||
|
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
|
||||||
|
return { chat: serializeChatLike(result.chat) };
|
||||||
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Params = z.object({ chatId: z.string() });
|
const Params = z.object({ chatId: z.string() });
|
||||||
@@ -963,7 +1066,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
|
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.title !== undefined) data.title = body.title;
|
if (body.title !== undefined) {
|
||||||
|
data.title = body.title;
|
||||||
|
data.titleGenerationPending = false;
|
||||||
|
}
|
||||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||||
|
|
||||||
@@ -1004,7 +1110,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
select: chatSummarySelect,
|
select: chatSummarySelect,
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
let suggestedRaw = "";
|
let suggestedRaw = "";
|
||||||
@@ -1022,8 +1128,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
await prisma.chat.updateMany({
|
||||||
where: { id: body.chatId, title: existing.title },
|
where: {
|
||||||
data: { title },
|
id: body.chatId,
|
||||||
|
title: existing.title,
|
||||||
|
titleGenerationPending: existing.titleGenerationPending,
|
||||||
|
},
|
||||||
|
data: { title, titleGenerationPending: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = await getChatSummary(body.chatId);
|
const chat = await getChatSummary(body.chatId);
|
||||||
@@ -1039,14 +1149,46 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
req.log.info({ chatId }, "delete chat requested");
|
req.log.info({ chatId }, "delete chat requested");
|
||||||
|
|
||||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
const target = await prisma.chat.findUnique({
|
||||||
if (result.count === 0) {
|
where: { id: chatId },
|
||||||
|
select: { parentChatId: true },
|
||||||
|
});
|
||||||
|
if (!target) {
|
||||||
req.log.warn({ chatId }, "delete chat target not found");
|
req.log.warn({ chatId }, "delete chat target not found");
|
||||||
return app.httpErrors.notFound("chat not found");
|
return app.httpErrors.notFound("chat not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
req.log.info({ chatId }, "chat deleted");
|
const familyRootId = target.parentChatId ?? chatId;
|
||||||
return { deleted: true };
|
if (chatDeletionRoots.has(familyRootId)) {
|
||||||
|
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
chatDeletionRoots.add(familyRootId);
|
||||||
|
try {
|
||||||
|
const familyIds = target.parentChatId
|
||||||
|
? [chatId]
|
||||||
|
: (
|
||||||
|
await prisma.chat.findMany({
|
||||||
|
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((chat) => chat.id);
|
||||||
|
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
||||||
|
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
||||||
|
return app.httpErrors.conflict("chat or fork has an active stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||||
|
if (result.count === 0) {
|
||||||
|
req.log.warn({ chatId }, "delete chat target no longer exists");
|
||||||
|
return app.httpErrors.notFound("chat not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
req.log.info({ chatId }, "chat deleted");
|
||||||
|
return { deleted: true };
|
||||||
|
} finally {
|
||||||
|
chatDeletionRoots.delete(familyRootId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/v1/searches", async (req) => {
|
app.get("/v1/searches", async (req) => {
|
||||||
@@ -1336,7 +1478,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
include: {
|
include: {
|
||||||
messages: { orderBy: { createdAt: "asc" } },
|
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||||
calls: { orderBy: { createdAt: "desc" } },
|
calls: { orderBy: { createdAt: "desc" } },
|
||||||
projectItems: starredProjectItemsSelect,
|
projectItems: starredProjectItemsSelect,
|
||||||
},
|
},
|
||||||
@@ -1430,10 +1572,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||||
const body = withRequestUserLocation(parsed.data, req);
|
const body = withRequestUserLocation(parsed.data, req);
|
||||||
|
|
||||||
// ensure chat exists if provided
|
// Ensure the chat exists and identify its family before reserving a stream.
|
||||||
|
let chatFamilyRootId: string | null = null;
|
||||||
if (body.chatId) {
|
if (body.chatId) {
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
const exists = await prisma.chat.findUnique({
|
||||||
|
where: { id: body.chatId },
|
||||||
|
select: { id: true, parentChatId: true },
|
||||||
|
});
|
||||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||||
|
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.persist !== false && body.chatId) {
|
if (body.persist !== false && body.chatId) {
|
||||||
@@ -1445,32 +1592,31 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
return app.httpErrors.conflict("chat completion already running");
|
return app.httpErrors.conflict("chat completion already running");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.clientRequestId) {
|
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
||||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||||
try {
|
}
|
||||||
|
|
||||||
|
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||||
|
try {
|
||||||
|
if (body.clientRequestId) {
|
||||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||||
if (completedSubmission) {
|
if (completedSubmission) {
|
||||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||||
clearActiveChatStream(body.chatId, reservedStream);
|
clearActiveChatStream(body.chatId, reservedStream);
|
||||||
return streamActiveRun(req, reply, reservedStream);
|
return streamActiveRun(req, reply, reservedStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
|
||||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
|
||||||
const configuredBody = await applyStoredChatSettings(body);
|
|
||||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
|
||||||
return streamActiveRun(req, reply, reservedStream);
|
|
||||||
} catch (err) {
|
|
||||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
|
||||||
clearActiveChatStream(body.chatId, reservedStream);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy requests without an idempotency key retain the original behavior.
|
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
const configuredBody = await applyStoredChatSettings(body);
|
||||||
return streamActiveRun(req, reply, stream);
|
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||||
|
return streamActiveRun(req, reply, reservedStream);
|
||||||
|
} catch (err) {
|
||||||
|
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||||
|
clearActiveChatStream(body.chatId, reservedStream);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync } 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";
|
||||||
|
|
||||||
|
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")}`;
|
||||||
|
process.env.OPENAI_API_KEY = "";
|
||||||
|
delete process.env.ADMIN_TOKEN;
|
||||||
|
|
||||||
|
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
||||||
|
cwd: serverRoot,
|
||||||
|
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
|
||||||
|
import("../src/routes.js"),
|
||||||
|
import("../src/db.js"),
|
||||||
|
]);
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
await app.register(sensible);
|
||||||
|
await registerRoutes(app);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
rmSync(databaseDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forks copy bounded history and keep every child grouped under the root", async () => {
|
||||||
|
const timestamp = new Date("2026-08-16T12:00:00.000Z");
|
||||||
|
const source = await prisma.chat.create({
|
||||||
|
data: {
|
||||||
|
title: "Planning session",
|
||||||
|
initiatedProvider: "openai",
|
||||||
|
initiatedModel: "gpt-4.1-mini",
|
||||||
|
lastUsedProvider: "anthropic",
|
||||||
|
lastUsedModel: "claude-sonnet-4-20250514",
|
||||||
|
additionalSystemPrompt: "Keep answers concise.",
|
||||||
|
enabledTools: ["web_search"],
|
||||||
|
messages: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
id: "source-message-0001",
|
||||||
|
createdAt: timestamp,
|
||||||
|
role: "user",
|
||||||
|
content: "Plan a trip",
|
||||||
|
metadata: {
|
||||||
|
clientRequestId: "source-request",
|
||||||
|
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source-message-0002",
|
||||||
|
createdAt: timestamp,
|
||||||
|
role: "assistant",
|
||||||
|
content: "First assistant response",
|
||||||
|
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "source-message-0003",
|
||||||
|
createdAt: timestamp,
|
||||||
|
role: "user",
|
||||||
|
content: "Only visible in a whole-chat fork",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
calls: {
|
||||||
|
create: {
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
request: { input: "Plan a trip" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const forkResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${source.id}/fork`,
|
||||||
|
payload: { messageId: source.messages[1].id },
|
||||||
|
});
|
||||||
|
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
|
||||||
|
const forkSummary = forkResponse.json().chat;
|
||||||
|
assert.equal(forkSummary.parentChatId, source.id);
|
||||||
|
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
|
||||||
|
assert.equal(forkSummary.titleGenerationPending, true);
|
||||||
|
assert.equal(forkSummary.starred, false);
|
||||||
|
|
||||||
|
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
|
||||||
|
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
|
||||||
|
const fork = detailResponse.json().chat;
|
||||||
|
assert.deepEqual(
|
||||||
|
fork.messages.map((message: any) => [message.role, message.content]),
|
||||||
|
[
|
||||||
|
["user", "Plan a trip"],
|
||||||
|
["assistant", "First assistant response"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.notEqual(fork.messages[0].id, source.messages[0].id);
|
||||||
|
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
|
||||||
|
assert.deepEqual(fork.messages[0].metadata, {
|
||||||
|
attachments: [{ kind: "text", filename: "notes.txt" }],
|
||||||
|
});
|
||||||
|
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
|
||||||
|
assert.equal(fork.initiatedProvider, "openai");
|
||||||
|
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
|
||||||
|
assert.equal(fork.lastUsedProvider, "anthropic");
|
||||||
|
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
|
||||||
|
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
|
||||||
|
assert.deepEqual(fork.enabledTools, ["web_search"]);
|
||||||
|
assert.deepEqual(fork.calls, []);
|
||||||
|
|
||||||
|
const childForkResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${forkSummary.id}/fork`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
|
||||||
|
const childFork = childForkResponse.json().chat;
|
||||||
|
assert.equal(childFork.parentChatId, source.id);
|
||||||
|
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
|
||||||
|
assert.equal(childFork.titleGenerationPending, true);
|
||||||
|
|
||||||
|
const wholeForkResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${source.id}/fork`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
|
||||||
|
const wholeFork = wholeForkResponse.json().chat;
|
||||||
|
assert.equal(wholeFork.parentChatId, source.id);
|
||||||
|
assert.equal(wholeFork.title, "Fork of Planning session");
|
||||||
|
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
|
||||||
|
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
|
||||||
|
assert.deepEqual(
|
||||||
|
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
|
||||||
|
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
|
||||||
|
);
|
||||||
|
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
|
||||||
|
|
||||||
|
const suggestedTitleResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/chats/title/suggest",
|
||||||
|
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
|
||||||
|
});
|
||||||
|
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
|
||||||
|
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
|
||||||
|
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
|
||||||
|
|
||||||
|
const repeatedTitleResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/v1/chats/title/suggest",
|
||||||
|
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
|
||||||
|
});
|
||||||
|
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
|
||||||
|
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
|
||||||
|
|
||||||
|
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
|
||||||
|
where: { id: source.id },
|
||||||
|
include: { messages: true, calls: true },
|
||||||
|
});
|
||||||
|
assert.equal(sourceAfterForks.messages.length, 3);
|
||||||
|
assert.equal(sourceAfterForks.calls.length, 1);
|
||||||
|
|
||||||
|
const manualTitleResponse = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/v1/chats/${forkSummary.id}`,
|
||||||
|
payload: { title: "Manual branch title" },
|
||||||
|
});
|
||||||
|
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
|
||||||
|
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
|
||||||
|
|
||||||
|
const activeForkStream = registerActiveChatStream(wholeFork.id);
|
||||||
|
try {
|
||||||
|
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||||
|
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
|
||||||
|
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
|
||||||
|
|
||||||
|
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
|
||||||
|
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
|
||||||
|
} finally {
|
||||||
|
clearActiveChatStream(wholeFork.id, activeForkStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
|
||||||
|
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
|
||||||
|
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
|
||||||
|
|
||||||
|
const unrelated = await prisma.chat.create({
|
||||||
|
data: {
|
||||||
|
messages: { create: { role: "assistant", content: "Unrelated response" } },
|
||||||
|
},
|
||||||
|
include: { messages: true },
|
||||||
|
});
|
||||||
|
const countBeforeInvalidForks = await prisma.chat.count();
|
||||||
|
|
||||||
|
const wrongChatResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${source.id}/fork`,
|
||||||
|
payload: { messageId: unrelated.messages[0].id },
|
||||||
|
});
|
||||||
|
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
|
||||||
|
assert.equal(wrongChatResponse.json().message, "message not found in chat");
|
||||||
|
|
||||||
|
const nonAssistantResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${source.id}/fork`,
|
||||||
|
payload: { messageId: source.messages[0].id },
|
||||||
|
});
|
||||||
|
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
|
||||||
|
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
|
||||||
|
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
|
||||||
|
|
||||||
|
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
|
||||||
|
const concurrentDeleteResponses = await Promise.all([
|
||||||
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||||
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
||||||
|
]);
|
||||||
|
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
|
||||||
|
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||||
|
|
||||||
|
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
|
||||||
|
const concurrentChildResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v1/chats/${concurrentRoot.id}/fork`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
|
||||||
|
const concurrentChild = concurrentChildResponse.json().chat;
|
||||||
|
const concurrentFamilyDeleteResponses = await Promise.all([
|
||||||
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
|
||||||
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
|
||||||
|
]);
|
||||||
|
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
|
||||||
|
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
|
||||||
|
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
|
||||||
|
|
||||||
|
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
||||||
|
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
|
||||||
|
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
|
||||||
|
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
|
||||||
|
});
|
||||||
+40
-21
@@ -127,21 +127,27 @@ function upsertWorkspaceItem(items: WorkspaceItem[], item: WorkspaceItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||||
|
const chatsById = new Map<string, ChatSummary>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type === "chat") chatsById.set(item.id, item);
|
||||||
|
}
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (item.type === "chat") {
|
if (item.type === "chat") {
|
||||||
const chat = item;
|
const chat = item;
|
||||||
|
const starOwner = chatsById.get(chat.parentChatId ?? chat.id) ?? chat;
|
||||||
return {
|
return {
|
||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
starred: chat.starred,
|
starred: starOwner.starred,
|
||||||
starredAt: chat.starredAt,
|
starredAt: starOwner.starredAt,
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,11 +985,11 @@ async function main() {
|
|||||||
focusComposer();
|
focusComposer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeSuggestTitle(chatId: string, content: string) {
|
async function maybeSuggestTitle(chat: ChatDetail, content: string) {
|
||||||
const chatSummary = chats.find((chat) => chat.id === chatId);
|
const needsGeneratedTitle = chat.titleGenerationPending || !chat.title?.trim();
|
||||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
if (!needsGeneratedTitle || pendingTitleGeneration.has(chat.id)) return;
|
||||||
if (hasExistingTitle || pendingTitleGeneration.has(chatId)) return;
|
|
||||||
|
|
||||||
|
const chatId = chat.id;
|
||||||
pendingTitleGeneration.add(chatId);
|
pendingTitleGeneration.add(chatId);
|
||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
@@ -993,6 +999,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1049,6 +1057,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
title: chat.title,
|
title: chat.title,
|
||||||
|
parentChatId: chat.parentChatId,
|
||||||
|
titleGenerationPending: chat.titleGenerationPending,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
starred: chat.starred,
|
||||||
@@ -1068,13 +1078,13 @@ async function main() {
|
|||||||
throw new Error("Unable to initialize chat");
|
throw new Error("Unable to initialize chat");
|
||||||
}
|
}
|
||||||
|
|
||||||
void maybeSuggestTitle(chatId, content);
|
|
||||||
|
|
||||||
let baseChat = selectedChat;
|
let baseChat = selectedChat;
|
||||||
if (!baseChat || baseChat.id !== chatId) {
|
if (!baseChat || baseChat.id !== chatId) {
|
||||||
baseChat = await api.getChat(chatId);
|
baseChat = await api.getChat(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void maybeSuggestTitle(baseChat, content);
|
||||||
|
|
||||||
const requestMessages: CompletionRequestMessage[] = [
|
const requestMessages: CompletionRequestMessage[] = [
|
||||||
...baseChat.messages
|
...baseChat.messages
|
||||||
.filter((message) => !isToolCallLogMessage(message))
|
.filter((message) => !isToolCallLogMessage(message))
|
||||||
@@ -1392,6 +1402,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
initiatedProvider: updated.initiatedProvider,
|
initiatedProvider: updated.initiatedProvider,
|
||||||
initiatedModel: updated.initiatedModel,
|
initiatedModel: updated.initiatedModel,
|
||||||
@@ -1405,12 +1417,16 @@ async function main() {
|
|||||||
async function handleToggleStarSelection() {
|
async function handleToggleStarSelection() {
|
||||||
if (!selectedItem) return;
|
if (!selectedItem) return;
|
||||||
|
|
||||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
|
||||||
const nextStarred = !currentItem?.starred;
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (selectedItem.kind === "chat") {
|
if (selectedItem.kind === "chat") {
|
||||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
const selectedSummary = chats.find((chat) => chat.id === selectedItem?.id);
|
||||||
|
const selectedParentChatId = selectedChat?.id === selectedItem.id
|
||||||
|
? selectedChat.parentChatId
|
||||||
|
: selectedSummary?.parentChatId;
|
||||||
|
const rootChatId = selectedParentChatId ?? selectedItem.id;
|
||||||
|
const rootSummary = chats.find((chat) => chat.id === rootChatId);
|
||||||
|
const updated = await api.updateChatStar(rootChatId, !rootSummary?.starred);
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
||||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
@@ -1421,6 +1437,8 @@ async function main() {
|
|||||||
selectedChat = {
|
selectedChat = {
|
||||||
...selectedChat,
|
...selectedChat,
|
||||||
title: updated.title,
|
title: updated.title,
|
||||||
|
parentChatId: updated.parentChatId,
|
||||||
|
titleGenerationPending: updated.titleGenerationPending,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
starred: updated.starred,
|
starred: updated.starred,
|
||||||
starredAt: updated.starredAt,
|
starredAt: updated.starredAt,
|
||||||
@@ -1431,7 +1449,8 @@ async function main() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
const currentItem = getSidebarItems().find((item) => item.kind === "search" && item.id === selectedItem?.id);
|
||||||
|
const updated = await api.updateSearchStar(selectedItem.id, !currentItem?.starred);
|
||||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
||||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type ModelCatalogResponse = {
|
|||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -68,6 +70,8 @@ export type ToolCallEvent = {
|
|||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
|
|||||||
+413
-202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { Paperclip, Search, SendHorizontal } from "lucide-preact";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type MutableValueRef = {
|
||||||
|
current: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
draftRef: MutableValueRef;
|
||||||
|
draftRevision: number;
|
||||||
|
error: string | null;
|
||||||
|
isSearchMode: boolean;
|
||||||
|
isSending: boolean;
|
||||||
|
pendingAttachmentCount: number;
|
||||||
|
attachmentButtonDisabled: boolean;
|
||||||
|
onOpenAttachmentPicker: () => void;
|
||||||
|
onPaste: (event: ClipboardEvent) => void;
|
||||||
|
onSend: (draft: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIRROR_SENTINEL = "\u200b";
|
||||||
|
const HAS_NON_WHITESPACE = /\S/;
|
||||||
|
|
||||||
|
export function ChatComposer({
|
||||||
|
draftRef,
|
||||||
|
draftRevision,
|
||||||
|
error,
|
||||||
|
isSearchMode,
|
||||||
|
isSending,
|
||||||
|
pendingAttachmentCount,
|
||||||
|
attachmentButtonDisabled,
|
||||||
|
onOpenAttachmentPicker,
|
||||||
|
onPaste,
|
||||||
|
onSend,
|
||||||
|
}: Props) {
|
||||||
|
// The draft stays in the DOM/ref so typing never schedules a render of the workspace transcript.
|
||||||
|
const textareaContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mirrorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hasDraft, setHasDraft] = useState(() => HAS_NON_WHITESPACE.test(draftRef.current));
|
||||||
|
const hasDraftRef = useRef(hasDraft);
|
||||||
|
|
||||||
|
const getTextarea = () => textareaContainerRef.current?.querySelector("textarea") ?? null;
|
||||||
|
|
||||||
|
const updateMirror = (value: string) => {
|
||||||
|
// The overlapping mirror lets normal layout size the textarea without synchronous scrollHeight reads.
|
||||||
|
if (mirrorRef.current) mirrorRef.current.textContent = `${value}${MIRROR_SENTINEL}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHasDraft = (value: string) => {
|
||||||
|
const nextHasDraft = HAS_NON_WHITESPACE.test(value);
|
||||||
|
if (nextHasDraft !== hasDraftRef.current) {
|
||||||
|
hasDraftRef.current = nextHasDraft;
|
||||||
|
setHasDraft(nextHasDraft);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const value = draftRef.current;
|
||||||
|
const textarea = getTextarea();
|
||||||
|
if (textarea && textarea.value !== value) {
|
||||||
|
textarea.value = value;
|
||||||
|
}
|
||||||
|
updateMirror(value);
|
||||||
|
updateHasDraft(value);
|
||||||
|
}, [draftRevision]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const textarea = getTextarea();
|
||||||
|
const draft = textarea?.value ?? draftRef.current;
|
||||||
|
const canSend = HAS_NON_WHITESPACE.test(draft) || (!isSearchMode && pendingAttachmentCount > 0);
|
||||||
|
if (isSending || !canSend) return;
|
||||||
|
|
||||||
|
draftRef.current = "";
|
||||||
|
if (textarea) textarea.value = "";
|
||||||
|
updateMirror("");
|
||||||
|
updateHasDraft("");
|
||||||
|
void onSend(draft);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={textareaContainerRef} className="grid max-h-40 min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={mirrorRef}
|
||||||
|
className="pointer-events-none invisible col-start-1 row-start-1 max-h-40 min-h-0 overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words px-3 py-3 text-base"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
id="composer-input"
|
||||||
|
rows={1}
|
||||||
|
onInput={(event) => {
|
||||||
|
const value = event.currentTarget.value;
|
||||||
|
draftRef.current = value;
|
||||||
|
updateMirror(value);
|
||||||
|
updateHasDraft(value);
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
onPaste(event);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
|
||||||
|
className="col-start-1 row-start-1 h-full max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
|
||||||
|
disabled={isSending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
|
||||||
|
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
|
||||||
|
{!isSearchMode ? (
|
||||||
|
<Button
|
||||||
|
className="h-10 w-10 rounded-lg"
|
||||||
|
onClick={onOpenAttachmentPicker}
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={attachmentButtonDisabled}
|
||||||
|
aria-label="Attach files"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className="h-10 w-10 rounded-lg"
|
||||||
|
onClick={submit}
|
||||||
|
size="icon"
|
||||||
|
disabled={isSending || (!hasDraft && (isSearchMode || pendingAttachmentCount === 0))}
|
||||||
|
aria-label={isSearchMode ? "Search" : "Send message"}
|
||||||
|
>
|
||||||
|
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSending: boolean;
|
isSending: boolean;
|
||||||
|
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolLogMetadata = {
|
type ToolLogMetadata = {
|
||||||
@@ -395,7 +396,7 @@ function ToolCallStack({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
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-") && message.content.trim().length === 0);
|
||||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||||
@@ -467,6 +468,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
? "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"
|
? "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"
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
|
onContextMenu={
|
||||||
|
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||||
|
? (event) => onMessageContextMenu(event, message.id)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||||
{isPendingAssistant ? (
|
{isPendingAssistant ? (
|
||||||
|
|||||||
@@ -131,6 +131,45 @@ 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)));
|
||||||
|
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar {
|
||||||
|
position: static;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-sidebar-backdrop,
|
||||||
|
.workspace-sidebar-trigger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export type ChatSummary = {
|
export type ChatSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -58,6 +60,8 @@ export type ToolCallEvent = {
|
|||||||
export type ChatDetail = {
|
export type ChatDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
parentChatId: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
@@ -291,6 +295,14 @@ export async function getChat(chatId: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function forkChat(chatId: string, messageId?: string) {
|
||||||
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(messageId ? { messageId } : {}),
|
||||||
|
});
|
||||||
|
return data.chat;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateChatTitle(chatId: string, title: string) {
|
export async function updateChatTitle(chatId: string, title: string) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type ForkGroupingItem = {
|
||||||
|
kind: "chat" | "search";
|
||||||
|
id: string;
|
||||||
|
parentChatId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatTitleState = {
|
||||||
|
title: string | null;
|
||||||
|
titleGenerationPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ForkPresentationItem = ForkGroupingItem & {
|
||||||
|
updatedAt: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupForkedChatItems<T extends ForkGroupingItem>(items: T[]): T[][] {
|
||||||
|
const groups = new Map<string, { firstIndex: number; items: Array<{ item: T; index: number }> }>();
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`;
|
||||||
|
const group = groups.get(groupKey);
|
||||||
|
if (group) {
|
||||||
|
group.items.push({ item, index });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groups.set(groupKey, { firstIndex: index, items: [{ item, index }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.sort((a, b) => a.firstIndex - b.firstIndex)
|
||||||
|
.map((group) =>
|
||||||
|
group.items
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null;
|
||||||
|
const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null;
|
||||||
|
if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1;
|
||||||
|
return a.index - b.index;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterSidebarItemsWithForkGroups<T extends ForkGroupingItem>(items: T[], matches: (item: T) => boolean): T[] {
|
||||||
|
return groupForkedChatItems(items)
|
||||||
|
.flatMap((group) => {
|
||||||
|
const matchingItems = group.filter(matches);
|
||||||
|
if (!matchingItems.length) return [];
|
||||||
|
const root = group.find((item) => item.kind === "chat" && item.parentChatId === null);
|
||||||
|
if (!root || matchingItems.includes(root)) return group;
|
||||||
|
return group.filter((item) => item === root || matchingItems.includes(item));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getForkGroupPresentation<T extends ForkPresentationItem>(groupItems: T[], allItems: T[]) {
|
||||||
|
const firstItem = groupItems[0];
|
||||||
|
if (!firstItem) return null;
|
||||||
|
|
||||||
|
const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null;
|
||||||
|
const familyItems = rootId
|
||||||
|
? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId))
|
||||||
|
: groupItems;
|
||||||
|
const updatedAt = familyItems.reduce(
|
||||||
|
(newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest),
|
||||||
|
firstItem.updatedAt
|
||||||
|
);
|
||||||
|
const starOwner = rootId
|
||||||
|
? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null)
|
||||||
|
: firstItem;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
starred: starOwner?.starred ?? false,
|
||||||
|
starredAt: starOwner?.starredAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) {
|
||||||
|
if (!chat || isRequestInFlight) return false;
|
||||||
|
return chat.titleGenerationPending || !chat.title?.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
filterSidebarItemsWithForkGroups,
|
||||||
|
getForkGroupPresentation,
|
||||||
|
groupForkedChatItems,
|
||||||
|
shouldRequestChatTitle,
|
||||||
|
} from "../src/lib/chat-forking.ts";
|
||||||
|
|
||||||
|
const root = { kind: "chat", id: "root", parentChatId: null };
|
||||||
|
const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" };
|
||||||
|
const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" };
|
||||||
|
const search = { kind: "search", id: "search", parentChatId: null };
|
||||||
|
|
||||||
|
test("fork groups are positioned by their newest member and always render root first", () => {
|
||||||
|
assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [
|
||||||
|
[root, nestedFork, firstFork],
|
||||||
|
[search],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar filtering keeps a matching fork with its root", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"),
|
||||||
|
[root, nestedFork]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar filtering keeps all children when their root matches", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"),
|
||||||
|
[root, firstFork, nestedFork]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a filtered family keeps its full-family date and root-owned star state", () => {
|
||||||
|
const fullFamily = [
|
||||||
|
{ ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" },
|
||||||
|
{ ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null },
|
||||||
|
{ ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null },
|
||||||
|
];
|
||||||
|
const filteredFamily = [fullFamily[0], fullFamily[1]];
|
||||||
|
|
||||||
|
assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), {
|
||||||
|
updatedAt: "2026-08-16T00:00:00.000Z",
|
||||||
|
starred: true,
|
||||||
|
starredAt: "2026-08-15T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fork placeholder title is replaced on its first submitted prompt", () => {
|
||||||
|
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true);
|
||||||
|
assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => {
|
||||||
|
assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true);
|
||||||
|
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false);
|
||||||
|
});
|
||||||
@@ -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-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-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/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user