2 Commits

Author SHA1 Message Date
22aa652257 Fix iOS chat scroll pinning 2026-06-07 19:58:04 -07:00
8f6e8c17a5 ios: add fastlane 2026-06-05 23:19:14 -07:00
12 changed files with 355 additions and 35 deletions

20
ios/.env.example Normal file
View File

@@ -0,0 +1,20 @@
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
FASTLANE_TEAM_ID=DQQH5H6GBD
FASTLANE_USER=you@example.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
SYBIL_APP_STORE_APPLE_ID=6759442828
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
# Optional App Store Connect API key settings for non-interactive upload and
# TestFlight build-number lookup.
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=

11
ios/.gitignore vendored
View File

@@ -1,2 +1,11 @@
*.xcodeproj
.env
.env.*
!.env.example
build/
*.ipa
*.dSYM.zip
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/

View File

@@ -24,8 +24,8 @@ targets:
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.9
CURRENT_PROJECT_VERSION: 10
MARKETING_VERSION: "1.10"
CURRENT_PROJECT_VERSION: 11
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES

3
ios/Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.227"

View File

@@ -7,38 +7,55 @@ struct SybilChatTranscriptView: View {
var isSending: Bool
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
private var hasPendingAssistant: Bool {
messages.contains { message in
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
}
ForEach(messages) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
}
Color.clear
.frame(height: 18 + bottomContentInset)
.id(bottomAnchorID)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.onAppear {
scrollToBottom(with: proxy, animated: false)
}
.onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true)
}
}
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
}
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
.scaleEffect(x: 1, y: -1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset)
.padding(.bottom, 18 + topContentInset)
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
let action = {
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
}
if animated {
withAnimation(.easeOut(duration: 0.18), action)
} else {
action()
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.scaleEffect(x: 1, y: -1)
}
}

View File

@@ -107,6 +107,7 @@ final class SybilViewModel {
var isLoadingCollections = false
var isLoadingSelection = false
var isCreatingSearchChat = false
var chatBottomPinRequestID = 0
var errorMessage: String?
var composer = ""
@@ -1699,6 +1700,10 @@ final class SybilViewModel {
isLoadingSelection = false
}
private func requestChatBottomPin() {
chatBottomPinRequestID += 1
}
private func startSelectionRefreshTask() -> Task<Void, Never> {
isLoadingSelection = true
let task = Task { [weak self] in
@@ -1752,6 +1757,7 @@ final class SybilViewModel {
}
selectedChat = chat
selectedSearch = nil
requestChatBottomPin()
if let provider = chat.lastUsedProvider,
let model = chat.lastUsedModel,
@@ -1824,6 +1830,7 @@ final class SybilViewModel {
} else {
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
}
requestChatBottomPin()
if chatID == nil {
let created = try await client.createChat(title: nil)
@@ -1871,6 +1878,7 @@ final class SybilViewModel {
if let draftPending = pendingDraftChatState {
pendingDraftChatState = nil
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
requestChatBottomPin()
} else if pendingChatStates[chatID] == nil {
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
} else {

View File

@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID
)
.id(transcriptScrollContextID)
}

View File

@@ -495,6 +495,7 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
#expect(snapshot.listSearches == 0)
#expect(snapshot.getChat == 1)
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
#expect(viewModel.chatBottomPinRequestID == 1)
}
@MainActor
@@ -682,6 +683,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
await sendTask.value
}
@MainActor
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_245)
let chat = makeChatSummary(id: "chat-pin", date: date)
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
let client = MockSybilClient(
chatsResponse: [chat],
chatDetails: ["chat-pin": detail]
)
await client.setCompletionStreamEvents([
.delta(CompletionStreamDelta(text: "partial ")),
.delta(CompletionStreamDelta(text: "response")),
.done(CompletionStreamDone(text: "partial response"))
])
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [chat]
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
viewModel.selectedItem = .chat("chat-pin")
viewModel.selectedChat = detail
viewModel.composer = "continue"
let initialPinRequestID = viewModel.chatBottomPinRequestID
await viewModel.sendComposer()
let snapshot = await client.currentSnapshot()
#expect(snapshot.runCompletionStream == 1)
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
}
@MainActor
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
let client = MockSybilClient()

9
ios/fastlane/Appfile Normal file
View File

@@ -0,0 +1,9 @@
require "dotenv"
Dotenv.load(File.expand_path("../.env", __dir__))
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?

