Compare commits

...
7 Commits
Author SHA1 Message Date
buzzert eb2b0d3ca0 Add chat thread forking
TestFlight / Build and upload (push) Successful in 1m52s
2026-08-16 17:42:39 -07:00
buzzert 42022bf055 Improve chat composer typing performance 2026-08-13 00:08:15 -07:00
buzzert 1ef491f39a Add foldable split-pane layout 2026-08-06 23:28:42 -07:00
buzzert b540f01759 Fix Sybil release paths
TestFlight / Build and upload (push) Successful in 1m50s
2026-07-27 17:36:31 -07:00
buzzert e719cbb92d Simplify iOS TestFlight automation
TestFlight / Build and upload (push) Failing after 18s
2026-07-27 17:29:47 -07:00
buzzert cb5a973ac7 Preserve provider changes before sending 2026-07-26 17:44:45 -07:00
buzzert e2443162b0 Preserve thread selection during background updates 2026-07-26 17:37:39 -07:00
31 changed files with 1948 additions and 600 deletions
+13 -18
View File
@@ -1,44 +1,39 @@
name: TestFlight name: TestFlight
on: on:
workflow_dispatch:
push: push:
tags: tags:
- "release/ios/v*" - "release/ios/v*.*.*"
jobs: jobs:
testflight: testflight:
name: Build and upload
runs-on: macos-arm64 runs-on: macos-arm64
timeout-minutes: 90
steps: steps:
- name: Checkout - name: Check out the release tag
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Ruby - name: Set up Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: "3.1.7" ruby-version: "3.3.11"
bundler-cache: true bundler-cache: true
working-directory: ios working-directory: ios
- name: Install XcodeGen - name: Install XcodeGen
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
- name: Upload to TestFlight - name: Build and upload to TestFlight
working-directory: ios working-directory: ios
env: env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_KEY_CONTENT }} ASC_KEY: ${{ secrets.ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
SYBIL_BUILD_NUMBER: ${{ github.run_number }} CI: "true"
FASTLANE_SKIP_UPDATE_CHECK: "1" FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120" FASTLANE_HIDE_CHANGELOG: "1"
# act_runner does not propagate setup-ruby's PATH changes into later run: bundle exec fastlane ios beta
# steps, so put the selected Ruby back on PATH or `bundle` resolves to
# the toolcache default and misses the gems installed above.
run: |
export PATH="/Users/runner/hostedtoolcache/Ruby/3.1.7/arm64/bin:${PATH}"
bundle exec fastlane ios beta
+32 -3
View File
@@ -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,
+2
View File
@@ -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
+5 -24
View File
@@ -1,24 +1,5 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2 ASC_KEY_ID=
FASTLANE_TEAM_ID=DQQH5H6GBD ASC_ISSUER_ID=
FASTLANE_SKIP_UPDATE_CHECK=1 ASC_KEY=
FASTLANE_HIDE_CHANGELOG=1 MATCH_PASSWORD=
SYBIL_APP_STORE_APPLE_ID=6759442828 MATCH_GIT_BASIC_AUTHORIZATION=
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
SYBIL_PROVISIONING_PROFILE_SPECIFIER=Sybil AppStore CI
SYBIL_PROVISIONING_PROFILE_UUID=
SYBIL_CODE_SIGN_IDENTITY=Apple Distribution: James Magahern (DQQH5H6GBD)
SYBIL_XCODE_CODE_SIGN_IDENTITY=6B74B268C4761720FB2051D01D8BB3E47B55D9F5
SYBIL_EXPORT_SIGNING_CERTIFICATE=Apple Distribution
SYBIL_SIGNING_CERTIFICATE_ID=
SYBIL_SIGNING_KEYCHAIN=
# App Store Connect API key settings for TestFlight upload and signing setup.
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_API_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_PATH=
APP_STORE_CONNECT_API_KEY_CONTENT=
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
# Optional deployment overrides.
SYBIL_BUILD_NUMBER=
SYBIL_VERSION_TAG=
+1 -1
View File
@@ -1,3 +1,3 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "fastlane" gem "fastlane", "2.237.0"
+75 -62
View File
@@ -1,46 +1,49 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
CFPropertyList (3.0.9) CFPropertyList (3.0.8)
abbrev (0.1.2) abbrev (0.1.2)
addressable (2.9.0) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17) artifactory (3.0.17)
atomos (0.1.3) atomos (0.1.3)
aws-eventstream (1.3.2) aws-eventstream (1.4.0)
aws-partitions (1.1109.0) aws-partitions (1.1274.0)
aws-sdk-core (3.224.1) aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
base64 base64
bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.101.0) aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0) aws-sdk-s3 (1.228.1)
aws-sdk-core (~> 3, >= 3.224.1) aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4) babosa (1.0.4)
base64 (0.2.0) base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0) claide (1.1.0)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
commander (4.6.0) commander (4.6.0)
highline (~> 2.0.0) highline (~> 2.0.0)
csv (3.3.5) csv (3.3.6)
declarative (0.0.20) declarative (0.0.20)
digest-crc (0.7.0) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701) domain_name (0.6.20240107)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1) dotenv (2.8.1)
emoji_regex (3.2.3) emoji_regex (3.2.3)
excon (0.109.0) excon (1.6.0)
logger
faraday (1.10.6) faraday (1.10.6)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0) faraday-em_synchrony (~> 1.0)
@@ -70,42 +73,45 @@ GEM
faraday_middleware (1.2.1) faraday_middleware (1.2.1)
faraday (~> 1.0) faraday (~> 1.0)
fastimage (2.4.1) fastimage (2.4.1)
fastlane (2.230.0) fastlane (2.237.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1.2) abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0) addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0) artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0) aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0) babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0) base64 (~> 0.2)
bundler (>= 1.12.0, < 3.0.0) benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2) colored (~> 1.2)
commander (~> 4.6) commander (~> 4.6)
csv (~> 3.3) csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0) dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0) emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0) excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0) faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0) faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0) fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3) google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1) google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31) google-cloud-storage (~> 1.31)
highline (~> 2.0) highline (~> 2.0)
http-cookie (~> 1.0.5) http-cookie (~> 1.0.5)
json (< 3.0.0) json (< 3.0.0)
jwt (>= 2.1.0, < 3) jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0) logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0) mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0) multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0) mutex_m (~> 0.3)
naturally (~> 2.2) naturally (~> 2.2)
nkf (~> 0.2.0) nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0) optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0) rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5) security (= 0.1.5)
@@ -120,41 +126,46 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0) fastlane-sirp (1.1.0)
gh_inspector (1.1.3) gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0) google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.11.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.11.3) google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
mutex_m
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml google-apis-iamcredentials_v1 (0.28.0)
google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.18.0)
google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.65.0)
google-apis-storage_v1 (0.29.0) google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.9.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0) google-cloud-env (2.2.2)
faraday (>= 0.17.3, < 3.0) base64 (~> 0.2)
google-cloud-errors (1.3.1) faraday (>= 1.0, < 3.a)
google-cloud-storage (1.45.0) google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-core (>= 0.18, < 2)
google-apis-storage_v1 (~> 0.29.0) google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.8.1) google-logging-utils (0.2.0)
faraday (>= 0.17.3, < 3.a) googleauth (1.17.2)
jwt (>= 1.4, < 3.0) faraday (>= 1.0, < 3.a)
multi_json (~> 1.11) google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
highline (2.0.3) highline (2.0.3)
http-cookie (1.0.8) http-cookie (1.0.8)
@@ -162,22 +173,24 @@ GEM
httpclient (2.9.0) httpclient (2.9.0)
mutex_m mutex_m
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.6) json (2.21.1)
jwt (2.10.3) jwt (3.2.0)
base64 base64
logger (1.7.0) logger (1.7.0)
mini_magick (4.13.2) mini_magick (4.13.2)
mini_mime (1.1.5) mini_mime (1.1.5)
multi_json (1.15.0) multi_json (1.21.1)
multipart-post (2.4.1) multipart-post (2.4.1)
mutex_m (0.3.0) mutex_m (0.3.0)
nanaimo (0.4.0) nanaimo (0.4.0)
naturally (2.3.0) naturally (2.3.0)
nkf (0.2.0) nkf (0.3.0)
optparse (0.8.1) optparse (0.8.1)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
plist (3.7.2) plist (3.7.2)
public_suffix (5.1.1) pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2) rake (13.4.2)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -189,11 +202,10 @@ GEM
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyzip (2.4.1) rubyzip (2.4.1)
security (0.1.5) security (0.1.5)
signet (0.18.0) signet (0.22.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10) simctl (1.6.10)
CFPropertyList CFPropertyList
naturally naturally
@@ -206,15 +218,16 @@ GEM
tty-spinner (0.9.3) tty-spinner (0.9.3)
tty-cursor (~> 0.7) tty-cursor (~> 0.7)
uber (0.1.0) uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0) unicode-display_width (2.6.0)
word_wrap (1.0.0) word_wrap (1.0.0)
xcodeproj (1.27.0) xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0) CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3) atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0) claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1) colored2 (~> 3.1)
nanaimo (~> 0.4.0) nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0) rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1) xcpretty (0.4.1)
rouge (~> 3.28.0) rouge (~> 3.28.0)
@@ -225,7 +238,7 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
fastlane fastlane (= 2.237.0)
BUNDLED WITH BUNDLED WITH
2.5.23 2.5.23
@@ -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
@@ -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)
) )
@@ -687,6 +690,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 +901,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 +1460,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 +1513,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 +1875,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 +1935,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 +2646,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
} }
} }
@@ -10,7 +10,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 +39,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 +68,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 +81,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 +210,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 +223,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 +432,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 +619,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 +646,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 +691,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 +885,72 @@ 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 @MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws { @Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient() let client = MockSybilClient()
+2
View File
@@ -0,0 +1,2 @@
app_identifier("net.buzzert.sybil2")
team_id("DQQH5H6GBD")
+33 -150
View File
@@ -1,164 +1,47 @@
require "shellwords"
default_platform(:ios) default_platform(:ios)
APP_IDENTIFIER = "net.buzzert.sybil2"
SCHEME = "Sybil"
TEAM_ID = "DQQH5H6GBD"
PROFILE_NAME = "Sybil AppStore CI"
CI_KEYCHAIN_NAME = "sybil_ci_keychain"
CI_KEYCHAIN_PASSWORD = "sybil-ci-keychain-password"
CI_KEYCHAIN_DB_PATH = File.expand_path("~/Library/Keychains/#{CI_KEYCHAIN_NAME}-db")
IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_PROJECT_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
def present?(value)
!value.to_s.strip.empty?
end
def ci?
present?(ENV["CI"])
end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = ENV["GITHUB_REF_NAME"] if !present?(tag)
tag = ENV["GITHUB_REF"].to_s.sub(%r{\Arefs/tags/}, "") if !present?(tag)
tag = sh("git describe --tags --abbrev=0").strip if !present?(tag)
match = tag.to_s.match(%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z})
unless match
UI.user_error!("Release tag must look like release/ios/v1.2.3; got #{tag.inspect}")
end
match[1]
end
# App Store Connect requires CFBundleVersion to be unique and strictly
# increasing app-wide (not just per marketing version), so we derive it from
# the monotonic CI run number rather than querying TestFlight (that query can
# lag behind builds still processing and hand back a colliding value).
def build_number
value = present?(ENV["SYBIL_BUILD_NUMBER"]) ? ENV["SYBIL_BUILD_NUMBER"] : ENV["GITHUB_RUN_NUMBER"]
unless value.to_s.match?(/\A\d+\z/)
UI.user_error!("Build number must come from SYBIL_BUILD_NUMBER/GITHUB_RUN_NUMBER; got #{value.inspect}")
end
value.to_i
end
def stamp_marketing_version(version)
contents = File.read(APP_PROJECT_SPEC)
updated = contents.sub(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
if updated == contents
UI.user_error!("Could not find MARKETING_VERSION in #{APP_PROJECT_SPEC}")
end
File.write(APP_PROJECT_SPEC, updated)
end
platform :ios do platform :ios do
private_lane :app_store_api_key do desc "Build a release tag and upload it to TestFlight"
app_store_connect_api_key( lane :beta do
key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"), setup_ci
issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"),
key_content: ENV.fetch("APP_STORE_CONNECT_KEY_CONTENT"), match(type: "appstore")
tag = ENV.fetch("GITHUB_REF_NAME")
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
ios_dir = File.expand_path("..", __dir__)
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
contents = File.read(project_spec)
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
UI.user_error!("Could not find version settings in #{project_spec}")
end
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
File.write(project_spec, contents)
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_content: ENV.fetch("ASC_KEY"),
is_key_content_base64: true is_key_content_base64: true
) )
end
# CI signs headlessly, so match needs a fresh unlocked keychain to import
# into. codesign resolves identities through the user keychain *search list*
# (first match wins; the --keychain flag does not restrict the lookup), and
# other projects' keychains on this runner hold the same identity but are
# usually locked — so ours must come first. delete_keychain in the beta
# lane's ensure removes both the keychain and its search-list entry, which
# also keeps our (later locked) copy from shadowing those other projects.
private_lane :prepare_ci_keychain do
next unless ci?
delete_keychain(name: CI_KEYCHAIN_NAME) if File.file?(CI_KEYCHAIN_DB_PATH)
create_keychain(
name: CI_KEYCHAIN_NAME,
password: CI_KEYCHAIN_PASSWORD,
unlock: true,
timeout: 3600,
add_to_search_list: false
)
others = sh("security list-keychains -d user", log: false)
.scan(/"([^"]+)"/)
.flatten
.reject { |path| path.include?(CI_KEYCHAIN_NAME) }
sh("security list-keychains -d user -s #{([CI_KEYCHAIN_DB_PATH] + others).shelljoin}")
ENV["MATCH_KEYCHAIN_NAME"] = CI_KEYCHAIN_NAME
ENV["MATCH_KEYCHAIN_PASSWORD"] = CI_KEYCHAIN_PASSWORD
end
private_lane :sync_signing do |options|
match(
type: "appstore",
readonly: options.fetch(:readonly),
app_identifier: APP_IDENTIFIER,
team_id: TEAM_ID,
profile_name: PROFILE_NAME,
git_url: ENV.fetch("MATCH_GIT_URL"),
git_branch: "master",
git_full_name: "Sybil Release Bot",
git_user_email: "james.magahern@me.com",
api_key: options.fetch(:api_key)
)
end
desc "Create or update match signing assets"
lane :setup_signing do
sync_signing(api_key: app_store_api_key, readonly: false)
end
desc "Build and upload to TestFlight"
lane :beta do
prepare_ci_keychain
api_key = app_store_api_key
version = release_version
stamp_marketing_version(version)
sh("xcodegen", "--spec", PROJECT_SPEC)
increment_version_number(version_number: version, xcodeproj: PROJECT_FILE)
increment_build_number(build_number: build_number, xcodeproj: PROJECT_FILE)
sync_signing(api_key: api_key, readonly: true)
build_app( build_app(
project: PROJECT_FILE, project: File.join(ios_dir, "Sybil.xcodeproj"),
scheme: SCHEME, scheme: "Sybil"
export_method: "app-store",
xcargs: [
"DEVELOPMENT_TEAM=#{TEAM_ID.shellescape}",
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY=Apple\\ Distribution",
"PROVISIONING_PROFILE_SPECIFIER=#{PROFILE_NAME.shellescape}"
].join(" "),
export_options: {
signingStyle: "manual",
teamID: TEAM_ID,
provisioningProfiles: {
APP_IDENTIFIER => PROFILE_NAME
}
}
) )
upload_to_testflight( upload_to_testflight(
api_key: api_key, api_key: api_key,
skip_waiting_for_build_processing: true skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
) )
ensure
delete_keychain(name: CI_KEYCHAIN_NAME) if ci? && File.file?(CI_KEYCHAIN_DB_PATH)
end end
end end
+7
View File
@@ -0,0 +1,7 @@
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
storage_mode("git")
type("appstore")
app_identifier(["net.buzzert.sybil2"])
team_id("DQQH5H6GBD")
profile_name("Sybil AppStore CI")
@@ -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");
+8 -1
View File
@@ -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
View File
@@ -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));
+252
View File
@@ -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
View File
@@ -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));
+4
View File
@@ -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;
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test --experimental-strip-types tests/*.test.mjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
+455 -240
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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 ? (
+39
View File
@@ -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)),
+12
View File
@@ -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",
+82
View File
@@ -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();
}
+24
View File
@@ -0,0 +1,24 @@
import type { Provider } from "./api";
type PersistedChatModel = {
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type ChatModelSelection = {
provider: Provider;
model: string;
};
export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return {
provider: chat.lastUsedProvider,
model: chat.lastUsedModel.trim(),
};
}
export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) {
if (!chatId || !selection) return null;
return JSON.stringify([chatId, selection.provider, selection.model]);
}
+31
View File
@@ -0,0 +1,31 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection;
selectFallback?: boolean;
};
export function resolveSidebarSelectionAfterRefresh(
current: SidebarSelection | null,
workspaceItems: WorkspaceSelectionItem[],
{ initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {}
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};
if (hasItem(current)) {
return current;
}
if (hasItem(initialSelection)) {
return initialSelection ?? null;
}
if (!selectFallback) {
return null;
}
const first = workspaceItems[0];
return first ? { kind: first.type, id: first.id } : null;
}
+59
View File
@@ -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);
});
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getChatModelSelection,
getChatModelSelectionSyncKey,
} from "../src/lib/chat-model-selection.ts";
test("chat model selections are normalized from persisted metadata", () => {
assert.deepEqual(
getChatModelSelection({
lastUsedProvider: "anthropic",
lastUsedModel: " claude-sonnet-4-5 ",
}),
{
provider: "anthropic",
model: "claude-sonnet-4-5",
}
);
});
test("unrelated chat updates do not change the model synchronization key", () => {
const beforeSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
const afterSettingsSave = getChatModelSelection({
lastUsedProvider: "openai",
lastUsedModel: "gpt-4.1-mini",
});
assert.equal(
getChatModelSelectionSyncKey("chat-1", beforeSettingsSave),
getChatModelSelectionSyncKey("chat-1", afterSettingsSave)
);
});
test("switching chats or persisted models changes the synchronization key", () => {
const original = { provider: "openai", model: "gpt-4.1-mini" };
const updated = { provider: "gemini", model: "gemini-3.5-flash" };
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-2", original)
);
assert.notEqual(
getChatModelSelectionSyncKey("chat-1", original),
getChatModelSelectionSyncKey("chat-1", updated)
);
});
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const workspaceItems = [
{ type: "chat", id: "completed-chat" },
{ type: "chat", id: "selected-chat" },
{ type: "search", id: "selected-search" },
];
test("a collection refresh preserves the current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
{ kind: "chat", id: "selected-chat" }
);
});
test("an initial route selection cannot override a current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(
{ kind: "search", id: "selected-search" },
workspaceItems,
{ initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true }
),
{ kind: "search", id: "selected-search" }
);
});
test("a collection refresh preserves an intentionally empty selection", () => {
assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null);
});
test("initial load can select the URL thread or fall back to the first item", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh(null, workspaceItems, {
initialSelection: { kind: "search", id: "selected-search" },
selectFallback: true,
}),
{ kind: "search", id: "selected-search" }
);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), {
kind: "chat",
id: "completed-chat",
});
});
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-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/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"}