177
ios/fastlane/Fastfile Normal file
View File

@@ -0,0 +1,177 @@
require "dotenv"
require "open3"
require "shellwords"
require "yaml"
Dotenv.load(File.expand_path("../.env", __dir__))
default_platform(:ios)
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
IOS_ROOT = File.expand_path("..", __dir__)
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
SCHEME = "Sybil"
TARGET = "SybilApp"
def present?(value)
!value.to_s.strip.empty?
end
def capture(command)
stdout, stderr, status = Open3.capture3(command)
return stdout.strip if status.success?
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
end
def app_project_settings
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
end
def local_marketing_version
app_project_settings.fetch("MARKETING_VERSION").to_s
end
def local_build_number
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
end
def normalize_version_tag(tag)
version = tag.to_s.strip.sub(/\Av/, "")
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
end
version
end
def release_version
tag = ENV["SYBIL_VERSION_TAG"]
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
normalize_version_tag(tag)
end
def xcode_build_setting(key, value)
"#{key}=#{value.to_s.shellescape}"
end
def app_store_connect_key_options
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
return nil unless present?(key_id) && present?(issuer_id)
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
if present?(key_path)
{
key_id: key_id,
issuer_id: issuer_id,
key_filepath: key_path
}
elsif present?(key_content)
{
key_id: key_id,
issuer_id: issuer_id,
key_content: key_content,
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
}
end
end
platform :ios do
desc "Show the version Fastlane will stamp into the next TestFlight archive"
lane :version do
UI.message("Git tag version: #{release_version}")
UI.message("Checked-in app version: #{local_marketing_version}")
UI.message("Checked-in build number: #{local_build_number}")
end
desc "Build Sybil and upload it to TestFlight"
lane :beta do
version = release_version
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
api_key = nil
if app_store_connect_key_options
api_key = app_store_connect_api_key(app_store_connect_key_options)
end
unless present?(build_number)
build_number = (local_build_number + 1).to_s
if api_key
begin
latest = latest_testflight_build_number(
app_identifier: APP_IDENTIFIER,
version: version,
api_key: api_key,
initial_build_number: local_build_number
).to_i
build_number = [latest + 1, local_build_number + 1].max.to_s
rescue StandardError => e
UI.important("Could not look up TestFlight build number: #{e.message}")
UI.important("Using checked-in build number + 1: #{build_number}")
end
end
end
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
xcode_args = [
"-allowProvisioningUpdates",
xcode_build_setting("MARKETING_VERSION", version),
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
].join(" ")
ipa_path = build_app(
project: PROJECT_FILE,
scheme: SCHEME,
clean: true,
sdk: "iphoneos",
export_method: "app-store",
output_directory: File.join(IOS_ROOT, "build/fastlane"),
output_name: "Sybil-#{version}-#{build_number}.ipa",
xcargs: xcode_args,
export_xcargs: "-allowProvisioningUpdates",
export_options: {
method: "app-store-connect",
destination: "export",
signingStyle: "automatic",
teamID: TEAM_ID,
manageAppVersionAndBuildNumber: false,
uploadSymbols: true,
stripSwiftSymbols: true
}
)
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
sh([
"xcrun altool",
"--upload-package #{ipa_path.shellescape}",
"--platform ios",
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
"--bundle-id #{APP_IDENTIFIER.shellescape}",
"--bundle-version #{build_number.shellescape}",
"--bundle-short-version-string #{version.shellescape}",
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
"--password @env:ITMS_TRANSPORTER_PASSWORD",
"--show-progress"
].join(" "))
end
end

40
ios/fastlane/README.md Normal file
View File

@@ -0,0 +1,40 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## iOS
### ios version
```sh
[bundle exec] fastlane ios version
```
Show the version Fastlane will stamp into the next TestFlight archive
### ios beta
```sh
[bundle exec] fastlane ios beta
```
Build Sybil and upload it to TestFlight
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View File

@@ -5,8 +5,10 @@ derived_data := "build/DerivedData"
default:
@just build
build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
generate:
xcodegen --spec project.yml
build: generate
if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \
@@ -16,13 +18,15 @@ build:
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
run:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
run: generate
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2
beta:
fastlane ios beta
screenshot path="build/sybil-screenshot.png":
mkdir -p "$(dirname '{{path}}')"
xcrun simctl io booted screenshot '{{path}}'